diff --git a/api/main.ts b/api/main.ts index 5622f2d..52dedbc 100644 --- a/api/main.ts +++ b/api/main.ts @@ -7,6 +7,7 @@ import usersRouter from "./routes/users.ts"; import avatarsRouter from "./routes/avatars.ts"; import wsRouter from "./routes/ws.ts"; import previewRouter from "./routes/preview.ts"; +import playlistsRouter from "./routes/playlists.ts"; import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; @@ -40,6 +41,10 @@ app.use( previewRouter.routes(), previewRouter.allowedMethods(), ); +app.use( + playlistsRouter.routes(), + playlistsRouter.allowedMethods(), +); app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/model/db.ts b/api/model/db.ts index a04f186..3285612 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -1,5 +1,10 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; -import { Dump, type RichContent, type User } from "./interfaces.ts"; +import { + Dump, + type Playlist, + type RichContent, + type User, +} from "./interfaces.ts"; export const db = new DatabaseSync("api/sql/gerbeur.db"); db.exec("PRAGMA foreign_keys = ON;"); @@ -133,3 +138,37 @@ export function userApiToRow(user: User): UserRow { avatar_mime: user.avatarMime ?? null, }; } + +export interface PlaylistRow { + id: string; + user_id: string; + title: string; + description: string | null; + is_public: number; + created_at: string; + image_mime: string | null; + [key: string]: SQLOutputValue; +} + +export function isPlaylistRow( + obj: Record, +): obj is PlaylistRow { + return !!obj && typeof obj.id === "string" && + typeof obj.user_id === "string" && + typeof obj.title === "string" && + typeof obj.is_public === "number" && + typeof obj.created_at === "string"; +} + +export function playlistRowToApi(row: PlaylistRow): Playlist { + return { + id: row.id, + userId: row.user_id, + title: row.title, + description: row.description ?? undefined, + isPublic: Boolean(row.is_public), + createdAt: new Date(row.created_at), + imageMime: row.image_mime ?? undefined, + dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, + }; +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index ff50aaa..ef2c5ad 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -141,6 +141,74 @@ export class APIException extends Error { } } +/** + * Playlists + */ + +export interface Playlist { + id: string; + userId: string; + title: string; + description?: string; + isPublic: boolean; + createdAt: Date; + imageMime?: string; + dumpCount?: number; +} + +export interface PlaylistWithDumps extends Playlist { + dumps: Dump[]; +} + +export interface PlaylistMembership { + playlist: Playlist; + hasDump: boolean; +} + +export interface CreatePlaylistRequest { + title: string; + description?: string; + isPublic: boolean; +} + +export interface UpdatePlaylistRequest { + title?: string; + description?: string; + isPublic?: boolean; +} + +export interface ReorderPlaylistRequest { + dumpIds: string[]; +} + +export function isCreatePlaylistRequest( + obj: unknown, +): obj is CreatePlaylistRequest { + return !!obj && typeof obj === "object" && + "title" in obj && typeof obj.title === "string" && + (!("description" in obj) || typeof obj.description === "string" || + obj.description === null) && + "isPublic" in obj && typeof obj.isPublic === "boolean"; +} + +export function isUpdatePlaylistRequest( + obj: unknown, +): obj is UpdatePlaylistRequest { + return !!obj && typeof obj === "object" && + (!("title" in obj) || typeof obj.title === "string") && + (!("description" in obj) || typeof obj.description === "string" || + obj.description === null) && + (!("isPublic" in obj) || typeof obj.isPublic === "boolean"); +} + +export function isReorderPlaylistRequest( + obj: unknown, +): obj is ReorderPlaylistRequest { + return !!obj && typeof obj === "object" && + "dumpIds" in obj && Array.isArray(obj.dumpIds) && + (obj.dumpIds as unknown[]).every((id) => typeof id === "string"); +} + /** * Request DTOs */ diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts new file mode 100644 index 0000000..33df4d6 --- /dev/null +++ b/api/routes/playlists.ts @@ -0,0 +1,219 @@ +import { Router } from "@oak/oak"; +import { verifyJWT } from "../lib/jwt.ts"; +import { + APIErrorCode, + APIException, + isCreatePlaylistRequest, + isReorderPlaylistRequest, + isUpdatePlaylistRequest, +} from "../model/interfaces.ts"; +import { authMiddleware, type AuthState } from "../middleware/auth.ts"; +import { + addDumpToPlaylist, + createPlaylist, + deletePlaylist, + getPlaylist, + getPlaylistImageMime, + getPlaylistMembershipsForDump, + removeDumpFromPlaylist, + reorderPlaylist, + setPlaylistImage, + updatePlaylist, +} from "../services/playlist-service.ts"; + +const PLAYLIST_IMAGES_DIR = "api/uploads/playlist-images"; +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; +const ALLOWED_IMAGE_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + +function checkImageMagicBytes(data: Uint8Array, mime: string): boolean { + if (mime === "image/webp") { + return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && + data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && + data[10] === 0x42 && data[11] === 0x50; + } + const magic: Record = { + "image/jpeg": [0xFF, 0xD8, 0xFF], + "image/png": [0x89, 0x50, 0x4E, 0x47], + "image/gif": [0x47, 0x49, 0x46, 0x38], + }; + return (magic[mime] ?? []).every((b, i) => data[i] === b); +} + +const router = new Router({ prefix: "/api/playlists" }); + +// GET /api/playlists/by-dump/:dumpId/memberships — must be before /:playlistId +router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { + const { dumpId } = ctx.params; + const userId = ctx.state.user.userId; + const memberships = getPlaylistMembershipsForDump(dumpId, userId); + ctx.response.body = { success: true, data: memberships }; +}); + +// POST /api/playlists — create +router.post("/", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + if (!isCreatePlaylistRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", + ); + } + const playlist = createPlaylist(body, ctx.state.user.userId); + ctx.response.status = 201; + ctx.response.body = { success: true, data: playlist }; +}); + +// GET /api/playlists/:playlistId — optional auth +router.get("/:playlistId", async (ctx) => { + let requestingUserId: string | null = null; + const authHeader = ctx.request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const payload = await verifyJWT(authHeader.substring(7)); + if (payload) requestingUserId = payload.userId; + } + const playlist = getPlaylist(ctx.params.playlistId, requestingUserId); + ctx.response.body = { success: true, data: playlist }; +}); + +// PUT /api/playlists/:playlistId — update metadata +router.put("/:playlistId", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + if (!isUpdatePlaylistRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", + ); + } + const playlist = updatePlaylist( + ctx.params.playlistId, + body, + ctx.state.user.userId, + ); + ctx.response.body = { success: true, data: playlist }; +}); + +// DELETE /api/playlists/:playlistId +router.delete("/:playlistId", authMiddleware, (ctx) => { + deletePlaylist(ctx.params.playlistId, ctx.state.user.userId); + ctx.response.status = 204; +}); + +// POST /api/playlists/:playlistId/dumps/:dumpId — add dump +router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { + addDumpToPlaylist( + ctx.params.playlistId, + ctx.params.dumpId, + ctx.state.user.userId, + ); + ctx.response.status = 204; +}); + +// DELETE /api/playlists/:playlistId/dumps/:dumpId — remove dump +router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { + removeDumpFromPlaylist( + ctx.params.playlistId, + ctx.params.dumpId, + ctx.state.user.userId, + ); + ctx.response.status = 204; +}); + +// POST /api/playlists/:playlistId/image — upload playlist image +router.post("/:playlistId/image", authMiddleware, async (ctx) => { + const contentType = ctx.request.headers.get("content-type") ?? ""; + if (!contentType.includes("multipart/form-data")) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "Expected multipart/form-data", + ); + } + + const body = await ctx.request.body.formData(); + const file = body.get("file"); + + if (!(file instanceof File)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); + } + + if (!ALLOWED_IMAGE_MIMES.has(file.type)) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "Only JPEG, PNG, GIF, WebP images are allowed", + ); + } + + if (file.size > MAX_IMAGE_SIZE) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "File too large (max 5 MB)", + ); + } + + const data = new Uint8Array(await file.arrayBuffer()); + if (!checkImageMagicBytes(data, file.type)) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "File content does not match declared type", + ); + } + + await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); + await Deno.writeFile(`${PLAYLIST_IMAGES_DIR}/${ctx.params.playlistId}`, data); + const playlist = setPlaylistImage( + ctx.params.playlistId, + file.type, + ctx.state.user.userId, + ); + ctx.response.body = { success: true, data: playlist }; +}); + +// GET /api/playlists/:playlistId/image — serve playlist image +router.get("/:playlistId/image", async (ctx) => { + const imageMime = getPlaylistImageMime(ctx.params.playlistId); + if (!imageMime) { + ctx.response.status = 404; + return; + } + + let data: Uint8Array; + try { + data = await Deno.readFile( + `${PLAYLIST_IMAGES_DIR}/${ctx.params.playlistId}`, + ); + } catch { + ctx.response.status = 404; + return; + } + + ctx.response.headers.set("Content-Type", imageMime); + ctx.response.headers.set("Content-Disposition", "inline"); + ctx.response.headers.set("Cache-Control", "public, max-age=3600"); + ctx.response.body = data; +}); + +// PUT /api/playlists/:playlistId/order — reorder +router.put("/:playlistId/order", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + if (!isReorderPlaylistRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", + ); + } + reorderPlaylist(ctx.params.playlistId, body.dumpIds, ctx.state.user.userId); + ctx.response.body = { success: true, data: null }; +}); + +export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts index 0aa4a94..7971331 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -7,7 +7,7 @@ import { isRegisterUserRequest, } from "../model/interfaces.ts"; -import { createJWT, verifyPassword } from "../lib/jwt.ts"; +import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { createUser, @@ -18,6 +18,7 @@ import { getDumpsByUser, getVotedDumpsByUser, } from "../services/dump-service.ts"; +import { listPlaylistsByUser } from "../services/playlist-service.ts"; // Users router const router = new Router({ prefix: "/api/users" }); @@ -140,6 +141,19 @@ router.get("/by-id/:userId", (ctx) => { ctx.response.body = { success: true, data: publicUser }; }); +// Playlists by user (optional auth: include private only if requester === owner) +router.get("/:username/playlists", async (ctx) => { + const user = getUserByUsername(ctx.params.username); + let requestingUserId: string | null = null; + const authHeader = ctx.request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const payload = await verifyJWT(authHeader.substring(7)); + if (payload) requestingUserId = payload.userId; + } + const playlists = listPlaylistsByUser(user.id, requestingUserId); + ctx.response.body = { success: true, data: playlists }; +}); + // Public user profile by username (no passwordHash) router.get("/:username", (ctx) => { const user = getUserByUsername(ctx.params.username); diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts new file mode 100644 index 0000000..7e1e6f6 --- /dev/null +++ b/api/services/playlist-service.ts @@ -0,0 +1,306 @@ +import type { SQLOutputValue } from "node:sqlite"; +import { + APIErrorCode, + APIException, + type CreatePlaylistRequest, + type Dump, + type Playlist, + type PlaylistMembership, + type PlaylistWithDumps, + type UpdatePlaylistRequest, +} from "../model/interfaces.ts"; +import { + db, + dumpRowToApi, + isDumpRow, + isPlaylistRow, + playlistRowToApi, +} from "../model/db.ts"; +import { + broadcastPlaylistCreated, + broadcastPlaylistDeleted, + broadcastPlaylistDumpsUpdated, + broadcastPlaylistUpdated, +} from "./ws-service.ts"; + +const DUMP_SELECT_COLS = + "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count"; + +function getPlaylistById(playlistId: string): Playlist { + const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get( + playlistId, + ); + if (!row || !isPlaylistRow(row)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + } + return playlistRowToApi(row); +} + +export function createPlaylist( + req: CreatePlaylistRequest, + userId: string, +): Playlist { + const id = crypto.randomUUID(); + const createdAt = new Date(); + db.prepare( + `INSERT INTO playlists (id, user_id, title, description, is_public, created_at) + VALUES (?, ?, ?, ?, ?, ?);`, + ).run( + id, + userId, + req.title, + req.description ?? null, + req.isPublic ? 1 : 0, + createdAt.toISOString(), + ); + const playlist: Playlist = { + id, + userId, + title: req.title, + description: req.description, + isPublic: req.isPublic, + createdAt, + }; + broadcastPlaylistCreated(playlist); + return playlist; +} + +export function getPlaylist( + playlistId: string, + requestingUserId: string | null, +): PlaylistWithDumps { + const playlist = getPlaylistById(playlistId); + + if (!playlist.isPublic && requestingUserId !== playlist.userId) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + } + + const rows = db.prepare( + `SELECT ${DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")} + FROM dumps d + INNER JOIN playlist_dumps pd ON d.id = pd.dump_id + WHERE pd.playlist_id = ? + ORDER BY pd.position ASC;`, + ).all(playlistId); + + const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi); + + return { ...playlist, dumps }; +} + +export function listPlaylistsByUser( + userId: string, + requestingUserId: string | null, +): Playlist[] { + const isOwner = requestingUserId === userId; + const sql = isOwner + ? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count + FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC;` + : `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count + FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC;`; + + const rows = db.prepare(sql).all(userId); + return rows.filter(isPlaylistRow).map(playlistRowToApi); +} + +export function updatePlaylist( + playlistId: string, + req: UpdatePlaylistRequest, + requestingUserId: string, +): Playlist { + const playlist = getPlaylistById(playlistId); + + if (playlist.userId !== requestingUserId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + } + + const newTitle = req.title ?? playlist.title; + const newDescription = "description" in req + ? (req.description ?? null) + : (playlist.description ?? null); + const newIsPublic = req.isPublic !== undefined + ? req.isPublic + : playlist.isPublic; + + db.prepare( + `UPDATE playlists SET title = ?, description = ?, is_public = ? WHERE id = ?;`, + ).run(newTitle, newDescription, newIsPublic ? 1 : 0, playlistId); + + const updated: Playlist = { + ...playlist, + title: newTitle, + description: newDescription ?? undefined, + isPublic: newIsPublic, + }; + broadcastPlaylistUpdated(updated); + return updated; +} + +export function deletePlaylist( + playlistId: string, + requestingUserId: string, +): void { + const playlist = getPlaylistById(playlistId); + + if (playlist.userId !== requestingUserId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + } + + db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlistId); + broadcastPlaylistDeleted(playlistId, playlist.userId, playlist.isPublic); +} + +export function setPlaylistImage( + playlistId: string, + imageMime: string, + requestingUserId: string, +): Playlist { + const playlist = getPlaylistById(playlistId); + if (playlist.userId !== requestingUserId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + } + db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run( + imageMime, + playlistId, + ); + const updated = getPlaylistById(playlistId); + broadcastPlaylistUpdated(updated); + return updated; +} + +export function addDumpToPlaylist( + playlistId: string, + dumpId: string, + requestingUserId: string, +): void { + const playlist = getPlaylistById(playlistId); + + if (playlist.userId !== requestingUserId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + } + + const maxRow = db.prepare( + `SELECT MAX(position) as max_pos FROM playlist_dumps WHERE playlist_id = ?;`, + ).get(playlistId) as { max_pos: number | null } | undefined; + + const nextPos = (maxRow?.max_pos ?? -1) + 1; + const addedAt = new Date().toISOString(); + + try { + db.prepare( + `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) + VALUES (?, ?, ?, ?);`, + ).run(playlistId, dumpId, nextPos, addedAt); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("UNIQUE") || msg.includes("unique")) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 409, + "Dump already in playlist", + ); + } + throw err; + } + + const dumpIds = getCurrentDumpIds(playlistId); + broadcastPlaylistDumpsUpdated(playlist, dumpIds); +} + +export function removeDumpFromPlaylist( + playlistId: string, + dumpId: string, + requestingUserId: string, +): void { + const playlist = getPlaylistById(playlistId); + + if (playlist.userId !== requestingUserId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + } + + db.prepare( + `DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`, + ).run(playlistId, dumpId); + + const dumpIds = getCurrentDumpIds(playlistId); + broadcastPlaylistDumpsUpdated(playlist, dumpIds); +} + +export function reorderPlaylist( + playlistId: string, + dumpIds: string[], + requestingUserId: string, +): void { + const playlist = getPlaylistById(playlistId); + + if (playlist.userId !== requestingUserId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); + } + + const currentIds = getCurrentDumpIds(playlistId); + const currentSet = new Set(currentIds); + const newSet = new Set(dumpIds); + + if ( + currentSet.size !== newSet.size || + !currentIds.every((id) => newSet.has(id)) + ) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "dumpIds must match current playlist members exactly", + ); + } + + const update = db.prepare( + `UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`, + ); + for (let i = 0; i < dumpIds.length; i++) { + update.run(i, playlistId, dumpIds[i]); + } + + broadcastPlaylistDumpsUpdated(playlist, dumpIds); +} + +export function getPlaylistMembershipsForDump( + dumpId: string, + userId: string, +): PlaylistMembership[] { + const rows = db.prepare( + `SELECT p.*, pd.dump_id IS NOT NULL as has_dump + FROM playlists p + LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ? + WHERE p.user_id = ? + ORDER BY p.created_at DESC;`, + ).all(dumpId, userId) as Array>; + + return rows.map((row) => { + if (!isPlaylistRow(row)) { + throw new APIException( + APIErrorCode.SERVER_ERROR, + 500, + "Malformed playlist data", + ); + } + return { + playlist: playlistRowToApi(row), + hasDump: Boolean(row.has_dump), + }; + }); +} + +export function getPlaylistImageMime(playlistId: string): string | undefined { + const row = db.prepare(`SELECT image_mime FROM playlists WHERE id = ?;`).get( + playlistId, + ) as + | { image_mime: string | null } + | undefined; + return row?.image_mime ?? undefined; +} + +function getCurrentDumpIds(playlistId: string): string[] { + const rows = db.prepare( + `SELECT dump_id FROM playlist_dumps WHERE playlist_id = ? ORDER BY position ASC;`, + ).all(playlistId) as Array<{ dump_id: string }>; + return rows.map((r) => r.dump_id); +} diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 66f808e..9800e35 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -1,4 +1,4 @@ -import type { Dump, OnlineUser } from "../model/interfaces.ts"; +import type { Dump, OnlineUser, Playlist } from "../model/interfaces.ts"; export interface WsClient { socket: WebSocket; @@ -82,9 +82,50 @@ export function broadcastVoteUpdate( } } +function sendToPlaylistAudience( + playlist: Pick, + data: unknown, +): void { + for (const client of clients) { + if (playlist.isPublic || client.userId === playlist.userId) { + send(client.socket, data); + } + } +} + +export function broadcastPlaylistCreated(playlist: Playlist): void { + sendToPlaylistAudience(playlist, { type: "playlist_created", playlist }); +} + +export function broadcastPlaylistUpdated(playlist: Playlist): void { + sendToPlaylistAudience(playlist, { type: "playlist_updated", playlist }); +} + +export function broadcastPlaylistDeleted( + playlistId: string, + userId: string, + isPublic: boolean, +): void { + sendToPlaylistAudience({ isPublic, userId }, { + type: "playlist_deleted", + playlistId, + userId, + }); +} + +export function broadcastPlaylistDumpsUpdated( + playlist: Playlist, + dumpIds: string[], +): void { + sendToPlaylistAudience(playlist, { + type: "playlist_dumps_updated", + playlistId: playlist.id, + dumpIds, + }); +} + // Keepalive: ping all clients every 30s, remove non-responsive ones const PING_INTERVAL = 30_000; -const _PONG_TIMEOUT = 5_000; setInterval(() => { for (const client of clients) { diff --git a/api/sql/schema.sql b/api/sql/schema.sql index 716645c..438d711 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -11,7 +11,7 @@ CREATE TABLE dumps ( file_mime TEXT, file_size INTEGER, vote_count INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users(id) + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE users ( @@ -31,3 +31,26 @@ CREATE TABLE votes ( FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + +-- v2: playlists +CREATE TABLE playlists ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + is_public INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + image_mime TEXT +); + +CREATE TABLE playlist_dumps ( + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (playlist_id, dump_id) +); + +CREATE INDEX idx_dumps_user ON dumps(user_id); +CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); +CREATE INDEX idx_playlists_user ON playlists(user_id); diff --git a/src/App.css b/src/App.css index 893955a..69fd254 100644 --- a/src/App.css +++ b/src/App.css @@ -67,9 +67,6 @@ display: flex; align-items: center; gap: 0.75rem; - /* margin-top: 1rem; */ - /* padding-top: 1rem; */ - /* border-top: 1px solid rgba(128, 128, 128, 0.18); */ } .dump-actions a { @@ -89,7 +86,7 @@ margin-left: auto; } -/* Forms */ +/* ── Forms ── */ .auth-form { display: flex; flex-direction: column; @@ -159,6 +156,13 @@ color-mix(in srgb, var(--color-accent) 18%, transparent); } +.dump-form input:disabled, +.dump-form textarea:disabled, +.auth-form input:disabled, +.auth-form textarea:disabled { + opacity: 0.6; +} + /* ── New dump form ── */ .dump-create-wrapper { width: 100%; @@ -194,7 +198,7 @@ padding: 1.5rem; } -/* Mode toggle — segmented control */ +/* ── Mode toggle — segmented control ── */ .dump-mode-toggle { display: flex; background: var(--color-bg); @@ -633,14 +637,7 @@ opacity: 0.6; } -.dump-form input:disabled, -.dump-form textarea:disabled, -.auth-form input:disabled, -.auth-form textarea:disabled { - opacity: 0.6; -} - -/* Online users */ +/* ── Online users ── */ .online-users { display: flex; flex-wrap: wrap; @@ -667,7 +664,7 @@ flex-shrink: 0; } -/* Vote button */ +/* ── Vote button ── */ .vote-btn { display: inline-flex; align-items: center; @@ -718,7 +715,7 @@ cursor: default; } -/* Dump OP line */ +/* ── Dump OP line ── */ .dump-op { display: flex; align-items: center; @@ -737,7 +734,7 @@ color: var(--color-accent); } -/* Avatar edit overlay */ +/* ── Avatar edit overlay ── */ .profile-avatar-wrapper { position: relative; flex-shrink: 0; @@ -762,7 +759,7 @@ opacity: 1; } -/* Public profile page */ +/* ── Public profile page ── */ .profile-header { display: flex; align-items: center; @@ -788,12 +785,6 @@ margin-bottom: 2.5rem; } -.profile-section h2 { - margin-bottom: 0.75rem; - border-bottom: 2px solid var(--color-border); - padding-bottom: 0.4rem; -} - .profile-section ul { list-style: none; margin: 0; @@ -806,7 +797,26 @@ font-size: 0.9rem; } -/* Profile page */ +/* ── Profile section header (shared between bare h2 and wrapper div) ── */ +.profile-section > h2, +.profile-section-header { + margin-bottom: 0.75rem; + border-bottom: 2px solid var(--color-border); + padding-bottom: 0.4rem; +} + +.profile-section-title { + margin: 0; +} + +.profile-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +/* ── Profile (own) page ── */ .profile-avatar-section { display: flex; align-items: center; @@ -986,7 +996,6 @@ text-decoration: none; } -/* Center slot: hidden on narrow, shown on wide */ .app-header-center { display: none; align-items: center; @@ -1167,18 +1176,78 @@ opacity: 1; } -/* ── Delete button ── */ +/* ── Buttons ── */ +.btn-primary, +.btn-secondary, +.btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + font-family: inherit; + font-size: 0.9rem; + font-weight: 600; + padding: 0.4rem 1rem; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s, + box-shadow 0.15s, + transform 0.1s; +} + +.btn-primary { + background: var(--color-accent); + color: var(--color-on-accent); + border: none; + box-shadow: 0 2px 8px + color-mix(in srgb, var(--color-accent) 40%, transparent); +} + +.btn-primary:hover { + background: var(--color-accent-hover); + box-shadow: 0 4px 14px + color-mix(in srgb, var(--color-accent) 50%, transparent); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: none; +} + +.btn-secondary { + background: none; + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.btn-secondary:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + .btn-danger { background: var(--color-danger-bg); color: var(--color-on-accent); - border-color: transparent; - font-size: 0.85rem; - padding: 0.4em 0.9em; + border: none; } .btn-danger:hover { background: var(--color-danger-hover); - border-color: transparent; +} + +/* ── Ghost icon buttons (shared base) ── */ +.modal-close-btn, +.playlist-remove-btn, +.playlist-card-delete-btn { + background: none; + border: none; + cursor: pointer; + line-height: 1; + border-radius: 4px; + transition: opacity 0.15s, color 0.15s; } /* ── Index page ── */ @@ -1201,32 +1270,6 @@ } } -.btn-primary { - background: var(--color-accent); - color: var(--color-on-accent); - font-weight: 700; - font-size: 0.95rem; - padding: 0.45rem 1.1rem; - border: none; - border-radius: 8px; - cursor: pointer; - box-shadow: 0 2px 8px - color-mix(in srgb, var(--color-accent) 40%, transparent); - transition: background 0.15s, box-shadow 0.15s, transform 0.1s; -} - -.btn-primary:hover { - background: var(--color-accent-hover); - box-shadow: 0 4px 14px - color-mix(in srgb, var(--color-accent) 50%, transparent); - transform: translateY(-1px); -} - -.btn-primary:active { - transform: translateY(0); - box-shadow: none; -} - .index-presence-avatar { display: block; flex-shrink: 0; @@ -1279,7 +1322,7 @@ } } -/* ── Feed sort buttons (shared between header center and below-header) ── */ +/* ── Feed sort buttons ── */ .feed-sort { display: flex; align-items: center; @@ -1324,24 +1367,38 @@ align-self: center; } -.dump-card { - display: grid; - grid-template-rows: 1fr; +/* ── Shared card skin (dump-card + playlist-card) ── */ +.dump-card, +.playlist-card { border: 2px solid var(--color-border); border-radius: 10px; background: var(--color-surface); + min-width: 0; +} + +.dump-card:hover, +.playlist-card:hover { + border-color: var(--color-accent); +} + +/* Card-specific layout */ +.dump-card { + display: grid; + grid-template-rows: 1fr; transition: border-color 0.15s, grid-template-rows 0.32s ease, opacity 0.25s ease; - min-width: 0; } -.dump-card:hover { - border-color: var(--color-accent); +.playlist-card { + position: relative; + transition: border-color 0.15s; } -.dump-card-inner { +/* ── Shared card inner layout ── */ +.dump-card-inner, +.playlist-card-inner { overflow: hidden; display: flex; align-items: flex-start; @@ -1351,7 +1408,9 @@ cursor: pointer; } -.dump-card-preview { +/* ── Shared card preview thumbnail ── */ +.dump-card-preview, +.playlist-card-preview { flex-shrink: 0; width: 48px; height: 48px; @@ -1365,17 +1424,22 @@ transition: transform 0.18s ease, box-shadow 0.18s ease; } -.dump-card-inner:hover .dump-card-preview { +.dump-card-inner:hover .dump-card-preview, +.playlist-card-inner:hover .playlist-card-preview { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); } -.dump-card-preview-icon { +/* ── Shared card preview icon ── */ +.dump-card-preview-icon, +.playlist-card-icon { font-size: 1.4rem; opacity: 0.7; } -.dump-card-body { +/* ── Shared card body ── */ +.dump-card-body, +.playlist-card-body { flex: 1; min-width: 0; display: flex; @@ -1383,7 +1447,9 @@ gap: 0.2rem; } -.dump-card-title { +/* ── Shared card title link ── */ +.dump-card-title, +.playlist-card-title { font-weight: 600; font-size: 1rem; color: var(--color-text); @@ -1393,26 +1459,516 @@ line-height: 1.35; } -.dump-card-inner:hover .dump-card-title { +.dump-card-inner:hover .dump-card-title, +.playlist-card-inner:hover .playlist-card-title { color: var(--color-accent); } -.dump-card-comment { +/* ── Shared card description / comment ── */ +.dump-card-comment, +.playlist-card-description { margin: 0; font-size: 0.85rem; opacity: 0.65; word-break: break-word; line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.dump-card-comment { + -webkit-line-clamp: 3; +} + +.playlist-card-description { + -webkit-line-clamp: 2; +} + +/* ── Shared card meta row ── */ +.dump-card-date, +.playlist-card-meta { + font-size: 0.78rem; + opacity: 0.45; + margin-top: 0.2rem; } .dump-card-date { display: block; - font-size: 0.78rem; - opacity: 0.45; - margin-top: 0.2rem; +} + +.playlist-card-meta { + display: flex; + align-items: center; + gap: 0.6rem; } .dump-card-vote { flex-shrink: 0; align-self: center; } + +/* ── Playlist card image thumbnail ── */ +.playlist-card-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* ── Playlist badge ── */ +.playlist-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.45rem; + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 20%, transparent); + color: var(--color-accent); +} + +.playlist-badge--private { + background: color-mix(in srgb, var(--color-text-muted) 20%, transparent); + color: var(--color-text-muted); +} + +/* ── Playlist card dump count ── */ +.playlist-card-count { + opacity: 0.7; +} + +/* ── Playlist card delete button ── */ +.playlist-card-delete-btn { + position: absolute; + top: 0.4rem; + right: 0.4rem; + color: var(--color-text); + opacity: 0; + font-size: 0.8rem; + padding: 0.2rem 0.35rem; +} + +.playlist-card:hover .playlist-card-delete-btn { + opacity: 0.4; +} + +.playlist-card-delete-btn:hover { + opacity: 1 !important; + color: var(--color-danger); +} + +/* ── Modal (shared) ── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--color-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +/* Shared modal box surface */ +.modal-card, +.confirm-modal { + background: var(--color-surface); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + width: 100%; +} + +.modal-card { + max-width: 420px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.confirm-modal { + max-width: 340px; + padding: 1.5rem 1.25rem 1.25rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border-subtle); +} + +.modal-title { + font-weight: 700; + font-size: 1rem; +} + +.modal-close-btn { + color: var(--color-text); + font-size: 1rem; + opacity: 0.6; + padding: 0.25rem; +} + +.modal-close-btn:hover { + opacity: 1; +} + +.modal-body { + padding: 1rem 1.25rem; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.confirm-modal-message { + margin: 0; + font-size: 0.95rem; + line-height: 1.5; +} + +.confirm-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +/* ── Membership rows ── */ +.playlist-membership-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.playlist-membership-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; +} + +.playlist-membership-row:hover { + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + +.playlist-membership-row--active { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.playlist-membership-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.playlist-membership-check { + font-size: 1rem; + color: var(--color-accent); + width: 1.2rem; + text-align: center; + flex-shrink: 0; +} + +/* ── Inline new-playlist form ── */ +.modal-new-playlist-toggle { + background: none; + border: 1px dashed var(--color-border-subtle); + border-radius: 8px; + color: var(--color-accent); + cursor: pointer; + padding: 0.6rem 1rem; + font-size: 0.9rem; + text-align: left; + width: 100%; + transition: border-color 0.15s, background 0.15s; +} + +.modal-new-playlist-toggle:hover { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.modal-new-playlist-form { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.modal-new-playlist-form input, +.modal-new-playlist-form textarea { + padding: 0.6rem 0.9rem; + border-radius: 8px; + border: 2px solid var(--color-border-subtle); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.95rem; + font-family: inherit; + outline: none; + transition: border-color 0.2s; + resize: vertical; +} + +.modal-new-playlist-form input:focus, +.modal-new-playlist-form textarea:focus { + border-color: var(--color-accent); +} + +/* ── Playlist detail page ── */ +.playlist-detail-header { + background: var(--color-surface); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; +} + +.playlist-detail-header-top { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.playlist-detail-img { + flex-shrink: 0; + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.playlist-detail-title { + margin: 0 0 0.25rem; + font-size: 1.5rem; + font-weight: 700; + word-break: break-word; +} + +.playlist-detail-description { + margin: 0 0 0.5rem; + opacity: 0.75; + line-height: 1.5; +} + +.playlist-detail-meta { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.82rem; + opacity: 0.6; +} + +/* ── Playlist edit button ── */ +.playlist-edit-btn { + margin-left: auto; + flex-shrink: 0; + background: none; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + cursor: pointer; + color: var(--color-text); + font-size: 0.85rem; + padding: 0.25rem 0.7rem; + opacity: 0.7; + transition: opacity 0.15s, border-color 0.15s, color 0.15s; +} + +.playlist-edit-btn:hover { + opacity: 1; + border-color: var(--color-accent); + color: var(--color-accent); +} + +/* ── Playlist edit form ── */ +.playlist-edit-form { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border-subtle); +} + +.playlist-edit-fields { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.playlist-edit-input, +.playlist-edit-textarea { + background: var(--color-bg); + border: 2px solid var(--color-border); + border-radius: 8px; + color: var(--color-text); + font-size: 0.95rem; + padding: 0.5rem 0.75rem; + font-family: inherit; + resize: vertical; + width: 100%; + box-sizing: border-box; + outline: none; + transition: border-color 0.2s; +} + +.playlist-edit-input:focus, +.playlist-edit-textarea:focus { + border-color: var(--color-accent); +} + +.playlist-edit-toggle { + align-self: flex-start; +} + +.playlist-edit-image-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.playlist-edit-img-preview { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--color-border); + display: block; +} + +.playlist-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +/* ── Playlist dump list ── */ +.playlist-dump-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.playlist-dump-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 0.25rem; + transition: background 0.15s; + border-radius: 12px; +} + +.playlist-dump-item--drag-over { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + outline: 2px dashed var(--color-accent); +} + +.playlist-dump-item .dump-card { + flex: 1; + min-width: 0; +} + +/* ── Playlist dump item action buttons ── */ +.playlist-remove-btn { + flex-shrink: 0; + color: var(--color-text); + opacity: 0.4; + font-size: 0.85rem; + padding: 0.25rem 0.35rem; +} + +.playlist-remove-btn:hover { + opacity: 1; + color: var(--color-danger); +} + +.playlist-cancel-btn { + flex-shrink: 0; + background: none; + border: 1px solid var(--color-border-subtle); + cursor: pointer; + color: var(--color-text); + opacity: 0.7; + font-size: 0.78rem; + padding: 0.2rem 0.5rem; + border-radius: 4px; + transition: opacity 0.15s, border-color 0.15s, color 0.15s; + white-space: nowrap; +} + +.playlist-cancel-btn:hover { + opacity: 1; + border-color: var(--color-accent); + color: var(--color-accent); +} + +.drag-handle { + cursor: grab; + opacity: 0.35; + font-size: 1.1rem; + flex-shrink: 0; + user-select: none; + padding: 0 0.25rem; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* ── Add to playlist button (dump detail) ── */ +.btn-add-playlist { + background: none; + border: none; + cursor: pointer; + color: var(--color-text); + font-size: 0.9rem; + opacity: 0.7; + padding: 0; + transition: opacity 0.15s, color 0.15s; +} + +.btn-add-playlist:hover { + opacity: 1; + color: var(--color-accent); +} + +/* ── My Playlists page ── */ +.my-playlists-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.my-playlists-title { + margin: 0; + font-size: 1.5rem; + font-weight: 700; +} + +/* ── New playlist toggle (profile page header) ── */ +.new-playlist-toggle { + background: none; + border: none; + cursor: pointer; + color: var(--color-accent); + font-size: 0.85rem; + padding: 0; + transition: opacity 0.15s; + white-space: nowrap; +} + +.new-playlist-toggle:hover { + opacity: 0.75; +} diff --git a/src/App.tsx b/src/App.tsx index 2e69d49..c4a036b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import { DumpEdit } from "./pages/DumpEdit.tsx"; import { UserLogin } from "./pages/UserLogin.tsx"; import { UserPublicProfile } from "./pages/UserPublicProfile.tsx"; import { UserRegister } from "./pages/UserRegister.tsx"; +import { PlaylistDetail } from "./pages/PlaylistDetail.tsx"; +import { MyPlaylists } from "./pages/MyPlaylists.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { WSProvider } from "./contexts/WSProvider.tsx"; @@ -57,6 +59,15 @@ function AppRoutes() { } /> } /> + + + + } + /> + } /> diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/AddToPlaylistModal.tsx new file mode 100644 index 0000000..1259e72 --- /dev/null +++ b/src/components/AddToPlaylistModal.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { API_URL } from "../config/api.ts"; +import { useAuth } from "../hooks/useAuth.ts"; +import type { + CreatePlaylistRequest, + PlaylistMembership, + RawPlaylist, + RawPlaylistMembership, +} from "../model.ts"; +import { + deserializePlaylist, + deserializePlaylistMembership, +} from "../model.ts"; + +interface AddToPlaylistModalProps { + dumpId: string; + onClose: () => void; +} + +export function AddToPlaylistModal( + { dumpId, onClose }: AddToPlaylistModalProps, +) { + const { authFetch } = useAuth(); + const [memberships, setMemberships] = useState([]); + const [loading, setLoading] = useState(true); + const [showNewForm, setShowNewForm] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newDescription, setNewDescription] = useState(""); + const [newIsPublic, setNewIsPublic] = useState(true); + const [creating, setCreating] = useState(false); + const backdropRef = useRef(null); + + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + }, []); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + useEffect(() => { + authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`) + .then((r) => r.json()) + .then((body) => { + if (body.success) { + setMemberships( + (body.data as RawPlaylistMembership[]).map( + deserializePlaylistMembership, + ), + ); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [dumpId]); + + const toggleMembership = async (membership: PlaylistMembership) => { + const { playlist, hasDump } = membership; + if (hasDump) { + await authFetch( + `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, + { method: "DELETE" }, + ); + setMemberships((prev) => + prev.map((m) => + m.playlist.id === playlist.id ? { ...m, hasDump: false } : m + ) + ); + } else { + await authFetch( + `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, + { method: "POST" }, + ); + setMemberships((prev) => + prev.map((m) => + m.playlist.id === playlist.id ? { ...m, hasDump: true } : m + ) + ); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newTitle.trim()) return; + setCreating(true); + try { + const req: CreatePlaylistRequest = { + title: newTitle.trim(), + description: newDescription.trim() || undefined, + isPublic: newIsPublic, + }; + const res = await authFetch(`${API_URL}/api/playlists`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + const body = await res.json(); + if (!body.success) return; + const playlist = deserializePlaylist(body.data as RawPlaylist); + + await authFetch( + `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, + { + method: "POST", + }, + ); + + setMemberships((prev) => [{ playlist, hasDump: true }, ...prev]); + setNewTitle(""); + setNewDescription(""); + setShowNewForm(false); + } finally { + setCreating(false); + } + }; + + return createPortal( +
{ + if (e.target === backdropRef.current) onClose(); + }} + > +
+
+ Add to playlist + +
+ +
+ {loading + ?

Loading…

+ : memberships.length === 0 && !showNewForm + ?

No playlists yet.

+ : ( +
    + {memberships.map((m) => ( +
  • toggleMembership(m)} + > + + {m.hasDump ? "✓" : "○"} + + + {m.playlist.title} + + {!m.playlist.isPublic && ( + + private + + )} +
  • + ))} +
+ )} + + {showNewForm + ? ( +
+ setNewTitle(e.target.value)} + autoFocus + required + /> +