v1 feature: added playlists

This commit is contained in:
khannurien
2026-03-16 16:52:53 +00:00
parent 867e64cb5b
commit be426eb150
25 changed files with 2958 additions and 101 deletions

View File

@@ -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`,

View File

@@ -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<string, SQLOutputValue>,
): 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,
};
}

View File

@@ -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
*/

219
api/routes/playlists.ts Normal file
View File

@@ -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<string, number[]> = {
"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<AuthState>({ 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;

View File

@@ -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);

View File

@@ -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<Record<string, SQLOutputValue>>;
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);
}

View File

@@ -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<Playlist, "isPublic" | "userId">,
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) {

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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() {
}
/>
<Route path="/users/:username" element={<UserPublicProfile />} />
<Route
path="/playlists"
element={
<RestrictedLoggedIn>
<MyPlaylists />
</RestrictedLoggedIn>
}
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
</Routes>
</BrowserRouter>
</WSProvider>

View File

@@ -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<PlaylistMembership[]>([]);
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<HTMLDivElement>(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(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">Add to playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{loading
? <p className="page-loading">Loading</p>
: memberships.length === 0 && !showNewForm
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
<li
key={m.playlist.id}
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
onClick={() => toggleMembership(m)}
>
<span className="playlist-membership-check">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">
{m.playlist.title}
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
</span>
)}
</li>
))}
</ul>
)}
{showNewForm
? (
<form className="modal-new-playlist-form" onSubmit={handleCreate}>
<input
type="text"
placeholder="Title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
rows={2}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={newIsPublic ? "active" : ""}
onClick={() => setNewIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!newIsPublic ? "active" : ""}
onClick={() => setNewIsPublic(false)}
>
Private
</button>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="submit" disabled={creating}>
{creating ? "Creating…" : "Create & Add"}
</button>
<button
type="button"
onClick={() => setShowNewForm(false)}
>
Cancel
</button>
</div>
</form>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
</button>
)}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -40,6 +40,9 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
>
{user.username}
</Link>
<Link to="/playlists" className="app-header-user">
Playlists
</Link>
<button
type="button"
className="btn-primary"

View File

@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
interface ConfirmModalProps {
message: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal(
{ message, confirmLabel = "Delete", onConfirm, onCancel }: ConfirmModalProps,
) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onCancel]);
return createPortal(
<div className="modal-backdrop" onClick={onCancel}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p>
<div className="confirm-modal-actions">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -49,7 +49,7 @@ export default function FilePreview(
}
if (mime.startsWith("audio/")) {
return <MediaPlayer src={fileUrl} kind="audio" />;
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
}
if (mime === "application/pdf") {

View File

@@ -74,13 +74,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
};
}, [dragging]);
// Stop any in-flight load on unmount.
// Stop playback on unmount; the browser aborts network requests when the element leaves the DOM.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
a.removeAttribute("src");
a.load();
};
}, []);

View File

@@ -0,0 +1,161 @@
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 { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
interface NewPlaylistFormProps {
onCreated: (playlist: Playlist) => void;
toggleLabel?: string;
toggleClassName?: string;
}
export function NewPlaylistForm(
{
onCreated,
toggleLabel = "+ New playlist",
toggleClassName = "new-playlist-toggle",
}: NewPlaylistFormProps,
) {
const { authFetch } = useAuth();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open]);
const close = () => {
setOpen(false);
setTitle("");
setDescription("");
setIsPublic(true);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description: description.trim() || undefined,
isPublic,
}),
});
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? "Failed to create playlist");
return;
}
onCreated(deserializePlaylist(body.data as RawPlaylist));
close();
} catch {
setError("Failed to create playlist");
} finally {
setSubmitting(false);
}
};
return (
<>
<button
type="button"
className={toggleClassName}
onClick={() => setOpen(true)}
>
{toggleLabel}
</button>
{open && createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) close();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">New playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={close}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={isPublic ? "active" : ""}
onClick={() => setIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
Private
</button>
</div>
{error && <p className="form-error">{error}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</button>
<button type="button" onClick={close}>Cancel</button>
</div>
</form>
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View File

@@ -0,0 +1,79 @@
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
interface PlaylistCardProps {
playlist: Playlist;
onDelete?: () => void;
}
export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
const navigate = useNavigate();
return (
<li className="playlist-card">
<div
className="playlist-card-inner"
onClick={() => navigate(`/playlists/${playlist.id}`)}
>
<div className="playlist-card-preview">
{playlist.imageMime
? (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-card-img"
/>
)
: <span className="playlist-card-icon">📋</span>}
</div>
<div className="playlist-card-body">
<Link
to={`/playlists/${playlist.id}`}
className="playlist-card-title"
onClick={(e) => e.stopPropagation()}
>
{playlist.title}
</Link>
{playlist.description && (
<p className="playlist-card-description">{playlist.description}</p>
)}
<div className="playlist-card-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
{playlist.dumpCount !== undefined && (
<span className="playlist-card-count">
{playlist.dumpCount}{" "}
{playlist.dumpCount === 1 ? "dump" : "dumps"}
</span>
)}
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</div>
</div>
</div>
{onDelete && (
<button
type="button"
className="playlist-card-delete-btn"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label="Delete playlist"
>
</button>
)}
</li>
);
}

View File

@@ -1,5 +1,5 @@
import { createContext } from "react";
import type { Dump, OnlineUser } from "../model.ts";
import type { Dump, OnlineUser, Playlist } from "../model.ts";
export interface VoteEvent {
dumpId: string;
@@ -7,6 +7,14 @@ export interface VoteEvent {
action: "cast" | "remove";
}
export interface PlaylistEvent {
type: "created" | "updated" | "deleted" | "dumps_updated";
playlistId: string;
playlist?: Playlist;
userId?: string;
dumpIds?: string[];
}
export interface WSContextValue {
onlineUsers: OnlineUser[];
voteCounts: Record<string, number>;
@@ -14,6 +22,8 @@ export interface WSContextValue {
recentDumps: Dump[];
deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null;
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>;
castVote: (dumpId: string) => void;
removeVote: (dumpId: string) => void;
}
@@ -25,6 +35,8 @@ export const WSContext = createContext<WSContextValue>({
recentDumps: [],
deletedDumpIds: new Set(),
lastVoteEvent: null,
lastPlaylistEvent: null,
deletedPlaylistIds: new Set(),
castVote: () => {},
removeVote: () => {},
});

View File

@@ -6,10 +6,15 @@ import {
useRef,
useState,
} from "react";
import { type VoteEvent, WSContext, type WSContextValue } from "./WSContext.ts";
import {
type PlaylistEvent,
type VoteEvent,
WSContext,
type WSContextValue,
} from "./WSContext.ts";
import { WS_URL } from "../config/api.ts";
import type { Dump, OnlineUser, RawDump } from "../model.ts";
import { deserializeDump } from "../model.ts";
import type { Dump, OnlineUser, RawDump, RawPlaylist } from "../model.ts";
import { deserializeDump, deserializePlaylist } from "../model.ts";
interface WSProviderProps {
children: ReactNode;
@@ -26,6 +31,12 @@ export function WSProvider({ children, token }: WSProviderProps) {
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
PlaylistEvent | null
>(null);
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
new Set(),
);
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
@@ -132,6 +143,40 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
}
case "playlist_created":
case "playlist_updated": {
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
setLastPlaylistEvent({
type: msg.type === "playlist_created" ? "created" : "updated",
playlistId: playlist.id,
playlist,
});
break;
}
case "playlist_deleted": {
const { playlistId, userId } = msg as {
playlistId: string;
userId: string;
};
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
break;
}
case "playlist_dumps_updated": {
const { playlistId, dumpIds } = msg as {
playlistId: string;
dumpIds: string[];
};
setLastPlaylistEvent({
type: "dumps_updated",
playlistId,
dumpIds,
});
break;
}
case "error":
// On error, revert any pending optimistic update for the affected dump
// (the revert timeout will handle it)
@@ -231,6 +276,8 @@ export function WSProvider({ children, token }: WSProviderProps) {
recentDumps,
deletedDumpIds,
lastVoteEvent,
lastPlaylistEvent,
deletedPlaylistIds,
castVote,
removeVote,
};

View File

@@ -95,6 +95,71 @@ export interface AuthResponse {
user: User;
}
/**
* 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 type RawPlaylist = WithStringDate<Playlist>;
export type RawPlaylistWithDumps =
& Omit<PlaylistWithDumps, "createdAt" | "dumps">
& {
createdAt: string;
dumps: RawDump[];
};
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
export function deserializePlaylist(raw: RawPlaylist): Playlist {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
export function deserializePlaylistWithDumps(
raw: RawPlaylistWithDumps,
): PlaylistWithDumps {
return {
...raw,
createdAt: new Date(raw.createdAt),
dumps: raw.dumps.map(deserializeDump),
};
}
export function deserializePlaylistMembership(
raw: RawPlaylistMembership,
): PlaylistMembership {
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
}
export interface CreatePlaylistRequest {
title: string;
description?: string;
isPublic: boolean;
}
export interface UpdatePlaylistRequest {
title?: string;
description?: string;
isPublic?: boolean;
}
/**
* API
*/

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
@@ -31,6 +32,7 @@ export function Dump() {
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
);
const [op, setOp] = useState<PublicUser | null>(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const { user } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
@@ -178,8 +180,23 @@ export function Dump() {
<div className="dump-actions">
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
<Link to="/"> Back to all dumps</Link>
{user && (
<button
type="button"
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
+ Playlist
</button>
)}
</div>
</div>
{playlistModalOpen && (
<AddToPlaylistModal
dumpId={dump.id}
onClose={() => setPlaylistModalOpen(false)}
/>
)}
</PageShell>
);
}

View File

@@ -8,6 +8,7 @@ import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
@@ -25,6 +26,7 @@ export function DumpEdit() {
const [url, setUrl] = useState("");
const [comment, setComment] = useState("");
const [newFile, setNewFile] = useState<File | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
if (!selectedDump) return;
@@ -229,7 +231,11 @@ export function DumpEdit() {
</div>
<div className="form-actions">
<button type="button" onClick={handleDelete} className="btn-danger">
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="btn-danger"
>
Delete dump
</button>
<div className="form-actions-right">
@@ -241,6 +247,14 @@ export function DumpEdit() {
</div>
</form>
</div>
{confirmDelete && (
<ConfirmModal
message="Delete this dump? This cannot be undone."
confirmLabel="Delete dump"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
)}
</PageShell>
);
}

149
src/pages/MyPlaylists.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; playlists: Playlist[] };
export function MyPlaylists() {
const { user, authFetch, token } = useAuth();
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
const [state, setState] = useState<State>({ status: "loading" });
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
fetch(`${API_URL}/api/users/${user.username}/playlists`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
setState({
status: "loaded",
playlists: (body.data as RawPlaylist[]).map(deserializePlaylist),
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error
? err.message
: "Failed to load playlists",
})
);
}, [user?.username]);
// Real-time WS updates
useEffect(() => {
if (!lastPlaylistEvent || !user) return;
const ev = lastPlaylistEvent;
if (ev.type === "created" && ev.playlist?.userId === user.id) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
});
} else if (ev.type === "updated" && ev.playlist?.userId === user.id) {
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: s.playlists.map((p) =>
p.id === ev.playlist!.id ? ev.playlist! : p
),
}
: s
);
} else if (ev.type === "deleted") {
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
}
: s
);
}
}, [lastPlaylistEvent, user]);
useEffect(() => {
if (!deletedPlaylistIds.size) return;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: s.playlists.filter((p) => !deletedPlaylistIds.has(p.id)),
}
: s
);
}, [deletedPlaylistIds]);
const handleDelete = async (playlistId: string) => {
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
setState((s) =>
s.status === "loaded"
? { ...s, playlists: s.playlists.filter((p) => p.id !== playlistId) }
: s
);
};
return (
<PageShell>
<div className="my-playlists-header">
<h1 className="my-playlists-title">My Playlists</h1>
<NewPlaylistForm
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((pl) => pl.id === p.id)) return s;
return { ...s, playlists: [p, ...s.playlists] };
})}
/>
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "error" && <p className="form-error">{state.error}</p>}
{state.status === "loaded" && (
state.playlists.length === 0
? <p className="empty-state">No playlists yet. Create one!</p>
: (
<ul className="dump-feed">
{state.playlists.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
onDelete={() => setConfirmDeleteId(p.id)}
/>
))}
</ul>
)
)}
{confirmDeleteId && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
onConfirm={() => {
handleDelete(confirmDeleteId);
setConfirmDeleteId(null);
}}
onCancel={() => setConfirmDeleteId(null)}
/>
)}
</PageShell>
);
}

View File

@@ -0,0 +1,630 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
import { deserializePlaylistWithDumps } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
type LoadState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; playlist: PlaylistWithDumps };
export function PlaylistDetail() {
const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate();
const { user, authFetch, token } = useAuth();
const {
voteCounts,
myVotes,
castVote,
removeVote,
deletedDumpIds,
lastPlaylistEvent,
} = useWS();
const [state, setState] = useState<LoadState>({ status: "loading" });
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
// fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const [dragSrcIndex, setDragSrcIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editIsPublic, setEditIsPublic] = useState(true);
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
const fetchPlaylist = () => {
if (!playlistId) return;
setState({ status: "loading" });
fetch(`${API_URL}/api/playlists/${playlistId}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => {
if (!r.ok) {
throw new Error(
r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
);
}
return r.json();
})
.then((body) => {
if (!body.success) throw new Error("Failed to load playlist");
const pl = deserializePlaylistWithDumps(
body.data as RawPlaylistWithDumps,
);
setState({ status: "loaded", playlist: pl });
const ids = new Set(pl.dumps.map((d) => d.id));
setActiveDumpIds(ids);
prevActiveDumpIdsRef.current = ids;
setFading({});
cancels.current.forEach((c) => c());
cancels.current.clear();
})
.catch((err) => {
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
});
});
};
useEffect(() => {
fetchPlaylist();
}, [playlistId]);
// Start the cooldown→dismissing→gone sequence for a dump being removed.
// After the sequence completes, the dump is removed from state.playlist.dumps.
function startFade(id: string) {
if (cancels.current.has(id)) return; // already fading
let dead = false;
let kill = () => {};
kill = () => {
dead = true;
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (dead) return;
// Remove from the playlist.dumps array now that animation is done
setState((prev) => {
if (prev.status !== "loaded") return prev;
return {
...prev,
playlist: {
...prev.playlist,
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
},
};
});
kill();
}, 350);
kill = () => {
dead = true;
clearTimeout(t2);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}, 2000);
kill = () => {
dead = true;
clearTimeout(t1);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}
// WS: playlist metadata updated or deleted
useEffect(() => {
if (!lastPlaylistEvent || !playlistId) return;
const ev = lastPlaylistEvent;
if (ev.playlistId !== playlistId) return;
if (ev.type === "dumps_updated" && ev.dumpIds) {
const newIds = new Set(ev.dumpIds);
const prev = prevActiveDumpIdsRef.current ?? new Set<string>();
// Removed: were active, not in new set → fade out
for (const id of prev) {
if (!newIds.has(id)) {
setActiveDumpIds((s) => {
const n = new Set(s);
n.delete(id);
return n;
});
startFade(id);
}
}
// Re-added while fading → cancel fade, restore to active
for (const id of newIds) {
if (!prev.has(id)) {
if (cancels.current.has(id)) {
cancels.current.get(id)!();
}
// If this is a brand-new dump we haven't seen, re-fetch
setState((s) => {
if (s.status !== "loaded") return s;
const known = s.playlist.dumps.some((d) => d.id === id);
if (!known) {
// Trigger a re-fetch asynchronously
setTimeout(fetchPlaylist, 0);
}
return s;
});
setActiveDumpIds((s) => new Set([...s, id]));
}
}
// Reorder active dumps in state to match the new dumpIds order
setState((prev) => {
if (prev.status !== "loaded") return prev;
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
const reordered = ev.dumpIds!
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!);
// Keep fading dumps appended at the end so they stay visible
const fadingDumps = prev.playlist.dumps.filter(
(d) => !newIds.has(d.id) && dumpMap.has(d.id),
);
return {
...prev,
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
};
});
prevActiveDumpIdsRef.current = newIds;
} else if (ev.type === "updated" && ev.playlist) {
setState((prev) => {
if (prev.status !== "loaded") return prev;
return {
...prev,
playlist: {
...prev.playlist,
title: ev.playlist!.title,
description: ev.playlist!.description,
isPublic: ev.playlist!.isPublic,
imageMime: ev.playlist!.imageMime,
},
};
});
} else if (ev.type === "deleted") {
navigate("/");
}
}, [lastPlaylistEvent, playlistId]);
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
useEffect(() => {
if (deletedDumpIds.size === 0) return;
setState((prev) => {
if (prev.status !== "loaded") return prev;
const filtered = prev.playlist.dumps.filter((d) =>
!deletedDumpIds.has(d.id)
);
if (filtered.length === prev.playlist.dumps.length) return prev;
return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
});
setActiveDumpIds((prev) => {
const n = new Set(prev);
for (const id of deletedDumpIds) n.delete(id);
return n;
});
}, [deletedDumpIds]);
const handleDragStart = (index: number) => setDragSrcIndex(index);
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (dragSrcIndex === null || dragSrcIndex === index) return;
setState((prev) => {
if (prev.status !== "loaded") return prev;
// Only reorder among active dumps
const activeDumps = prev.playlist.dumps.filter((d) =>
activeDumpIds.has(d.id)
);
const fadingDumps = prev.playlist.dumps.filter((d) =>
!activeDumpIds.has(d.id)
);
const reordered = [...activeDumps];
const [moved] = reordered.splice(dragSrcIndex, 1);
reordered.splice(index, 0, moved);
setDragSrcIndex(index);
setDragOverIndex(index);
return {
...prev,
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
};
});
};
const handleDragEnd = async () => {
if (state.status !== "loaded" || !playlistId) return;
setDragSrcIndex(null);
setDragOverIndex(null);
const activeDumps = state.playlist.dumps.filter((d) =>
activeDumpIds.has(d.id)
);
try {
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dumpIds: activeDumps.map((d) => d.id) }),
});
} catch {
fetchPlaylist();
}
};
const handleRemoveDump = (dumpId: string) => {
if (!playlistId) return;
// Fire-and-forget the API call; animate immediately
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "DELETE",
}).catch(() => {
// On failure, cancel the fade and restore the item
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
});
setActiveDumpIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
startFade(dumpId);
};
const handleCancelRemove = (dumpId: string) => {
if (!playlistId) return;
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// Re-add server-side since DELETE already fired
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "POST",
}).catch(() => {});
};
const openEdit = () => {
if (state.status !== "loaded") return;
setEditTitle(state.playlist.title);
setEditDescription(state.playlist.description ?? "");
setEditIsPublic(state.playlist.isPublic);
setImageFile(null);
setImagePreview(null);
setEditError(null);
setEditOpen(true);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
const url = URL.createObjectURL(file);
setImagePreview(url);
};
const handleEditSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!playlistId || state.status !== "loaded") return;
setEditSaving(true);
setEditError(null);
try {
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: editTitle,
description: editDescription || undefined,
isPublic: editIsPublic,
}),
});
if (imageFile) {
const fd = new FormData();
fd.append("file", imageFile);
await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, {
method: "POST",
body: fd,
});
}
setEditOpen(false);
fetchPlaylist();
} catch (err) {
setEditError(err instanceof Error ? err.message : "Save failed");
} finally {
setEditSaving(false);
}
};
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading playlist</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<button
className="logout-btn"
type="button"
onClick={() => navigate("/")}
>
Back
</button>
}
/>
);
}
const { playlist } = state;
const isOwner = !!user && user.id === playlist.userId;
// Active dumps in playlist order; fading dumps appended so they stay visible
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
const visibleDumps = playlist.dumps.filter((d) =>
activeDumpIds.has(d.id) || d.id in fading
);
return (
<PageShell>
<div className="playlist-detail-header">
<div className="playlist-detail-header-top">
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div>
<h1 className="playlist-detail-title">{playlist.title}</h1>
{playlist.description && (
<p className="playlist-detail-description">
{playlist.description}
</p>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</div>
</div>
{isOwner && !editOpen && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
{isOwner && editOpen && (
<form className="playlist-edit-form" onSubmit={handleEditSave}>
<div className="playlist-edit-fields">
<input
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) =>
setEditTitle(e.target.value)}
placeholder="Title"
required
/>
<textarea
className="playlist-edit-textarea"
value={editDescription}
onChange={(e) =>
setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
/>
<div className="dump-mode-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
</button>
</div>
<div className="playlist-edit-image-row">
{imagePreview
? (
<img
src={imagePreview}
alt="Preview"
className="playlist-edit-img-preview"
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt="Current"
className="playlist-edit-img-preview"
/>
)}
<button
type="button"
className="btn-secondary"
onClick={() => imageInputRef.current?.click()}
>
{playlist.imageMime || imageFile
? "Change image"
: "Add image"}
</button>
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: "none" }}
onChange={handleImageChange}
/>
</div>
</div>
{editError && <p className="form-error">{editError}</p>}
<div className="playlist-edit-actions">
<button
type="submit"
className="btn-primary"
disabled={editSaving}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
</div>
</form>
)}
</div>
{visibleDumps.length === 0
? <p className="empty-state">No dumps in this playlist yet.</p>
: (
<div className="playlist-dump-list">
{visibleDumps.map((dump) => {
const isActive = activeDumpIds.has(dump.id);
const phase = fading[dump.id];
// drag index is within the active-only list
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
const cardCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<div
key={dump.id}
className={`playlist-dump-item${
activeIndex === dragOverIndex && isActive
? " playlist-dump-item--drag-over"
: ""
}`}
draggable={isOwner && isActive}
onDragStart={isOwner && isActive
? () => handleDragStart(activeIndex)
: undefined}
onDragOver={isOwner && isActive
? (e) => handleDragOver(e, activeIndex)
: undefined}
onDragEnd={isOwner && isActive ? handleDragEnd : undefined}
>
{isOwner && isActive && (
<span className="drag-handle" aria-hidden></span>
)}
<DumpCard
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
className={cardCls}
/>
{isOwner && (isActive
? (
<button
type="button"
className="playlist-remove-btn"
onClick={() => handleRemoveDump(dump.id)}
aria-label="Remove from playlist"
>
</button>
)
: phase === "cooldown" && (
<button
type="button"
className="playlist-cancel-btn"
onClick={() => handleCancelRemove(dump.id)}
aria-label="Cancel removal"
>
Undo
</button>
))}
</div>
);
})}
</div>
)}
</PageShell>
);
}

