/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …". A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it. A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.5 KiB
TypeScript
173 lines
5.5 KiB
TypeScript
/**
|
|
* Playlists are ordered collections of dumps their owners curate. This module
|
|
* types them, with the bodies that create, edit and reorder them. The client
|
|
* shows them on `/@owner/playlists/:slug`, on profiles and in search results.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { Dump } from "./dump.ts";
|
|
|
|
/**
|
|
* The orders a list of playlists comes in, the first being the default one:
|
|
* `updated`, by {@link playlistActivity}, the latest first; `new`, by
|
|
* creation, the newest first; `dumps`, the fullest first. `GET /api/playlists`
|
|
* and `GET /api/users/:key/playlists` take one as `?sort=`, the `/playlists`
|
|
* page and a profile's playlists tab show them as tabs and keep the one
|
|
* picked in their URL's `?sort=`.
|
|
*/
|
|
export const PLAYLIST_SORTS = ["updated", "new", "dumps"] as const;
|
|
/** One of the {@link PLAYLIST_SORTS}. */
|
|
export type PlaylistSort = (typeof PLAYLIST_SORTS)[number];
|
|
|
|
/**
|
|
* Returns the order a `?sort=` names, the default one when it is missing or
|
|
* names none: like a page number, an order never fails a list.
|
|
*/
|
|
export function parsePlaylistSort(value: string | null): PlaylistSort {
|
|
return PLAYLIST_SORTS.find((sort) => sort === value) ?? PLAYLIST_SORTS[0];
|
|
}
|
|
|
|
/**
|
|
* Returned by the playlist routes, and by the search, users and dumps routes
|
|
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket
|
|
* messages carry it too.
|
|
*/
|
|
export interface Playlist {
|
|
id: string;
|
|
userId: string;
|
|
title: string;
|
|
/**
|
|
* Names the playlist in its page's path, `playlistPath`
|
|
* (`shared/paths.ts`) building it with {@link ownerUsername}. Made from the
|
|
* title on creation, it changes only when edited, and is unique among its
|
|
* owner's playlists.
|
|
*/
|
|
slug: string;
|
|
/** It is written in Markdown. */
|
|
description?: string;
|
|
/**
|
|
* Only its owner sees a private playlist. Moderators don't, though they may
|
|
* still edit or delete it.
|
|
*/
|
|
isPublic: boolean;
|
|
createdAt: Date;
|
|
/**
|
|
* `playlistImageUrl` puts it on the cover's URL, so a new cover isn't hidden
|
|
* by the cached old one.
|
|
*/
|
|
updatedAt?: Date;
|
|
/** It is undefined when the playlist has no cover. */
|
|
imageMime?: string;
|
|
/**
|
|
* It counts the dumps the viewer may see in it, as its page lists them. It
|
|
* is undefined when the query didn't count them.
|
|
*/
|
|
dumpCount?: number;
|
|
/**
|
|
* When the latest of the dumps the viewer may see was added to it, which
|
|
* {@link playlistActivity} reads. It is undefined when it holds none, or
|
|
* when the query didn't count them.
|
|
*/
|
|
lastDumpAt?: Date;
|
|
/** The owner's current name, which the playlist's path starts with. */
|
|
ownerUsername: string;
|
|
/**
|
|
* The ids of the playlist categories it is filed under. The services fill
|
|
* them in on every playlist they return.
|
|
*/
|
|
categoryIds?: string[];
|
|
}
|
|
|
|
/**
|
|
* Returns when `playlist` last took a dump, or was created if it holds none:
|
|
* what the `updated` order sorts by and the cards show. Its title, cover and
|
|
* description may change after without bringing it back up a list.
|
|
*/
|
|
export function playlistActivity(
|
|
playlist: Pick<Playlist, "createdAt" | "lastDumpAt">,
|
|
): Date {
|
|
return playlist.lastDumpAt ?? playlist.createdAt;
|
|
}
|
|
|
|
/**
|
|
* Returns the comparison that puts playlists in the order `sort` names, as
|
|
* the API pages them, the newer first on a tie. `usePlaylistListSync` finds
|
|
* with it where a playlist the socket announces goes in a list.
|
|
*/
|
|
export function comparePlaylists(
|
|
sort: PlaylistSort,
|
|
): (a: Playlist, b: Playlist) => number {
|
|
const byCreation = (a: Playlist, b: Playlist) =>
|
|
b.createdAt.getTime() - a.createdAt.getTime();
|
|
switch (sort) {
|
|
case "updated":
|
|
return (a, b) =>
|
|
playlistActivity(b).getTime() - playlistActivity(a).getTime() ||
|
|
byCreation(a, b);
|
|
case "dumps":
|
|
return (a, b) =>
|
|
(b.dumpCount ?? 0) - (a.dumpCount ?? 0) || byCreation(a, b);
|
|
case "new":
|
|
return byCreation;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
|
|
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in
|
|
* the playlist's order.
|
|
*/
|
|
export interface PlaylistWithDumps extends Playlist {
|
|
dumps: Dump[];
|
|
}
|
|
|
|
/**
|
|
* Returned by `GET /api/me/playlists?contains=:dumpId`, one per playlist of
|
|
* the caller's. `PlaylistMembershipPanel` ticks the playlists
|
|
* with `hasDump`, in the add-to-playlist modal and the dump form.
|
|
*/
|
|
export interface PlaylistMembership {
|
|
playlist: Playlist;
|
|
hasDump: boolean;
|
|
}
|
|
|
|
/** Passed to `POST /api/playlists` by `PlaylistCreateForm`. */
|
|
export interface CreatePlaylistRequest {
|
|
title: string;
|
|
description?: string;
|
|
isPublic: boolean;
|
|
/** The playlist categories to file it under, none when left out. */
|
|
categoryIds?: string[];
|
|
}
|
|
|
|
/**
|
|
* Passed to `PUT /api/playlists/:playlistId` by `PlaylistDetail`. A field left
|
|
* out keeps its value.
|
|
*/
|
|
export interface UpdatePlaylistRequest {
|
|
title?: string;
|
|
/**
|
|
* A new slug, which the edit form's slug field sends. A title change never
|
|
* changes the slug by itself.
|
|
*/
|
|
slug?: string;
|
|
/** `null` removes the description. */
|
|
description?: string | null;
|
|
isPublic?: boolean;
|
|
/** Replaces its playlist categories; `[]` takes it out of every one. */
|
|
categoryIds?: string[];
|
|
}
|
|
|
|
/**
|
|
* Passed to `PUT /api/playlists/:playlistId/order` by `PlaylistDetail`, after
|
|
* a drag or to put back a dump whose removal was undone.
|
|
*/
|
|
export interface ReorderPlaylistRequest {
|
|
/**
|
|
* Every dump of the playlist the caller can see, in the new order. The dumps
|
|
* hidden from them keep their slots.
|
|
*/
|
|
dumpIds: string[];
|
|
}
|