diff --git a/api/lib/slugify.ts b/api/lib/slugify.ts new file mode 100644 index 0000000..03d27d5 --- /dev/null +++ b/api/lib/slugify.ts @@ -0,0 +1,18 @@ +export const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function slugify(title: string): string { + const slug = title + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") // strip diacritics + .replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash + .replace(/^-+|-+$/g, "") // trim leading/trailing dashes + .substring(0, 60); + return slug || "untitled"; +} + +/** Stable slug tied to the record's id — unique by construction. */ +export function makeSlug(title: string, id: string): string { + return `${slugify(title)}-${id.substring(0, 8)}`; +} diff --git a/api/model/db.ts b/api/model/db.ts index 9eaa1c2..b09a1b0 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -8,10 +8,41 @@ import { type RichContent, type User, } from "./interfaces.ts"; +import { makeSlug } from "../lib/slugify.ts"; export const db = new DatabaseSync("api/sql/gerbeur.db"); db.exec("PRAGMA foreign_keys = ON;"); +// Add columns to existing tables if missing (idempotent migrations) +for ( + const [table, col, def] of [ + ["dumps", "updated_at", "TEXT"], + ["users", "updated_at", "TEXT"], + ["playlists", "updated_at", "TEXT"], + ["comments", "updated_at", "TEXT"], + ["dumps", "slug", "TEXT"], + ["playlists", "slug", "TEXT"], + ] as [string, string, string][] +) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { + name: string; + }[]; + if (!cols.some((c) => c.name === col)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def};`); + } +} + +// Backfill slugs for any records created before this migration +for (const table of ["dumps", "playlists"] as const) { + const rows = db.prepare( + `SELECT id, title FROM ${table} WHERE slug IS NULL;`, + ).all() as { id: string; title: string }[]; + const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`); + for (const row of rows) { + update.run(makeSlug(row.title, row.id), row.id); + } +} + // Purge expired unused invites on startup db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, @@ -28,6 +59,8 @@ export interface DumpRow { comment: string | null; user_id: string; created_at: string; + updated_at: string | null; + slug: string | null; url: string | null; rich_content: string | null; file_name: string | null; @@ -45,6 +78,7 @@ export interface UserRow { password_hash: string; is_admin: number; created_at: string; + updated_at: string | null; avatar_mime: string | null; invited_by: string | null; // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by @@ -100,9 +134,11 @@ export function dumpRowToApi(row: DumpRow): Dump { id: row.id, kind: row.kind as "url" | "file", title: row.title, + slug: row.slug ?? undefined, comment: row.comment ?? undefined, userId: row.user_id, createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, url: row.url ?? undefined, richContent: row.rich_content ? (JSON.parse(row.rich_content) as RichContent) @@ -121,9 +157,11 @@ export function dumpApiToRow(dump: Dump): DumpRow { id: dump.id, kind: dump.kind, title: dump.title, + slug: dump.slug ?? null, comment: dump.comment ?? null, user_id: dump.userId, created_at: dump.createdAt.toISOString(), + updated_at: dump.updatedAt?.toISOString() ?? null, url: dump.url ?? null, rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null, file_name: dump.fileName ?? null, @@ -142,6 +180,7 @@ export function userRowToApi(row: UserRow): User { passwordHash: row.password_hash, isAdmin: Boolean(row.is_admin), createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, avatarMime: row.avatar_mime ?? undefined, invitedByUsername: typeof row.invited_by_username === "string" ? row.invited_by_username @@ -156,6 +195,7 @@ export function userApiToRow(user: User): UserRow { password_hash: user.passwordHash, is_admin: user.isAdmin ? 1 : 0, created_at: user.createdAt.toISOString(), + updated_at: user.updatedAt?.toISOString() ?? null, avatar_mime: user.avatarMime ?? null, invited_by: null, invited_by_username: null, @@ -169,6 +209,7 @@ export interface CommentRow { parent_id: string | null; body: string; created_at: string; + updated_at: string | null; deleted: number; author_username: string; author_avatar_mime: string | null; @@ -199,6 +240,7 @@ export function commentRowToApi(row: CommentRow): Comment { parentId: row.parent_id ?? undefined, body: row.body, createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, deleted: Boolean(row.deleted), authorUsername: row.author_username, authorAvatarMime: row.author_avatar_mime ?? undefined, @@ -209,9 +251,11 @@ export interface PlaylistRow { id: string; user_id: string; title: string; + slug: string | null; description: string | null; is_public: number; created_at: string; + updated_at: string | null; image_mime: string | null; [key: string]: SQLOutputValue; } @@ -231,9 +275,11 @@ export function playlistRowToApi(row: PlaylistRow): Playlist { id: row.id, userId: row.user_id, title: row.title, + slug: row.slug ?? undefined, description: row.description ?? undefined, isPublic: Boolean(row.is_public), createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, imageMime: row.image_mime ?? undefined, dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined, ownerUsername: typeof row.owner_username === "string" diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index cd7d918..d91be12 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -17,9 +17,11 @@ export interface Dump { id: string; kind: "url" | "file"; title: string; + slug?: string; comment?: string; userId: string; createdAt: Date; + updatedAt?: Date; url?: string; richContent?: RichContent; fileName?: string; @@ -40,6 +42,7 @@ export interface User { passwordHash: string; isAdmin: boolean; createdAt: Date; + updatedAt?: Date; avatarMime?: string; invitedByUsername?: string; } @@ -177,6 +180,7 @@ export interface Comment { parentId?: string; body: string; createdAt: Date; + updatedAt?: Date; deleted: boolean; authorUsername: string; authorAvatarMime?: string; @@ -197,6 +201,18 @@ export function isCreateCommentRequest( o.parentId === null); } +export interface UpdateCommentRequest { + body: string; +} + +export function isUpdateCommentRequest( + obj: unknown, +): obj is UpdateCommentRequest { + if (!obj || typeof obj !== "object") return false; + const o = obj as Record; + return typeof o.body === "string" && (o.body as string).trim().length > 0; +} + /** * Playlists */ @@ -205,9 +221,11 @@ export interface Playlist { id: string; userId: string; title: string; + slug?: string; description?: string; isPublic: boolean; createdAt: Date; + updatedAt?: Date; imageMime?: string; dumpCount?: number; ownerUsername?: string; @@ -384,7 +402,8 @@ export type NotificationType = | "user_followed" | "user_dump_posted" | "playlist_dump_added" - | "dump_upvoted"; + | "dump_upvoted" + | "user_mentioned"; export interface PlaylistFollowedData { followerId: string; @@ -419,12 +438,22 @@ export interface DumpUpvotedData { dumpTitle: string; } +export interface UserMentionedData { + mentionerId: string; + mentionerUsername: string; + contextType: "comment" | "dump" | "playlist"; + contextId: string; + contextTitle: string; + dumpId?: string; +} + export type NotificationData = | PlaylistFollowedData | UserFollowedData | UserDumpPostedData | PlaylistDumpAddedData - | DumpUpvotedData; + | DumpUpvotedData + | UserMentionedData; export interface Notification { id: string; diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 3ee4e5a..04c27bf 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -5,6 +5,7 @@ import { type APIResponse, type Comment, isCreateCommentRequest, + isUpdateCommentRequest, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { verifyJWT } from "../lib/jwt.ts"; @@ -12,11 +13,13 @@ import { createComment, deleteComment, getComments, + updateComment, } from "../services/comment-service.ts"; import { getDump } from "../services/dump-service.ts"; import { broadcastCommentCreated, broadcastCommentDeleted, + broadcastCommentUpdated, } from "../services/ws-service.ts"; const router = new Router({ prefix: "/api" }); @@ -62,6 +65,29 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); +// PATCH /api/comments/:commentId — auth required +router.patch("/comments/:commentId", authMiddleware, async (ctx) => { + const userId = ctx.state.user.userId as string; + const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean; + const body = await ctx.request.body.json(); + if (!isUpdateCommentRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid comment data", + ); + } + const { comment, isPrivate } = updateComment( + ctx.params.commentId, + body.body, + userId, + isAdmin, + ); + if (!isPrivate) broadcastCommentUpdated(comment); + const responseBody: APIResponse = { success: true, data: comment }; + ctx.response.body = responseBody; +}); + // DELETE /api/comments/:commentId — auth required router.delete("/comments/:commentId", authMiddleware, (ctx) => { const userId = ctx.state.user.userId as string; diff --git a/api/routes/users.ts b/api/routes/users.ts index 852d551..a46dc9a 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -14,6 +14,7 @@ import { createUser, getUserById, getUserByUsername, + searchUsers, } from "../services/user-service.ts"; import { redeemInvite, validateInvite } from "../services/invite-service.ts"; import { @@ -131,6 +132,13 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { } }); +// User search for @mention autocomplete +router.get("/search", (ctx) => { + const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); + const results = searchUsers(q, 8); + ctx.response.body = { success: true, data: results }; +}); + // Public user profile by internal ID (used when only userId is available, e.g. dump.userId) router.get("/by-id/:userId", (ctx) => { const user = getUserById(ctx.params.userId); diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 8cbc97d..7168863 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -10,9 +10,10 @@ import { db, isCommentRow, } from "../model/db.ts"; +import { notifyMentions } from "./notification-service.ts"; const SELECT_COLS = - `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.deleted, + `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, u.username as author_username, u.avatar_mime as author_avatar_mime`; function fetchComment(commentId: string): Comment { @@ -59,7 +60,63 @@ export function createComment( body.trim(), createdAt.toISOString(), ); - return fetchComment(id); + const comment = fetchComment(id); + const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( + dumpId, + ) as { title: string } | undefined; + notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId); + return comment; +} + +export function updateComment( + commentId: string, + body: string, + requestingUserId: string, + isAdmin: boolean, +): { comment: Comment; 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 existing = fetchComment(commentId); + if (existing.deleted) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Cannot edit a deleted comment", + ); + } + if (existing.userId !== requestingUserId && !isAdmin) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to edit this comment", + ); + } + const now = new Date().toISOString(); + db.prepare(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?;`).run( + body.trim(), + now, + commentId, + ); + const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get( + row.dump_id, + ) as { title: string } | undefined; + notifyMentions( + requestingUserId, + body, + "comment", + commentId, + dumpRow?.title ?? "", + row.dump_id, + ); + return { + comment: fetchComment(commentId), + dumpId: row.dump_id, + isPrivate: Boolean(row.is_private), + }; } export function deleteComment( diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index b8d61cd..f007f63 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -12,7 +12,11 @@ import { broadcastDumpUpdated, broadcastNewDump, } from "./ws-service.ts"; -import { notifyUserFollowersNewDump } from "./notification-service.ts"; +import { + notifyMentions, + notifyUserFollowersNewDump, +} from "./notification-service.ts"; +import { makeSlug, UUID_RE } from "../lib/slugify.ts"; const UPLOADS_DIR = "api/uploads"; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB @@ -45,13 +49,13 @@ function titleFromUrl(url: string): string { } const BASE_COLS = - "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; + "id, kind, title, slug, 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," + + "d.id, d.kind, d.title, d.slug, 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( @@ -67,14 +71,16 @@ export async function createUrlDump( const richContent = await fetchRichContent(request.url); const title = richContent?.title ?? titleFromUrl(request.url); const isPrivate = request.isPrivate ?? false; + const slug = makeSlug(title, dumpId); db.prepare( - `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content, is_private) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, + `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, ).run( dumpId, "url", title, + slug, request.comment ?? null, userId, createdAt.toISOString(), @@ -87,6 +93,7 @@ export async function createUrlDump( id: dumpId, kind: "url", title, + slug, comment: request.comment, userId, createdAt, @@ -100,6 +107,7 @@ export async function createUrlDump( broadcastNewDump(dump); notifyUserFollowersNewDump(userId, dumpId, title); } + if (request.comment) notifyMentions(userId, request.comment, "dump", dumpId, title); return dump; } @@ -126,6 +134,7 @@ export async function createFileDump( const dumpId = crypto.randomUUID(); const createdAt = new Date(); + const slug = makeSlug(file.name, dumpId); await Deno.mkdir(UPLOADS_DIR, { recursive: true }); const data = new Uint8Array(await file.arrayBuffer()); @@ -134,12 +143,13 @@ 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, is_private) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, + `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, ).run( dumpId, "file", file.name, + slug, comment ?? null, userId, createdAt.toISOString(), @@ -158,6 +168,7 @@ export async function createFileDump( id: dumpId, kind: "file", title: file.name, + slug, comment, userId, createdAt, @@ -172,14 +183,17 @@ export async function createFileDump( broadcastNewDump(dump); notifyUserFollowersNewDump(userId, dumpId, file.name); } + if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name); return 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); +function fetchDump(idOrSlug: string): Dump { + const row = UUID_RE.test(idOrSlug) + ? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug) + : db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get( + idOrSlug, + ); if (!row || !isDumpRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } @@ -234,6 +248,8 @@ export async function updateDump( ): Promise { const dump = fetchDump(dumpId); + const now = new Date(); + // File dumps: only comment and isPrivate are editable if (dump.kind === "file") { const updatedDump: Dump = { @@ -244,10 +260,27 @@ export async function updateDump( isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate, + updatedAt: now, }; - db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`) - .run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId); - if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + db.prepare( + `UPDATE dumps SET comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, + ).run( + updatedDump.comment ?? null, + updatedDump.isPrivate ? 1 : 0, + now.toISOString(), + dumpId, + ); + if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); + else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + if (updatedDump.comment) { + notifyMentions( + dump.userId, + updatedDump.comment, + "dump", + dumpId, + updatedDump.title, + ); + } return updatedDump; } @@ -265,9 +298,11 @@ export async function updateDump( title = richContent?.title ?? titleFromUrl(newUrl); } + const newSlug = makeSlug(title, dumpId); const updatedDump: Dump = { ...dump, title, + slug: newSlug, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, @@ -276,17 +311,20 @@ export async function updateDump( isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate, + updatedAt: now, }; const row = dumpApiToRow(updatedDump); const result = db.prepare( - `UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`, + `UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`, ).run( row.title, + row.slug, row.comment, row.url, row.rich_content, row.is_private, + now.toISOString(), row.id, ); @@ -294,7 +332,11 @@ export async function updateDump( throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } - if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); + else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); + if (updatedDump.comment) { + notifyMentions(dump.userId, updatedDump.comment, "dump", dumpId, updatedDump.title); + } return updatedDump; } @@ -326,17 +368,31 @@ export async function replaceFileDump( const data = new Uint8Array(await file.arrayBuffer()); await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data); + const now = new Date(); + const newSlug = makeSlug(file.name, dumpId); db.prepare( - `UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`, - ).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId); + `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`, + ).run( + file.name, + newSlug, + file.name, + file.type, + file.size, + comment ?? null, + now.toISOString(), + dumpId, + ); + if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name); return { ...dump, title: file.name, + slug: newSlug, fileName: file.name, fileMime: file.type, fileSize: file.size, comment, + updatedAt: now, }; } @@ -376,19 +432,20 @@ export function getVotedDumpsByUser( let totalRow: { count: number } | undefined; let rawRows: unknown[]; - if (requestingUserId) { + if (requestingUserId === userId) { + // Own profile: include private dumps the user themselves voted on and owns. 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); + ).all(userId, 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 OR d.user_id = ?);`, - ).get(userId, requestingUserId) as { count: number } | undefined; + ).get(userId, userId) as { count: number } | undefined; } else { rawRows = db.prepare( `SELECT ${dumpCols} diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index d296ea8..85686fe 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -7,6 +7,9 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts"; import { sendToUser } from "./ws-service.ts"; +// Regex: matches @username not already inside a markdown link ([...] or (...) +const MENTION_RE = /(? m[1].toLowerCase()), + )]; + + for (const username of usernames) { + const mentionedRow = db.prepare( + `SELECT id FROM users WHERE lower(username) = ?;`, + ).get(username) as { id: string } | undefined; + if (!mentionedRow || mentionedRow.id === mentionerUserId) continue; + + createNotification( + mentionedRow.id, + "user_mentioned", + { + mentionerId: mentionerUserId, + mentionerUsername: mentionerRow.username, + contextType, + contextId, + contextTitle, + dumpId, + }, + `mention:${contextType}:${contextId}:${mentionedRow.id}`, + ); + } +} + export function notifyPlaylistFollowersNewDump( playlistId: string, playlistTitle: string, diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 67077cb..f3414c7 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -22,19 +22,23 @@ import { broadcastPlaylistDumpsUpdated, broadcastPlaylistUpdated, } from "./ws-service.ts"; -import { notifyPlaylistFollowersNewDump } from "./notification-service.ts"; +import { + notifyMentions, + notifyPlaylistFollowersNewDump, +} from "./notification-service.ts"; +import { makeSlug, UUID_RE } from "../lib/slugify.ts"; const DUMP_SELECT_COLS = - "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; + "id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; const PLAYLIST_SELECT = `p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count FROM playlists p LEFT JOIN users u ON u.id = p.user_id`; -function getPlaylistById(playlistId: string): Playlist { - const row = db.prepare( - `SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`, - ).get(playlistId); +function getPlaylistById(idOrSlug: string): Playlist { + const row = UUID_RE.test(idOrSlug) + ? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) + : db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug); if (!row || !isPlaylistRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); } @@ -47,13 +51,15 @@ export function createPlaylist( ): Playlist { const id = crypto.randomUUID(); const createdAt = new Date(); + const slug = makeSlug(req.title, id); db.prepare( - `INSERT INTO playlists (id, user_id, title, description, is_public, created_at) - VALUES (?, ?, ?, ?, ?, ?);`, + `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( id, userId, req.title, + slug, req.description ?? null, req.isPublic ? 1 : 0, createdAt.toISOString(), @@ -62,10 +68,12 @@ export function createPlaylist( id, userId, title: req.title, + slug, description: req.description, isPublic: req.isPublic, createdAt, }; + if (req.description) notifyMentions(userId, req.description, "playlist", id, req.title); broadcastPlaylistCreated(playlist); return playlist; } @@ -91,7 +99,7 @@ export function getPlaylist( WHERE pd.playlist_id = ? AND (d.is_private = 0 OR d.user_id = ?) ORDER BY pd.position ASC;`, - ).all(playlistId, requestingUserId ?? ""); + ).all(playlist.id, requestingUserId ?? ""); const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi); // Owners always see their own private dumps; strip them for non-owners regardless @@ -145,16 +153,21 @@ export function updatePlaylist( ? req.isPublic : playlist.isPublic; + const now = new Date(); + const newSlug = makeSlug(newTitle, playlist.id); db.prepare( - `UPDATE playlists SET title = ?, description = ?, is_public = ? WHERE id = ?;`, - ).run(newTitle, newDescription, newIsPublic ? 1 : 0, playlistId); + `UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`, + ).run(newTitle, newSlug, newDescription, newIsPublic ? 1 : 0, now.toISOString(), playlist.id); const updated: Playlist = { ...playlist, title: newTitle, + slug: newSlug, description: newDescription ?? undefined, isPublic: newIsPublic, + updatedAt: now, }; + if (newDescription) notifyMentions(requestingUserId, newDescription, "playlist", playlist.id, newTitle); broadcastPlaylistUpdated(updated); return updated; } @@ -169,8 +182,8 @@ export function deletePlaylist( throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } - db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlistId); - broadcastPlaylistDeleted(playlistId, playlist.userId, playlist.isPublic); + db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); + broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic); } export function setPlaylistImage( @@ -184,9 +197,9 @@ export function setPlaylistImage( } db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run( imageMime, - playlistId, + playlist.id, ); - const updated = getPlaylistById(playlistId); + const updated = getPlaylistById(playlist.id); broadcastPlaylistUpdated(updated); return updated; } @@ -204,7 +217,7 @@ export function addDumpToPlaylist( const minRow = db.prepare( `SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`, - ).get(playlistId) as { min_pos: number | null } | undefined; + ).get(playlist.id) as { min_pos: number | null } | undefined; const nextPos = (minRow?.min_pos ?? 1) - 1; const addedAt = new Date().toISOString(); @@ -213,7 +226,7 @@ export function addDumpToPlaylist( db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, ?, ?);`, - ).run(playlistId, dumpId, nextPos, addedAt); + ).run(playlist.id, dumpId, nextPos, addedAt); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("UNIQUE") || msg.includes("unique")) { @@ -226,7 +239,7 @@ export function addDumpToPlaylist( throw err; } - const dumpIds = getCurrentDumpIds(playlistId); + const dumpIds = getCurrentDumpIds(playlist.id); broadcastPlaylistDumpsUpdated(playlist, dumpIds); if (playlist.isPublic) { @@ -235,7 +248,7 @@ export function addDumpToPlaylist( ) as { title: string } | undefined; if (dumpRow) { notifyPlaylistFollowersNewDump( - playlistId, + playlist.id, playlist.title, dumpId, dumpRow.title, @@ -257,9 +270,9 @@ export function removeDumpFromPlaylist( db.prepare( `DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`, - ).run(playlistId, dumpId); + ).run(playlist.id, dumpId); - const dumpIds = getCurrentDumpIds(playlistId); + const dumpIds = getCurrentDumpIds(playlist.id); broadcastPlaylistDumpsUpdated(playlist, dumpIds); } @@ -274,7 +287,7 @@ export function reorderPlaylist( throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } - const currentIds = getCurrentDumpIds(playlistId); + const currentIds = getCurrentDumpIds(playlist.id); const currentSet = new Set(currentIds); const newSet = new Set(dumpIds); @@ -293,7 +306,7 @@ export function reorderPlaylist( `UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`, ); for (let i = 0; i < dumpIds.length; i++) { - update.run(i, playlistId, dumpIds[i]); + update.run(i, playlist.id, dumpIds[i]); } broadcastPlaylistDumpsUpdated(playlist, dumpIds); diff --git a/api/services/user-service.ts b/api/services/user-service.ts index d39e8c1..d7a9c54 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -81,6 +81,25 @@ export function getUserByUsername(username: string): User { return userRowToApi(userRow); } +export function searchUsers( + query: string, + limit: number, +): { id: string; username: string; avatarMime: string | null }[] { + if (!query) return []; + const rows = db.prepare( + `SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`, + ).all(`${query}%`, limit) as { + id: string; + username: string; + avatar_mime: string | null; + }[]; + return rows.map((r) => ({ + id: r.id, + username: r.username, + avatarMime: r.avatar_mime, + })); +} + export function listUsers(): User[] { const userRows = db.prepare( `${USER_SELECT}`, @@ -101,20 +120,23 @@ export async function updateUser( const { password, ...requestFields } = request; + const now = new Date(); const updatedUser: User = { ...user, passwordHash: password ? await hashPassword(password) : user.passwordHash, ...requestFields, + updatedAt: now, }; const updatedUserRow = userApiToRow(updatedUser); const userResult = db.prepare( - `UPDATE users SET username = ?, password_hash = ?, is_admin = ? WHERE id = ?`, + `UPDATE users SET username = ?, password_hash = ?, is_admin = ?, updated_at = ? WHERE id = ?`, ).run( updatedUserRow.username, updatedUserRow.password_hash, updatedUserRow.is_admin, + now.toISOString(), updatedUserRow.id, ); @@ -127,8 +149,8 @@ export async function updateUser( export function updateUserAvatar(userId: string, mime: string): void { const result = db.prepare( - `UPDATE users SET avatar_mime = ? WHERE id = ?`, - ).run(mime, userId); + `UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`, + ).run(mime, new Date().toISOString(), userId); if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index dfd2ef7..320d50c 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -117,7 +117,11 @@ export function broadcastPlaylistCreated(playlist: Playlist): void { } export function broadcastPlaylistUpdated(playlist: Playlist): void { - sendToPlaylistAudience(playlist, { type: "playlist_updated", playlist }); + // Broadcast to ALL clients so non-owners can react to visibility changes + // (e.g. remove a now-private playlist from their feed). + for (const client of clients) { + send(client.socket, { type: "playlist_updated", playlist }); + } } export function broadcastPlaylistDeleted( @@ -158,6 +162,12 @@ export function broadcastCommentDeleted( } } +export function broadcastCommentUpdated(comment: Comment): void { + for (const client of clients) { + send(client.socket, { type: "comment_updated", comment }); + } +} + // 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 a5014fe..cfead5e 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -5,6 +5,7 @@ CREATE TABLE dumps ( comment TEXT, user_id TEXT NOT NULL, created_at TEXT NOT NULL, + updated_at TEXT, url TEXT, rich_content TEXT, file_name TEXT, @@ -21,6 +22,7 @@ CREATE TABLE users ( password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, + updated_at TEXT, avatar_mime TEXT, invited_by TEXT REFERENCES users(id) ); @@ -41,6 +43,7 @@ CREATE TABLE playlists ( description TEXT, is_public INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, + updated_at TEXT, image_mime TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); @@ -62,6 +65,7 @@ CREATE TABLE comments ( parent_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, + updated_at TEXT, 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, diff --git a/deno.lock b/deno.lock index 1d432a8..89b225e 100644 --- a/deno.lock +++ b/deno.lock @@ -5,7 +5,7 @@ "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@oak/commons@1": "1.0.1", "jsr:@oak/oak@^17.2.0": "17.2.0", - "jsr:@panva/jose@^6.2.1": "6.2.1", + "jsr:@panva/jose@^6.2.1": "6.2.2", "jsr:@std/assert@1": "1.0.19", "jsr:@std/bytes@1": "1.0.6", "jsr:@std/crypto@1": "1.0.5", @@ -20,12 +20,12 @@ "jsr:@std/path@1.0": "1.0.9", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@tajpouria/cors@^1.2.1": "1.2.1", - "npm:@deno/vite-plugin@^1.0.6": "1.0.6_vite@8.0.0__@types+node@24.12.0", + "npm:@deno/vite-plugin@^1.0.6": "1.0.6_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0", "npm:@eslint/js@^9.39.4": "9.39.4", "npm:@types/node@^24.12.0": "24.12.0", "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14", "npm:@types/react@^19.2.14": "19.2.14", - "npm:@vitejs/plugin-react@^6.0.1": "6.0.1_vite@8.0.0__@types+node@24.12.0", + "npm:@vitejs/plugin-react@^6.0.1": "6.0.1_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0", "npm:eslint-plugin-react-hooks@^7.0.1": "7.0.1_eslint@9.39.4", "npm:eslint-plugin-react-refresh@~0.5.2": "0.5.2_eslint@9.39.4", "npm:eslint@^9.39.4": "9.39.4", @@ -36,10 +36,10 @@ "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-eslint@^8.56.1": "8.57.1_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", - "npm:vite@8": "8.0.0_@types+node@24.12.0" + "npm:vite@*": "8.0.1_@types+node@24.12.0", + "npm:vite@8": "8.0.1_@types+node@24.12.0" }, "jsr": { "@db/sqlite@0.13.0": { @@ -81,8 +81,8 @@ "npm:path-to-regexp" ] }, - "@panva/jose@6.2.1": { - "integrity": "6725a90a47be84c57a0f889c73bf09c6a209019c816f1f029f9084929fadfcbf" + "@panva/jose@6.2.2": { + "integrity": "bfe1178a9d2f53effa5ee6c1786b6534a39690b03969472b3ad690600d8898ec" }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e" @@ -211,15 +211,15 @@ "@babel/helper-validator-option@7.27.1": { "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" }, - "@babel/helpers@7.28.6": { - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "@babel/helpers@7.29.2": { + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dependencies": [ "@babel/template", "@babel/types" ] }, - "@babel/parser@7.29.0": { - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "@babel/parser@7.29.2": { + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dependencies": [ "@babel/types" ], @@ -252,21 +252,21 @@ "@babel/helper-validator-identifier" ] }, - "@deno/vite-plugin@1.0.6_vite@8.0.0__@types+node@24.12.0": { + "@deno/vite-plugin@1.0.6_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0": { "integrity": "sha512-Sh5XqvFuKAwjARTesi0n6xRpEXm1V0UeqKh+SxIrexCofxOaieNDMqXZD02RiZCg0mrJ43V8eCMuVrDfq6mLmg==", "dependencies": [ "vite" ] }, - "@emnapi/core@1.9.0": { - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "@emnapi/core@1.9.1": { + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dependencies": [ "@emnapi/wasi-threads", "tslib" ] }, - "@emnapi/runtime@1.9.0": { - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "@emnapi/runtime@1.9.1": { + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dependencies": [ "tslib" ] @@ -385,103 +385,100 @@ "@tybys/wasm-util" ] }, - "@oxc-project/runtime@0.115.0": { - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==" + "@oxc-project/types@0.120.0": { + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==" }, - "@oxc-project/types@0.115.0": { - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==" - }, - "@rolldown/binding-android-arm64@1.0.0-rc.9": { - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "@rolldown/binding-android-arm64@1.0.0-rc.10": { + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "os": ["android"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-arm64@1.0.0-rc.9": { - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "@rolldown/binding-darwin-arm64@1.0.0-rc.10": { + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-x64@1.0.0-rc.9": { - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "@rolldown/binding-darwin-x64@1.0.0-rc.10": { + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "os": ["darwin"], "cpu": ["x64"] }, - "@rolldown/binding-freebsd-x64@1.0.0-rc.9": { - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "@rolldown/binding-freebsd-x64@1.0.0-rc.10": { + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9": { - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10": { + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "os": ["linux"], "cpu": ["arm"] }, - "@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9": { - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10": { + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-arm64-musl@1.0.0-rc.9": { - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "@rolldown/binding-linux-arm64-musl@1.0.0-rc.10": { + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9": { - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10": { + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9": { - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10": { + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "os": ["linux"], "cpu": ["s390x"] }, - "@rolldown/binding-linux-x64-gnu@1.0.0-rc.9": { - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "@rolldown/binding-linux-x64-gnu@1.0.0-rc.10": { + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-linux-x64-musl@1.0.0-rc.9": { - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "@rolldown/binding-linux-x64-musl@1.0.0-rc.10": { + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-openharmony-arm64@1.0.0-rc.9": { - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "@rolldown/binding-openharmony-arm64@1.0.0-rc.10": { + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rolldown/binding-wasm32-wasi@1.0.0-rc.9": { - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "@rolldown/binding-wasm32-wasi@1.0.0-rc.10": { + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "dependencies": [ "@napi-rs/wasm-runtime" ], "cpu": ["wasm32"] }, - "@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9": { - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10": { + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "os": ["win32"], "cpu": ["arm64"] }, - "@rolldown/binding-win32-x64-msvc@1.0.0-rc.9": { - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "@rolldown/binding-win32-x64-msvc@1.0.0-rc.10": { + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "os": ["win32"], "cpu": ["x64"] }, + "@rolldown/pluginutils@1.0.0-rc.10": { + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==" + }, "@rolldown/pluginutils@1.0.0-rc.7": { "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==" }, - "@rolldown/pluginutils@1.0.0-rc.9": { - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==" - }, "@tybys/wasm-util@0.10.1": { "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dependencies": [ "tslib" ] }, - "@types/debug@4.1.12": { - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "@types/debug@4.1.13": { + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dependencies": [ "@types/ms" ] @@ -537,8 +534,8 @@ "@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==", + "@typescript-eslint/eslint-plugin@8.57.1_@typescript-eslint+parser@8.57.1__eslint@9.39.4__typescript@5.9.3_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dependencies": [ "@eslint-community/regexpp", "@typescript-eslint/parser", @@ -553,8 +550,8 @@ "typescript" ] }, - "@typescript-eslint/parser@8.57.0_eslint@9.39.4_typescript@5.9.3": { - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "@typescript-eslint/parser@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dependencies": [ "@typescript-eslint/scope-manager", "@typescript-eslint/types", @@ -565,8 +562,8 @@ "typescript" ] }, - "@typescript-eslint/project-service@8.57.0_typescript@5.9.3": { - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "@typescript-eslint/project-service@8.57.1_typescript@5.9.3": { + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "dependencies": [ "@typescript-eslint/tsconfig-utils", "@typescript-eslint/types", @@ -574,21 +571,21 @@ "typescript" ] }, - "@typescript-eslint/scope-manager@8.57.0": { - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "@typescript-eslint/scope-manager@8.57.1": { + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/visitor-keys" ] }, - "@typescript-eslint/tsconfig-utils@8.57.0_typescript@5.9.3": { - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "@typescript-eslint/tsconfig-utils@8.57.1_typescript@5.9.3": { + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dependencies": [ "typescript" ] }, - "@typescript-eslint/type-utils@8.57.0_eslint@9.39.4_typescript@5.9.3": { - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "@typescript-eslint/type-utils@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/typescript-estree", @@ -599,11 +596,11 @@ "typescript" ] }, - "@typescript-eslint/types@8.57.0": { - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==" + "@typescript-eslint/types@8.57.1": { + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==" }, - "@typescript-eslint/typescript-estree@8.57.0_typescript@5.9.3": { - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "@typescript-eslint/typescript-estree@8.57.1_typescript@5.9.3": { + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dependencies": [ "@typescript-eslint/project-service", "@typescript-eslint/tsconfig-utils", @@ -617,8 +614,8 @@ "typescript" ] }, - "@typescript-eslint/utils@8.57.0_eslint@9.39.4_typescript@5.9.3": { - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "@typescript-eslint/utils@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dependencies": [ "@eslint-community/eslint-utils", "@typescript-eslint/scope-manager", @@ -628,8 +625,8 @@ "typescript" ] }, - "@typescript-eslint/visitor-keys@8.57.0": { - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "@typescript-eslint/visitor-keys@8.57.1": { + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "dependencies": [ "@typescript-eslint/types", "eslint-visitor-keys@5.0.1" @@ -638,7 +635,7 @@ "@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": { + "@vitejs/plugin-react@6.0.1_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0": { "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", "dependencies": [ "@rolldown/pluginutils@1.0.0-rc.7", @@ -682,8 +679,8 @@ "balanced-match@4.0.4": { "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" }, - "baseline-browser-mapping@2.10.8": { - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "baseline-browser-mapping@2.10.10": { + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "bin": true }, "brace-expansion@1.1.12": { @@ -713,8 +710,8 @@ "callsites@3.1.0": { "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, - "caniuse-lite@1.0.30001779": { - "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==" + "caniuse-lite@1.0.30001780": { + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==" }, "ccount@2.0.1": { "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" @@ -797,8 +794,8 @@ "dequal" ] }, - "electron-to-chromium@1.5.313": { - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==" + "electron-to-chromium@1.5.321": { + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, "escalade@3.2.0": { "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" @@ -952,8 +949,8 @@ "keyv" ] }, - "flatted@3.4.1": { - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==" + "flatted@3.4.2": { + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==" }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", @@ -1786,11 +1783,11 @@ "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, - "rolldown@1.0.0-rc.9": { - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "rolldown@1.0.0-rc.10": { + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "dependencies": [ "@oxc-project/types", - "@rolldown/pluginutils@1.0.0-rc.9" + "@rolldown/pluginutils@1.0.0-rc.10" ], "optionalDependencies": [ "@rolldown/binding-android-arm64", @@ -1881,8 +1878,8 @@ "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==", + "ts-api-utils@2.5.0_typescript@5.9.3": { + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dependencies": [ "typescript" ] @@ -1896,8 +1893,8 @@ "prelude-ls" ] }, - "typescript-eslint@8.57.0_eslint@9.39.4_typescript@5.9.3": { - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "typescript-eslint@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dependencies": [ "@typescript-eslint/eslint-plugin", "@typescript-eslint/parser", @@ -1988,10 +1985,9 @@ "vfile-message" ] }, - "vite@8.0.0_@types+node@24.12.0": { - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "vite@8.0.1_@types+node@24.12.0": { + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dependencies": [ - "@oxc-project/runtime", "@types/node", "lightningcss", "picomatch", diff --git a/src/App.css b/src/App.css index bd5847c..627d370 100644 --- a/src/App.css +++ b/src/App.css @@ -1,6 +1,6 @@ /* ── Markdown prose ── */ .md p { - margin: 0 0 0.7em; + margin: 0 0 0.85em; } .md p:last-child { margin-bottom: 0; @@ -120,7 +120,7 @@ .dump-comment { font-size: 1.05rem; - line-height: 1.6; + line-height: 1.72; opacity: 0.85; border-left: 3px solid var(--color-accent); margin: 0; @@ -318,6 +318,138 @@ opacity: 0.7; } +/* ── FileDropZone ── */ +.fdz-wrapper { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.fdz-label { + font-size: 0.9rem; + font-weight: 500; +} + +.fdz { + border: 2px dashed var(--color-border-subtle); + border-radius: 10px; + background: var(--color-surface); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + outline: none; +} + +.fdz:hover:not(.fdz--disabled):not(.fdz--filled), +.fdz:focus-visible { + border-color: var(--color-accent); +} + +.fdz--drag { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface)); +} + +.fdz--disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.fdz--filled { + cursor: default; + border-style: solid; +} + +.fdz__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 2rem 1.5rem; + user-select: none; +} + +.fdz__upload-icon { + width: 2rem; + height: 2rem; + opacity: 0.4; + margin-bottom: 0.2rem; +} + +.fdz__hint { + margin: 0; + font-size: 0.9rem; + font-weight: 500; +} + +.fdz__browse { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.fdz__browse-link { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.fdz__limit { + margin: 0.25rem 0 0; + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.fdz__file { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; +} + +.fdz__file-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.fdz__file-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + flex: 1; +} + +.fdz__file-name { + font-size: 0.9rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fdz__file-size { + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.fdz__clear { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + color: var(--color-text-muted); + font-size: 0.8rem; + padding: 0.3rem 0.5rem; + border-radius: 4px; + transition: color 0.12s, background 0.12s; + line-height: 1; +} + +.fdz__clear:hover { + color: var(--color-text); + background: var(--color-border-subtle); +} + /* ── Local file / URL preview (DumpCreate) ── */ .local-preview-image { width: 100%; @@ -559,7 +691,8 @@ .file-preview-pdf { width: 100%; - min-height: 420px; + height: 80vh; + min-height: 600px; border-radius: 8px; border: 2px solid var(--color-border); display: block; @@ -1907,16 +2040,22 @@ body.has-player .fab-new { .dump-card-meta { display: flex; - align-items: center; + align-items: baseline; gap: 0.5rem; margin-top: 0.2rem; } .dump-card-date { - display: block; margin-top: 0; } +.dump-edited-label, +.playlist-edited-label { + font-size: 0.72rem; + opacity: 0.5; + font-style: italic; +} + .playlist-card-meta { display: flex; align-items: center; @@ -2213,9 +2352,10 @@ body.has-player .fab-new { } .playlist-detail-description { + font-size: 1rem; margin: 0 0 0.5rem; opacity: 0.75; - line-height: 1.5; + line-height: 1.75; } .playlist-detail-meta { @@ -2518,11 +2658,32 @@ body.has-player .fab-new { .comment-time { font-size: 0.75rem; color: var(--color-text-muted); + text-decoration: none; +} +.comment-time:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +@keyframes comment-highlight { + 0% { background: color-mix(in srgb, var(--color-accent) 18%, transparent); } + 100% { background: transparent; } +} +.comment-node--highlight { + border-radius: 6px; + animation: comment-highlight 2s ease-out forwards; +} + +.comment-edited { + font-size: 0.72rem; + color: var(--color-text-muted); + opacity: 0.7; + font-style: italic; } .comment-body { font-size: 0.9rem; - line-height: 1.6; + line-height: 1.65; color: var(--color-text); } @@ -2582,6 +2743,20 @@ body.has-player .fab-new { border: 1px solid var(--color-border-subtle); } +.comment-top-form-inner { + display: flex; + gap: 0.75rem; + align-items: flex-start; +} + +.comment-top-form-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; +} + .comment-reply-textarea { width: 100%; box-sizing: border-box; @@ -2592,8 +2767,9 @@ body.has-player .fab-new { font-family: inherit; font-size: 0.875rem; color: var(--color-text); - resize: vertical; - min-height: 4.5rem; + resize: none; + overflow: hidden; + min-height: 2.4rem; transition: border-color 0.15s, box-shadow 0.15s; } @@ -2866,15 +3042,12 @@ body.has-player .fab-new { gap: 1rem; } .notification-item { - display: flex; - align-items: center; - gap: 0.875rem; - padding: 0.875rem 1rem; background: var(--color-surface); border-radius: 10px; border: 1px solid var(--color-border-subtle); border-left: 3px solid transparent; transition: background 0.12s, border-color 0.12s; + overflow: hidden; } .notification-item:hover { background: color-mix( @@ -2883,6 +3056,15 @@ body.has-player .fab-new { var(--color-text) 8% ); } +.notification-item-link { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.875rem 1rem; + text-decoration: none; + color: inherit; + width: 100%; +} .notification-item--unread { border-left-color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface)); @@ -2945,15 +3127,6 @@ body.has-player .fab-new { white-space: nowrap; flex-shrink: 0; } -.notif-link { - color: var(--color-text); - text-decoration: none; - font-weight: 600; -} -.notif-link:hover { - color: var(--color-accent); - text-decoration: underline; -} .load-more-btn { display: block; margin: 1.5rem auto 0; @@ -2974,3 +3147,64 @@ body.has-player .fab-new { opacity: 0.5; cursor: not-allowed; } + +/* ── Mention autocomplete ── */ +.mention-textarea-wrap { + position: relative; +} + +.mention-textarea-wrap textarea { + width: 100%; + box-sizing: border-box; +} +.mention-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 200; + list-style: none; + margin: 2px 0 0; + padding: 4px 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + max-height: 220px; + overflow-y: auto; +} +.mention-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + transition: background 0.1s; +} +.mention-dropdown-item:hover, +.mention-dropdown-item--selected { + background: var(--color-bg); +} +.mention-dropdown-username { + font-size: 0.9rem; + color: var(--color-text); +} +.notif-icon--mention { + font-weight: 700; + font-family: monospace; +} + +/* ── Tooltip ── */ +.tooltip { + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border-subtle); + padding: 0.3em 0.65em; + border-radius: 5px; + font-size: 0.78rem; + font-style: normal; + white-space: nowrap; + pointer-events: none; + z-index: 9999; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); +} diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index ddcdab8..544c6be 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -69,13 +69,6 @@ export function AppHeader( - )} diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index 0981ca3..cbf8eb6 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -5,8 +5,11 @@ import type { Comment, RawComment, User } from "../model.ts"; import { deserializeComment } from "../model.ts"; import { Avatar } from "./Avatar.tsx"; import { Markdown } from "./Markdown.tsx"; +import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx"; import { relativeTime } from "../utils/relativeTime.ts"; import { ErrorCard } from "./ErrorCard.tsx"; +import { Tooltip } from "./Tooltip.tsx"; +import { ConfirmModal } from "./ConfirmModal.tsx"; interface CommentThreadProps { dumpId: string; @@ -15,6 +18,7 @@ interface CommentThreadProps { token: string | null; onCommentCreated: (comment: Comment) => void; onCommentDeleted: (commentId: string) => void; + onCommentUpdated: (comment: Comment) => void; } function buildTree(comments: Comment[]): Map { @@ -27,7 +31,7 @@ function buildTree(comments: Comment[]): Map { return map; } -const MAX_INDENT_DEPTH = 4; +const MAX_INDENT_DEPTH = 6; interface CommentNodeProps { comment: Comment; @@ -38,6 +42,7 @@ interface CommentNodeProps { token: string | null; onCommentCreated: (comment: Comment) => void; onCommentDeleted: (commentId: string) => void; + onCommentUpdated: (comment: Comment) => void; } function CommentNode({ @@ -49,12 +54,20 @@ function CommentNode({ token, onCommentCreated, onCommentDeleted, + onCommentUpdated, }: CommentNodeProps) { const [replyOpen, setReplyOpen] = useState(false); const [replyBody, setReplyBody] = useState(""); const [submitting, setSubmitting] = useState(false); const [replyError, setReplyError] = useState(null); - const replyTextareaRef = useRef(null); + const [editOpen, setEditOpen] = useState(false); + const [editBody, setEditBody] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(false); + const [editSubmitting, setEditSubmitting] = useState(false); + const [editError, setEditError] = useState(null); + + const replyEditorRef = useRef(null); + const editEditorRef = useRef(null); const children = tree.get(comment.id) ?? []; @@ -98,8 +111,38 @@ function CommentNode({ } } + async function handleEditSave(e: React.FormEvent) { + e.preventDefault(); + if (!editBody.trim() || !token) return; + setEditSubmitting(true); + setEditError(null); + try { + const res = await fetch(`${API_URL}/api/comments/${comment.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ body: editBody }), + }); + const data = await res.json(); + if (data.success) { + onCommentUpdated(deserializeComment(data.data as RawComment)); + setEditOpen(false); + } else { + setEditError(data.error?.message ?? "Failed to save edit."); + } + } catch { + setEditError("Could not reach the server. Please try again."); + } finally { + setEditSubmitting(false); + } + } + const canDelete = !comment.deleted && !!currentUser && (currentUser.id === comment.userId || currentUser.isAdmin); + const canEdit = !comment.deleted && !!currentUser && + (currentUser.id === comment.userId || currentUser.isAdmin); if (comment.deleted) { return ( @@ -118,7 +161,7 @@ function CommentNode({ {children.length > 0 && (
    = MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined} + style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined} > {children.map((child) => ( ))}
@@ -141,7 +185,7 @@ function CommentNode({ return (
  • -
    +
    {comment.authorUsername} - + + + + + {comment.updatedAt && ( + + + edited {relativeTime(comment.updatedAt)} + + + )}
    - {comment.body} + {editOpen + ? ( +
    + { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleEditSave(e); + }} + autoResize + rows={1} + /> + {editError && ( + + )} +
    + + +
    + + ) + : {comment.body}}
    - {currentUser && ( + {currentUser && !editOpen && ( )} - {canDelete && ( + {canEdit && !editOpen && ( + + )} + {canDelete && !editOpen && ( )} + {confirmDelete && ( + { setConfirmDelete(false); handleDelete(); }} + onCancel={() => setConfirmDelete(false)} + /> + )}
    {replyOpen && (
    -