v3: added localization, use global player for uploaded audio/video files
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user