Compare commits

...

2 Commits

Author SHA1 Message Date
khannurien
0e138be6df v3: youtube embeds now honour the timestamp in the source url, with a migration backfilling existing dumps
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:47:26 +00:00
khannurien
a9b94c0bfb v3: added an "in collections" section to dump pages, listing the collections a dump belongs to
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:41:36 +00:00
11 changed files with 287 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts"; import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts"; import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts"; import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -27,6 +28,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens }, { name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
{ name: "0008_chat_messages", up: up0008ChatMessages }, { name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply }, { name: "0009_chat_reply", up: up0009ChatReply },
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
]; ];
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,76 @@
import type { DatabaseSync } from "node:sqlite";
// Backfills the `start` parameter on stored YouTube embed URLs.
//
// `rich_content.embedUrl` is computed once, at dump creation, and previously
// dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so
// those videos always opened at 0:00 in the global player. The provider now
// translates `t`/`start` into the `start` param the iframe player honours; this
// migration applies the same translation to rows written before that fix.
//
// Purely local — it re-derives the param from the already-stored source URL and
// makes no network calls. Self-contained (no imports from api/services) so it
// stays reproducible even if the provider's parsing evolves later.
//
// Idempotent: rows whose embed URL already carries a `start` are skipped, so a
// fresh database built from schema.sql is simply a no-op.
/** Parse `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
const u = new URL(url);
raw = u.searchParams.get("t") ?? u.searchParams.get("start");
} catch {
return null;
}
if (!raw) return null;
if (/^\d+$/.test(raw)) return Number(raw);
const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw);
if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null;
return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 +
Number(hms[3] ?? 0);
}
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, url, rich_content FROM dumps
WHERE kind = 'url' AND url IS NOT NULL AND rich_content IS NOT NULL;`,
).all() as { id: string; url: string; rich_content: string }[];
const update = db.prepare(
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
);
let patched = 0;
for (const row of rows) {
let rich: { type?: string; embedUrl?: string };
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
}
if (rich.type !== "youtube" || !rich.embedUrl) continue;
const start = extractStartSeconds(row.url);
if (!start) continue;
let embed: URL;
try {
embed = new URL(rich.embedUrl);
} catch {
continue;
}
if (embed.searchParams.has("start")) continue;
embed.searchParams.set("start", String(start));
update.run(JSON.stringify({ ...rich, embedUrl: embed.toString() }), row.id);
patched++;
}
if (patched > 0) {
console.log(`[migrate] 0010: added start offset to ${patched} embed(s)`);
}
}

View File

@@ -8,6 +8,7 @@ import {
isCreateUrlDumpRequest, isCreateUrlDumpRequest,
isUpdateDumpRequest, isUpdateDumpRequest,
type PaginatedData, type PaginatedData,
type Playlist,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
@@ -30,6 +31,7 @@ import {
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts"; import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts";
const router = new Router({ prefix: "/api/dumps" }); const router = new Router({ prefix: "/api/dumps" });
@@ -128,6 +130,18 @@ router.get("/:dumpId/related", async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/:dumpId/playlists", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
// Resolve through getDump so private dumps 404 for anyone but their owner.
const dump = getDump(ctx.params.dumpId, requestingUserId);
const playlists = getPlaylistsForDump(dump.id, requestingUserId);
const responseBody: APIResponse<Playlist[]> = {
success: true,
data: playlists,
};
ctx.response.body = responseBody;
});
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);

View File

@@ -382,6 +382,26 @@ export function getPlaylistMembershipsForDump(
}); });
} }
/**
* The playlists a dump appears in — the "in collections" counterpart to
* backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for
* everyone, plus the requesting user's own private ones. Capped at 20 — a
* small, bounded list of supporting context.
*/
export function getPlaylistsForDump(
dumpId: string,
requestingUserId?: string,
): Playlist[] {
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT}
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? "");
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
export function getPlaylistImageInfo( export function getPlaylistImageInfo(
idOrSlug: string, idOrSlug: string,
): { id: string; imageMime: string } | undefined { ): { id: string; imageMime: string } | undefined {

View File

@@ -32,6 +32,30 @@ function extractPlaylistId(url: string): string | null {
} }
} }
/**
* Extract the playback offset in seconds from a YouTube URL.
* Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start`
* (plain seconds). Returns null when absent or unparseable.
*/
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
const u = new URL(url);
raw = u.searchParams.get("t") ?? u.searchParams.get("start");
} catch {
return null;
}
if (!raw) return null;
const plain = /^\d+$/.exec(raw);
if (plain) return Number(raw);
const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw);
if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null;
return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 +
Number(hms[3] ?? 0);
}
/** Matches /channel/UC…, /@handle, /c/name, /user/name */ /** Matches /channel/UC…, /@handle, /c/name, /user/name */
function extractChannelPath(url: string): string | null { function extractChannelPath(url: string): string | null {
try { try {
@@ -134,6 +158,8 @@ export const youtubeProvider: RichContentProvider = {
const embedParams = new URLSearchParams({ rel: "0" }); const embedParams = new URLSearchParams({ rel: "0" });
if (listId) embedParams.set("list", listId); if (listId) embedParams.set("list", listId);
const start = extractStartSeconds(url);
if (start) embedParams.set("start", String(start));
return { return {
type: "youtube", type: "youtube",

View File

@@ -4155,6 +4155,53 @@ body.has-player .chat-fab {
} }
} }
/* "In collections" reuses the related-section chrome (and its per-theme
overrides), so the playlist cards inside it get the same subordinate,
tightened treatment the backlink dump cards do. */
.collections-section .playlist-card {
border-width: 1px;
border-radius: 8px;
background: transparent;
opacity: 0.78;
transition: border-color 0.15s, opacity 0.15s;
}
.collections-section .playlist-card:hover {
opacity: 1;
}
.collections-section .playlist-card-inner {
padding: 0.45rem 0.65rem;
gap: 0.6rem;
}
.collections-section .playlist-card-preview {
width: 48px;
height: 48px;
align-self: center;
}
.collections-section .playlist-card-icon {
font-size: 1.15rem;
}
.collections-section .playlist-card-title {
font-size: 0.88rem;
}
.collections-section .playlist-card-description {
font-size: 0.78rem;
-webkit-line-clamp: 1;
line-clamp: 1;
}
@media (max-width: 512px) {
.collections-section .playlist-card-preview {
width: 40px;
height: 40px;
}
}
.comment-list { .comment-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

File diff suppressed because one or more lines are too long

View File

@@ -30,12 +30,12 @@ msgstr "{0, plural, one {# dump} other {# dumps}}"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#. placeholder {1}: names[1] #. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:528 #: src/components/ChatModal.tsx:531
msgid "{0} and {1} are typing…" msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…" msgstr "{0} and {1} are typing…"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#: src/components/ChatModal.tsx:527 #: src/components/ChatModal.tsx:530
msgid "{0} is typing…" msgid "{0} is typing…"
msgstr "{0} is typing…" msgstr "{0} is typing…"
@@ -58,8 +58,8 @@ msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:264 #: src/pages/Dump.tsx:291
#: src/pages/Dump.tsx:494 #: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:181 #: src/pages/DumpEdit.tsx:181
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -78,7 +78,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:335 #: src/pages/Dump.tsx:362
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Playlist" msgstr "+ Playlist"
@@ -202,7 +202,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/CommentThread.tsx:124 #: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:376 #: src/pages/Dump.tsx:403
#: src/pages/DumpEdit.tsx:460 #: src/pages/DumpEdit.tsx:460
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674 #: src/pages/UserPublicProfile.tsx:1674
@@ -214,8 +214,8 @@ msgstr "Cancel"
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Cancel removal" msgstr "Cancel removal"
#: src/components/ChatModal.tsx:619 #: src/components/ChatModal.tsx:622
#: src/components/ChatModal.tsx:620 #: src/components/ChatModal.tsx:623
msgid "Cancel reply" msgid "Cancel reply"
msgstr "Cancel reply" msgstr "Cancel reply"
@@ -248,7 +248,7 @@ msgstr "Change password…"
#: src/components/ChatButton.tsx:17 #: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50 #: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:533 #: src/components/ChatModal.tsx:536
msgid "Chat" msgid "Chat"
msgstr "Chat" msgstr "Chat"
@@ -426,7 +426,7 @@ msgstr "Earlier"
#: src/components/ChatModal.tsx:172 #: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173 #: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:490 #: src/pages/Dump.tsx:517
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -445,7 +445,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152 #: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:429 #: src/pages/Dump.tsx:456
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -455,7 +455,7 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150 #: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:427 #: src/pages/Dump.tsx:454
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
@@ -632,6 +632,10 @@ msgstr "Hot"
msgid "If that address is registered you'll receive a reset link shortly." msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly."
#: src/pages/Dump.tsx:551
msgid "In collections"
msgstr "In collections"
#: src/pages/UserRegister.tsx:96 #: src/pages/UserRegister.tsx:96
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invalid invite" msgstr "Invalid invite"
@@ -675,11 +679,11 @@ msgstr "Live updates unavailable."
msgid "Load more" msgid "Load more"
msgstr "Load more" msgstr "Load more"
#: src/components/ChatModal.tsx:546 #: src/components/ChatModal.tsx:549
msgid "Load older messages" msgid "Load older messages"
msgstr "Load older messages" msgstr "Load older messages"
#: src/pages/Dump.tsx:240 #: src/pages/Dump.tsx:267
#: src/pages/DumpEdit.tsx:157 #: src/pages/DumpEdit.tsx:157
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
@@ -705,7 +709,7 @@ msgid "Loading profile…"
msgstr "Loading profile…" msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52 #: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:545 #: src/components/ChatModal.tsx:548
#: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289 #: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192 #: src/components/UserListPopover.tsx:192
@@ -824,7 +828,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet." msgid "No invitees yet."
msgstr "No invitees yet." msgstr "No invitees yet."
#: src/components/ChatModal.tsx:553 #: src/components/ChatModal.tsx:556
msgid "No messages yet. Say hello!" msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!" msgstr "No messages yet. Say hello!"
@@ -942,7 +946,7 @@ msgstr "Posting…"
#: src/components/JournalCard.tsx:121 #: src/components/JournalCard.tsx:121
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:435 #: src/pages/Dump.tsx:462
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -984,7 +988,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:502 #: src/pages/Dump.tsx:529
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -1014,7 +1018,7 @@ msgstr "Replace file"
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
#: src/components/ChatModal.tsx:609 #: src/components/ChatModal.tsx:612
msgid "Replying to" msgid "Replying to"
msgstr "Replying to" msgstr "Replying to"
@@ -1035,7 +1039,7 @@ msgstr "Reset password"
msgid "Reset to default" msgid "Reset to default"
msgstr "Reset to default" msgstr "Reset to default"
#: src/pages/Dump.tsx:257 #: src/pages/Dump.tsx:284
#: src/pages/DumpEdit.tsx:174 #: src/pages/DumpEdit.tsx:174
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
@@ -1046,7 +1050,7 @@ msgstr "Role"
#: src/components/ChatModal.tsx:222 #: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:395
#: src/pages/DumpEdit.tsx:463 #: src/pages/DumpEdit.tsx:463
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666 #: src/pages/UserPublicProfile.tsx:1666
@@ -1056,7 +1060,7 @@ msgstr "Save"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:367 #: src/pages/Dump.tsx:394
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663 #: src/pages/UserPublicProfile.tsx:1663
@@ -1082,7 +1086,7 @@ msgstr "Search failed"
msgid "Searching…" msgid "Searching…"
msgstr "Searching…" msgstr "Searching…"
#: src/components/ChatModal.tsx:655 #: src/components/ChatModal.tsx:658
msgid "Send" msgid "Send"
msgstr "Send" msgstr "Send"
@@ -1107,7 +1111,7 @@ msgstr "Set new password"
msgid "Settings" msgid "Settings"
msgstr "Settings" msgstr "Settings"
#: src/components/ChatModal.tsx:529 #: src/components/ChatModal.tsx:532
msgid "Several people are typing…" msgid "Several people are typing…"
msgstr "Several people are typing…" msgstr "Several people are typing…"
@@ -1167,7 +1171,7 @@ msgstr "Title is required."
msgid "Today" msgid "Today"
msgstr "Today" msgstr "Today"
#: src/components/ChatModal.tsx:632 #: src/components/ChatModal.tsx:635
msgid "Type a message…" msgid "Type a message…"
msgstr "Type a message…" msgstr "Type a message…"

File diff suppressed because one or more lines are too long

View File

@@ -30,12 +30,12 @@ msgstr "{0, plural, one {# reco} other {# recos}}"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#. placeholder {1}: names[1] #. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:528 #: src/components/ChatModal.tsx:531
msgid "{0} and {1} are typing…" msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…" msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#: src/components/ChatModal.tsx:527 #: src/components/ChatModal.tsx:530
msgid "{0} is typing…" msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…" msgstr "{0} est en train d'écrire…"
@@ -58,8 +58,8 @@ msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
#: src/pages/Dump.tsx:264 #: src/pages/Dump.tsx:291
#: src/pages/Dump.tsx:494 #: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:181 #: src/pages/DumpEdit.tsx:181
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
@@ -78,7 +78,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:335 #: src/pages/Dump.tsx:362
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Collection" msgstr "+ Collection"
@@ -202,7 +202,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/CommentThread.tsx:124 #: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:376 #: src/pages/Dump.tsx:403
#: src/pages/DumpEdit.tsx:460 #: src/pages/DumpEdit.tsx:460
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674 #: src/pages/UserPublicProfile.tsx:1674
@@ -214,8 +214,8 @@ msgstr "Annuler"
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Annuler la suppression" msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:619 #: src/components/ChatModal.tsx:622
#: src/components/ChatModal.tsx:620 #: src/components/ChatModal.tsx:623
msgid "Cancel reply" msgid "Cancel reply"
msgstr "Annuler la réponse" msgstr "Annuler la réponse"
@@ -248,7 +248,7 @@ msgstr "Changer le mot de passe…"
#: src/components/ChatButton.tsx:17 #: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50 #: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:533 #: src/components/ChatModal.tsx:536
msgid "Chat" msgid "Chat"
msgstr "Tribune" msgstr "Tribune"
@@ -426,7 +426,7 @@ msgstr "Plus tôt"
#: src/components/ChatModal.tsx:172 #: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173 #: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:490 #: src/pages/Dump.tsx:517
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -445,7 +445,7 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152 #: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:429 #: src/pages/Dump.tsx:456
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -455,7 +455,7 @@ msgstr "modifié {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150 #: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:427 #: src/pages/Dump.tsx:454
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
@@ -632,6 +632,10 @@ msgstr "Tendances"
msgid "If that address is registered you'll receive a reset link shortly." msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#: src/pages/Dump.tsx:551
msgid "In collections"
msgstr "Dans les collections"
#: src/pages/UserRegister.tsx:96 #: src/pages/UserRegister.tsx:96
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invitation invalide" msgstr "Invitation invalide"
@@ -675,11 +679,11 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more" msgid "Load more"
msgstr "Charger plus" msgstr "Charger plus"
#: src/components/ChatModal.tsx:546 #: src/components/ChatModal.tsx:549
msgid "Load older messages" msgid "Load older messages"
msgstr "Charger les messages plus anciens" msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:240 #: src/pages/Dump.tsx:267
#: src/pages/DumpEdit.tsx:157 #: src/pages/DumpEdit.tsx:157
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Chargement de la reco…" msgstr "Chargement de la reco…"
@@ -705,7 +709,7 @@ msgid "Loading profile…"
msgstr "Chargement du profil…" msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52 #: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:545 #: src/components/ChatModal.tsx:548
#: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289 #: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192 #: src/components/UserListPopover.tsx:192
@@ -824,7 +828,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet." msgid "No invitees yet."
msgstr "Aucun invité pour le moment." msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:553 #: src/components/ChatModal.tsx:556
msgid "No messages yet. Say hello!" msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !" msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -942,7 +946,7 @@ msgstr "Publication…"
#: src/components/JournalCard.tsx:121 #: src/components/JournalCard.tsx:121
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:435 #: src/pages/Dump.tsx:462
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
@@ -984,7 +988,7 @@ msgstr "Inscription…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
#: src/pages/Dump.tsx:502 #: src/pages/Dump.tsx:529
msgid "Related" msgid "Related"
msgstr "Connexe" msgstr "Connexe"
@@ -1014,7 +1018,7 @@ msgstr "Remplacer le fichier"
msgid "Reply" msgid "Reply"
msgstr "Répondre" msgstr "Répondre"
#: src/components/ChatModal.tsx:609 #: src/components/ChatModal.tsx:612
msgid "Replying to" msgid "Replying to"
msgstr "En réponse à" msgstr "En réponse à"
@@ -1035,7 +1039,7 @@ msgstr "Réinitialiser le mot de passe"
msgid "Reset to default" msgid "Reset to default"
msgstr "Réinitialiser par défaut" msgstr "Réinitialiser par défaut"
#: src/pages/Dump.tsx:257 #: src/pages/Dump.tsx:284
#: src/pages/DumpEdit.tsx:174 #: src/pages/DumpEdit.tsx:174
msgid "Retry" msgid "Retry"
msgstr "Réessayer" msgstr "Réessayer"
@@ -1046,7 +1050,7 @@ msgstr "Rôle"
#: src/components/ChatModal.tsx:222 #: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:395
#: src/pages/DumpEdit.tsx:463 #: src/pages/DumpEdit.tsx:463
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666 #: src/pages/UserPublicProfile.tsx:1666
@@ -1056,7 +1060,7 @@ msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:367 #: src/pages/Dump.tsx:394
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663 #: src/pages/UserPublicProfile.tsx:1663
@@ -1082,7 +1086,7 @@ msgstr "Recherche échouée"
msgid "Searching…" msgid "Searching…"
msgstr "Recherche…" msgstr "Recherche…"
#: src/components/ChatModal.tsx:655 #: src/components/ChatModal.tsx:658
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
@@ -1107,7 +1111,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
#: src/components/ChatModal.tsx:529 #: src/components/ChatModal.tsx:532
msgid "Several people are typing…" msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…" msgstr "Plusieurs personnes écrivent…"
@@ -1167,7 +1171,7 @@ msgstr "Un titre est requis."
msgid "Today" msgid "Today"
msgstr "Aujourd'hui" msgstr "Aujourd'hui"
#: src/components/ChatModal.tsx:632 #: src/components/ChatModal.tsx:635
msgid "Type a message…" msgid "Type a message…"
msgstr "Écrivez un message…" msgstr "Écrivez un message…"

View File

@@ -11,14 +11,17 @@ import { API_URL, VALIDATION } from "../config/api.ts";
import type { import type {
Comment, Comment,
Dump, Dump,
Playlist,
PublicUser, PublicUser,
RawComment, RawComment,
RawDump, RawDump,
RawPlaylist,
UpdateDumpRequest, UpdateDumpRequest,
} from "../model.ts"; } from "../model.ts";
import { import {
deserializeComment, deserializeComment,
deserializeDump, deserializeDump,
deserializePlaylist,
deserializePublicUser, deserializePublicUser,
parseAPIResponse, parseAPIResponse,
} from "../model.ts"; } from "../model.ts";
@@ -33,6 +36,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx"; import FilePreview from "../components/FilePreview.tsx";
import { VoteButton } from "../components/VoteButton.tsx"; import { VoteButton } from "../components/VoteButton.tsx";
import { DumpCard } from "../components/DumpCard.tsx"; import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx"; import { Markdown } from "../components/Markdown.tsx";
@@ -67,6 +71,7 @@ export function Dump() {
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]); const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false); const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState(""); const [titleDraft, setTitleDraft] = useState("");
@@ -82,6 +87,7 @@ export function Dump() {
removeVote, removeVote,
lastDumpEvent, lastDumpEvent,
lastCommentEvent, lastCommentEvent,
lastPlaylistEvent,
connectionEpoch, connectionEpoch,
} = useWS(); } = useWS();
@@ -180,6 +186,28 @@ export function Dump() {
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, token]); }, [selectedDump, token]);
// Fetch the collections this dump appears in (public ones, plus the
// viewer's own private ones — the server does the filtering)
useEffect(() => {
if (!selectedDump) return;
const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
if (body.success) {
setCollections((body.data as RawPlaylist[]).map(deserializePlaylist));
}
})
.catch(() => {});
return () => controller.abort();
// lastPlaylistEvent: adding/removing this dump, flipping a collection's
// visibility or deleting it all change the list — playlist events are rare
// enough to just refetch on any of them.
}, [selectedDump, token, lastPlaylistEvent]);
// Scroll to and highlight a comment when navigating to #comment-{id} // Scroll to and highlight a comment when navigating to #comment-{id}
useEffect(() => { useEffect(() => {
if (!location.hash.startsWith("#comment-")) return; if (!location.hash.startsWith("#comment-")) return;
@@ -517,6 +545,20 @@ export function Dump() {
</section> </section>
)} )}
{/* In collections (playlists containing this dump) */}
{collections.length > 0 && (
<section className="related-section collections-section">
<h2 className="related-section-title">
<Trans>In collections</Trans>
</h2>
<ul className="dump-feed">
{collections.map((collection) => (
<PlaylistCard key={collection.id} playlist={collection} />
))}
</ul>
</section>
)}
{/* Comments */} {/* Comments */}
<CommentThread <CommentThread
dumpId={dump.id} dumpId={dump.id}