v3: added localization, use global player for uploaded audio/video files

This commit is contained in:
khannurien
2026-04-03 15:29:33 +00:00
parent 378b3ffa46
commit 0ce80398a4
64 changed files with 4248 additions and 941 deletions

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
@@ -105,7 +107,7 @@ export function Dump() {
}
})();
return () => controller.abort();
}, [selectedDump, preloaded]);
}, [selectedDump, preloaded, token]);
useEffect(() => {
if (!lastDumpEvent) return;
@@ -143,16 +145,14 @@ export function Dump() {
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("comment-node--highlight");
const t = setTimeout(
const timer = setTimeout(
() => el.classList.remove("comment-node--highlight"),
2000,
);
return () => clearTimeout(t);
return () => clearTimeout(timer);
}, [comments, location.hash]);
// React to WS comment events
// Note: selectedDump may be a slug, but lastCommentEvent.dumpId is always a UUID.
// Compare against the loaded dump's actual ID.
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
useEffect(() => {
if (
@@ -190,7 +190,7 @@ export function Dump() {
if (dumpState.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading dump</p>
<p className="page-loading"><Trans>Loading dump</Trans></p>
</PageShell>
);
}
@@ -206,14 +206,14 @@ export function Dump() {
type="button"
onClick={() => globalThis.location.reload()}
>
Retry
<Trans>Retry</Trans>
</button>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
Back to all dumps
<Trans> Back to all dumps</Trans>
</button>
</>
}
@@ -245,7 +245,7 @@ export function Dump() {
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
+ Playlist
<Trans>+ Playlist</Trans>
</button>
)}
<div className="dump-op">
@@ -271,14 +271,16 @@ export function Dump() {
</time>
</Tooltip>
{dump.updatedAt && (
<Tooltip text={`Edited ${dump.updatedAt.toLocaleString()}`}>
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
<span className="dump-edited-label">
edited {relativeTime(dump.updatedAt)}
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
</span>
</Tooltip>
)}
{dump.isPrivate && (
<span className="dump-card-private-badge">private</span>
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
</div>
@@ -291,7 +293,7 @@ export function Dump() {
{/* Main content */}
<div className="dump-rich-content">
{dump.kind === "file"
? <FilePreview dump={dump} />
? <FilePreview dump={dump} global />
: dump.richContent
? <RichContentCard richContent={dump.richContent} />
: (
@@ -308,8 +310,12 @@ export function Dump() {
{/* Actions */}
<div className="dump-actions">
{canEdit && <Link to={`${dumpUrl(dump)}/edit`}>Edit</Link>}
<Link to="/"> Back to all dumps</Link>
{canEdit && (
<Link to={`${dumpUrl(dump)}/edit`}>
<Trans>Edit</Trans>
</Link>
)}
<Link to="/"><Trans> Back to all dumps</Trans></Link>
</div>
{/* Comments */}

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
@@ -60,7 +62,7 @@ export function DumpEdit() {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump]);
}, [selectedDump, token]);
const handleSave = async () => {
if (state.status !== "loaded") return;
@@ -138,7 +140,7 @@ export function DumpEdit() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading dump</p>
<p className="page-loading"><Trans>Loading dump</Trans></p>
</PageShell>
);
}
@@ -154,14 +156,14 @@ export function DumpEdit() {
type="button"
onClick={() => globalThis.location.reload()}
>
Retry
<Trans>Retry</Trans>
</button>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
Back to all dumps
<Trans> Back to all dumps</Trans>
</button>
</>
}
@@ -175,7 +177,7 @@ export function DumpEdit() {
<PageShell>
<div className="form-page form-page--two-col">
<div className="form-page-header">
<p className="form-page-eyebrow">Editing</p>
<p className="form-page-eyebrow"><Trans>Editing</Trans></p>
<h1 className="form-page-title">{dump.title}</h1>
</div>
@@ -201,7 +203,7 @@ export function DumpEdit() {
onClick={handleRefreshMetadata}
disabled={refreshing}
>
{refreshing ? "Refreshing…" : "Refresh metadata"}
{refreshing ? <Trans>Refreshing</Trans> : <Trans>Refresh metadata</Trans>}
</button>
)}
</div>
@@ -216,7 +218,7 @@ export function DumpEdit() {
{dump.kind === "url"
? (
<div className="form-group">
<label htmlFor="url">URL</label>
<label htmlFor="url"><Trans>URL</Trans></label>
<input
id="url"
type="url"
@@ -236,20 +238,22 @@ export function DumpEdit() {
<FileDropZone
file={newFile}
onChange={setNewFile}
label="Replace file"
hint="Drop a replacement here"
label={t`Replace file`}
hint={t`Drop a replacement here`}
showLimit={false}
/>
</div>
)}
<div className="form-group">
<label htmlFor="comment">Why are you dumping this?</label>
<label htmlFor="comment">
<Trans>Why are you dumping this?</Trans>
</label>
<TextEditor
id="comment"
value={comment}
onChange={setComment}
placeholder="Tell the community what makes this worth their time..."
placeholder={t`Tell the community what makes this worth their time...`}
rows={3}
/>
</div>
@@ -260,14 +264,14 @@ export function DumpEdit() {
className={!isPrivate ? "active" : ""}
onClick={() => setIsPrivate(false)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
onClick={() => setIsPrivate(true)}
>
Private
<Trans>Private</Trans>
</button>
</div>
@@ -277,21 +281,23 @@ export function DumpEdit() {
onClick={() => setConfirmDelete(true)}
className="btn-danger"
>
Delete dump
<Trans>Delete dump</Trans>
</button>
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
Cancel
<Trans>Cancel</Trans>
</Link>
<button type="submit" className="btn-primary">Save</button>
<button type="submit" className="btn-primary">
<Trans>Save</Trans>
</button>
</div>
</div>
</form>
</div>
{confirmDelete && (
<ConfirmModal
message="Delete this dump? This cannot be undone."
confirmLabel="Delete dump"
message={t`Delete this dump? This cannot be undone.`}
confirmLabel={t`Delete dump`}
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>

View File

@@ -11,11 +11,8 @@ import { useLocation } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx";
import {
type FeedTab,
FeedTabBar,
VALID_TABS,
} from "../components/FeedTabBar.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";

View File

@@ -1,5 +1,7 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -110,62 +112,62 @@ function notificationContent(n: Notification): React.ReactNode {
case "user_followed": {
const d = data as UserFollowedData;
return (
<>
<Trans>
<strong>{d.followerUsername}</strong>
{" started following you"}
</>
</Trans>
);
}
case "playlist_followed": {
const d = data as PlaylistFollowedData;
return (
<>
<Trans>
<strong>{d.followerUsername}</strong>
{" followed your playlist "}
<strong>{d.playlistTitle}</strong>
</>
</Trans>
);
}
case "user_dump_posted": {
const d = data as UserDumpPostedData;
return (
<>
<Trans>
<strong>{d.dumperUsername}</strong>
{" posted "}
<strong>{d.dumpTitle}</strong>
</>
</Trans>
);
}
case "playlist_dump_added": {
const d = data as PlaylistDumpAddedData;
return (
<>
<Trans>
<strong>{d.dumpTitle}</strong>
{" was added to "}
<strong>{d.playlistTitle}</strong>
</>
</Trans>
);
}
case "dump_upvoted": {
const d = data as DumpUpvotedData;
return (
<>
<Trans>
<strong>{d.voterUsername}</strong>
{" upvoted "}
<strong>{d.dumpTitle}</strong>
</>
</Trans>
);
}
case "user_mentioned": {
const d = data as UserMentionedData;
const where = d.contextTitle ||
(d.contextType === "comment" ? "a comment" : "a post");
(d.contextType === "comment" ? t`a comment` : t`a post`);
return (
<>
<Trans>
<strong>{d.mentionerUsername}</strong>
{" mentioned you in "}
<strong>{where}</strong>
</>
</Trans>
);
}
}
@@ -173,13 +175,13 @@ function notificationContent(n: Notification): React.ReactNode {
function timeAgo(date: Date): string {
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
if (secs < 60) return "just now";
if (secs < 60) return t`just now`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
if (mins < 60) return t`${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
if (hrs < 24) return t`${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
if (days < 7) return t`${days}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
@@ -215,8 +217,6 @@ export function Notifications() {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server
authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
@@ -231,7 +231,6 @@ export function Notifications() {
page: 1,
loadingMore: false,
});
// Mark read server-side after we've shown the unread state
return authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
});
@@ -251,17 +250,19 @@ export function Notifications() {
setState({ status: "error", error: err.message });
}
});
}, []);
}, [authFetch, clearUnreadNotifications]);
useEffect(() => {
if (!lastNotification) return;
const [prevLastNotification, setPrevLastNotification] = useState(
lastNotification,
);
if (prevLastNotification !== lastNotification && lastNotification !== null) {
setPrevLastNotification(lastNotification);
setState((s) => {
if (s.status !== "loaded") return s;
if (s.items.some((n) => n.id === lastNotification.id)) return s;
// Keep as unread so it gets highlighted when it arrives
return { ...s, items: [lastNotification, ...s.items] };
});
}, [lastNotification]);
}
const loadMore = () => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
@@ -304,27 +305,31 @@ export function Notifications() {
<div className="notifications-header">
<h1 className="notifications-title">
<span className="notifications-title-bell">🔔</span>
Notifications
<Trans>Notifications</Trans>
</h1>
{state.status === "loaded" && totalUnread > 0 && (
<span className="notifications-unread-pill">
{totalUnread} new
{totalUnread} <Trans>new</Trans>
</span>
)}
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "loading" && (
<p className="page-loading"><Trans>Loading</Trans></p>
)}
{state.status === "error" && (
<ErrorCard title="Failed to load" message={state.error} />
<ErrorCard title={t`Failed to load`} message={state.error} />
)}
{state.status === "loaded" && state.items.length === 0 && (
<div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span>
<p>Nothing here yet.</p>
<p><Trans>Nothing here yet.</Trans></p>
<p className="notifications-empty-hint">
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
<Trans>
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
</Trans>
</p>
</div>
)}
@@ -332,7 +337,9 @@ export function Notifications() {
{state.status === "loaded" && state.items.length > 0 &&
groupByDate(state.items).map(({ label, items }) => (
<section key={label} className="notif-group">
<h2 className="notif-group-label">{label}</h2>
<h2 className="notif-group-label">
{label === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`}
</h2>
<ul className="notification-list">
{items.map((n) => (
<li
@@ -376,7 +383,7 @@ export function Notifications() {
onClick={loadMore}
disabled={state.loadingMore}
>
{state.loadingMore ? "Loading…" : "Load more"}
{state.loadingMore ? <Trans>Loading</Trans> : <Trans>Load more</Trans>}
</button>
)}
</div>

View File

@@ -1,5 +1,13 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type {
PlaylistWithDumps,
@@ -108,7 +116,7 @@ export function PlaylistDetail() {
const fetchAbortRef = useRef<AbortController | null>(null);
const fetchPlaylist = () => {
const fetchPlaylist = useCallback(() => {
if (!playlistId) return;
fetchAbortRef.current?.abort();
const controller = new AbortController();
@@ -152,12 +160,12 @@ export function PlaylistDetail() {
error: friendlyFetchError(err),
});
});
};
}, [playlistId, token]);
useEffect(() => {
fetchPlaylist();
return () => fetchAbortRef.current?.abort();
}, [playlistId]);
}, [fetchPlaylist]);
// Start the cooldown→dismissing→gone sequence for a dump being removed.
// After the sequence completes, the dump is removed from state.playlist.dumps.
@@ -337,7 +345,7 @@ export function PlaylistDetail() {
} else if (ev.type === "deleted") {
navigate("/");
}
}, [lastPlaylistEvent, playlistUUID]);
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
useEffect(() => {
@@ -579,7 +587,7 @@ export function PlaylistDetail() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading playlist</p>
<p className="page-loading"><Trans>Loading playlist</Trans></p>
</PageShell>
);
}
@@ -594,7 +602,7 @@ export function PlaylistDetail() {
type="button"
onClick={() => navigate("/")}
>
Back
<Trans> Back</Trans>
</button>
}
/>
@@ -654,21 +662,21 @@ export function PlaylistDetail() {
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setEditOpen(false)}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
<Trans>Delete</Trans>
</button>
</div>
)
@@ -687,7 +695,7 @@ export function PlaylistDetail() {
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
<Trans>Edit</Trans>
</button>
)}
</div>
@@ -699,7 +707,7 @@ export function PlaylistDetail() {
className="playlist-edit-textarea"
value={editDescription}
onChange={setEditDescription}
placeholder="Description (optional)"
placeholder={t`Description (optional)`}
autoResize
rows={1}
/>
@@ -719,14 +727,14 @@ export function PlaylistDetail() {
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
<Trans>Public</Trans>
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
<Trans>Private</Trans>
</button>
</div>
)
@@ -737,7 +745,7 @@ export function PlaylistDetail() {
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
</span>
{playlist.ownerUsername && (
<Link
@@ -754,10 +762,10 @@ export function PlaylistDetail() {
</Tooltip>
{playlist.updatedAt && (
<Tooltip
text={`Edited ${playlist.updatedAt.toLocaleString()}`}
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
>
<span className="playlist-edited-label">
edited {relativeTime(playlist.updatedAt)}
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
</span>
</Tooltip>
)}
@@ -765,14 +773,14 @@ export function PlaylistDetail() {
)}
</div>
{editError && (
<ErrorCard title="Failed to save" message={editError} />
<ErrorCard title={t`Failed to save`} message={editError} />
)}
</div>
</div>
</div>
{visibleDumps.length === 0
? <p className="empty-state">No dumps in this playlist yet.</p>
? <p className="empty-state"><Trans>No dumps in this playlist yet.</Trans></p>
: (
<div
className="playlist-dump-list"
@@ -827,7 +835,7 @@ export function PlaylistDetail() {
type="button"
className="playlist-remove-btn"
onClick={() => handleRemoveDump(dump.id)}
aria-label="Remove from playlist"
aria-label={t`Remove from playlist`}
>
</button>
@@ -837,9 +845,9 @@ export function PlaylistDetail() {
type="button"
className="playlist-cancel-btn"
onClick={() => handleCancelRemove(dump.id)}
aria-label="Cancel removal"
aria-label={t`Cancel removal`}
>
Undo
<Trans>Undo</Trans>
</button>
))}
</div>
@@ -849,8 +857,8 @@ export function PlaylistDetail() {
)}
{confirmDelete && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
message={t`Delete this playlist? This cannot be undone.`}
confirmLabel={t`Delete playlist`}
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
@@ -151,10 +153,10 @@ export function Search() {
!state.dumps.loadingMore,
);
function setTab(t: Tab) {
function setTab(tab: Tab) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", t);
next.set("tab", tab);
return next;
}, { replace: true });
}
@@ -165,13 +167,13 @@ export function Search() {
? state.playlists.length
: null;
function tabLabel(t: Tab, count: number | null) {
const label = t === "dumps"
? "Dumps"
: t === "users"
? "Users"
: "Playlists";
return count !== null ? `${label} (${count})` : label;
function tabLabel(tab: Tab, count: number | null) {
const label = tab === "dumps"
? t`Dumps`
: tab === "users"
? t`Users`
: t`Playlists`;
return count !== null ? t`${label} (${count})` : label;
}
return (
@@ -180,18 +182,18 @@ export function Search() {
<main className="search-page">
{q && (
<div className="search-tabs">
{(["dumps", "users", "playlists"] as Tab[]).map((t) => (
{(["dumps", "users", "playlists"] as Tab[]).map((tabKey) => (
<button
key={t}
key={tabKey}
type="button"
className={`feed-sort-btn${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
className={`feed-sort-btn${tab === tabKey ? " active" : ""}`}
onClick={() => setTab(tabKey)}
>
{tabLabel(
t,
t === "dumps"
tabKey,
tabKey === "dumps"
? dumpCount
: t === "users"
: tabKey === "users"
? userCount
: playlistCount,
)}
@@ -201,21 +203,21 @@ export function Search() {
)}
{state.status === "idle" && (
<p className="search-page-empty">Enter a query to search.</p>
<p className="search-page-empty"><Trans>Enter a query to search.</Trans></p>
)}
{state.status === "loading" && (
<p className="search-page-empty">Searching</p>
<p className="search-page-empty"><Trans>Searching</Trans></p>
)}
{state.status === "error" && (
<ErrorCard title="Search failed" message={state.error} />
<ErrorCard title={t`Search failed`} message={state.error} />
)}
{state.status === "loaded" && tab === "dumps" && (
<>
{state.dumps.items.length === 0
? <p className="search-page-empty">No dumps match "{q}".</p>
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
: (
<ul className="dump-feed">
{state.dumps.items.map((dump) => (
@@ -234,17 +236,17 @@ export function Search() {
)}
<div ref={sentinelRef} />
{state.dumps.loadingMore && (
<p className="feed-loading-more">Loading more</p>
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
)}
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
<p className="feed-end">You've reached the end.</p>
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
)}
</>
)}
{state.status === "loaded" && tab === "users" && (
state.users.length === 0
? <p className="search-page-empty">No users match "{q}".</p>
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
: (
<ul className="user-results">
{state.users.map((u) => (
@@ -268,7 +270,7 @@ export function Search() {
{state.status === "loaded" && tab === "playlists" && (
state.playlists.length === 0
? <p className="search-page-empty">No playlists match "{q}".</p>
? <p className="search-page-empty">{t`No playlists match "${q}".`}</p>
: (
<ul className="dump-feed">
{state.playlists.map((p) => (

View File

@@ -1,4 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
@@ -45,7 +47,7 @@ export function UserDumps() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
<p className="page-loading"><Trans>Loading</Trans></p>
</PageShell>
);
}
@@ -56,7 +58,7 @@ export function UserDumps() {
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
Back to profile
<Trans> Back to profile</Trans>
</Link>
}
/>
@@ -70,14 +72,14 @@ export function UserDumps() {
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Dumps"
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
<Trans>+ New dump</Trans>
</button>
)}
/>
@@ -87,7 +89,7 @@ export function UserDumps() {
)}
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
: (
<ul className="dump-feed">
{dumps.map((dump) => (
@@ -106,9 +108,11 @@ export function UserDumps() {
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{!hasMore && dumps.length > 0 && (
<p className="index-status">All {dumps.length} dumps loaded.</p>
<p className="index-status">
<Trans>All <Plural value={dumps.length} one="# dump" other="# dumps" /> loaded.</Trans>
</p>
)}
</PageShell>
);

View File

@@ -1,6 +1,8 @@
import { useState } from "react";
import type { SubmitEvent } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import {
@@ -57,17 +59,17 @@ export function UserLogin() {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Log in</h1>
<h1 className="auth-card-title"><Trans>Log in</Trans></h1>
{state.status === "error" && (
<ErrorCard title="Login failed" message={state.error} />
<ErrorCard title={t`Login failed`} message={state.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder="Username"
placeholder={t`Username`}
required
disabled={state.status === "submitting"}
autoFocus
@@ -75,7 +77,7 @@ export function UserLogin() {
<input
name="password"
type="password"
placeholder="Password"
placeholder={t`Password`}
required
disabled={state.status === "submitting"}
/>
@@ -84,12 +86,14 @@ export function UserLogin() {
className="btn-primary"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Logging in…" : "Log in"}
{state.status === "submitting"
? <Trans>Logging in</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<p className="auth-card-footer">
This is a mirage.
<Trans>This is a mirage.</Trans>
</p>
</div>
</PageShell>

View File

@@ -6,6 +6,8 @@ import {
useState,
} from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
@@ -74,6 +76,11 @@ export function UserPlaylists() {
);
const [state, setState] = useState<State>({ status: "loading" });
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
}
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
@@ -114,7 +121,6 @@ export function UserPlaylists() {
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
const authHeaders: HeadersInit = token
@@ -190,7 +196,7 @@ export function UserPlaylists() {
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
}, [username, cachedCreated, cachedFollowed, token]);
const loadMoreCreated = useCallback(() => {
if (
@@ -332,7 +338,7 @@ export function UserPlaylists() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
<p className="page-loading"><Trans>Loading</Trans></p>
</PageShell>
);
}
@@ -343,7 +349,7 @@ export function UserPlaylists() {
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
Back to profile
<Trans> Back to profile</Trans>
</Link>
}
/>
@@ -357,7 +363,7 @@ export function UserPlaylists() {
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Playlists"
title={t`Playlists`}
actions={isOwnProfile && (
<NewPlaylistForm
toggleClassName="btn-primary"
@@ -377,12 +383,13 @@ export function UserPlaylists() {
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
Created ({created.items.length}
{created.hasMore ? "+" : ""})
<Trans>
Created ({created.items.length}{created.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{created.items.length === 0
? <p className="empty-state">No playlists yet.</p>
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
: (
<ul className="dump-feed">
{created.items.map((p) => (
@@ -399,19 +406,24 @@ export function UserPlaylists() {
)}
<div ref={createdSentinelRef} />
{created.loadingMore && (
<p className="feed-loading-more">Loading more</p>
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
)}
</section>
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
<Trans>
Followed ({followed.items.length}{followed.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{followed.items.length === 0
? <p className="empty-state">No followed playlists yet.</p>
? (
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{followed.items.map((p) => (
@@ -421,14 +433,14 @@ export function UserPlaylists() {
)}
<div ref={followedSentinelRef} />
{followed.loadingMore && (
<p className="feed-loading-more">Loading more</p>
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
)}
</section>
{confirmDeleteId && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
message={t`Delete this playlist? This cannot be undone.`}
confirmLabel={t`Delete playlist`}
onConfirm={() => {
handleDelete(confirmDeleteId);
setConfirmDeleteId(null);

View File

@@ -6,6 +6,8 @@ import React, {
useState,
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
@@ -57,10 +59,10 @@ function InviteButton() {
`${globalThis.location.origin}/register?token=${body.data.token}`;
setInviteUrl(url);
} else {
setError("Failed to generate invite");
setError(t`Failed to generate invite`);
}
} catch {
setError("Failed to generate invite");
setError(t`Failed to generate invite`);
}
}
@@ -76,7 +78,7 @@ function InviteButton() {
<div className="invite-result">
<span className="invite-url">{inviteUrl}</span>
<button type="button" className="invite-copy-btn" onClick={copy}>
{copied ? "Copied!" : "Copy"}
{copied ? t`Copied!` : t`Copy`}
</button>
</div>
);
@@ -85,9 +87,9 @@ function InviteButton() {
return (
<div className="invite-generate">
<button type="button" className="invite-btn" onClick={generate}>
+ Invite someone
<Trans>+ Invite someone</Trans>
</button>
{error && <ErrorCard title="Failed to generate invite" message={error} />}
{error && <ErrorCard title={t`Failed to generate invite`} message={error} />}
</div>
);
}
@@ -240,10 +242,15 @@ export function UserPublicProfile() {
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) return;
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
prevMyVotesRef.current = null;
}
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
@@ -358,7 +365,7 @@ export function UserPublicProfile() {
}
})();
return () => controller.abort();
}, [username]);
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
// Own profile: prepend dumps newly voted by the user to the preview list
useEffect(() => {
@@ -505,7 +512,7 @@ export function UserPublicProfile() {
: prev
);
} catch {
setAvatarError("Upload failed");
setAvatarError(t`Upload failed`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
@@ -536,7 +543,7 @@ export function UserPublicProfile() {
}
setEmailEditing(false);
} catch {
setEmailError("Failed to save");
setEmailError(t`Failed to save`);
} finally {
setEmailSaving(false);
}
@@ -571,7 +578,7 @@ export function UserPublicProfile() {
);
setDescEditing(false);
} catch {
setDescError("Failed to save");
setDescError(t`Failed to save`);
} finally {
setDescSaving(false);
}
@@ -580,7 +587,7 @@ export function UserPublicProfile() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading profile</p>
<p className="page-loading"><Trans>Loading profile</Trans></p>
</PageShell>
);
}
@@ -596,11 +603,11 @@ export function UserPublicProfile() {
type="button"
onClick={() => navigate("/")}
>
Back
<Trans> Back</Trans>
</button>
{me && (
<button className="btn-border" type="button" onClick={logout}>
Log out
<Trans>Log out</Trans>
</button>
)}
</>
@@ -623,7 +630,7 @@ export function UserPublicProfile() {
version={profileUser.updatedAt?.getTime()}
/>
{isOwnProfile && (
<label className="avatar-change-overlay" title="Change avatar">
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
ref={fileInputRef}
@@ -641,7 +648,7 @@ export function UserPublicProfile() {
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
invited by{" "}
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
@@ -682,7 +689,7 @@ export function UserPublicProfile() {
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving ? "Saving…" : "Save"}
{emailSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -690,11 +697,11 @@ export function UserPublicProfile() {
onClick={() => setEmailEditing(false)}
disabled={emailSaving}
>
Cancel
<Trans>Cancel</Trans>
</button>
</div>
{emailError && (
<ErrorCard title="Failed to save" message={emailError} />
<ErrorCard title={t`Failed to save`} message={emailError} />
)}
</form>
)
@@ -708,7 +715,7 @@ export function UserPublicProfile() {
}}
title="Edit email"
>
{me?.email ?? "Add email…"}
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
</span>
@@ -716,7 +723,7 @@ export function UserPublicProfile() {
)
)}
{avatarError && (
<ErrorCard title="Failed to update avatar" message={avatarError} />
<ErrorCard title={t`Failed to update avatar`} message={avatarError} />
)}
{!isOwnProfile && (
<FollowUserButton
@@ -728,7 +735,7 @@ export function UserPublicProfile() {
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="btn-border" onClick={logout}>
Log out
<Trans>Log out</Trans>
</button>
</div>
)}
@@ -745,7 +752,7 @@ export function UserPublicProfile() {
value={descDraft}
onChange={setDescDraft}
onSubmit={handleDescSave}
placeholder="Tell people about yourself…"
placeholder={t`Tell people about yourself…`}
autoResize
/>
<div className="profile-description-actions">
@@ -755,7 +762,7 @@ export function UserPublicProfile() {
onClick={handleDescSave}
disabled={descSaving}
>
{descSaving ? "Saving…" : "Save"}
{descSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -763,10 +770,10 @@ export function UserPublicProfile() {
onClick={() => setDescEditing(false)}
disabled={descSaving}
>
Cancel
<Trans>Cancel</Trans>
</button>
{descError && (
<ErrorCard title="Failed to save" message={descError} />
<ErrorCard title={t`Failed to save`} message={descError} />
)}
</div>
</div>
@@ -792,7 +799,7 @@ export function UserPublicProfile() {
)
: (
<div className="profile-description-empty">
Add a bio
<Trans>Add a bio</Trans>
</div>
)}
{isOwnProfile && (
@@ -807,7 +814,7 @@ export function UserPublicProfile() {
<div className="profile-columns">
<DumpList
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
@@ -819,7 +826,7 @@ export function UserPublicProfile() {
/>
<UpvotedDumpList
title={`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
dumps={votes.items}
profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
@@ -835,8 +842,7 @@ export function UserPublicProfile() {
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
<Trans>Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
@@ -856,7 +862,7 @@ export function UserPublicProfile() {
)}
</div>
{playlists.items.length === 0
? <p className="empty-state">No playlists yet.</p>
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
@@ -869,7 +875,7 @@ export function UserPublicProfile() {
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
View all
<Trans>View all </Trans>
</Link>
)}
</section>
@@ -913,7 +919,7 @@ function DumpList(
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
<Trans>+ New dump</Trans>
</button>
)}
</div>
@@ -921,7 +927,7 @@ function DumpList(
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
: (
<ul className="dump-feed">
{dumps.map((dump) => (
@@ -939,7 +945,7 @@ function DumpList(
</ul>
)}
{dumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">View all </Link>
<Link to={viewAllHref} className="profile-view-all"><Trans>View all </Trans></Link>
)}
</section>
);
@@ -985,9 +991,17 @@ function UpvotedDumpList(
const prevMyVotesRef = useRef<Set<string> | null>(null);
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
// setVotedIds and startFading/cancelFading must be called together synchronously
// in the same effect to guarantee a single render where the DOM node isn't
// unmounted — converting to render-phase isn't possible because startFading/
// cancelFading are themselves setState calls that can't run during render.
useEffect(() => {
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
// setVotedIds must fire here alongside prevMyVotesRef mutation; render-phase
// isn't possible because startFading/cancelFading (below) are also setState
// calls that cannot be invoked during render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes);
return;
@@ -1000,11 +1014,16 @@ function UpvotedDumpList(
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
// Non-own profile: sync votedIds with WS vote events for the profile user.
// Same constraint as above: setVotedIds and startFading/cancelFading must
// fire together so the DOM node stays mounted throughout the transition.
useEffect(() => {
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// setVotedIds + startFading must be coordinated in the same effect body
// to guarantee a single render — render-phase can't call startFading (setState).
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
@@ -1027,7 +1046,7 @@ function UpvotedDumpList(
<h2 className="profile-section-title">{title}</h2>
</div>
{visibleDumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
: (
<ul className="dump-feed">
{visibleDumps.map((dump) => {
@@ -1054,7 +1073,7 @@ function UpvotedDumpList(
</ul>
)}
{visibleDumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">View all </Link>
<Link to={viewAllHref} className="profile-view-all"><Trans>View all </Trans></Link>
)}
</section>
);

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import {
@@ -30,16 +32,19 @@ export function UserRegister() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [tokenState, setTokenState] = useState<TokenState>({
status: "checking",
});
const [tokenState, setTokenState] = useState<TokenState>(() =>
token ? { status: "checking" } : { status: "invalid" }
);
const [formState, setFormState] = useState<FormState>({ status: "idle" });
const [prevToken, setPrevToken] = useState(token);
if (prevToken !== token) {
setPrevToken(token);
setTokenState(token ? { status: "checking" } : { status: "invalid" });
}
useEffect(() => {
if (!token) {
setTokenState({ status: "invalid" });
return;
}
if (!token) return;
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
.then((r) => {
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
@@ -86,7 +91,7 @@ export function UserRegister() {
if (tokenState.status === "checking") {
return (
<PageShell centered>
<p className="page-loading">Checking invite</p>
<p className="page-loading"><Trans>Checking invite</Trans></p>
</PageShell>
);
}
@@ -96,8 +101,8 @@ export function UserRegister() {
<PageShell centered>
<div className="page-error-wrap">
<ErrorCard
title="Invalid invite"
message="This invite link is missing, expired, or already used."
title={t`Invalid invite`}
message={t`This invite link is missing, expired, or already used.`}
/>
</div>
</PageShell>
@@ -107,34 +112,34 @@ export function UserRegister() {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Register</h1>
<h1 className="auth-card-title"><Trans>Register</Trans></h1>
{formState.status === "error" && (
<ErrorCard title="Registration failed" message={formState.error} />
<ErrorCard title={t`Registration failed`} message={formState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder="Username"
placeholder={t`Username`}
required
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
title={`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
disabled={formState.status === "submitting"}
autoFocus
/>
<input
name="email"
type="email"
placeholder="Email address"
placeholder={t`Email address`}
required
disabled={formState.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder={`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
required
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
@@ -145,12 +150,14 @@ export function UserRegister() {
className="btn-primary"
disabled={formState.status === "submitting"}
>
{formState.status === "submitting" ? "Registering…" : "Register"}
{formState.status === "submitting"
? <Trans>Registering</Trans>
: <Trans>Register</Trans>}
</button>
</form>
<p className="auth-card-footer">
Already have an account? <Link to="/login">Log in</Link>
<Trans>Already have an account? <Link to="/login">Log in</Link></Trans>
</p>
</div>
</PageShell>

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
@@ -38,23 +40,36 @@ export function UserUpvoted() {
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
// Reset vote tracking when username changes
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setVotedIds(new Set());
}
useEffect(() => {
cancelAll();
setVotedIds(new Set());
prevMyVotesRef.current = null;
}, [username]);
}, [username, cancelAll]);
// Seed votedIds once items are loaded
useEffect(() => {
if (state.status !== "loaded") return;
setVotedIds(new Set(state.items.map((d) => d.id)));
}, [state.status]);
const [prevStateStatus, setPrevStateStatus] = useState(state.status);
const [prevStateItems, setPrevStateItems] = useState(
state.status === "loaded" ? state.items : null,
);
const currentItems = state.status === "loaded" ? state.items : null;
if (
prevStateStatus !== state.status ||
prevStateItems !== currentItems
) {
setPrevStateStatus(state.status);
setPrevStateItems(currentItems);
if (state.status === "loaded") {
setVotedIds(new Set(state.items.map((d) => d.id)));
}
}
// Own profile: keep votedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -66,13 +81,13 @@ export function UserUpvoted() {
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId, startFading, cancelFading]);
// WS vote events
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
@@ -96,12 +111,12 @@ export function UserUpvoted() {
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
}, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]);
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
<p className="page-loading"><Trans>Loading</Trans></p>
</PageShell>
);
}
@@ -112,7 +127,7 @@ export function UserUpvoted() {
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
Back to profile
<Trans> Back to profile</Trans>
</Link>
}
/>
@@ -129,11 +144,11 @@ export function UserUpvoted() {
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Upvoted"
title={t`Upvoted`}
/>
{visibleDumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
: (
<ul className="dump-feed">
{visibleDumps.map((dump) => {
@@ -161,9 +176,13 @@ export function UserUpvoted() {
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
)}
{!hasMore && visibleDumps.length > 0 && (
<p className="index-status">All {votes.length} upvoted dumps loaded.</p>
<p className="index-status">
<Trans>All <Plural value={votes.length} one="# upvoted dump" other="# upvoted dumps" /> loaded.</Trans>
</p>
)}
</PageShell>
);

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
@@ -69,10 +71,10 @@ function FollowedSubFeed({
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") {
return <p className="index-status">Loading</p>;
return <p className="index-status"><Trans>Loading</Trans></p>;
}
if (state.status === "error") {
return <ErrorCard title="Failed to load" message={state.error} />;
return <ErrorCard title={t`Failed to load`} message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
@@ -98,7 +100,7 @@ function FollowedSubFeed({
))}
</ul>
<div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more">Loading more</p>}
{state.loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
</>
);
}
@@ -320,14 +322,14 @@ export function FollowedFeed({
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
onClick={() => setSection("users")}
>
From people
<Trans>From people</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${section === "playlists" ? " active" : ""}`}
onClick={() => setSection("playlists")}
>
From playlists
<Trans>From playlists</Trans>
</button>
</div>
@@ -340,7 +342,7 @@ export function FollowedFeed({
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
emptyMessage={t`Follow some users to see their dumps here.`}
onLoadMore={loadMoreUsers}
/>
)}
@@ -354,7 +356,7 @@ export function FollowedFeed({
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some public playlists to see their dumps here."
emptyMessage={t`Follow some public playlists to see their dumps here.`}
onLoadMore={loadMorePlaylists}
/>
)}

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
@@ -24,10 +26,10 @@ export function HotFeed(
[dumps],
);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return <p className="index-status">No dumps yet. Be the first!</p>;
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
}
return (
@@ -47,9 +49,9 @@ export function HotFeed(
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">You've reached the end.</p>
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
)}
</>
);

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import {
JournalCard,
@@ -36,10 +38,10 @@ export function JournalFeed(
});
}, [dumps]);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (tiered.length === 0) {
return <p className="index-status">No dumps yet. Be the first!</p>;
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
}
return (
@@ -60,9 +62,9 @@ export function JournalFeed(
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{!hasMore && tiered.length > 0 && (
<p className="feed-end">You've reached the end.</p>
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
)}
</>
);

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import type { MainFeedProps } from "./types.ts";
@@ -24,10 +26,10 @@ export function NewFeed(
[dumps],
);
if (loading) return <p className="index-status">Loading</p>;
if (error) return <ErrorCard title="Failed to load" message={error} />;
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return <p className="index-status">No dumps yet. Be the first!</p>;
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
}
return (
@@ -47,9 +49,9 @@ export function NewFeed(
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">You've reached the end.</p>
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
)}
</>
);