View File

@@ -12,21 +12,39 @@ import {
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
type ProfileState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; user: PublicUser; dumps: Dump[]; votes: Dump[] };
| {
status: "loaded";
user: PublicUser;
dumps: Dump[];
votes: Dump[];
playlists: Playlist[];
};
export function UserPublicProfile() {
const { username } = useParams();
const navigate = useNavigate();
const { user: me, authFetch, login, logout } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
const { user: me, authFetch, login, logout, token } = useAuth();
const {
voteCounts,
myVotes,
lastVoteEvent,
castVote,
removeVote,
lastPlaylistEvent,
deletedPlaylistIds,
} = useWS();
const [state, setState] = useState<ProfileState>({ status: "loading" });
const [uploading, setUploading] = useState(false);
@@ -45,10 +63,13 @@ export function UserPublicProfile() {
(async () => {
try {
const [userRes, dumpsRes, votesRes] = await Promise.all([
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}/dumps`),
fetch(`${API_URL}/api/users/${username}/votes`),
fetch(`${API_URL}/api/users/${username}/playlists`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}),
]);
if (!userRes.ok) {
@@ -59,20 +80,26 @@ export function UserPublicProfile() {
);
}
const [userBody, dumpsBody, votesBody] = await Promise.all([
const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise
.all([
userRes.json(),
dumpsRes.json(),
votesRes.json(),
playlistsRes.json(),
]);
const votes: Dump[] = votesBody.success
? votesBody.data.map(deserializeDump)
: [];
const playlists: Playlist[] = playlistsBody.success
? (playlistsBody.data as RawPlaylist[]).map(deserializePlaylist)
: [];
setState({
status: "loaded",
user: deserializePublicUser(userBody.data),
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
votes,
playlists,
});
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
} catch (err) {
@@ -148,6 +175,56 @@ export function UserPublicProfile() {
}
}, [lastVoteEvent, me, profileUserId]);
// Real-time playlist updates
useEffect(() => {
if (!lastPlaylistEvent || state.status !== "loaded") return;
const profileUserId = state.user.id;
const isOwnProfile = me?.id === profileUserId;
const ev = lastPlaylistEvent;
if (
ev.type === "created" && ev.playlist &&
ev.playlist.userId === profileUserId
) {
if (ev.playlist.isPublic || isOwnProfile) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
});
}
} else if (
ev.type === "updated" && ev.playlist &&
ev.playlist.userId === profileUserId
) {
setState((s) => {
if (s.status !== "loaded") return s;
const updated = s.playlists.map((p) =>
p.id === ev.playlist!.id ? ev.playlist! : p
).filter((p) => p.isPublic || isOwnProfile);
return { ...s, playlists: updated };
});
} else if (ev.type === "deleted") {
setState((s) => {
if (s.status !== "loaded") return s;
return {
...s,
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
};
});
}
}, [lastPlaylistEvent, me]);
useEffect(() => {
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
setState((s) => {
if (s.status !== "loaded") return s;
const filtered = s.playlists.filter((p) => !deletedPlaylistIds.has(p.id));
if (filtered.length === s.playlists.length) return s;
return { ...s, playlists: filtered };
});
}, [deletedPlaylistIds]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || state.status !== "loaded") return;
@@ -230,7 +307,7 @@ export function UserPublicProfile() {
);
}
const { user: profileUser, dumps, votes } = state;
const { user: profileUser, dumps, votes, playlists } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
@@ -277,6 +354,7 @@ export function UserPublicProfile() {
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
/>
<UpvotedDumpList
@@ -290,6 +368,31 @@ export function UserPublicProfile() {
removeVote={removeVote}
/>
</div>
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.length})
</h2>
{isOwnProfile && (
<NewPlaylistForm
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((pl) => pl.id === p.id)) return s;
return { ...s, playlists: [p, ...s.playlists] };
})}
/>
)}
</div>
{playlists.length === 0
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="dump-feed">
{playlists.map((p) => <PlaylistCard key={p.id} playlist={p} />)}
</ul>
)}
</section>
</PageShell>
);
}
@@ -297,7 +400,16 @@ export function UserPublicProfile() {
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
function DumpList(
{ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
{
title,
dumps,
voteCounts,
myVotes,
canVote,
castVote,
removeVote,
isOwnProfile,
}: {
title: string;
dumps: Dump[];
voteCounts: Record<string, number>;
@@ -305,11 +417,24 @@ function DumpList(
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
isOwnProfile?: boolean;
},
) {
const navigate = useNavigate();
return (
<section className="profile-section">
<h2>{title}</h2>
<div className="profile-section-header">
<h2 className="profile-section-title">{title}</h2>
{isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => navigate("/dumps/new")}
>
+ New dump
</button>
)}
</div>
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
: (
@@ -446,7 +571,9 @@ function UpvotedDumpList(
return (
<section className="profile-section">
<h2>{title}</h2>
<div className="profile-section-header">
<h2 className="profile-section-title">{title}</h2>
</div>
{visibleDumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
: (