From 867e64cb5b2332f379ab271cdc6527bba308c117 Mon Sep 17 00:00:00 2001 From: khannurien Date: Mon, 16 Mar 2026 11:08:39 +0000 Subject: [PATCH] v1 review pass: fixed some minor bugs --- api/lib/jwt.ts | 2 +- api/model/db.ts | 1 - api/routes/avatars.ts | 18 +- api/routes/dumps.ts | 30 ++- api/routes/files.ts | 58 +++++- api/routes/preview.ts | 5 +- api/routes/users.ts | 5 +- api/routes/ws.ts | 18 +- api/services/dump-service.ts | 68 ++++++- api/services/providers/youtube.ts | 9 +- api/services/rich-content-service.ts | 11 +- api/services/vote-service.ts | 6 +- api/services/ws-service.ts | 17 +- src/App.css | 217 ++++++++++++++------ src/components/AppHeader.tsx | 90 +++++++-- src/components/Avatar.tsx | 4 +- src/components/DumpCard.tsx | 22 +- src/components/FilePreview.tsx | 4 +- src/components/MediaPlayer.tsx | 108 ++++++++-- src/components/PageError.tsx | 17 ++ src/components/PageShell.tsx | 4 +- src/components/RichContentCard.tsx | 2 +- src/components/VoteButton.tsx | 5 +- src/contexts/AuthProvider.tsx | 6 +- src/contexts/WSContext.ts | 8 + src/contexts/WSProvider.tsx | 77 +++++-- src/index.css | 46 ++++- src/main.tsx | 4 +- src/model.ts | 26 +++ src/pages/Dump.tsx | 71 +++++-- src/pages/DumpCreate.tsx | 196 ++++++++++-------- src/pages/DumpEdit.tsx | 70 +++++-- src/pages/Index.tsx | 87 ++++++-- src/pages/UserLogin.tsx | 9 +- src/pages/UserPublicProfile.tsx | 290 ++++++++++++++++++++------- src/pages/UserRegister.tsx | 9 +- src/utils/relativeTime.ts | 8 +- 37 files changed, 1228 insertions(+), 400 deletions(-) create mode 100644 src/components/PageError.tsx diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 05fc10c..8729d57 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -3,7 +3,7 @@ import { jwtVerify, SignJWT } from "@panva/jose"; import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts"; -const JWT_SECRET = "tp-M1-SOR-2026"; +const JWT_SECRET = "FIXME-gerbeur-dev-env"; const JWT_KEY = new TextEncoder().encode(JWT_SECRET); export async function createJWT( diff --git a/api/model/db.ts b/api/model/db.ts index 09ca54d..a04f186 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -2,7 +2,6 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { Dump, type RichContent, type User } from "./interfaces.ts"; export const db = new DatabaseSync("api/sql/gerbeur.db"); -db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); /** diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index a520d1a..d14cd38 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -39,7 +39,11 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { const authPayload = ctx.state.user; const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Expected multipart/form-data"); + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "Expected multipart/form-data", + ); } const body = await ctx.request.body.formData(); @@ -58,13 +62,21 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { } if (file.size > MAX_AVATAR_SIZE) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)"); + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "File too large (max 5 MB)", + ); } const data = new Uint8Array(await file.arrayBuffer()); if (!checkMagicBytes(data, file.type)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File content does not match declared type"); + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "File content does not match declared type", + ); } await Deno.mkdir(AVATARS_DIR, { recursive: true }); diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 39a372f..4512a38 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -87,7 +87,11 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { const dump = getDump(dumpId); if (userId !== dump.userId) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump"); + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to update dump", + ); } const formData = await ctx.request.body.formData(); @@ -95,7 +99,11 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { const comment = formData.get("comment"); if (!(file instanceof File)) { - throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "A file is required"); + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "A file is required", + ); } const updatedDump = await replaceFileDump( @@ -113,13 +121,21 @@ router.put("/:dumpId", authMiddleware, async (ctx) => { const body = await ctx.request.body.json(); if (!isUpdateDumpRequest(body)) { - throw new APIException(APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input"); + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 422, + "Erroneous user input", + ); } const dump = getDump(dumpId); if (userId !== dump.userId) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump"); + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to update dump", + ); } const updatedDump = await updateDump(dumpId, body); @@ -133,7 +149,11 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => { const dump = getDump(dumpId); if (userId !== dump.userId) { - throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete dump"); + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to delete dump", + ); } await deleteDump(dumpId); diff --git a/api/routes/files.ts b/api/routes/files.ts index 7e67839..0b78590 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -15,20 +15,62 @@ router.get("/:dumpId", async (ctx) => { const dump = getDump(dumpId); if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "No file for this dump"); + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "No file for this dump", + ); } + const path = `api/uploads/${dumpId}`; + + let data: Uint8Array; try { - const data = await Deno.readFile(`api/uploads/${dumpId}`); - ctx.response.headers.set("Content-Type", dump.fileMime); - ctx.response.headers.set( - "Content-Disposition", - `inline; filename="${dump.fileName}"`, - ); - ctx.response.body = data; + data = await Deno.readFile(path); } catch { throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found"); } + + const total = data.byteLength; + ctx.response.headers.set("Content-Type", dump.fileMime); + ctx.response.headers.set( + "Content-Disposition", + `inline; filename="${dump.fileName}"`, + ); + ctx.response.headers.set("Accept-Ranges", "bytes"); + + const rangeHeader = ctx.request.headers.get("Range"); + + if (rangeHeader) { + const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); + if (!match) { + ctx.response.status = 416; + ctx.response.headers.set("Content-Range", `bytes */${total}`); + return; + } + + const start = match[1] + ? parseInt(match[1], 10) + : total - parseInt(match[2], 10); + const end = match[2] + ? Math.min(parseInt(match[2], 10), total - 1) + : total - 1; + + if (start > end || start >= total) { + ctx.response.status = 416; + ctx.response.headers.set("Content-Range", `bytes */${total}`); + return; + } + + const chunk = data.subarray(start, end + 1); + ctx.response.status = 206; + ctx.response.headers.set("Content-Range", `bytes ${start}-${end}/${total}`); + ctx.response.headers.set("Content-Length", String(chunk.byteLength)); + ctx.response.body = chunk; + } else { + ctx.response.headers.set("Content-Length", String(total)); + ctx.response.body = data; + } }); export default router; diff --git a/api/routes/preview.ts b/api/routes/preview.ts index df70d35..f817358 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -1,5 +1,8 @@ import { Router } from "@oak/oak"; -import { fetchRichContent, isValidHttpUrl } from "../services/rich-content-service.ts"; +import { + fetchRichContent, + isValidHttpUrl, +} from "../services/rich-content-service.ts"; const previewRouter = new Router(); diff --git a/api/routes/users.ts b/api/routes/users.ts index ccf37e3..0aa4a94 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -14,7 +14,10 @@ import { getUserById, getUserByUsername, } from "../services/user-service.ts"; -import { getDumpsByUser, getVotedDumpsByUser } from "../services/dump-service.ts"; +import { + getDumpsByUser, + getVotedDumpsByUser, +} from "../services/dump-service.ts"; // Users router const router = new Router({ prefix: "/api/users" }); diff --git a/api/routes/ws.ts b/api/routes/ws.ts index ccdeaf4..978a945 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -5,10 +5,14 @@ import { broadcastVoteUpdate, getOnlineUsers, register, - type WsClient, unregister, + type WsClient, } from "../services/ws-service.ts"; -import { castVote, getUserVotes, removeVote } from "../services/vote-service.ts"; +import { + castVote, + getUserVotes, + removeVote, +} from "../services/vote-service.ts"; import { getUserById } from "../services/user-service.ts"; import { APIException } from "../model/interfaces.ts"; @@ -38,7 +42,9 @@ router.get("/ws", async (ctx) => { let avatarMime: string | undefined; if (authPayload) { - try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ } + try { + avatarMime = getUserById(authPayload.userId).avatarMime; + } catch { /* user not found */ } } const client: WsClient = { @@ -100,7 +106,9 @@ function handleVote( const { socket } = client; if (!client.userId) { - socket.send(JSON.stringify({ type: "error", message: "Authentication required" })); + socket.send( + JSON.stringify({ type: "error", message: "Authentication required" }), + ); return; } @@ -122,7 +130,7 @@ function handleVote( voteCount: newCount, })); - broadcastVoteUpdate(dumpId, newCount); + broadcastVoteUpdate(dumpId, newCount, client.userId, action); } catch (err) { const message = err instanceof APIException ? err.message : "Vote failed"; socket.send(JSON.stringify({ type: "error", message })); diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 6624cf1..28b670a 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -69,7 +69,17 @@ export async function createUrlDump( richContent ? JSON.stringify(richContent) : null, ); - const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 }; + const dump: Dump = { + id: dumpId, + kind: "url", + title, + comment: request.comment, + userId, + createdAt, + url: request.url, + richContent, + voteCount: 0, + }; broadcastNewDump(dump); return dump; } @@ -87,7 +97,11 @@ export async function createFileDump( ); } if (file.size > MAX_FILE_SIZE) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)"); + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "File too large (max 50 MB)", + ); } const dumpId = crypto.randomUUID(); @@ -153,7 +167,11 @@ export function listDumps(): Dump[] { ).all(); 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); @@ -167,7 +185,12 @@ export async function updateDump( // File dumps: only comment is editable if (dump.kind === "file") { - const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment }; + const updatedDump = { + ...dump, + comment: "comment" in request + ? (request.comment ?? undefined) + : dump.comment, + }; db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`) .run(updatedDump.comment ?? null, dumpId); return updatedDump; @@ -190,7 +213,9 @@ export async function updateDump( const updatedDump: Dump = { ...dump, title, - comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, + comment: "comment" in request + ? (request.comment ?? undefined) + : dump.comment, url: newUrl, richContent, }; @@ -213,10 +238,18 @@ export async function replaceFileDump( comment: string | undefined, ): Promise { if (!isAllowedMime(file.type)) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, `File type '${file.type}' is not allowed`); + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + `File type '${file.type}' is not allowed`, + ); } if (file.size > MAX_FILE_SIZE) { - throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)"); + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "File too large (max 50 MB)", + ); } const dump = getDump(dumpId); @@ -231,7 +264,14 @@ export async function replaceFileDump( `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); - return { ...dump, title: file.name, fileName: file.name, fileMime: file.type, fileSize: file.size, comment }; + return { + ...dump, + title: file.name, + fileName: file.name, + fileMime: file.type, + fileSize: file.size, + comment, + }; } export function getDumpsByUser(userId: string): Dump[] { @@ -239,7 +279,11 @@ export function getDumpsByUser(userId: string): Dump[] { `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`, ).all(userId); 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); } @@ -253,7 +297,11 @@ export function getVotedDumpsByUser(userId: string): Dump[] { ORDER BY v.created_at DESC;`, ).all(userId); 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); } diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index 54d8d97..720522e 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -29,6 +29,13 @@ export const youtubeProvider: RichContentProvider = { // oembed failed — thumbnail still works } - return { type: "youtube", siteName: "YouTube", url, videoId, title, thumbnailUrl }; + return { + type: "youtube", + siteName: "YouTube", + url, + videoId, + title, + thumbnailUrl, + }; }, }; diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 85f7379..25e1dbe 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -33,8 +33,10 @@ export async function fetchWithTimeout( return await fetch(url, { signal: controller.signal, headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept": + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", }, }); @@ -51,7 +53,10 @@ function decodeHtmlEntities(str: string): string { .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))) - .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))); + .replace( + /&#x([0-9a-f]+);/gi, + (_, hex) => String.fromCodePoint(parseInt(hex, 16)), + ); } export function extractOgTag( diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 6d3eb81..f8eaa34 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -18,7 +18,11 @@ export function castVote(dumpId: string, userId: string): number { } catch (err) { db.exec("ROLLBACK;"); if (err instanceof Error && err.message.includes("UNIQUE constraint")) { - throw new APIException(APIErrorCode.VALIDATION_ERROR, 409, "Already voted"); + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 409, + "Already voted", + ); } throw err; } diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 8a0994a..66f808e 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -65,15 +65,26 @@ export function broadcastDumpDeleted(dumpId: string): void { } } -export function broadcastVoteUpdate(dumpId: string, voteCount: number): void { +export function broadcastVoteUpdate( + dumpId: string, + voteCount: number, + voterId: string, + action: "cast" | "remove", +): void { for (const client of clients) { - send(client.socket, { type: "votes_update", dumpId, voteCount }); + send(client.socket, { + type: "votes_update", + dumpId, + voteCount, + voterId, + action, + }); } } // Keepalive: ping all clients every 30s, remove non-responsive ones const PING_INTERVAL = 30_000; -const PONG_TIMEOUT = 5_000; +const _PONG_TIMEOUT = 5_000; setInterval(() => { for (const client of clients) { diff --git a/src/App.css b/src/App.css index 0a15b67..893955a 100644 --- a/src/App.css +++ b/src/App.css @@ -89,7 +89,6 @@ margin-left: auto; } - /* Forms */ .auth-form { display: flex; @@ -139,7 +138,11 @@ .dump-form textarea:hover, .auth-form input:hover, .auth-form textarea:hover { - border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%); + border-color: color-mix( + in srgb, + var(--color-accent) 45%, + var(--color-border) 55% + ); } .dump-form input:focus, @@ -147,8 +150,13 @@ .auth-form input:focus, .auth-form textarea:focus { border-color: var(--color-accent); - background-color: color-mix(in srgb, var(--color-accent) 4%, var(--color-bg) 96%); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); + background-color: color-mix( + in srgb, + var(--color-accent) 4%, + var(--color-bg) 96% + ); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--color-accent) 18%, transparent); } /* ── New dump form ── */ @@ -212,7 +220,7 @@ .dump-mode-toggle button.active { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .dump-mode-toggle button:not(.active):hover { @@ -309,7 +317,7 @@ border-radius: 0; border: none; background: linear-gradient(transparent, rgba(0, 0, 0, 0.38)); - color: #fff; + color: var(--color-on-accent); opacity: 0; pointer-events: none; transition: opacity 0.25s ease; @@ -320,14 +328,13 @@ pointer-events: auto; } - .video-player-controls .audio-player-time { - color: #fff; + color: var(--color-on-accent); opacity: 0.85; } .video-player-controls .audio-player-vol-btn { - color: #fff; + color: var(--color-on-accent); } .video-player-controls .audio-player-track { @@ -350,7 +357,11 @@ flex-wrap: nowrap; gap: 0.6rem; padding: 0.75rem 1rem; - background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%); + background: color-mix( + in srgb, + var(--color-accent) 8%, + var(--color-surface) 92% + ); border-radius: 0 0 12px 12px; border: 2px solid var(--color-border); width: 100%; @@ -366,7 +377,7 @@ border-radius: 50%; border: none; background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); cursor: pointer; display: grid; place-items: center; @@ -398,7 +409,11 @@ min-width: 48px; height: 4px; border-radius: 2px; - background: color-mix(in srgb, var(--color-accent) 20%, var(--color-border) 80%); + background: color-mix( + in srgb, + var(--color-accent) 20%, + var(--color-border) 80% + ); } .audio-player-track--volume { @@ -480,7 +495,7 @@ .file-download-link:hover { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .dump-file-notice { @@ -489,7 +504,6 @@ opacity: 0.8; } - .rich-content-card { display: flex; border: 2px solid var(--color-border); @@ -505,33 +519,33 @@ } .rich-content-card--youtube { - border-color: #c00; + border-color: var(--color-youtube); } .rich-content-card--youtube:hover { - border-color: #f00; + border-color: var(--color-youtube-hover); } .rich-content-card--youtube .rich-content-badge { - background: #c00; + background: var(--color-youtube); } .rich-content-card--bandcamp { - border-color: #1da0c3; + border-color: var(--color-bandcamp); } .rich-content-card--bandcamp:hover { - border-color: #25c8f0; + border-color: var(--color-bandcamp-hover); } .rich-content-card--bandcamp .rich-content-badge { - background: #1da0c3; + background: var(--color-bandcamp); } .rich-content-card--soundcloud { - border-color: #f50; + border-color: var(--color-soundcloud); } .rich-content-card--soundcloud:hover { - border-color: #f73; + border-color: var(--color-soundcloud-hover); } .rich-content-card--soundcloud .rich-content-badge { - background: #f50; + background: var(--color-soundcloud); } .rich-content-thumbnail { @@ -552,8 +566,8 @@ .rich-content-badge { display: inline-block; - background: #c00; - color: #fff; + background: var(--color-youtube); + color: var(--color-on-accent); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; @@ -588,7 +602,6 @@ display: block; } - .dump-card--fading { opacity: 0.28; } @@ -648,7 +661,7 @@ justify-content: center; border-radius: 50%; background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); font-weight: 700; user-select: none; flex-shrink: 0; @@ -682,13 +695,13 @@ .vote-btn--active { border-color: var(--color-accent); background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .vote-btn--active:hover:not(:disabled) { background: var(--color-accent-hover); border-color: var(--color-accent-hover); - color: #fff; + color: var(--color-on-accent); } .vote-btn:focus { @@ -696,7 +709,8 @@ } .vote-btn:focus-visible { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 35%, transparent); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--color-accent) 35%, transparent); } .vote-btn:disabled { @@ -704,7 +718,6 @@ cursor: default; } - /* Dump OP line */ .dump-op { display: flex; @@ -734,8 +747,8 @@ position: absolute; inset: 0; border-radius: 50%; - background: rgba(0, 0, 0, 0.45); - color: #fff; + background: var(--color-overlay); + color: var(--color-on-accent); font-size: 1.1rem; display: flex; align-items: center; @@ -813,6 +826,26 @@ font-weight: 600; } +.profile-header .logout-btn { + margin-top: 0.5rem; +} + +.logout-btn { + padding: 0.3rem 0.9rem; + border: 1.5px solid var(--color-border); + border-radius: 6px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.logout-btn:hover { + border-color: var(--color-danger); + color: var(--color-danger); +} + .avatar-upload-label { display: inline-block; padding: 0.4rem 1rem; @@ -827,16 +860,15 @@ .avatar-upload-label:hover { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } .form-error { - color: #e55; + color: var(--color-danger); margin: 0; font-size: 0.9rem; } - /* ── Shared layout ── */ .page-shell { min-height: 100vh; @@ -874,9 +906,15 @@ padding: 2rem 0; } +.page-error-actions { + display: flex; + align-items: center; + gap: 1rem; +} + .error-banner { - background: #a02b2b; - color: #fff; + background: var(--color-danger-bg); + color: var(--color-on-accent); padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.9rem; @@ -884,9 +922,6 @@ /* ── Shared header ── */ .app-header { - position: sticky; - top: 0; - z-index: 10; display: flex; align-items: center; gap: 1rem; @@ -896,6 +931,45 @@ border-bottom: 2px solid var(--color-border); } +/* ── Floating "+ New" button ── */ +.fab-new { + position: fixed; + bottom: 2rem; + right: max(1.5rem, calc((100vw - 860px) / 2 + 1.5rem)); + z-index: 20; + background: var(--color-accent); + color: var(--color-on-accent); + border: none; + border-radius: 999px; + padding: 0.6rem 1.4rem; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 20px + color-mix(in srgb, var(--color-accent) 50%, transparent); + opacity: 0; + pointer-events: none; + transform: translateY(10px) scale(0.94); + transition: + opacity 0.2s ease, + transform 0.2s ease, + background 0.15s, + box-shadow 0.15s; +} + +.fab-new--visible { + opacity: 1; + pointer-events: auto; + transform: none; +} + +.fab-new:hover { + background: var(--color-accent-hover); + box-shadow: 0 6px 24px + color-mix(in srgb, var(--color-accent) 60%, transparent); + transform: translateY(-1px); +} + @media (min-width: 740px) { .app-header--has-center { display: grid; @@ -920,7 +994,9 @@ } @media (min-width: 740px) { - .app-header-center { display: flex; } + .app-header-center { + display: flex; + } } .header-center-slot { @@ -951,12 +1027,12 @@ font-size: 0.95rem; padding: 0.35rem 0.85rem; border-radius: 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-header-user-bg); transition: background 0.15s; } .app-header-user:hover { - background: rgba(0, 0, 0, 0.32); + background: var(--color-header-user-bg-hover); } /* ── Auth card ── */ @@ -988,8 +1064,14 @@ /* ── Form pages (DumpCreate / DumpEdit) ── */ @keyframes page-enter { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: none; + } } .form-page { @@ -1004,9 +1086,15 @@ .form-page--two-col { grid-template-columns: 1fr 1fr; } - .form-page--two-col .form-page-header { grid-column: 1 / -1; } - .form-page--two-col .dump-edit-preview { border-radius: 0 0 0 12px; } - .form-page--two-col .dump-form { border-radius: 0 0 12px 0; } + .form-page--two-col .form-page-header { + grid-column: 1 / -1; + } + .form-page--two-col .dump-edit-preview { + border-radius: 0 0 0 12px; + } + .form-page--two-col .dump-form { + border-radius: 0 0 12px 0; + } } .form-page-header { @@ -1055,7 +1143,7 @@ align-items: center; justify-content: space-between; padding-top: 0.75rem; - border-top: 1px solid rgba(128, 128, 128, 0.18); + border-top: 1px solid var(--color-border-subtle); margin-top: 0.5rem; gap: 0.75rem; } @@ -1081,15 +1169,15 @@ /* ── Delete button ── */ .btn-danger { - background: #a02b2b; - color: #fff; + background: var(--color-danger-bg); + color: var(--color-on-accent); border-color: transparent; font-size: 0.85rem; padding: 0.4em 0.9em; } .btn-danger:hover { - background: #c03030; + background: var(--color-danger-hover); border-color: transparent; } @@ -1115,20 +1203,22 @@ .btn-primary { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); font-weight: 700; font-size: 0.95rem; padding: 0.45rem 1.1rem; border: none; border-radius: 8px; cursor: pointer; - box-shadow: 0 2px 8px color-mix(in srgb, var(--color-accent) 40%, transparent); + box-shadow: 0 2px 8px + color-mix(in srgb, var(--color-accent) 40%, transparent); transition: background 0.15s, box-shadow 0.15s, transform 0.1s; } .btn-primary:hover { background: var(--color-accent-hover); - box-shadow: 0 4px 14px color-mix(in srgb, var(--color-accent) 50%, transparent); + box-shadow: 0 4px 14px + color-mix(in srgb, var(--color-accent) 50%, transparent); transform: translateY(-1px); } @@ -1162,7 +1252,6 @@ z-index: 2; } - .index-status { text-align: center; opacity: 0.6; @@ -1170,7 +1259,7 @@ } .index-status--error { - color: #e55; + color: var(--color-danger); opacity: 1; } @@ -1185,7 +1274,9 @@ } @media (min-width: 740px) { - .index-below-header { display: none; } + .index-below-header { + display: none; + } } /* ── Feed sort buttons (shared between header center and below-header) ── */ @@ -1216,7 +1307,7 @@ .feed-sort-btn.active { border-color: var(--color-accent); background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); } /* ── Dump feed ── */ @@ -1239,7 +1330,10 @@ border: 2px solid var(--color-border); border-radius: 10px; background: var(--color-surface); - transition: border-color 0.15s, grid-template-rows 0.32s ease, opacity 0.25s ease; + transition: + border-color 0.15s, + grid-template-rows 0.32s ease, + opacity 0.25s ease; min-width: 0; } @@ -1299,7 +1393,6 @@ line-height: 1.35; } - .dump-card-inner:hover .dump-card-title { color: var(--color-accent); } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 103a9b1..87eb854 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,32 +1,82 @@ -import type { ReactNode } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Link, useNavigate } from "react-router"; import { useAuth } from "../hooks/useAuth.ts"; export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) { const { user } = useAuth(); const navigate = useNavigate(); + const headerRef = useRef(null); + const [showFab, setShowFab] = useState(false); + + useEffect(() => { + const el = headerRef.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => setShowFab(!entry.isIntersecting), + { threshold: 0 }, + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); return ( -
- 🚚 gerbeur + <> +
+ 🚚 gerbeur - {centerSlot &&
{centerSlot}
} + {centerSlot &&
{centerSlot}
} - -
+ +
+ + {user && createPortal( + , + document.body, + )} + ); } diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index edc63f0..587d586 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -8,7 +8,9 @@ interface AvatarProps { size?: number; } -export function Avatar({ userId, username, hasAvatar, size = 36 }: AvatarProps) { +export function Avatar( + { userId, username, hasAvatar, size = 36 }: AvatarProps, +) { const [imgFailed, setImgFailed] = useState(false); const sizeStyle = { width: size, height: size }; diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx index 9a2ae5b..8070d14 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/DumpCard.tsx @@ -15,12 +15,18 @@ interface DumpCardProps { className?: string; } -export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote, className }: DumpCardProps) { +export function DumpCard( + { dump, voteCount, voted, canVote, castVote, removeVote, className }: + DumpCardProps, +) { const navigate = useNavigate(); return (
  • -
    navigate(`/dumps/${dump.id}`)}> +
    navigate(`/dumps/${dump.id}`)} + >
    e.stopPropagation() : undefined} @@ -33,11 +39,19 @@ export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote
    - e.stopPropagation()}> + e.stopPropagation()} + > {dump.title} {dump.comment &&

    {dump.comment}

    } -
    diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 1e2ef14..45a3c52 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -16,7 +16,9 @@ function mimeIcon(mime: string): string { return "📁"; } -export default function FilePreview({ dump, compact = false }: FilePreviewProps) { +export default function FilePreview( + { dump, compact = false }: FilePreviewProps, +) { const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`; const mime = dump.fileMime ?? ""; diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 7a2bf83..aa3debe 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -23,8 +23,12 @@ const IconPause = () => ( const IconVolume = ({ muted }: { muted: boolean }) => ( {muted - ? - : } + ? ( + + ) + : ( + + )} ); @@ -55,7 +59,9 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { useEffect(() => { const a = mediaRef.current!; - const onTime = () => { if (!dragging) setCurrent(a.currentTime); }; + const onTime = () => { + if (!dragging) setCurrent(a.currentTime); + }; const onDuration = () => setDuration(a.duration); const onEnded = () => setPlaying(false); a.addEventListener("timeupdate", onTime); @@ -68,31 +74,52 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { }; }, [dragging]); - // Show controls when paused; schedule hide when playing + // Stop any in-flight load on unmount. + useEffect(() => { + const a = mediaRef.current!; + return () => { + a.pause(); + a.removeAttribute("src"); + a.load(); + }; + }, []); + + // Schedule controls hide when playing; controls are always visible when paused (derived below) useEffect(() => { if (kind !== "video") return; if (hideTimer.current) clearTimeout(hideTimer.current); if (playing) { - hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY); - } else { - setControlsVisible(true); + hideTimer.current = setTimeout( + () => setControlsVisible(false), + HIDE_DELAY, + ); } - return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; }, [playing, kind]); + // Controls are always visible when paused or for audio; otherwise follow controlsVisible state + const showingControls = kind !== "video" || !playing || controlsVisible; + const showControlsTemporarily = () => { if (kind !== "video") return; setControlsVisible(true); if (hideTimer.current) clearTimeout(hideTimer.current); if (playing) { - hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY); + hideTimer.current = setTimeout( + () => setControlsVisible(false), + HIDE_DELAY, + ); } }; const toggle = () => { const a = mediaRef.current!; - if (playing) { a.pause(); setPlaying(false); } - else { a.play(); setPlaying(true); } + if (playing) { + a.pause(); + setPlaying(false); + } else a.play().then(() => setPlaying(true)).catch(() => {}); }; const seek = (e: React.ChangeEvent) => { @@ -105,7 +132,10 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { const v = Number(e.target.value); setVolume(v); mediaRef.current!.volume = v; - if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; } + if (v > 0 && muted) { + setMuted(false); + mediaRef.current!.muted = false; + } }; const toggleMute = () => { @@ -122,18 +152,29 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { const controls = ( <> - {fmt(current)}
    -
    +
    setDragging(true)} onMouseUp={() => setDragging(false)} onChange={seek} @@ -144,15 +185,26 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { {fmt(duration)}
    -
    -
    +
    @@ -160,7 +212,12 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
    {kind === "video" && ( - )} @@ -170,11 +227,12 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { if (kind === "video") { return (
    playing && setControlsVisible(false)} > - {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
    - + + + + + } + /> ); } @@ -112,9 +134,17 @@ export function Dump() { size={22} /> {op - ? {op.username} + ? ( + + {op.username} + + ) : } -
    @@ -133,7 +163,12 @@ export function Dump() { : dump.richContent ? : ( - + {dump.url} )} diff --git a/src/pages/DumpCreate.tsx b/src/pages/DumpCreate.tsx index 53faa22..ef47a29 100644 --- a/src/pages/DumpCreate.tsx +++ b/src/pages/DumpCreate.tsx @@ -24,19 +24,25 @@ type UrlPreview = | { status: "done"; richContent: RichContent | null }; function LocalFilePreview({ file }: { file: File }) { - const src = URL.createObjectURL(file); + const [src, setSrc] = useState(null); const mime = file.type; - useEffect(() => () => URL.revokeObjectURL(src), [src]); + useEffect(() => { + const url = URL.createObjectURL(file); + setSrc(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + if (!src) return null; if (mime.startsWith("image/")) { return {file.name}; } if (mime.startsWith("video/")) { - return ; + return ; } if (mime.startsWith("audio/")) { - return ; + return ; } return (
    @@ -78,15 +84,22 @@ export function DumpCreate() { setUrlPreview({ status: "loading" }); debounceRef.current = setTimeout(async () => { try { - const res = await fetch(`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`); + const res = await fetch( + `${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`, + ); const body = await res.json(); - setUrlPreview({ status: "done", richContent: body.success ? body.data : null }); + setUrlPreview({ + status: "done", + richContent: body.success ? body.data : null, + }); } catch { setUrlPreview({ status: "done", richContent: null }); } }, 600); - return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; }, [url]); const handleSubmit = async (e: SubmitEvent) => { @@ -173,8 +186,8 @@ export function DumpCreate() { } catch { /* not a URL */ } }; - window.addEventListener("paste", handler); - return () => window.removeEventListener("paste", handler); + globalThis.addEventListener("paste", handler); + return () => globalThis.removeEventListener("paste", handler); }, []); return ( @@ -186,7 +199,11 @@ export function DumpCreate() {
    -
    - {state.status === "error" && ( -

    {state.error}

    - )} - - {mode === "url" - ? ( - <> -
    - - setUrl(e.target.value)} - onPaste={(e) => { - const pastedFile = e.clipboardData.files[0]; - if (pastedFile) { - e.preventDefault(); - setMode("file"); - setUrl(""); - setUrlPreview({ status: "idle" }); - setFile(pastedFile); - setState({ status: "idle" }); - } - }} - disabled={submitting} - placeholder="https://..." - required - autoFocus - /> -
    - {urlPreview.status === "loading" && ( -

    Fetching preview…

    - )} - {urlPreview.status === "done" && urlPreview.richContent && ( - - )} - - ) - : ( - <> -
    - - setFile(e.target.files?.[0] ?? null)} - disabled={submitting} - required - /> -
    - {file && } - + + {state.status === "error" && ( +

    {state.error}

    )} -
    - -