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>
This commit is contained in:
khannurien
2026-08-08 11:41:36 +00:00
parent b84fcdd523
commit a9b94c0bfb
8 changed files with 183 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ import {
isCreateUrlDumpRequest,
isUpdateDumpRequest,
type PaginatedData,
type Playlist,
} from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
@@ -30,6 +31,7 @@ import {
} from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts";
const router = new Router({ prefix: "/api/dumps" });
@@ -128,6 +130,18 @@ router.get("/:dumpId/related", async (ctx) => {
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) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
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(
idOrSlug: string,
): { id: string; imageMime: string } | undefined {

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

View File

@@ -11,14 +11,17 @@ import { API_URL, VALIDATION } from "../config/api.ts";
import type {
Comment,
Dump,
Playlist,
PublicUser,
RawComment,
RawDump,
RawPlaylist,
UpdateDumpRequest,
} from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializePlaylist,
deserializePublicUser,
parseAPIResponse,
} from "../model.ts";
@@ -33,6 +36,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
import { VoteButton } from "../components/VoteButton.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx";
@@ -67,6 +71,7 @@ export function Dump() {
const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
@@ -82,6 +87,7 @@ export function Dump() {
removeVote,
lastDumpEvent,
lastCommentEvent,
lastPlaylistEvent,
connectionEpoch,
} = useWS();
@@ -180,6 +186,28 @@ export function Dump() {
return () => controller.abort();
}, [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}
useEffect(() => {
if (!location.hash.startsWith("#comment-")) return;
@@ -517,6 +545,20 @@ export function Dump() {
</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 */}
<CommentThread
dumpId={dump.id}