diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e31d590..e8ae42d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,8 @@ } }, "features": { - "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {} + "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {}, + "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": {} } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. diff --git a/Dockerfile b/Dockerfile index 3678c8d..7332a5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ # ── Stage 1: build Vite frontend ───────────────────────────────────────────── -FROM denoland/deno:2.7.5 AS builder +FROM denoland/deno:2.7.11 AS builder WORKDIR /app COPY deno.json deno.lock package.json ./ -COPY tsconfig*.json vite.config.ts ./ +COPY tsconfig*.json vite.config.ts lingui.config.ts ./ RUN deno install COPY index.html ./ COPY public/ ./public/ +COPY scripts/ ./scripts/ COPY src/ ./src/ # In same-origin deployments (API serves the frontend), no build args are needed @@ -24,7 +25,9 @@ ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \ RUN deno task build # ── Stage 2: runtime ────────────────────────────────────────────────────────── -FROM denoland/deno:2.7.5 +FROM denoland/deno:2.7.11 + +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -35,9 +38,9 @@ COPY api/ ./api/ COPY --from=builder /app/dist/ ./dist/ COPY public/ ./public/ -# Persistent data: database and user uploads must be mounted as volumes. +# Persistent data: database and uploaded/generated files must be mounted as volumes. VOLUME ["/app/api/sql", "/app/api/uploads"] EXPOSE 8000 -CMD ["sh", "-c", "deno run -A api/sql/init.ts && exec deno run -A api/main.ts"] +CMD ["deno", "task", "start"] diff --git a/api/config.ts b/api/config.ts index ed32b91..b620f7f 100644 --- a/api/config.ts +++ b/api/config.ts @@ -32,6 +32,7 @@ export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; +export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB export const ALLOWED_IMAGE_MIMES = new Set([ diff --git a/api/main.ts b/api/main.ts index 093f3e8..5cb0190 100644 --- a/api/main.ts +++ b/api/main.ts @@ -4,6 +4,7 @@ import { oakCors } from "@tajpouria/cors"; import attachmentsRouter from "./routes/attachments.ts"; import dumpsRouter from "./routes/dumps.ts"; import filesRouter from "./routes/files.ts"; +import thumbnailsRouter from "./routes/thumbnails.ts"; import usersRouter from "./routes/users.ts"; import avatarsRouter from "./routes/avatars.ts"; import wsRouter from "./routes/ws.ts"; @@ -44,6 +45,10 @@ app.use( filesRouter.routes(), filesRouter.allowedMethods(), ); +app.use( + thumbnailsRouter.routes(), + thumbnailsRouter.allowedMethods(), +); app.use( attachmentsRouter.routes(), attachmentsRouter.allowedMethods(), diff --git a/api/model/db.ts b/api/model/db.ts index 960b42c..5dcfc0a 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -19,6 +19,11 @@ import { export const db = new DatabaseSync(DB_PATH); db.exec("PRAGMA foreign_keys = ON;"); +// Purge expired/used password reset tokens on startup +db.prepare( + `DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`, +).run(); + // Purge expired unused invites on startup db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts new file mode 100644 index 0000000..dd24e28 --- /dev/null +++ b/api/routes/thumbnails.ts @@ -0,0 +1,110 @@ +import { Router } from "@oak/oak"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { getDump } from "../services/dump-service.ts"; +import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; + +const router = new Router({ prefix: "/api/thumbnails" }); + +async function ffmpegAvailable(): Promise { + try { + const cmd = new Deno.Command("ffmpeg", { + args: ["-version"], + stderr: "null", + stdout: "null", + }); + const { success } = await cmd.output(); + return success; + } catch { + return false; + } +} + +async function generateThumbnail( + srcPath: string, + outPath: string, +): Promise { + await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); + const cmd = new Deno.Command("ffmpeg", { + args: [ + "-ss", + "00:00:01", + "-i", + srcPath, + "-frames:v", + "1", + "-vf", + "scale=320:-1", + "-f", + "image2", + "-y", + outPath, + ], + stdout: "null", + stderr: "null", + }); + const { success } = await cmd.output(); + if (!success) throw new Error("ffmpeg failed"); +} + +router.get("/:dumpId", async (ctx) => { + const { dumpId } = ctx.params; + + if (!/^[0-9a-f-]{36}$/.test(dumpId)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); + } + + const dump = getDump(dumpId); + + if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) { + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "No video for this dump", + ); + } + + const thumbPath = `${THUMBNAILS_DIR}/${dumpId}.jpg`; + + // Serve cached thumbnail if it exists + try { + const data = await Deno.readFile(thumbPath); + ctx.response.headers.set("Content-Type", "image/jpeg"); + ctx.response.headers.set( + "Cache-Control", + "public, max-age=31536000, immutable", + ); + ctx.response.body = data; + return; + } catch { + // Not cached yet — generate it + } + + if (!await ffmpegAvailable()) { + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "Thumbnail generation unavailable", + ); + } + + const srcPath = `${DUMPS_DIR}/${dumpId}`; + try { + await generateThumbnail(srcPath, thumbPath); + } catch { + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "Could not generate thumbnail", + ); + } + + const data = await Deno.readFile(thumbPath); + ctx.response.headers.set("Content-Type", "image/jpeg"); + ctx.response.headers.set( + "Cache-Control", + "public, max-age=31536000, immutable", + ); + ctx.response.body = data; +}); + +export default router; diff --git a/deno.json b/deno.json index 02195ce..64b0569 100644 --- a/deno.json +++ b/deno.json @@ -1,11 +1,12 @@ { "tasks": { - "dev": "deno run --env-file -A npm:vite & deno run -A server:start", - "build": "deno run --env-file -A npm:vite build", + "dev": "deno run --env-file -A npm:vite & deno task server:start", + "build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build", "server:start": "deno run --env-file -A --watch api/main.ts", - "serve": "deno task build && deno run -A server:start", + "serve": "deno task build && deno task server:start", "i18n:extract": "deno run -A scripts/lingui-extract.ts", - "i18n:compile": "deno run -A scripts/lingui-compile.ts" + "i18n:compile": "deno run -A scripts/lingui-compile.ts", + "start": "deno run -A api/sql/init.ts && deno run -A api/main.ts" }, "nodeModulesDir": "auto", "compilerOptions": { diff --git a/deno.lock b/deno.lock index d1556fa..c28570b 100644 --- a/deno.lock +++ b/deno.lock @@ -22,22 +22,23 @@ "jsr:@tajpouria/cors@^1.2.1": "1.2.1", "npm:@eslint/js@^9.39.4": "9.39.4", "npm:@lingui/cli@*": "5.9.4_typescript@5.9.3", - "npm:@lingui/cli@6.0.0-next.2": "6.0.0-next.2", - "npm:@lingui/conf@6.0.0-next.2": "6.0.0-next.2", - "npm:@lingui/core@6.0.0-next.2": "6.0.0-next.2", - "npm:@lingui/format-po@6.0.0-next.2": "6.0.0-next.2", - "npm:@lingui/react@6.0.0-next.2": "6.0.0-next.2_react@19.2.4", - "npm:@lingui/swc-plugin@6.0.0-next.2": "6.0.0-next.2_@lingui+core@6.0.0-next.2", - "npm:@lingui/vite-plugin@6.0.0-next.2": "6.0.0-next.2_vite@8.0.1__@types+node@24.12.0", + "npm:@lingui/cli@6.0.0-next.3": "6.0.0-next.3", + "npm:@lingui/conf@6.0.0-next.3": "6.0.0-next.3", + "npm:@lingui/core@6.0.0-next.3": "6.0.0-next.3", + "npm:@lingui/format-po@6.0.0-next.3": "6.0.0-next.3", + "npm:@lingui/react@6.0.0-next.3": "6.0.0-next.3_react@19.2.4", + "npm:@lingui/swc-plugin@6.0.0-next.2": "6.0.0-next.2_@lingui+core@6.0.0-next.3", + "npm:@lingui/vite-plugin@6.0.0-next.3": "6.0.0-next.3_vite@8.0.1__@types+node@24.12.0__jiti@2.6.1_jiti@2.6.1", "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-swc@^4.3.0": "4.3.0_vite@8.0.1__@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", + "npm:@vitejs/plugin-react-swc@^4.3.0": "4.3.0_vite@8.0.1__@types+node@24.12.0__jiti@2.6.1_jiti@2.6.1", + "npm:eslint-plugin-react-hooks@^7.0.1": "7.0.1_eslint@9.39.4__jiti@2.6.1_jiti@2.6.1", + "npm:eslint-plugin-react-refresh@~0.5.2": "0.5.2_eslint@9.39.4__jiti@2.6.1_jiti@2.6.1", + "npm:eslint@^9.39.4": "9.39.4_jiti@2.6.1", "npm:frimousse@0.3": "0.3.0_react@19.2.4_typescript@5.9.3", "npm:globals@^17.4.0": "17.4.0", + "npm:jiti@^2.6.1": "2.6.1", "npm:marked@15": "15.0.12", "npm:nodemailer@*": "8.0.4", "npm:nodemailer@^8.0.4": "8.0.4", @@ -47,10 +48,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.1_eslint@9.39.4_typescript@5.9.3", + "npm:typescript-eslint@^8.56.1": "8.57.1_eslint@9.39.4__jiti@2.6.1_typescript@5.9.3_jiti@2.6.1", "npm:typescript@~5.9.3": "5.9.3", - "npm:vite@*": "8.0.1_@types+node@24.12.0", - "npm:vite@8": "8.0.1_@types+node@24.12.0" + "npm:vite@*": "8.0.1_@types+node@24.12.0_jiti@2.6.1", + "npm:vite@8": "8.0.1_@types+node@24.12.0_jiti@2.6.1" }, "jsr": { "@db/sqlite@0.13.0": { @@ -418,7 +419,7 @@ "os": ["win32"], "cpu": ["x64"] }, - "@eslint-community/eslint-utils@4.9.1_eslint@9.39.4": { + "@eslint-community/eslint-utils@4.9.1_eslint@9.39.4__jiti@2.6.1_jiti@2.6.1": { "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dependencies": [ "eslint", @@ -541,8 +542,8 @@ "@lingui/babel-plugin-extract-messages@5.9.4": { "integrity": "sha512-sFH5lufIBCOLwjM2hyByMIi7gaGjAPhU7md8XMQYgcEjUVtzjBQvZ9APGDdDQ5BB8xRDyqF2kvaJpJvWZu19zA==" }, - "@lingui/babel-plugin-extract-messages@6.0.0-next.2": { - "integrity": "sha512-lefaO/jHaJabVODRZwBIIXrwFV01kQvsSDBuEQfA0BhNXhYW61iFot//4dsGQ3FBiKXiskjQbcSFHdaBNT4YBw==" + "@lingui/babel-plugin-extract-messages@6.0.0-next.3": { + "integrity": "sha512-6lr2C5NjGSdnzY5qsWlfteBN4CtN4OlfMB4JceS0+mfatVTGd+koaFAE4rLB6xGO79EOoI3c2PqYHGiED8Vdtw==" }, "@lingui/babel-plugin-lingui-macro@5.9.4_typescript@5.9.3": { "integrity": "sha512-Gj+H48MQWY6rV40TBVG7U91/KETznbXOJpJsf8U4merBRPZgOMCy6VuWZGy1i+YJZJF/LiberlsCCEiiPbBRqg==", @@ -555,13 +556,13 @@ "@lingui/message-utils@5.9.4" ] }, - "@lingui/babel-plugin-lingui-macro@6.0.0-next.2": { - "integrity": "sha512-aWrHmo6pGEBH6vO8/AClkIQ6hSyzwROz+sO1gTBIJnwqj81VgSzyTvbCWpYW5fXCrB8yQiPbyxqD40kSsaJnzQ==", + "@lingui/babel-plugin-lingui-macro@6.0.0-next.3": { + "integrity": "sha512-vGXSKF4HHuCQCIIk0hc5sgHD+u7mhAZmksMTmT52ghks2lrLvD+DFUwnRCxMVaJoEilQu3TMXIVS4wwzf3hB1g==", "dependencies": [ "@babel/core", "@babel/types", - "@lingui/conf@6.0.0-next.2", - "@lingui/message-utils@6.0.0-next.2" + "@lingui/conf@6.0.0-next.3", + "@lingui/message-utils@6.0.0-next.3" ] }, "@lingui/cli@5.9.4_typescript@5.9.3": { @@ -597,19 +598,19 @@ ], "bin": true }, - "@lingui/cli@6.0.0-next.2": { - "integrity": "sha512-nZj7EaLUC4YJqgR8GJDOq1hGjwxuDoVGRd6C3qXlJY/yaPDY333CIW9VXFm2RSGozFlnppTOp48ESZo1k0mRZA==", + "@lingui/cli@6.0.0-next.3": { + "integrity": "sha512-HbVS5nkqR3/GPZyDAp3d2Km/YdQE9YIn5iKAy/WDOMpVMyR34Mi+O3uFyFoMugnB17qVqjmoAUVVxFlTc6CIKw==", "dependencies": [ "@babel/core", "@babel/generator", "@babel/parser", "@babel/types", - "@lingui/babel-plugin-extract-messages@6.0.0-next.2", - "@lingui/babel-plugin-lingui-macro@6.0.0-next.2", - "@lingui/conf@6.0.0-next.2", - "@lingui/core@6.0.0-next.2", - "@lingui/format-po@6.0.0-next.2", - "@lingui/message-utils@6.0.0-next.2", + "@lingui/babel-plugin-extract-messages@6.0.0-next.3", + "@lingui/babel-plugin-lingui-macro@6.0.0-next.3", + "@lingui/conf@6.0.0-next.3", + "@lingui/core@6.0.0-next.3", + "@lingui/format-po@6.0.0-next.3", + "@lingui/message-utils@6.0.0-next.3", "chokidar@5.0.0", "cli-table3", "commander@14.0.3", @@ -635,8 +636,8 @@ "picocolors" ] }, - "@lingui/conf@6.0.0-next.2": { - "integrity": "sha512-DZGvd0yVOP6bhG+wZei0nPIHiyO3LlgG/u/DFFyeCPT+hr4j9hFRH6efsN5hRgCZ4qf8sxiKWwvOAhfLIcbKsQ==", + "@lingui/conf@6.0.0-next.3": { + "integrity": "sha512-GZjVN4sP+WOp9+SRdjvYPn7kOXe2jBUNNETYFxN7hb7pEXS1TmehgruNjDr/td4TRDKBxXLI+OaJMlZWaDQE8g==", "dependencies": [ "jest-validate", "jiti", @@ -655,11 +656,11 @@ "@lingui/babel-plugin-lingui-macro@5.9.4_typescript@5.9.3" ] }, - "@lingui/core@6.0.0-next.2": { - "integrity": "sha512-JuGvuIlfW4HyCK2cvagqN9o5AG9TwJQ9wP36scxtobufFC0wNCxh7lC5sfsYz+KJwY62duCdYEiXfbMhBvmW1Q==", + "@lingui/core@6.0.0-next.3": { + "integrity": "sha512-ugIBl1cwxlHg7tY98qT9h1M5v6CurDBre/Vjzfv3F2RXC4J7iB4LGJ2QSbVIYAQVIK/H0pgrv1Lh1L8IwgEPZQ==", "dependencies": [ - "@lingui/babel-plugin-lingui-macro@6.0.0-next.2", - "@lingui/message-utils@6.0.0-next.2" + "@lingui/babel-plugin-lingui-macro@6.0.0-next.3", + "@lingui/message-utils@6.0.0-next.3" ] }, "@lingui/format-po@5.9.4_typescript@5.9.3": { @@ -671,11 +672,11 @@ "pofile" ] }, - "@lingui/format-po@6.0.0-next.2": { - "integrity": "sha512-vlQCy0tFYKGLT2VS8rlYjh36CBAcQAdzezo6IphtIeg5E3jQYWukrVsAVKqurUHe3/kKVNyyWEMODeH84pIJiA==", + "@lingui/format-po@6.0.0-next.3": { + "integrity": "sha512-fFtNFzVaSAefPIJHuoCUSTXy/up9OfJTed3gJm9CgpovxaGVBQfuDQtS2lSghaWiLtGo9UqdkROPrSzYdTqulQ==", "dependencies": [ - "@lingui/conf@6.0.0-next.2", - "@lingui/message-utils@6.0.0-next.2", + "@lingui/conf@6.0.0-next.3", + "@lingui/message-utils@6.0.0-next.3", "pofile" ] }, @@ -686,33 +687,33 @@ "js-sha256" ] }, - "@lingui/message-utils@6.0.0-next.2": { - "integrity": "sha512-3vI2La2XLD/mrsdkghMmuuHuCS9+pvGXOKofCNjDqiayvCPV9q5YeOGa3jb+frOLfIuJ/IiHplNO4wIq3y2kuw==", + "@lingui/message-utils@6.0.0-next.3": { + "integrity": "sha512-oXTGhXyFoZua0ahVNDwhs7/3tXVAL8NPz7SOTRYORyxDREQwHYTqYXHijHRGquBvL5QEHy28uYSQvy5Szojn8A==", "dependencies": [ "@messageformat/date-skeleton", "@messageformat/parser", "js-sha256" ] }, - "@lingui/react@6.0.0-next.2_react@19.2.4": { - "integrity": "sha512-EdzP+8a2UDLwzWPDG2+ctoFrU4oAyWFvijimTm6m6o39s/WHci+yH/eD7tNFr+bOUaynwoTI7azxfJJzbaDZNQ==", + "@lingui/react@6.0.0-next.3_react@19.2.4": { + "integrity": "sha512-tSu+Y8Xsi5oHdjXZaa5o76B4UHdVmrLdQUVDxgMXx+E4s/D30cTkuVtqoKr3kdY/StiEU7aKb08qUdfE52CWyA==", "dependencies": [ - "@lingui/babel-plugin-lingui-macro@6.0.0-next.2", - "@lingui/core@6.0.0-next.2", + "@lingui/babel-plugin-lingui-macro@6.0.0-next.3", + "@lingui/core@6.0.0-next.3", "react" ] }, - "@lingui/swc-plugin@6.0.0-next.2_@lingui+core@6.0.0-next.2": { + "@lingui/swc-plugin@6.0.0-next.2_@lingui+core@6.0.0-next.3": { "integrity": "sha512-rD20Y7gpquPUw7e/V33lOcsTa28kuuP2GpIK74iQ46KFWm067DKNUkGfcfeEHG6ORBJck6W+KvqeOZ/B4vID1Q==", "dependencies": [ - "@lingui/core@6.0.0-next.2" + "@lingui/core@6.0.0-next.3" ] }, - "@lingui/vite-plugin@6.0.0-next.2_vite@8.0.1__@types+node@24.12.0": { - "integrity": "sha512-dK+imM95tZ7LoJoh8VOp7P20cjgXoC2oWx9dW7zoYz40jZsvIssRYQ4a4jiYM4UNp+fMdaAo5t0GDbqIJ9EZGw==", + "@lingui/vite-plugin@6.0.0-next.3_vite@8.0.1__@types+node@24.12.0__jiti@2.6.1_jiti@2.6.1": { + "integrity": "sha512-Ewmg3jcBTp8VEfqkgsr6/YJJ7cphLtLSW+RGkCJREko6pa/a4V3U7hA/xKo2Z2Q1u95E8ngfWJO3qDXLGDb+DA==", "dependencies": [ - "@lingui/cli@6.0.0-next.2", - "@lingui/conf@6.0.0-next.2", + "@lingui/cli@6.0.0-next.3", + "@lingui/conf@6.0.0-next.3", "vite" ] }, @@ -1000,7 +1001,7 @@ "@types/yargs-parser" ] }, - "@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": { + "@typescript-eslint/eslint-plugin@8.57.1_@typescript-eslint+parser@8.57.1__eslint@9.39.4___jiti@2.6.1__typescript@5.9.3__jiti@2.6.1_eslint@9.39.4__jiti@2.6.1_typescript@5.9.3_jiti@2.6.1": { "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dependencies": [ "@eslint-community/regexpp", @@ -1016,7 +1017,7 @@ "typescript" ] }, - "@typescript-eslint/parser@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "@typescript-eslint/parser@8.57.1_eslint@9.39.4__jiti@2.6.1_typescript@5.9.3_jiti@2.6.1": { "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dependencies": [ "@typescript-eslint/scope-manager", @@ -1050,7 +1051,7 @@ "typescript" ] }, - "@typescript-eslint/type-utils@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "@typescript-eslint/type-utils@8.57.1_eslint@9.39.4__jiti@2.6.1_typescript@5.9.3_jiti@2.6.1": { "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dependencies": [ "@typescript-eslint/types", @@ -1080,7 +1081,7 @@ "typescript" ] }, - "@typescript-eslint/utils@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "@typescript-eslint/utils@8.57.1_eslint@9.39.4__jiti@2.6.1_typescript@5.9.3_jiti@2.6.1": { "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dependencies": [ "@eslint-community/eslint-utils", @@ -1101,7 +1102,7 @@ "@ungap/structured-clone@1.3.0": { "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" }, - "@vitejs/plugin-react-swc@4.3.0_vite@8.0.1__@types+node@24.12.0": { + "@vitejs/plugin-react-swc@4.3.0_vite@8.0.1__@types+node@24.12.0__jiti@2.6.1_jiti@2.6.1": { "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", "dependencies": [ "@rolldown/pluginutils@1.0.0-rc.7", @@ -1165,8 +1166,8 @@ "base64-js@1.5.1": { "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, - "baseline-browser-mapping@2.10.10": { - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "baseline-browser-mapping@2.10.16": { + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", "bin": true }, "binary-extensions@2.3.0": { @@ -1199,8 +1200,8 @@ "fill-range" ] }, - "browserslist@4.28.1": { - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "browserslist@4.28.2": { + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dependencies": [ "baseline-browser-mapping", "caniuse-lite", @@ -1223,8 +1224,8 @@ "camelcase@6.3.0": { "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" }, - "caniuse-lite@1.0.30001780": { - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==" + "caniuse-lite@1.0.30001786": { + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==" }, "ccount@2.0.1": { "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" @@ -1398,8 +1399,8 @@ "dequal" ] }, - "electron-to-chromium@1.5.321": { - "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" + "electron-to-chromium@1.5.332": { + "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==" }, "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" @@ -1452,7 +1453,7 @@ "escape-string-regexp@5.0.0": { "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, - "eslint-plugin-react-hooks@7.0.1_eslint@9.39.4": { + "eslint-plugin-react-hooks@7.0.1_eslint@9.39.4__jiti@2.6.1_jiti@2.6.1": { "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dependencies": [ "@babel/core", @@ -1463,7 +1464,7 @@ "zod-validation-error" ] }, - "eslint-plugin-react-refresh@0.5.2_eslint@9.39.4": { + "eslint-plugin-react-refresh@0.5.2_eslint@9.39.4__jiti@2.6.1_jiti@2.6.1": { "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", "dependencies": [ "eslint" @@ -1485,7 +1486,7 @@ "eslint-visitor-keys@5.0.1": { "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==" }, - "eslint@9.39.4": { + "eslint@9.39.4_jiti@2.6.1": { "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dependencies": [ "@eslint-community/eslint-utils", @@ -1517,12 +1518,16 @@ "ignore@5.3.2", "imurmurhash", "is-glob", + "jiti", "json-stable-stringify-without-jsonify", "lodash.merge", "minimatch@3.1.5", "natural-compare", "optionator" ], + "optionalPeers": [ + "jiti" + ], "bin": true }, "esm@3.2.25": { @@ -2902,7 +2907,7 @@ "prelude-ls" ] }, - "typescript-eslint@8.57.1_eslint@9.39.4_typescript@5.9.3": { + "typescript-eslint@8.57.1_eslint@9.39.4__jiti@2.6.1_typescript@5.9.3_jiti@2.6.1": { "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "dependencies": [ "@typescript-eslint/eslint-plugin", @@ -2965,7 +2970,7 @@ "unist-util-visit-parents" ] }, - "update-browserslist-db@1.2.3_browserslist@4.28.1": { + "update-browserslist-db@1.2.3_browserslist@4.28.2": { "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dependencies": [ "browserslist", @@ -2997,10 +3002,11 @@ "vfile-message" ] }, - "vite@8.0.1_@types+node@24.12.0": { + "vite@8.0.1_@types+node@24.12.0_jiti@2.6.1": { "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dependencies": [ "@types/node", + "jiti", "lightningcss", "picomatch@4.0.3", "postcss", @@ -3011,7 +3017,8 @@ "fsevents" ], "optionalPeers": [ - "@types/node" + "@types/node", + "jiti" ], "bin": true }, @@ -3065,12 +3072,13 @@ "packageJson": { "dependencies": [ "npm:@eslint/js@^9.39.4", - "npm:@lingui/cli@6.0.0-next.2", - "npm:@lingui/core@6.0.0-next.2", - "npm:@lingui/format-po@6.0.0-next.2", - "npm:@lingui/react@6.0.0-next.2", + "npm:@lingui/cli@6.0.0-next.3", + "npm:@lingui/conf@6.0.0-next.3", + "npm:@lingui/core@6.0.0-next.3", + "npm:@lingui/format-po@6.0.0-next.3", + "npm:@lingui/react@6.0.0-next.3", "npm:@lingui/swc-plugin@6.0.0-next.2", - "npm:@lingui/vite-plugin@6.0.0-next.2", + "npm:@lingui/vite-plugin@6.0.0-next.3", "npm:@types/node@^24.12.0", "npm:@types/react-dom@^19.2.3", "npm:@types/react@^19.2.14", @@ -3080,6 +3088,7 @@ "npm:eslint@^9.39.4", "npm:frimousse@0.3", "npm:globals@^17.4.0", + "npm:jiti@^2.6.1", "npm:react-dom@^19.2.4", "npm:react-markdown@^10.1.0", "npm:react-router@^7.13.1", diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 05432ac..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,32 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist', 'src/locales']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - }, - }, -]) diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..fe309ca --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["dist", "src/locales"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, +]); diff --git a/lingui.config.cjs b/lingui.config.ts similarity index 52% rename from lingui.config.cjs rename to lingui.config.ts index b7e99be..969a5d8 100644 --- a/lingui.config.cjs +++ b/lingui.config.ts @@ -1,7 +1,7 @@ -const { formatter } = require("@lingui/format-po"); +import { formatter } from "@lingui/format-po"; +import type { LinguiConfig } from "@lingui/conf"; -/** @type {import("@lingui/conf").LinguiConfig} */ -module.exports = { +const config: LinguiConfig = { locales: ["en", "fr"], sourceLocale: "en", catalogs: [ @@ -12,3 +12,5 @@ module.exports = { ], format: formatter(), }; + +export default config; diff --git a/package.json b/package.json index ec5c0f7..8c2082b 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,16 @@ "preview": "vite preview" }, "dependencies": { - "@lingui/cli": "6.0.0-next.2", - "@lingui/core": "6.0.0-next.2", - "@lingui/format-po": "6.0.0-next.2", - "@lingui/react": "6.0.0-next.2", + "@lingui/cli": "6.0.0-next.3", + "@lingui/conf": "6.0.0-next.3", + "@lingui/core": "6.0.0-next.3", + "@lingui/format-po": "6.0.0-next.3", + "@lingui/react": "6.0.0-next.3", "@types/react": "^19.2.14", "@lingui/swc-plugin": "6.0.0-next.2", "@vitejs/plugin-react-swc": "^4.3.0", "frimousse": "^0.3.0", + "jiti": "^2.6.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -25,7 +27,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { - "@lingui/vite-plugin": "6.0.0-next.2", + "@lingui/vite-plugin": "6.0.0-next.3", "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", "@types/react-dom": "^19.2.3", diff --git a/src/App.css b/src/App.css index 0133b02..d6162c7 100644 --- a/src/App.css +++ b/src/App.css @@ -1807,6 +1807,10 @@ body.has-player .fab-new { display: grid; grid-template-columns: auto 1fr auto; } + + .app-header--has-center .app-header-nav { + grid-column: 3; + } } .app-header-brand { diff --git a/src/App.tsx b/src/App.tsx index 9b23145..0d451e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,8 @@ +import { lazy, Suspense } from "react"; import { BrowserRouter, Route, Routes } from "react-router"; -import { Index } from "./pages/Index.tsx"; import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; -import { Dump } from "./pages/Dump.tsx"; -import { DumpEdit } from "./pages/DumpEdit.tsx"; -import { UserLogin } from "./pages/UserLogin.tsx"; -import { UserPublicProfile } from "./pages/UserPublicProfile.tsx"; -import { UserRegister } from "./pages/UserRegister.tsx"; -import { UserDumps } from "./pages/UserDumps.tsx"; -import { UserUpvoted } from "./pages/UserUpvoted.tsx"; -import { UserPlaylists } from "./pages/UserPlaylists.tsx"; -import { PlaylistDetail } from "./pages/PlaylistDetail.tsx"; -import { Notifications } from "./pages/Notifications.tsx"; -import { Search } from "./pages/Search.tsx"; -import { ResetPassword } from "./pages/ResetPassword.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; @@ -25,54 +13,106 @@ import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import "./App.css"; +const Index = lazy(() => + import("./pages/Index.tsx").then((m) => ({ default: m.Index })) +); +const Dump = lazy(() => + import("./pages/Dump.tsx").then((m) => ({ default: m.Dump })) +); +const DumpEdit = lazy(() => + import("./pages/DumpEdit.tsx").then((m) => ({ default: m.DumpEdit })) +); +const UserLogin = lazy(() => + import("./pages/UserLogin.tsx").then((m) => ({ default: m.UserLogin })) +); +const UserPublicProfile = lazy(() => + import("./pages/UserPublicProfile.tsx").then((m) => ({ + default: m.UserPublicProfile, + })) +); +const UserRegister = lazy(() => + import("./pages/UserRegister.tsx").then((m) => ({ default: m.UserRegister })) +); +const UserDumps = lazy(() => + import("./pages/UserDumps.tsx").then((m) => ({ default: m.UserDumps })) +); +const UserUpvoted = lazy(() => + import("./pages/UserUpvoted.tsx").then((m) => ({ default: m.UserUpvoted })) +); +const UserPlaylists = lazy(() => + import("./pages/UserPlaylists.tsx").then((m) => ({ + default: m.UserPlaylists, + })) +); +const PlaylistDetail = lazy(() => + import("./pages/PlaylistDetail.tsx").then((m) => ({ + default: m.PlaylistDetail, + })) +); +const Notifications = lazy(() => + import("./pages/Notifications.tsx").then((m) => ({ + default: m.Notifications, + })) +); +const Search = lazy(() => + import("./pages/Search.tsx").then((m) => ({ default: m.Search })) +); +const ResetPassword = lazy(() => + import("./pages/ResetPassword.tsx").then((m) => ({ + default: m.ResetPassword, + })) +); + function AppRoutes() { return ( - - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - - - - } - /> - + + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + + + + } + /> + + ); } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 76d3c94..4d0fcd2 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,13 +1,16 @@ -import { type ReactNode, useState } from "react"; +import { lazy, type ReactNode, Suspense, useState } from "react"; import { Link, useNavigate } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; -import { DumpCreateModal } from "./DumpCreateModal.tsx"; import { NotificationBell } from "./NotificationBell.tsx"; import { UserMenu } from "./UserMenu.tsx"; +const DumpCreateModal = lazy(() => + import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal })) +); + export function AppHeader( { centerSlot, disableNew, initialDumpUrl }: { centerSlot?: ReactNode; @@ -88,10 +91,12 @@ export function AppHeader( )} {createModalOpen && ( - setCreateModalOpen(false)} - initialUrl={initialDumpUrl} - /> + + setCreateModalOpen(false)} + initialUrl={initialDumpUrl} + /> + )} ); diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index c22b0ab..204d0e0 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -118,6 +118,21 @@ function AudioFilePreview( ); } +function VideoThumb({ src, fallback }: { src: string; fallback: string }) { + const [failed, setFailed] = useState(false); + if (failed) { + return {fallback}; + } + return ( + setFailed(true)} + /> + ); +} + function mimeIcon(mime: string): string { if (mime.startsWith("video/")) return "🎬"; if (mime.startsWith("audio/")) return "🎵"; @@ -148,6 +163,7 @@ export default function FilePreview( ); } if (mime.startsWith("video/")) { + const thumbUrl = `${API_URL}/api/thumbnails/${dump.id}`; return ( ); @@ -217,12 +225,9 @@ export default function FilePreview( >