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 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>