From 7c098e7c4c14765c800398a93c5584974460eb95 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 21 Mar 2026 13:55:22 +0000 Subject: [PATCH] v2: global player, infinite scroll, image picker, threaded comments --- .gitignore | 1 + api/main.ts | 5 + api/model/db.ts | 75 ++- api/model/interfaces.ts | 45 +- api/routes/comments.ts | 77 +++ api/routes/dumps.ts | 56 +- api/routes/users.ts | 49 +- api/services/comment-service.ts | 78 +++ api/services/dump-service.ts | 206 +++++-- api/services/playlist-service.ts | 48 +- api/services/providers/bandcamp.ts | 1 + api/services/providers/soundcloud.ts | 1 + api/services/providers/youtube.ts | 26 +- api/services/ws-service.ts | 20 +- api/sql/schema.sql | 87 +-- deno.json | 2 +- deno.lock | 711 +++++++++++++++++++++++- package.json | 4 +- src/App.css | 742 +++++++++++++++++++++++--- src/App.tsx | 16 +- src/components/AddToPlaylistModal.tsx | 101 +--- src/components/AppHeader.tsx | 12 +- src/components/CommentThread.tsx | 353 ++++++++++++ src/components/DumpCard.tsx | 32 +- src/components/DumpCreateModal.tsx | 536 +++++++++++++++++++ src/components/GlobalPlayer.tsx | 62 +++ src/components/ImagePicker.tsx | 70 +++ src/components/Markdown.tsx | 29 + src/components/NewPlaylistForm.tsx | 97 +--- src/components/PlaylistCreateForm.tsx | 112 ++++ src/components/RichContentCard.tsx | 62 ++- src/contexts/AuthProvider.tsx | 18 +- src/contexts/PlayerContext.ts | 19 + src/contexts/PlayerProvider.tsx | 14 + src/contexts/WSContext.ts | 13 +- src/contexts/WSProvider.tsx | 46 +- src/hooks/useAuth.ts | 16 + src/hooks/useFeedCache.ts | 56 ++ src/hooks/useInfiniteScroll.ts | 26 + src/index.css | 3 +- src/model.ts | 33 ++ src/pages/Dump.tsx | 150 ++++-- src/pages/DumpEdit.tsx | 54 +- src/pages/Index.tsx | 141 ++++- src/pages/MyPlaylists.tsx | 59 +- src/pages/PlaylistDetail.tsx | 305 ++++++----- src/pages/UserPublicProfile.tsx | 385 ++++++++++--- vite.config.ts | 3 + 48 files changed, 4346 insertions(+), 711 deletions(-) create mode 100644 api/routes/comments.ts create mode 100644 api/services/comment-service.ts create mode 100644 src/components/CommentThread.tsx create mode 100644 src/components/DumpCreateModal.tsx create mode 100644 src/components/GlobalPlayer.tsx create mode 100644 src/components/ImagePicker.tsx create mode 100644 src/components/Markdown.tsx create mode 100644 src/components/PlaylistCreateForm.tsx create mode 100644 src/contexts/PlayerContext.ts create mode 100644 src/contexts/PlayerProvider.tsx create mode 100644 src/hooks/useFeedCache.ts create mode 100644 src/hooks/useInfiniteScroll.ts diff --git a/.gitignore b/.gitignore index d48a67f..2859ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,7 @@ vite.config.ts.timestamp-* # Database *.db +*.db-* # Uploads api/uploads/ diff --git a/api/main.ts b/api/main.ts index 52dedbc..a6aaa70 100644 --- a/api/main.ts +++ b/api/main.ts @@ -8,6 +8,7 @@ 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 commentsRouter from "./routes/comments.ts"; import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; @@ -45,6 +46,10 @@ app.use( playlistsRouter.routes(), playlistsRouter.allowedMethods(), ); +app.use( + commentsRouter.routes(), + commentsRouter.allowedMethods(), +); app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/model/db.ts b/api/model/db.ts index 3285612..8bb64f8 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -1,5 +1,6 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { + type Comment, Dump, type Playlist, type RichContent, @@ -9,6 +10,32 @@ import { export const db = new DatabaseSync("api/sql/gerbeur.db"); db.exec("PRAGMA foreign_keys = ON;"); +// Migration: add is_private column if it doesn't exist yet +try { + db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`); +} catch { /* column already exists */ } + +// Migration: create comments table if it doesn't exist yet +try { + db.exec(`CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 + );`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`); +} catch { /* already exists */ } + +// Migration: add deleted column to comments if it doesn't exist yet +try { + db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`); +} catch { /* column already exists */ } + /** * Database Row Types */ @@ -26,6 +53,8 @@ export interface DumpRow { file_mime: string | null; file_size: number | null; vote_count: number; + comment_count?: number; + is_private: number; [key: string]: SQLOutputValue; // Index signature } @@ -62,7 +91,8 @@ export function isDumpRow(obj: Record): obj is DumpRow { (typeof obj.file_mime === "string" || obj.file_mime === null) && "file_size" in obj && (typeof obj.file_size === "number" || obj.file_size === null) && - "vote_count" in obj && typeof obj.vote_count === "number"; + "vote_count" in obj && typeof obj.vote_count === "number" && + "is_private" in obj && typeof obj.is_private === "number"; } export function isUserRow(obj: Record): obj is UserRow { @@ -97,6 +127,8 @@ export function dumpRowToApi(row: DumpRow): Dump { fileMime: row.file_mime ?? undefined, fileSize: row.file_size ?? undefined, voteCount: row.vote_count, + commentCount: row.comment_count ?? 0, + isPrivate: Boolean(row.is_private), }; } @@ -114,6 +146,7 @@ export function dumpApiToRow(dump: Dump): DumpRow { file_mime: dump.fileMime ?? null, file_size: dump.fileSize ?? null, vote_count: dump.voteCount, + is_private: dump.isPrivate ? 1 : 0, }; } @@ -139,6 +172,46 @@ export function userApiToRow(user: User): UserRow { }; } +export interface CommentRow { + id: string; + dump_id: string; + user_id: string; + parent_id: string | null; + body: string; + created_at: string; + deleted: number; + author_username: string; + author_avatar_mime: string | null; + [key: string]: SQLOutputValue; +} + +export function isCommentRow(obj: Record): obj is CommentRow { + return !!obj && typeof obj === "object" && + typeof obj.id === "string" && + typeof obj.dump_id === "string" && + typeof obj.user_id === "string" && + (typeof obj.parent_id === "string" || obj.parent_id === null) && + typeof obj.body === "string" && + typeof obj.created_at === "string" && + typeof obj.deleted === "number" && + typeof obj.author_username === "string" && + (typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null); +} + +export function commentRowToApi(row: CommentRow): Comment { + return { + id: row.id, + dumpId: row.dump_id, + userId: row.user_id, + parentId: row.parent_id ?? undefined, + body: row.body, + createdAt: new Date(row.created_at), + deleted: Boolean(row.deleted), + authorUsername: row.author_username, + authorAvatarMime: row.author_avatar_mime ?? undefined, + }; +} + export interface PlaylistRow { id: string; user_id: string; diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index ef2c5ad..b5203da 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -10,6 +10,7 @@ export interface RichContent { description?: string; thumbnailUrl?: string; videoId?: string; + embedUrl?: string; } export interface Dump { @@ -25,6 +26,8 @@ export interface Dump { fileMime?: string; fileSize?: number; voteCount: number; + commentCount: number; + isPrivate: boolean; } /** @@ -130,6 +133,12 @@ export interface APIFailure { export type APIResponse = APISuccess | APIFailure; +export interface PaginatedData { + items: T[]; + total: number; + hasMore: boolean; +} + export class APIException extends Error { readonly code: APIErrorCode; readonly status: number; @@ -141,6 +150,34 @@ export class APIException extends Error { } } +/** + * Comments + */ + +export interface Comment { + id: string; + dumpId: string; + userId: string; + parentId?: string; + body: string; + createdAt: Date; + deleted: boolean; + authorUsername: string; + authorAvatarMime?: string; +} + +export interface CreateCommentRequest { + body: string; + parentId?: string; +} + +export function isCreateCommentRequest(obj: unknown): obj is CreateCommentRequest { + if (!obj || typeof obj !== "object") return false; + const o = obj as Record; + return typeof o.body === "string" && (o.body as string).trim().length > 0 && + (!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null); +} + /** * Playlists */ @@ -216,6 +253,7 @@ export function isReorderPlaylistRequest( export interface CreateUrlDumpRequest { url: string; comment?: string; + isPrivate?: boolean; } export function isCreateUrlDumpRequest( @@ -225,12 +263,14 @@ export function isCreateUrlDumpRequest( typeof obj === "object" && "url" in obj && typeof obj.url === "string" && (!("comment" in obj) || - typeof obj.comment === "string" || obj.comment === null); + typeof obj.comment === "string" || obj.comment === null) && + (!("isPrivate" in obj) || typeof obj.isPrivate === "boolean"); } export interface UpdateDumpRequest { url?: string; comment?: string; + isPrivate?: boolean; } export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { @@ -238,7 +278,8 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { typeof obj === "object" && (!("url" in obj) || typeof obj.url === "string" || obj.url === null) && (!("comment" in obj) || - typeof obj.comment === "string" || obj.comment === null); + typeof obj.comment === "string" || obj.comment === null) && + (!("isPrivate" in obj) || typeof obj.isPrivate === "boolean"); } /** diff --git a/api/routes/comments.ts b/api/routes/comments.ts new file mode 100644 index 0000000..f496632 --- /dev/null +++ b/api/routes/comments.ts @@ -0,0 +1,77 @@ +import { Router } from "@oak/oak"; +import { + APIErrorCode, + APIException, + type APIResponse, + type Comment, + isCreateCommentRequest, +} from "../model/interfaces.ts"; +import { authMiddleware } from "../middleware/auth.ts"; +import { verifyJWT } from "../lib/jwt.ts"; +import { + createComment, + deleteComment, + getComments, +} from "../services/comment-service.ts"; +import { getDump } from "../services/dump-service.ts"; +import { + broadcastCommentCreated, + broadcastCommentDeleted, +} from "../services/ws-service.ts"; + +const router = new Router({ prefix: "/api" }); + +// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments) +router.get("/dumps/:dumpId/comments", async (ctx) => { + let requestingUserId: string | undefined; + const authHeader = ctx.request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const payload = await verifyJWT(authHeader.substring(7)); + if (payload) requestingUserId = payload.userId; + } + const dump = getDump(ctx.params.dumpId, requestingUserId); + const comments = getComments(dump.id); + const responseBody: APIResponse = { success: true, data: comments }; + ctx.response.body = responseBody; +}); + +// POST /api/dumps/:dumpId/comments — auth required +router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { + const userId = ctx.state.user.userId as string; + const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean; + const dump = getDump(ctx.params.dumpId, userId); + const body = await ctx.request.body.json(); + if (!isCreateCommentRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid comment data", + ); + } + const comment = createComment( + dump.id, + userId, + body.body, + body.parentId ?? undefined, + ); + if (!dump.isPrivate) broadcastCommentCreated(comment); + const responseBody: APIResponse = { success: true, data: comment }; + ctx.response.status = 201; + ctx.response.body = responseBody; +}); + +// DELETE /api/comments/:commentId — auth required +router.delete("/comments/:commentId", authMiddleware, (ctx) => { + const userId = ctx.state.user.userId as string; + const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean; + const { dumpId, isPrivate } = deleteComment( + ctx.params.commentId, + userId, + isAdmin, + ); + if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId); + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 4512a38..9e6ac80 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -7,15 +7,18 @@ import { type Dump, isCreateUrlDumpRequest, isUpdateDumpRequest, + type PaginatedData, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; +import { verifyJWT } from "../lib/jwt.ts"; import { createFileDump, createUrlDump, deleteDump, getDump, listDumps, + refreshDumpMetadata, replaceFileDump, updateDump, } from "../services/dump-service.ts"; @@ -35,6 +38,7 @@ router.post( const formData = await ctx.request.body.formData(); const file = formData.get("file"); const comment = formData.get("comment"); + const isPrivate = formData.get("isPrivate") === "true"; if (!(file instanceof File)) { throw new APIException( @@ -48,6 +52,7 @@ router.post( file, typeof comment === "string" && comment ? comment : undefined, userId, + isPrivate, ); } else { const body = await ctx.request.body.json(); @@ -69,15 +74,32 @@ router.post( }, ); -router.get("/:dumpId", (ctx) => { - const dump = getDump(ctx.params.dumpId); +router.get("/:dumpId", async (ctx) => { + let requestingUserId: string | undefined; + const authHeader = ctx.request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const payload = await verifyJWT(authHeader.substring(7)); + if (payload) requestingUserId = payload.userId; + } + const dump = getDump(ctx.params.dumpId, requestingUserId); const responseBody: APIResponse = { success: true, data: dump }; ctx.response.body = responseBody; }); -router.get("/", (ctx) => { - const dumps = listDumps(); - const responseBody: APIResponse = { success: true, data: dumps }; +router.get("/", async (ctx) => { + let requestingUserId: string | undefined; + const authHeader = ctx.request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + const payload = await verifyJWT(authHeader.substring(7)); + if (payload) requestingUserId = payload.userId; + } + const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); + const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); + const { items, total } = listDumps(page, limit, requestingUserId); + const responseBody: APIResponse> = { + success: true, + data: { items, total, hasMore: page * limit < total }, + }; ctx.response.body = responseBody; }); @@ -85,7 +107,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user?.userId; - const dump = getDump(dumpId); + const dump = getDump(dumpId, userId); if (userId !== dump.userId) { throw new APIException( APIErrorCode.UNAUTHORIZED, @@ -128,7 +150,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { ); } - const dump = getDump(dumpId); + const dump = getDump(dumpId, userId); if (userId !== dump.userId) { throw new APIException( @@ -143,10 +165,28 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); +router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => { + const dumpId = ctx.params.dumpId; + const userId = ctx.state.user?.userId; + const dump = getDump(dumpId, userId); + + if (userId !== dump.userId) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to update dump", + ); + } + + const updatedDump = await refreshDumpMetadata(dumpId); + const responseBody: APIResponse = { success: true, data: updatedDump }; + ctx.response.body = responseBody; +}); + router.delete("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user?.userId; - const dump = getDump(dumpId); + const dump = getDump(dumpId, userId); if (userId !== dump.userId) { throw new APIException( diff --git a/api/routes/users.ts b/api/routes/users.ts index 7971331..2cafd25 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -5,6 +5,7 @@ import { APIException, isLoginUserRequest, isRegisterUserRequest, + type PaginatedData, } from "../model/interfaces.ts"; import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts"; @@ -150,8 +151,13 @@ router.get("/:username/playlists", async (ctx) => { 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 }; + const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); + const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); + const { items, total } = listPlaylistsByUser(user.id, requestingUserId, page, limit); + ctx.response.body = { + success: true, + data: { items, total, hasMore: page * limit < total } satisfies PaginatedData, + }; }); // Public user profile by username (no passwordHash) @@ -161,18 +167,41 @@ router.get("/:username", (ctx) => { ctx.response.body = { success: true, data: publicUser }; }); -// Dumps posted by user -router.get("/:username/dumps", (ctx) => { +// Dumps posted by user (optional auth: owner sees their private dumps) +router.get("/:username/dumps", async (ctx) => { const user = getUserByUsername(ctx.params.username); - const dumps = getDumpsByUser(user.id); - ctx.response.body = { success: true, data: dumps }; + 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 page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); + const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); + const includePrivate = requestingUserId === user.id; + const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); + ctx.response.body = { + success: true, + data: { items, total, hasMore: page * limit < total } satisfies PaginatedData, + }; }); -// Dumps upvoted by user -router.get("/:username/votes", (ctx) => { +// Dumps upvoted by user (optional auth: hide private dump entries for non-owners) +router.get("/:username/votes", async (ctx) => { const user = getUserByUsername(ctx.params.username); - const dumps = getVotedDumpsByUser(user.id); - ctx.response.body = { success: true, data: dumps }; + 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 page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); + const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); + const { items, total } = getVotedDumpsByUser(user.id, page, limit, requestingUserId); + ctx.response.body = { + success: true, + data: { items, total, hasMore: page * limit < total } satisfies PaginatedData, + }; }); export default router; diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts new file mode 100644 index 0000000..baac5e2 --- /dev/null +++ b/api/services/comment-service.ts @@ -0,0 +1,78 @@ +import { + APIErrorCode, + APIException, + type Comment, +} from "../model/interfaces.ts"; +import { + commentRowToApi, + type CommentRow, + db, + isCommentRow, +} from "../model/db.ts"; + +const SELECT_COLS = + `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.deleted, + u.username as author_username, u.avatar_mime as author_avatar_mime`; + +function fetchComment(commentId: string): Comment { + const row = db.prepare( + `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, + ).get(commentId); + if (!row || !isCommentRow(row as Record)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); + } + return commentRowToApi(row as CommentRow); +} + +export function getComments(dumpId: string): Comment[] { + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id + WHERE c.dump_id = ? ORDER BY c.created_at ASC;`, + ).all(dumpId); + const typed = rows as Parameters[0][]; + if (!typed.every(isCommentRow)) { + throw new APIException( + APIErrorCode.SERVER_ERROR, + 500, + "Malformed comment data", + ); + } + return typed.map(commentRowToApi); +} + +export function createComment( + dumpId: string, + userId: string, + body: string, + parentId?: string, +): Comment { + const id = crypto.randomUUID(); + const createdAt = new Date(); + db.prepare( + `INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`, + ).run(id, dumpId, userId, parentId ?? null, body.trim(), createdAt.toISOString()); + return fetchComment(id); +} + +export function deleteComment( + commentId: string, + requestingUserId: string, + isAdmin: boolean, +): { dumpId: string; isPrivate: boolean } { + const row = db.prepare( + `SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`, + ).get(commentId) as { dump_id: string; is_private: number } | undefined; + if (!row) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); + } + const comment = fetchComment(commentId); + if (comment.userId !== requestingUserId && !isAdmin) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to delete this comment", + ); + } + db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(commentId); + return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) }; +} diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 28b670a..6791bdc 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -7,7 +7,11 @@ import { } from "../model/interfaces.ts"; import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts"; import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts"; -import { broadcastDumpDeleted, broadcastNewDump } from "./ws-service.ts"; +import { + broadcastDumpDeleted, + broadcastDumpUpdated, + broadcastNewDump, +} from "./ws-service.ts"; const UPLOADS_DIR = "api/uploads"; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB @@ -39,8 +43,15 @@ function titleFromUrl(url: string): string { } } -const SELECT_COLS = - "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count"; +const BASE_COLS = + "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; + +const SELECT_COLS = `${BASE_COLS}, + (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; + +const SELECT_COLS_ALIASED = + "d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + + " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count"; export async function createUrlDump( request: CreateUrlDumpRequest, @@ -54,10 +65,11 @@ export async function createUrlDump( const createdAt = new Date(); const richContent = await fetchRichContent(request.url); const title = richContent?.title ?? titleFromUrl(request.url); + const isPrivate = request.isPrivate ?? false; db.prepare( - `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content) - VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, + `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content, is_private) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, ).run( dumpId, "url", @@ -67,6 +79,7 @@ export async function createUrlDump( createdAt.toISOString(), request.url, richContent ? JSON.stringify(richContent) : null, + isPrivate ? 1 : 0, ); const dump: Dump = { @@ -79,8 +92,10 @@ export async function createUrlDump( url: request.url, richContent, voteCount: 0, + commentCount: 0, + isPrivate, }; - broadcastNewDump(dump); + if (!isPrivate) broadcastNewDump(dump); return dump; } @@ -88,6 +103,7 @@ export async function createFileDump( file: File, comment: string | undefined, userId: string, + isPrivate = false, ): Promise { if (!isAllowedMime(file.type)) { throw new APIException( @@ -114,8 +130,8 @@ export async function createFileDump( await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data); db.prepare( - `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, + `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size, is_private) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, ).run( dumpId, "file", @@ -126,6 +142,7 @@ export async function createFileDump( file.name, file.type, file.size, + isPrivate ? 1 : 0, ); } catch (err) { // Roll back the file if DB insert fails @@ -144,55 +161,80 @@ export async function createFileDump( fileMime: file.type, fileSize: file.size, voteCount: 0, + commentCount: 0, + isPrivate, }; - broadcastNewDump(dump); + if (!isPrivate) broadcastNewDump(dump); return dump; } -export function getDump(dumpId: string): Dump { +// Internal fetch — no privacy check. Use only when ownership is already enforced. +function fetchDump(dumpId: string): Dump { const row = db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`, ).get(dumpId); - if (!row || !isDumpRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } - return dumpRowToApi(row); } -export function listDumps(): Dump[] { - const rows = db.prepare( - `SELECT ${SELECT_COLS} FROM dumps;`, - ).all(); +// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own. +export function getDump(dumpId: string, requestingUserId?: string): Dump { + const dump = fetchDump(dumpId); + if (dump.isPrivate && dump.userId !== requestingUserId) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + } + return dump; +} + +export function listDumps( + page: number, + limit: number, + requestingUserId?: string, +): { items: Dump[]; total: number } { + const offset = (page - 1) * limit; + // Show public dumps + the requesting user's own private dumps + const rows = requestingUserId + ? db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`, + ).all(requestingUserId, limit, offset) + : db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`, + ).all(limit, offset); + const totalRow = requestingUserId + ? db.prepare( + `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?);`, + ).get(requestingUserId) as { count: number } | undefined + : db.prepare( + `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0;`, + ).get() as { count: number } | undefined; if (!rows || !rows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); } - return rows.map(dumpRowToApi); + return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; } export async function updateDump( dumpId: string, request: UpdateDumpRequest, ): Promise { - const dump = getDump(dumpId); + const dump = fetchDump(dumpId); - // File dumps: only comment is editable + // File dumps: only comment and isPrivate are editable if (dump.kind === "file") { - const updatedDump = { + const updatedDump: Dump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, + isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate, }; - db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`) - .run(updatedDump.comment ?? null, dumpId); + db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`) + .run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId); + if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); return updatedDump; } @@ -218,17 +260,19 @@ export async function updateDump( : dump.comment, url: newUrl, richContent, + isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate, }; const row = dumpApiToRow(updatedDump); const result = db.prepare( - `UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ? WHERE id = ?;`, - ).run(row.title, row.comment, row.url, row.rich_content, row.id); + `UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`, + ).run(row.title, row.comment, row.url, row.rich_content, row.is_private, row.id); if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } + if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); return updatedDump; } @@ -252,7 +296,7 @@ export async function replaceFileDump( ); } - const dump = getDump(dumpId); + const dump = fetchDump(dumpId); if (dump.kind !== "file") { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump"); } @@ -274,40 +318,98 @@ export async function replaceFileDump( }; } -export function getDumpsByUser(userId: string): Dump[] { +export function getDumpsByUser( + userId: string, + page: number, + limit: number, + includePrivate: boolean, +): { items: Dump[]; total: number } { + const offset = (page - 1) * limit; + const privacyFilter = includePrivate ? "" : " AND is_private = 0"; const rows = db.prepare( - `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`, - ).all(userId); + `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, + ).all(userId, limit, offset); + const totalRow = db.prepare( + `SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`, + ).get(userId) as { count: number } | undefined; if (!rows.every(isDumpRow)) { - throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", - ); + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); } - return rows.map(dumpRowToApi); + return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; } -export function getVotedDumpsByUser(userId: string): Dump[] { - const rows = db.prepare( - `SELECT ${SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")} - FROM dumps d - INNER JOIN votes v ON d.id = v.dump_id - WHERE v.user_id = ? - ORDER BY v.created_at DESC;`, - ).all(userId); +export function getVotedDumpsByUser( + userId: string, + page: number, + limit: number, + requestingUserId: string | null, +): { items: Dump[]; total: number } { + const offset = (page - 1) * limit; + const dumpCols = SELECT_COLS_ALIASED; + + let totalRow: { count: number } | undefined; + let rawRows: unknown[]; + + if (requestingUserId) { + rawRows = db.prepare( + `SELECT ${dumpCols} + FROM dumps d + INNER JOIN votes v ON d.id = v.dump_id + WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?) + ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, + ).all(userId, requestingUserId, limit, offset); + totalRow = db.prepare( + `SELECT COUNT(*) as count FROM dumps d + INNER JOIN votes v ON d.id = v.dump_id + WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`, + ).get(userId, requestingUserId) as { count: number } | undefined; + } else { + rawRows = db.prepare( + `SELECT ${dumpCols} + FROM dumps d + INNER JOIN votes v ON d.id = v.dump_id + WHERE v.user_id = ? AND d.is_private = 0 + ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`, + ).all(userId, limit, offset); + totalRow = db.prepare( + `SELECT COUNT(*) as count FROM dumps d + INNER JOIN votes v ON d.id = v.dump_id + WHERE v.user_id = ? AND d.is_private = 0;`, + ).get(userId) as { count: number } | undefined; + } + + const rows = rawRows as Parameters[0][]; if (!rows.every(isDumpRow)) { + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); + } + return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; +} + +export async function refreshDumpMetadata(dumpId: string): Promise { + const dump = fetchDump(dumpId); + + if (dump.kind !== "url" || !dump.url) { throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Malformed dump data", + APIErrorCode.BAD_REQUEST, + 400, + "Only URL dumps support metadata refresh", ); } - return rows.map(dumpRowToApi); + + const richContent = await fetchRichContent(dump.url); + const title = richContent?.title ?? titleFromUrl(dump.url); + + const updatedDump: Dump = { ...dump, title, richContent }; + const row = dumpApiToRow(updatedDump); + db.prepare( + `UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`, + ).run(row.title, row.rich_content, row.id); + + return updatedDump; } export async function deleteDump(dumpId: string): Promise { - const dump = getDump(dumpId); + const dump = fetchDump(dumpId); const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId); diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 7e1e6f6..a035dbc 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -24,7 +24,7 @@ import { } 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"; + "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; function getPlaylistById(playlistId: string): Playlist { const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get( @@ -75,32 +75,54 @@ export function getPlaylist( throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); } + const dumpCols = DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", "); + const isOwner = requestingUserId === playlist.userId; + + // For public playlists (or when viewed by non-owner), filter out private dumps const rows = db.prepare( - `SELECT ${DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")} + `SELECT ${dumpCols} FROM dumps d INNER JOIN playlist_dumps pd ON d.id = pd.dump_id WHERE pd.playlist_id = ? + AND (d.is_private = 0 OR d.user_id = ?) ORDER BY pd.position ASC;`, - ).all(playlistId); + ).all(playlistId, requestingUserId ?? ""); const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi); + // Owners always see their own private dumps; strip them for non-owners regardless + const visibleDumps = isOwner + ? dumps + : dumps.filter((d) => !d.isPrivate); - return { ...playlist, dumps }; + return { ...playlist, dumps: visibleDumps }; } export function listPlaylistsByUser( userId: string, requestingUserId: string | null, -): Playlist[] { + page: number, + limit: number, +): { items: Playlist[]; total: number } { const isOwner = requestingUserId === userId; + const offset = (page - 1) * limit; + + const countSql = isOwner + ? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;` + : `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`; 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;` + FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;` : `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;`; + FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`; - const rows = db.prepare(sql).all(userId); - return rows.filter(isPlaylistRow).map(playlistRowToApi); + const totalRow = db.prepare(countSql).get(userId) as + | { count: number } + | undefined; + const rows = db.prepare(sql).all(userId, limit, offset); + return { + items: rows.filter(isPlaylistRow).map(playlistRowToApi), + total: totalRow?.count ?? 0, + }; } export function updatePlaylist( @@ -179,11 +201,11 @@ export function addDumpToPlaylist( 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 minRow = db.prepare( + `SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`, + ).get(playlistId) as { min_pos: number | null } | undefined; - const nextPos = (maxRow?.max_pos ?? -1) + 1; + const nextPos = (minRow?.min_pos ?? 1) - 1; const addedAt = new Date().toISOString(); try { diff --git a/api/services/providers/bandcamp.ts b/api/services/providers/bandcamp.ts index 526e980..84a149e 100644 --- a/api/services/providers/bandcamp.ts +++ b/api/services/providers/bandcamp.ts @@ -32,6 +32,7 @@ export const bandcampProvider: RichContentProvider = { title: extractOgTag(html, "title"), description: extractOgTag(html, "description"), thumbnailUrl: extractOgTag(html, "image"), + embedUrl: extractOgTag(html, "video") ?? undefined, }; }, }; diff --git a/api/services/providers/soundcloud.ts b/api/services/providers/soundcloud.ts index 9b5d6ec..aa2ef02 100644 --- a/api/services/providers/soundcloud.ts +++ b/api/services/providers/soundcloud.ts @@ -30,6 +30,7 @@ export const soundcloudProvider: RichContentProvider = { title: extractOgTag(html, "title"), description: extractOgTag(html, "description"), thumbnailUrl: extractOgTag(html, "image"), + embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&visual=true&auto_play=false`, }; }, }; diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index 720522e..1e000af 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -2,18 +2,35 @@ import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; import { fetchWithTimeout } from "../rich-content-service.ts"; -const YOUTUBE_REGEX = - /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; +function extractVideoId(url: string): string | null { + try { + const u = new URL(url); + if (u.hostname === "youtu.be") { + return u.pathname.slice(1).split("/")[0] || null; + } + if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") { + if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) { + return u.searchParams.get("v"); + } + if (u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")) { + return u.pathname.split("/")[2] || null; + } + } + } catch { + // invalid URL + } + return null; +} export const youtubeProvider: RichContentProvider = { name: "youtube", matches(url: string): boolean { - return YOUTUBE_REGEX.test(url); + return extractVideoId(url) !== null; }, async fetch(url: string): Promise { - const videoId = url.match(YOUTUBE_REGEX)![1]; + const videoId = extractVideoId(url)!; const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; let title: string | undefined; @@ -36,6 +53,7 @@ export const youtubeProvider: RichContentProvider = { videoId, title, thumbnailUrl, + embedUrl: `https://www.youtube.com/embed/${videoId}?rel=0`, }; }, }; diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 9800e35..614c66a 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -1,4 +1,4 @@ -import type { Dump, OnlineUser, Playlist } from "../model/interfaces.ts"; +import type { Comment, Dump, OnlineUser, Playlist } from "../model/interfaces.ts"; export interface WsClient { socket: WebSocket; @@ -59,6 +59,12 @@ export function broadcastNewDump(dump: Dump): void { } } +export function broadcastDumpUpdated(dump: Dump): void { + for (const client of clients) { + send(client.socket, { type: "dump_updated", dump }); + } +} + export function broadcastDumpDeleted(dumpId: string): void { for (const client of clients) { send(client.socket, { type: "dump_deleted", dumpId }); @@ -124,6 +130,18 @@ export function broadcastPlaylistDumpsUpdated( }); } +export function broadcastCommentCreated(comment: Comment): void { + for (const client of clients) { + send(client.socket, { type: "comment_created", comment }); + } +} + +export function broadcastCommentDeleted(commentId: string, dumpId: string): void { + for (const client of clients) { + send(client.socket, { type: "comment_deleted", commentId, dumpId }); + } +} + // Keepalive: ping all clients every 30s, remove non-responsive ones const PING_INTERVAL = 30_000; diff --git a/api/sql/schema.sql b/api/sql/schema.sql index 438d711..c9b4f63 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -1,56 +1,77 @@ CREATE TABLE dumps ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - title TEXT NOT NULL, - comment TEXT, - user_id TEXT NOT NULL, - created_at TEXT NOT NULL, - url TEXT, - rich_content TEXT, - file_name TEXT, - file_mime TEXT, - file_size INTEGER, - vote_count INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + comment TEXT, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + url TEXT, + rich_content TEXT, + file_name TEXT, + file_mime TEXT, + file_size INTEGER, + vote_count INTEGER NOT NULL DEFAULT 0, + is_private INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - avatar_mime TEXT + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + avatar_mime TEXT ); CREATE TABLE votes ( - dump_id TEXT NOT NULL, - user_id TEXT NOT NULL, + dump_id TEXT NOT NULL, + user_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (dump_id, user_id), - FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + 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, + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, is_public INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - image_mime TEXT + created_at TEXT NOT NULL, + image_mime TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); 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, + playlist_id TEXT NOT NULL, + dump_id TEXT NOT NULL, position INTEGER NOT NULL, - added_at TEXT NOT NULL, - PRIMARY KEY (playlist_id, dump_id) + added_at TEXT NOT NULL, + PRIMARY KEY (playlist_id, dump_id), + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, + FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE ); -CREATE INDEX idx_dumps_user ON dumps(user_id); +-- v3: comments +CREATE TABLE comments ( + id TEXT PRIMARY KEY, + dump_id TEXT NOT NULL, + user_id TEXT NOT NULL, + parent_id TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE +); + +CREATE INDEX idx_dumps_user ON dumps(user_id); +CREATE INDEX idx_votes_user ON votes(user_id); +CREATE INDEX idx_playlists_user ON playlists(user_id); CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); -CREATE INDEX idx_playlists_user ON playlists(user_id); +CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); +CREATE INDEX idx_comments_dump ON comments(dump_id, created_at); diff --git a/deno.json b/deno.json index 7e8a9b1..0506ce5 100644 --- a/deno.json +++ b/deno.json @@ -2,7 +2,7 @@ "tasks": { "dev": "deno run -A npm:vite & deno run -A server:start", "build": "deno run -A npm:vite build", - "server:start": "deno run -A --watch ./api/main.ts", + "server:start": "deno run -A --watch api/main.ts", "serve": "deno run -A build && deno run -A server:start" }, "nodeModulesDir": "auto", diff --git a/deno.lock b/deno.lock index 286193e..1d432a8 100644 --- a/deno.lock +++ b/deno.lock @@ -32,8 +32,10 @@ "npm:globals@^17.4.0": "17.4.0", "npm:path-to-regexp@^6.3.0": "6.3.0", "npm:react-dom@^19.2.4": "19.2.4_react@19.2.4", + "npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.2.14_react@19.2.4", "npm:react-router@^7.13.1": "7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4", "npm:react@^19.2.4": "19.2.4", + "npm:remark-gfm@^4.0.1": "4.0.1", "npm:typescript-eslint@^8.56.1": "8.57.0_eslint@9.39.4_typescript@5.9.3", "npm:typescript@~5.9.3": "5.9.3", "npm:vite@*": "8.0.0_@types+node@24.12.0", @@ -478,12 +480,39 @@ "tslib" ] }, + "@types/debug@4.1.12": { + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": [ + "@types/ms" + ] + }, + "@types/estree-jsx@1.0.5": { + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": [ + "@types/estree" + ] + }, "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, + "@types/hast@3.0.4": { + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": [ + "@types/unist@3.0.3" + ] + }, "@types/json-schema@7.0.15": { "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, + "@types/mdast@4.0.4": { + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": [ + "@types/unist@3.0.3" + ] + }, + "@types/ms@2.1.0": { + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "@types/node@24.12.0": { "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dependencies": [ @@ -502,6 +531,12 @@ "csstype" ] }, + "@types/unist@2.0.11": { + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "@types/unist@3.0.3": { + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, "@typescript-eslint/eslint-plugin@8.57.0_@typescript-eslint+parser@8.57.0__eslint@9.39.4__typescript@5.9.3_eslint@9.39.4_typescript@5.9.3": { "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "dependencies": [ @@ -600,6 +635,9 @@ "eslint-visitor-keys@5.0.1" ] }, + "@ungap/structured-clone@1.3.0": { + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, "@vitejs/plugin-react@6.0.1_vite@8.0.0__@types+node@24.12.0": { "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", "dependencies": [ @@ -635,6 +673,9 @@ "argparse@2.0.1": { "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "bail@2.0.2": { + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==" + }, "balanced-match@1.0.2": { "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, @@ -675,6 +716,9 @@ "caniuse-lite@1.0.30001779": { "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==" }, + "ccount@2.0.1": { + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" + }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ @@ -682,6 +726,18 @@ "supports-color" ] }, + "character-entities-html4@2.1.0": { + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==" + }, + "character-entities-legacy@3.0.0": { + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==" + }, + "character-entities@2.0.2": { + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==" + }, + "character-reference-invalid@2.0.1": { + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==" + }, "color-convert@2.0.1": { "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": [ @@ -691,6 +747,9 @@ "color-name@1.1.4": { "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "comma-separated-tokens@2.0.3": { + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" + }, "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, @@ -717,12 +776,27 @@ "ms" ] }, + "decode-named-character-reference@1.3.0": { + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dependencies": [ + "character-entities" + ] + }, "deep-is@0.1.4": { "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "dequal@2.0.3": { + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, "detect-libc@2.1.2": { "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, + "devlop@1.1.0": { + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": [ + "dequal" + ] + }, "electron-to-chromium@1.5.313": { "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==" }, @@ -732,6 +806,9 @@ "escape-string-regexp@4.0.0": { "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, + "escape-string-regexp@5.0.0": { + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" + }, "eslint-plugin-react-hooks@7.0.1_eslint@9.39.4": { "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dependencies": [ @@ -784,7 +861,7 @@ "chalk", "cross-spawn", "debug", - "escape-string-regexp", + "escape-string-regexp@4.0.0", "eslint-scope", "eslint-visitor-keys@4.2.1", "espree", @@ -828,9 +905,15 @@ "estraverse@5.3.0": { "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" }, + "estree-util-is-identifier-name@3.0.0": { + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==" + }, "esutils@2.0.3": { "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "extend@3.0.2": { + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, @@ -895,6 +978,32 @@ "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "hast-util-to-jsx-runtime@2.3.6": { + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": [ + "@types/estree", + "@types/hast", + "@types/unist@3.0.3", + "comma-separated-tokens", + "devlop", + "estree-util-is-identifier-name", + "hast-util-whitespace", + "mdast-util-mdx-expression", + "mdast-util-mdx-jsx", + "mdast-util-mdxjs-esm", + "property-information", + "space-separated-tokens", + "style-to-js", + "unist-util-position", + "vfile-message" + ] + }, + "hast-util-whitespace@3.0.0": { + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": [ + "@types/hast" + ] + }, "hermes-estree@0.25.1": { "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==" }, @@ -904,6 +1013,9 @@ "hermes-estree" ] }, + "html-url-attributes@3.0.1": { + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==" + }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, @@ -920,6 +1032,22 @@ "imurmurhash@0.1.4": { "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" }, + "inline-style-parser@0.2.7": { + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, + "is-alphabetical@2.0.1": { + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==" + }, + "is-alphanumerical@2.0.1": { + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": [ + "is-alphabetical", + "is-decimal" + ] + }, + "is-decimal@2.0.1": { + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==" + }, "is-extglob@2.1.1": { "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" }, @@ -929,6 +1057,12 @@ "is-extglob" ] }, + "is-hexadecimal@2.0.1": { + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==" + }, + "is-plain-obj@4.1.0": { + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" + }, "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, @@ -1055,12 +1189,424 @@ "lodash.merge@4.6.2": { "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "longest-streak@3.1.0": { + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==" + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ "yallist" ] }, + "markdown-table@3.0.4": { + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" + }, + "mdast-util-find-and-replace@3.0.2": { + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": [ + "@types/mdast", + "escape-string-regexp@5.0.0", + "unist-util-is", + "unist-util-visit-parents" + ] + }, + "mdast-util-from-markdown@2.0.3": { + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dependencies": [ + "@types/mdast", + "@types/unist@3.0.3", + "decode-named-character-reference", + "devlop", + "mdast-util-to-string", + "micromark", + "micromark-util-decode-numeric-character-reference", + "micromark-util-decode-string", + "micromark-util-normalize-identifier", + "micromark-util-symbol", + "micromark-util-types", + "unist-util-stringify-position" + ] + }, + "mdast-util-gfm-autolink-literal@2.0.1": { + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": [ + "@types/mdast", + "ccount", + "devlop", + "mdast-util-find-and-replace", + "micromark-util-character" + ] + }, + "mdast-util-gfm-footnote@2.1.0": { + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": [ + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown", + "micromark-util-normalize-identifier" + ] + }, + "mdast-util-gfm-strikethrough@2.0.0": { + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": [ + "@types/mdast", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm-table@2.0.0": { + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": [ + "@types/mdast", + "devlop", + "markdown-table", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm-task-list-item@2.0.0": { + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": [ + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm@3.1.0": { + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": [ + "mdast-util-from-markdown", + "mdast-util-gfm-autolink-literal", + "mdast-util-gfm-footnote", + "mdast-util-gfm-strikethrough", + "mdast-util-gfm-table", + "mdast-util-gfm-task-list-item", + "mdast-util-to-markdown" + ] + }, + "mdast-util-mdx-expression@2.0.1": { + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": [ + "@types/estree-jsx", + "@types/hast", + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-mdx-jsx@3.2.0": { + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": [ + "@types/estree-jsx", + "@types/hast", + "@types/mdast", + "@types/unist@3.0.3", + "ccount", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown", + "parse-entities", + "stringify-entities", + "unist-util-stringify-position", + "vfile-message" + ] + }, + "mdast-util-mdxjs-esm@2.0.1": { + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": [ + "@types/estree-jsx", + "@types/hast", + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-phrasing@4.1.0": { + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": [ + "@types/mdast", + "unist-util-is" + ] + }, + "mdast-util-to-hast@13.2.1": { + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dependencies": [ + "@types/hast", + "@types/mdast", + "@ungap/structured-clone", + "devlop", + "micromark-util-sanitize-uri", + "trim-lines", + "unist-util-position", + "unist-util-visit", + "vfile" + ] + }, + "mdast-util-to-markdown@2.1.2": { + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": [ + "@types/mdast", + "@types/unist@3.0.3", + "longest-streak", + "mdast-util-phrasing", + "mdast-util-to-string", + "micromark-util-classify-character", + "micromark-util-decode-string", + "unist-util-visit", + "zwitch" + ] + }, + "mdast-util-to-string@4.0.0": { + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": [ + "@types/mdast" + ] + }, + "micromark-core-commonmark@2.0.3": { + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dependencies": [ + "decode-named-character-reference", + "devlop", + "micromark-factory-destination", + "micromark-factory-label", + "micromark-factory-space", + "micromark-factory-title", + "micromark-factory-whitespace", + "micromark-util-character", + "micromark-util-chunked", + "micromark-util-classify-character", + "micromark-util-html-tag-name", + "micromark-util-normalize-identifier", + "micromark-util-resolve-all", + "micromark-util-subtokenize", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-autolink-literal@2.1.0": { + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": [ + "micromark-util-character", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-footnote@2.1.0": { + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": [ + "devlop", + "micromark-core-commonmark", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-normalize-identifier", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-strikethrough@2.1.0": { + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": [ + "devlop", + "micromark-util-chunked", + "micromark-util-classify-character", + "micromark-util-resolve-all", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-table@2.1.1": { + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": [ + "devlop", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-tagfilter@2.0.0": { + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": [ + "micromark-util-types" + ] + }, + "micromark-extension-gfm-task-list-item@2.1.0": { + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": [ + "devlop", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm@3.0.0": { + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": [ + "micromark-extension-gfm-autolink-literal", + "micromark-extension-gfm-footnote", + "micromark-extension-gfm-strikethrough", + "micromark-extension-gfm-table", + "micromark-extension-gfm-tagfilter", + "micromark-extension-gfm-task-list-item", + "micromark-util-combine-extensions", + "micromark-util-types" + ] + }, + "micromark-factory-destination@2.0.1": { + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dependencies": [ + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-factory-label@2.0.1": { + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dependencies": [ + "devlop", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-factory-space@2.0.1": { + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dependencies": [ + "micromark-util-character", + "micromark-util-types" + ] + }, + "micromark-factory-title@2.0.1": { + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dependencies": [ + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-factory-whitespace@2.0.1": { + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dependencies": [ + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-character@2.1.1": { + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dependencies": [ + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-chunked@2.0.1": { + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dependencies": [ + "micromark-util-symbol" + ] + }, + "micromark-util-classify-character@2.0.1": { + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dependencies": [ + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-combine-extensions@2.0.1": { + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dependencies": [ + "micromark-util-chunked", + "micromark-util-types" + ] + }, + "micromark-util-decode-numeric-character-reference@2.0.2": { + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dependencies": [ + "micromark-util-symbol" + ] + }, + "micromark-util-decode-string@2.0.1": { + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dependencies": [ + "decode-named-character-reference", + "micromark-util-character", + "micromark-util-decode-numeric-character-reference", + "micromark-util-symbol" + ] + }, + "micromark-util-encode@2.0.1": { + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==" + }, + "micromark-util-html-tag-name@2.0.1": { + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==" + }, + "micromark-util-normalize-identifier@2.0.1": { + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dependencies": [ + "micromark-util-symbol" + ] + }, + "micromark-util-resolve-all@2.0.1": { + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dependencies": [ + "micromark-util-types" + ] + }, + "micromark-util-sanitize-uri@2.0.1": { + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dependencies": [ + "micromark-util-character", + "micromark-util-encode", + "micromark-util-symbol" + ] + }, + "micromark-util-subtokenize@2.1.0": { + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dependencies": [ + "devlop", + "micromark-util-chunked", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-symbol@2.0.1": { + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==" + }, + "micromark-util-types@2.0.2": { + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==" + }, + "micromark@4.0.2": { + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dependencies": [ + "@types/debug", + "debug", + "decode-named-character-reference", + "devlop", + "micromark-core-commonmark", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-chunked", + "micromark-util-combine-extensions", + "micromark-util-decode-numeric-character-reference", + "micromark-util-encode", + "micromark-util-normalize-identifier", + "micromark-util-resolve-all", + "micromark-util-sanitize-uri", + "micromark-util-subtokenize", + "micromark-util-symbol", + "micromark-util-types" + ] + }, "minimatch@10.2.4": { "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dependencies": [ @@ -1115,6 +1661,18 @@ "callsites" ] }, + "parse-entities@4.0.2": { + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": [ + "@types/unist@2.0.11", + "character-entities-legacy", + "character-reference-invalid", + "decode-named-character-reference", + "is-alphanumerical", + "is-decimal", + "is-hexadecimal" + ] + }, "path-exists@4.0.0": { "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, @@ -1141,6 +1699,9 @@ "prelude-ls@1.2.1": { "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, + "property-information@7.1.0": { + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==" + }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, @@ -1151,6 +1712,24 @@ "scheduler" ] }, + "react-markdown@10.1.0_@types+react@19.2.14_react@19.2.4": { + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dependencies": [ + "@types/hast", + "@types/mdast", + "@types/react", + "devlop", + "hast-util-to-jsx-runtime", + "html-url-attributes", + "mdast-util-to-hast", + "react", + "remark-parse", + "remark-rehype", + "unified", + "unist-util-visit", + "vfile" + ] + }, "react-router@7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4": { "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "dependencies": [ @@ -1166,6 +1745,44 @@ "react@19.2.4": { "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" }, + "remark-gfm@4.0.1": { + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": [ + "@types/mdast", + "mdast-util-gfm", + "micromark-extension-gfm", + "remark-parse", + "remark-stringify", + "unified" + ] + }, + "remark-parse@11.0.0": { + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": [ + "@types/mdast", + "mdast-util-from-markdown", + "micromark-util-types", + "unified" + ] + }, + "remark-rehype@11.1.2": { + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": [ + "@types/hast", + "@types/mdast", + "mdast-util-to-hast", + "unified", + "vfile" + ] + }, + "remark-stringify@11.0.0": { + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": [ + "@types/mdast", + "mdast-util-to-markdown", + "unified" + ] + }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, @@ -1220,9 +1837,31 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "space-separated-tokens@2.0.2": { + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" + }, + "stringify-entities@4.0.4": { + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": [ + "character-entities-html4", + "character-entities-legacy" + ] + }, "strip-json-comments@3.1.1": { "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "style-to-js@1.1.21": { + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": [ + "style-to-object" + ] + }, + "style-to-object@1.0.14": { + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": [ + "inline-style-parser" + ] + }, "supports-color@7.2.0": { "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": [ @@ -1236,6 +1875,12 @@ "picomatch" ] }, + "trim-lines@3.0.1": { + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==" + }, + "trough@2.2.0": { + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==" + }, "ts-api-utils@2.4.0_typescript@5.9.3": { "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dependencies": [ @@ -1269,6 +1914,51 @@ "undici-types@7.16.0": { "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" }, + "unified@11.0.5": { + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": [ + "@types/unist@3.0.3", + "bail", + "devlop", + "extend", + "is-plain-obj", + "trough", + "vfile" + ] + }, + "unist-util-is@6.0.1": { + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": [ + "@types/unist@3.0.3" + ] + }, + "unist-util-position@5.0.0": { + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": [ + "@types/unist@3.0.3" + ] + }, + "unist-util-stringify-position@4.0.0": { + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": [ + "@types/unist@3.0.3" + ] + }, + "unist-util-visit-parents@6.0.2": { + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": [ + "@types/unist@3.0.3", + "unist-util-is" + ] + }, + "unist-util-visit@5.1.0": { + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dependencies": [ + "@types/unist@3.0.3", + "unist-util-is", + "unist-util-visit-parents" + ] + }, "update-browserslist-db@1.2.3_browserslist@4.28.1": { "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dependencies": [ @@ -1284,6 +1974,20 @@ "punycode" ] }, + "vfile-message@4.0.3": { + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": [ + "@types/unist@3.0.3", + "unist-util-stringify-position" + ] + }, + "vfile@6.0.3": { + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": [ + "@types/unist@3.0.3", + "vfile-message" + ] + }, "vite@8.0.0_@types+node@24.12.0": { "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "dependencies": [ @@ -1327,6 +2031,9 @@ }, "zod@4.3.6": { "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" + }, + "zwitch@2.0.4": { + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } }, "workspace": { @@ -1349,8 +2056,10 @@ "npm:eslint@^9.39.4", "npm:globals@^17.4.0", "npm:react-dom@^19.2.4", + "npm:react-markdown@^10.1.0", "npm:react-router@^7.13.1", "npm:react@^19.2.4", + "npm:remark-gfm@^4.0.1", "npm:typescript-eslint@^8.56.1", "npm:typescript@~5.9.3", "npm:vite@8" diff --git a/package.json b/package.json index f4bdc20..6b4b9f0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "@vitejs/plugin-react": "^6.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router": "^7.13.1" + "react-markdown": "^10.1.0", + "react-router": "^7.13.1", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/App.css b/src/App.css index 69fd254..d75109a 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,71 @@ +/* ── Markdown prose ── */ +.md p { + margin: 0 0 0.7em; +} +.md p:last-child { + margin-bottom: 0; +} +.md a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} +.md a:hover { + text-decoration: none; +} +.md strong { font-weight: 700; } +.md em { font-style: italic; } +.md code { + font-family: monospace; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.md pre { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.75rem 1rem; + overflow-x: auto; + margin: 0.6em 0; +} +.md pre code { + background: none; + border: none; + padding: 0; +} +.md ul, .md ol { + padding-left: 1.5em; + margin: 0.4em 0; +} +.md li { margin: 0.15em 0; } +.md blockquote { + border-left: 3px solid var(--color-border); + margin: 0.5em 0; + padding: 0.2em 0.75em; + opacity: 0.75; +} +.md h1, .md h2, .md h3, +.md h4, .md h5, .md h6 { + margin: 0.6em 0 0.2em; + font-weight: 700; + line-height: 1.25; +} + +/* Compact / card mode: strip vertical spacing */ +.md--inline p, +.md--inline ul, +.md--inline ol, +.md--inline pre, +.md--inline blockquote { + margin: 0; +} +.md--inline li { margin: 0; } +.md--inline ul, +.md--inline ol { padding-left: 1.2em; } + /* ── Dump detail page ── */ .dump-detail { display: flex; @@ -16,9 +84,21 @@ } .dump-header-block { - display: flex; - align-items: flex-start; - gap: 1rem; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 1rem; + row-gap: 0.3rem; + align-items: center; +} + +.dump-title, +.dump-op { + grid-column: 2; +} + +.dump-header-block .vote-btn, +.dump-header-block .btn-add-playlist { + justify-self: center; } .dump-header-info { @@ -29,6 +109,7 @@ min-width: 0; } + .dump-title { margin: 0; font-size: 1.5rem; @@ -513,19 +594,17 @@ border: 2px solid var(--color-border); border-radius: 10px; overflow: hidden; - text-decoration: none; - color: var(--color-text); transition: border-color 0.2s; } -.rich-content-card:hover { +.rich-content-card:has(.rich-content-body:hover) { border-color: var(--color-accent); } .rich-content-card--youtube { border-color: var(--color-youtube); } -.rich-content-card--youtube:hover { +.rich-content-card--youtube:has(.rich-content-body:hover) { border-color: var(--color-youtube-hover); } .rich-content-card--youtube .rich-content-badge { @@ -535,7 +614,7 @@ .rich-content-card--bandcamp { border-color: var(--color-bandcamp); } -.rich-content-card--bandcamp:hover { +.rich-content-card--bandcamp:has(.rich-content-body:hover) { border-color: var(--color-bandcamp-hover); } .rich-content-card--bandcamp .rich-content-badge { @@ -545,13 +624,157 @@ .rich-content-card--soundcloud { border-color: var(--color-soundcloud); } -.rich-content-card--soundcloud:hover { +.rich-content-card--soundcloud:has(.rich-content-body:hover) { border-color: var(--color-soundcloud-hover); } .rich-content-card--soundcloud .rich-content-badge { background: var(--color-soundcloud); } +.rich-content-embed { + width: 100%; + display: block; + border: 2px solid var(--color-border); + border-radius: 10px; + overflow: hidden; + margin-top: 0.75rem; +} +.rich-content-embed iframe { + width: 100%; + border: none; + display: block; +} +.embed-youtube { + aspect-ratio: 16/9; +} +.embed-youtube iframe { + height: 100%; +} +.embed-soundcloud { + height: 166px; +} +.embed-bandcamp { + height: 120px; +} + +/* ── Global persistent player ── */ +.global-player { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 2.5rem); + max-width: 860px; + z-index: 1000; + background: var(--color-surface); + border: 2px solid var(--color-border); + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); + animation: player-enter 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both; + transition: opacity 0.2s ease, box-shadow 0.2s ease; +} +.global-player--reduced { + opacity: 0.6; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} +.global-player--reduced:hover { + opacity: 1; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); +} +@keyframes player-enter { + from { + transform: translateX(-50%) translateY(1.5rem); + } + to { + transform: translateX(-50%) translateY(0); + } +} +.global-player-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; +} +.global-player-title { + flex: 1; + font-weight: 600; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.global-player-body { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.3s ease; +} +.global-player--reduced .global-player-body { + grid-template-rows: 0fr; +} +.global-player-iframe-wrap { + overflow: hidden; + min-height: 0; + border-radius: 0 0 8px 8px; +} +.global-player iframe { + width: 100%; + border: none; + display: block; +} +.global-player-iframe--youtube { + aspect-ratio: 16/9; + max-height: 40vh; +} +.global-player-iframe--soundcloud { + height: 166px; +} +.global-player-iframe--bandcamp { + height: 120px; +} +.global-player.global-player--bandcamp { + max-width: 600px; +} +.feed-loading-more { + text-align: center; + padding: 1rem; + color: var(--color-text-muted); + font-size: 0.85rem; +} + +body.has-player { + padding-bottom: var(--player-height, 0px); +} +body.has-player .fab-new { + bottom: calc(var(--player-height, 0px) + 1rem); +} +.rich-content-thumbnail-btn { + position: relative; + padding: 0; + border: none; + background: none; + cursor: pointer; + display: flex; + align-self: stretch; + overflow: hidden; + flex-shrink: 0; +} + +.rich-content-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.25); + color: #fff; + font-size: 1.75rem; + transition: background 0.18s ease; +} + +.rich-content-thumbnail-btn:hover .rich-content-play-overlay { + background: rgba(0, 0, 0, 0.5); +} + .rich-content-thumbnail { width: 180px; min-width: 180px; @@ -562,6 +785,8 @@ .rich-content-body { display: flex; flex-direction: column; + text-decoration: none; + color: var(--color-text); gap: 0.4rem; padding: 0.9rem 1.1rem; flex: 1; @@ -735,6 +960,54 @@ } /* ── Avatar edit overlay ── */ +/* ── ImagePicker (reusable clickable cover image) ── */ +.img-picker { + position: relative; + flex-shrink: 0; + cursor: pointer; + outline: none; +} + +.img-picker-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border: 1px solid var(--color-border); +} + +.img-picker-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--color-border); + background: var(--color-bg); + color: var(--color-text); + font-size: 1.5rem; + opacity: 0.4; + box-sizing: border-box; +} + +.img-picker-overlay { + position: absolute; + inset: 0; + background: var(--color-overlay); + color: var(--color-on-accent); + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s; +} + +.img-picker:hover .img-picker-overlay, +.img-picker:focus-visible .img-picker-overlay { + opacity: 1; +} + .profile-avatar-wrapper { position: relative; flex-shrink: 0; @@ -764,13 +1037,12 @@ display: flex; align-items: center; gap: 1.5rem; - margin-bottom: 2rem; } .profile-columns { display: grid; grid-template-columns: 1fr; - gap: 0; + gap: 1.5rem; } @media (min-width: 900px) { @@ -781,9 +1053,7 @@ } } -.profile-section { - margin-bottom: 2.5rem; -} +.profile-section {} .profile-section ul { list-style: none; @@ -881,24 +1151,23 @@ /* ── Shared layout ── */ .page-shell { - min-height: 100vh; display: flex; flex-direction: column; } .page-content { - flex: 1; width: 100%; max-width: 860px; margin: 0 auto; - padding: 2rem 1.25rem; + padding: 2rem 1.25rem 0; box-sizing: border-box; animation: page-enter 0.2s ease both; + display: flex; + flex-direction: column; + gap: 2.5rem; } .page-content--centered { - display: flex; - flex-direction: column; align-items: center; padding-top: 2.5rem; } @@ -1138,6 +1407,10 @@ border-radius: 0 0 12px 12px; } +.dump-edit-refresh { + margin-top: 0.75rem; +} + .dump-form { display: flex; flex-direction: column; @@ -1252,7 +1525,6 @@ /* ── Index page ── */ .index-page { - min-height: 100vh; display: flex; flex-direction: column; animation: page-enter 0.2s ease both; @@ -1354,10 +1626,21 @@ } /* ── Dump feed ── */ +@keyframes card-enter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .dump-feed { list-style: none; margin: 0; - padding: 1rem 1.25rem; + padding: 1rem 1.25rem 0; display: flex; flex-direction: column; gap: 1rem; @@ -1367,6 +1650,10 @@ align-self: center; } +.dump-feed > li { + animation: card-enter 0.2s ease both; +} + /* ── Shared card skin (dump-card + playlist-card) ── */ .dump-card, .playlist-card { @@ -1493,8 +1780,16 @@ margin-top: 0.2rem; } +.dump-card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.2rem; +} + .dump-card-date { display: block; + margin-top: 0; } .playlist-card-meta { @@ -1587,6 +1882,21 @@ flex-direction: column; } +.modal-card--wide { + max-width: 600px; +} + +.dump-create-success { + margin-bottom: 1rem; + font-size: 0.95rem; + color: var(--color-text-muted); +} + +.dump-create-success a { + color: var(--color-accent); + font-weight: 600; +} + .confirm-modal { max-width: 340px; padding: 1.5rem 1.25rem 1.25rem; @@ -1733,7 +2043,6 @@ background: var(--color-surface); border-radius: 12px; padding: 1.25rem; - margin-bottom: 1rem; } .playlist-detail-header-top { @@ -1772,10 +2081,24 @@ opacity: 0.6; } -/* ── Playlist edit button ── */ -.playlist-edit-btn { - margin-left: auto; +/* ── Playlist header inline edit ── */ +.playlist-detail-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.playlist-header-actions { flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.4rem; +} + +.playlist-edit-btn { background: none; border: 1px solid var(--color-border-subtle); border-radius: 6px; @@ -1793,19 +2116,6 @@ 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); @@ -1813,43 +2123,32 @@ border-radius: 8px; color: var(--color-text); font-size: 0.95rem; - padding: 0.5rem 0.75rem; + padding: 0.4rem 0.65rem; font-family: inherit; - resize: vertical; width: 100%; box-sizing: border-box; outline: none; transition: border-color 0.2s; } +.playlist-edit-textarea { + resize: none; + overflow: hidden; + line-height: 1.5; +} + +.playlist-edit-input { + font-size: 1.1rem; + font-weight: 700; +} + .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-detail-meta .playlist-edit-toggle { + opacity: 1; } /* ── Playlist dump list ── */ @@ -1927,18 +2226,23 @@ /* ── Add to playlist button (dump detail) ── */ .btn-add-playlist { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.6rem; + border: 2px solid var(--color-border); + border-radius: 8px; 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; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; + transition: border-color 0.15s, color 0.15s; } .btn-add-playlist:hover { - opacity: 1; + border-color: var(--color-accent); color: var(--color-accent); } @@ -1948,7 +2252,6 @@ align-items: center; justify-content: space-between; gap: 1rem; - margin-bottom: 1.5rem; } .my-playlists-title { @@ -1972,3 +2275,302 @@ .new-playlist-toggle:hover { opacity: 0.75; } + +/* ── Public/Private toggle ── */ +.toggle-row { + display: flex; + align-items: center; + gap: 0.6rem; + cursor: pointer; + margin-bottom: 0.5rem; +} + +.toggle-label { + font-size: 0.9rem; + color: var(--color-text); + user-select: none; +} + +.toggle-hint { + font-size: 0.8rem; + color: var(--color-text-muted); + user-select: none; +} + +.toggle-switch { + position: relative; + display: inline-flex; + align-items: center; + width: 2.4rem; + height: 1.3rem; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-thumb { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--color-text-muted); + transition: background 0.2s; + cursor: pointer; +} + +.toggle-thumb::after { + content: ""; + position: absolute; + left: 0.15rem; + top: 50%; + transform: translateY(-50%); + width: 1rem; + height: 1rem; + border-radius: 50%; + background: #fff; + transition: left 0.2s; +} + +.toggle-switch input:checked + .toggle-thumb { + background: var(--color-accent); +} + +.toggle-switch input:checked + .toggle-thumb::after { + left: calc(100% - 1.15rem); +} + +/* ── Dump card comment count ── */ +.dump-card-comment-count { + font-size: 0.72rem; + color: var(--color-text-muted); +} + +/* ── Dump card private badge ── */ +.dump-card-private-badge { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1em 0.45em; + border-radius: 4px; + background: color-mix(in srgb, var(--color-text-muted) 18%, transparent); + color: var(--color-text-muted); +} + +/* ── Comments ── */ +.comment-section { + margin-top: 2.5rem; + padding-top: 1.75rem; + border-top: 2px solid var(--color-border); +} + +.comment-section-title { + font-size: 0.8rem; + font-weight: 700; + color: var(--color-text-muted); + margin: 0 0 1.25rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.comment-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.comment-node { + display: flex; + flex-direction: column; +} + +.comment-node-inner { + display: flex; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + border-radius: 8px; + background: var(--color-surface); + transition: background 0.12s; +} + +.comment-node-inner:hover { + background: color-mix(in srgb, var(--color-surface) 80%, var(--color-accent) 20%); +} + +.comment-avatar { + flex-shrink: 0; + padding-top: 1px; +} + +.comment-content { + flex: 1; + min-width: 0; +} + +.comment-meta { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.comment-author { + font-weight: 700; + font-size: 0.85rem; + color: var(--color-accent); + text-decoration: none; +} + +.comment-author:hover { + text-decoration: underline; +} + +.comment-time { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.comment-body { + font-size: 0.9rem; + line-height: 1.6; + color: var(--color-text); +} + +.comment-actions { + display: flex; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.comment-action-btn { + background: none; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.15rem 0.45rem; + font-size: 0.75rem; + color: var(--color-text-muted); + cursor: pointer; + font-family: inherit; + transition: color 0.1s, border-color 0.1s, background 0.1s; +} + +.comment-action-btn:hover { + color: var(--color-text); + border-color: var(--color-border-subtle); + background: color-mix(in srgb, var(--color-text) 6%, transparent); +} + +.comment-delete-btn:hover { + color: var(--color-danger); + border-color: color-mix(in srgb, var(--color-danger) 40%, transparent); + background: color-mix(in srgb, var(--color-danger) 10%, transparent); +} + +.comment-replies { + padding-left: 1.25rem; + margin-left: 1.1rem; + margin-top: 0.35rem; + border-left: 2px solid color-mix(in srgb, var(--color-accent) 30%, transparent); + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.comment-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.comment-top-form { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border-subtle); +} + +.comment-reply-textarea { + width: 100%; + box-sizing: border-box; + background: var(--color-bg); + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + padding: 0.55rem 0.75rem; + font-family: inherit; + font-size: 0.875rem; + color: var(--color-text); + resize: vertical; + min-height: 4.5rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.comment-reply-textarea:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent); +} + +.comment-form-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.comment-submit-btn { + background: var(--color-accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.4rem 1rem; + font-family: inherit; + font-size: 0.82rem; + font-weight: 700; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + letter-spacing: 0.02em; +} + +.comment-submit-btn:not(:disabled):hover { + background: var(--color-accent-hover); +} + +.comment-submit-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.comment-form-error { + margin: 0; + font-size: 0.8rem; + color: var(--color-danger); +} + +.comment-node-inner--deleted { + opacity: 0.35; +} + +.comment-node-inner--deleted:hover { + background: var(--color-surface); +} + +.comment-avatar-placeholder { + border-radius: 50%; + background: var(--color-border-subtle); +} + +.comment-deleted-placeholder { + font-size: 0.85rem; + font-style: italic; + color: var(--color-text-muted); + margin: 0; + padding: 0.2rem 0; +} diff --git a/src/App.tsx b/src/App.tsx index c4a036b..af25489 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { Index } from "./pages/Index.tsx"; import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; import { Dump } from "./pages/Dump.tsx"; -import { DumpCreate } from "./pages/DumpCreate.tsx"; import { DumpEdit } from "./pages/DumpEdit.tsx"; import { UserLogin } from "./pages/UserLogin.tsx"; import { UserPublicProfile } from "./pages/UserPublicProfile.tsx"; @@ -13,8 +12,10 @@ import { PlaylistDetail } from "./pages/PlaylistDetail.tsx"; import { MyPlaylists } from "./pages/MyPlaylists.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx"; +import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { WSProvider } from "./contexts/WSProvider.tsx"; import { useAuth } from "./hooks/useAuth.ts"; +import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import "./App.css"; @@ -25,14 +26,6 @@ function AppRoutes() { } /> - - - - } - /> } /> - + + + + ); } diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/AddToPlaylistModal.tsx index 1259e72..0f0cf79 100644 --- a/src/components/AddToPlaylistModal.tsx +++ b/src/components/AddToPlaylistModal.tsx @@ -3,15 +3,11 @@ 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"; +import { deserializePlaylistMembership } from "../model.ts"; +import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; interface AddToPlaylistModalProps { dumpId: string; @@ -25,10 +21,6 @@ export function AddToPlaylistModal( const [memberships, setMemberships] = useState([]); const [loading, setLoading] = useState(true); const [showNewForm, setShowNewForm] = useState(false); - const [newTitle, setNewTitle] = useState(""); - const [newDescription, setNewDescription] = useState(""); - const [newIsPublic, setNewIsPublic] = useState(true); - const [creating, setCreating] = useState(false); const backdropRef = useRef(null); useEffect(() => { @@ -87,41 +79,6 @@ export function AddToPlaylistModal( } }; - 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(
- setNewTitle(e.target.value)} - autoFocus - required - /> -