v1 feature: added playlists
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
219
api/routes/playlists.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
306
api/services/playlist-service.ts
Normal file
306
api/services/playlist-service.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
704
src/App.css
704
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;
|
||||
}
|
||||
|
||||
11
src/App.tsx
11
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() {
|
||||
}
|
||||
/>
|
||||
<Route path="/users/:username" element={<UserPublicProfile />} />
|
||||
<Route
|
||||
path="/playlists"
|
||||
element={
|
||||
<RestrictedLoggedIn>
|
||||
<MyPlaylists />
|
||||
</RestrictedLoggedIn>
|
||||
}
|
||||
/>
|
||||
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</WSProvider>
|
||||
|
||||
237
src/components/AddToPlaylistModal.tsx
Normal file
237
src/components/AddToPlaylistModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
36
src/components/ConfirmModal.tsx
Normal file
36
src/components/ConfirmModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
161
src/components/NewPlaylistForm.tsx
Normal file
161
src/components/NewPlaylistForm.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
src/components/PlaylistCard.tsx
Normal file
79
src/components/PlaylistCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
65
src/model.ts
65
src/model.ts
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
149
src/pages/MyPlaylists.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
630
src/pages/PlaylistDetail.tsx
Normal file
630
src/pages/PlaylistDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
userRes.json(),
|
||||
dumpsRes.json(),
|
||||
votesRes.json(),
|
||||
]);
|
||||
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>
|
||||
: (
|
||||
|
||||
Reference in New Issue
Block a user