v3: performance pass, bundle size pass, i18n pass, docker pass

This commit is contained in:
khannurien
2026-04-08 13:19:39 +00:00
parent 20b9bfe7b4
commit 1321e374bf
21 changed files with 502 additions and 301 deletions

View File

@@ -25,7 +25,8 @@
} }
}, },
"features": { "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. // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.

View File

@@ -1,14 +1,15 @@
# ── Stage 1: build Vite frontend ───────────────────────────────────────────── # ── Stage 1: build Vite frontend ─────────────────────────────────────────────
FROM denoland/deno:2.7.5 AS builder FROM denoland/deno:2.7.11 AS builder
WORKDIR /app WORKDIR /app
COPY deno.json deno.lock package.json ./ 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 RUN deno install
COPY index.html ./ COPY index.html ./
COPY public/ ./public/ COPY public/ ./public/
COPY scripts/ ./scripts/
COPY src/ ./src/ COPY src/ ./src/
# In same-origin deployments (API serves the frontend), no build args are needed # 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 RUN deno task build
# ── Stage 2: runtime ────────────────────────────────────────────────────────── # ── 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 WORKDIR /app
@@ -35,9 +38,9 @@ COPY api/ ./api/
COPY --from=builder /app/dist/ ./dist/ COPY --from=builder /app/dist/ ./dist/
COPY public/ ./public/ 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"] VOLUME ["/app/api/sql", "/app/api/uploads"]
EXPOSE 8000 EXPOSE 8000
CMD ["sh", "-c", "deno run -A api/sql/init.ts && exec deno run -A api/main.ts"] CMD ["deno", "task", "start"]

View File

@@ -32,6 +32,7 @@ export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; 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 MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const ALLOWED_IMAGE_MIMES = new Set([ export const ALLOWED_IMAGE_MIMES = new Set([

View File

@@ -4,6 +4,7 @@ import { oakCors } from "@tajpouria/cors";
import attachmentsRouter from "./routes/attachments.ts"; import attachmentsRouter from "./routes/attachments.ts";
import dumpsRouter from "./routes/dumps.ts"; import dumpsRouter from "./routes/dumps.ts";
import filesRouter from "./routes/files.ts"; import filesRouter from "./routes/files.ts";
import thumbnailsRouter from "./routes/thumbnails.ts";
import usersRouter from "./routes/users.ts"; import usersRouter from "./routes/users.ts";
import avatarsRouter from "./routes/avatars.ts"; import avatarsRouter from "./routes/avatars.ts";
import wsRouter from "./routes/ws.ts"; import wsRouter from "./routes/ws.ts";
@@ -44,6 +45,10 @@ app.use(
filesRouter.routes(), filesRouter.routes(),
filesRouter.allowedMethods(), filesRouter.allowedMethods(),
); );
app.use(
thumbnailsRouter.routes(),
thumbnailsRouter.allowedMethods(),
);
app.use( app.use(
attachmentsRouter.routes(), attachmentsRouter.routes(),
attachmentsRouter.allowedMethods(), attachmentsRouter.allowedMethods(),

View File

@@ -19,6 +19,11 @@ import {
export const db = new DatabaseSync(DB_PATH); export const db = new DatabaseSync(DB_PATH);
db.exec("PRAGMA foreign_keys = ON;"); 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 // Purge expired unused invites on startup
db.prepare( db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,

110
api/routes/thumbnails.ts Normal file
View File

@@ -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<boolean> {
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<void> {
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;

View File

@@ -1,11 +1,12 @@
{ {
"tasks": { "tasks": {
"dev": "deno run --env-file -A npm:vite & deno run -A server:start", "dev": "deno run --env-file -A npm:vite & deno task server:start",
"build": "deno run --env-file -A npm:vite build", "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", "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: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", "nodeModulesDir": "auto",
"compilerOptions": { "compilerOptions": {

161
deno.lock generated
View File

@@ -22,22 +22,23 @@
"jsr:@tajpouria/cors@^1.2.1": "1.2.1", "jsr:@tajpouria/cors@^1.2.1": "1.2.1",
"npm:@eslint/js@^9.39.4": "9.39.4", "npm:@eslint/js@^9.39.4": "9.39.4",
"npm:@lingui/cli@*": "5.9.4_typescript@5.9.3", "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/cli@6.0.0-next.3": "6.0.0-next.3",
"npm:@lingui/conf@6.0.0-next.2": "6.0.0-next.2", "npm:@lingui/conf@6.0.0-next.3": "6.0.0-next.3",
"npm:@lingui/core@6.0.0-next.2": "6.0.0-next.2", "npm:@lingui/core@6.0.0-next.3": "6.0.0-next.3",
"npm:@lingui/format-po@6.0.0-next.2": "6.0.0-next.2", "npm:@lingui/format-po@6.0.0-next.3": "6.0.0-next.3",
"npm:@lingui/react@6.0.0-next.2": "6.0.0-next.2_react@19.2.4", "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.2", "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.2": "6.0.0-next.2_vite@8.0.1__@types+node@24.12.0", "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/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-dom@^19.2.3": "19.2.3_@types+react@19.2.14",
"npm:@types/react@^19.2.14": "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:@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", "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", "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", "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:frimousse@0.3": "0.3.0_react@19.2.4_typescript@5.9.3",
"npm:globals@^17.4.0": "17.4.0", "npm:globals@^17.4.0": "17.4.0",
"npm:jiti@^2.6.1": "2.6.1",
"npm:marked@15": "15.0.12", "npm:marked@15": "15.0.12",
"npm:nodemailer@*": "8.0.4", "npm:nodemailer@*": "8.0.4",
"npm:nodemailer@^8.0.4": "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-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:react@^19.2.4": "19.2.4",
"npm:remark-gfm@^4.0.1": "4.0.1", "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:typescript@~5.9.3": "5.9.3",
"npm:vite@*": "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" "npm:vite@8": "8.0.1_@types+node@24.12.0_jiti@2.6.1"
}, },
"jsr": { "jsr": {
"@db/sqlite@0.13.0": { "@db/sqlite@0.13.0": {
@@ -418,7 +419,7 @@
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"] "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==", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dependencies": [ "dependencies": [
"eslint", "eslint",
@@ -541,8 +542,8 @@
"@lingui/babel-plugin-extract-messages@5.9.4": { "@lingui/babel-plugin-extract-messages@5.9.4": {
"integrity": "sha512-sFH5lufIBCOLwjM2hyByMIi7gaGjAPhU7md8XMQYgcEjUVtzjBQvZ9APGDdDQ5BB8xRDyqF2kvaJpJvWZu19zA==" "integrity": "sha512-sFH5lufIBCOLwjM2hyByMIi7gaGjAPhU7md8XMQYgcEjUVtzjBQvZ9APGDdDQ5BB8xRDyqF2kvaJpJvWZu19zA=="
}, },
"@lingui/babel-plugin-extract-messages@6.0.0-next.2": { "@lingui/babel-plugin-extract-messages@6.0.0-next.3": {
"integrity": "sha512-lefaO/jHaJabVODRZwBIIXrwFV01kQvsSDBuEQfA0BhNXhYW61iFot//4dsGQ3FBiKXiskjQbcSFHdaBNT4YBw==" "integrity": "sha512-6lr2C5NjGSdnzY5qsWlfteBN4CtN4OlfMB4JceS0+mfatVTGd+koaFAE4rLB6xGO79EOoI3c2PqYHGiED8Vdtw=="
}, },
"@lingui/babel-plugin-lingui-macro@5.9.4_typescript@5.9.3": { "@lingui/babel-plugin-lingui-macro@5.9.4_typescript@5.9.3": {
"integrity": "sha512-Gj+H48MQWY6rV40TBVG7U91/KETznbXOJpJsf8U4merBRPZgOMCy6VuWZGy1i+YJZJF/LiberlsCCEiiPbBRqg==", "integrity": "sha512-Gj+H48MQWY6rV40TBVG7U91/KETznbXOJpJsf8U4merBRPZgOMCy6VuWZGy1i+YJZJF/LiberlsCCEiiPbBRqg==",
@@ -555,13 +556,13 @@
"@lingui/message-utils@5.9.4" "@lingui/message-utils@5.9.4"
] ]
}, },
"@lingui/babel-plugin-lingui-macro@6.0.0-next.2": { "@lingui/babel-plugin-lingui-macro@6.0.0-next.3": {
"integrity": "sha512-aWrHmo6pGEBH6vO8/AClkIQ6hSyzwROz+sO1gTBIJnwqj81VgSzyTvbCWpYW5fXCrB8yQiPbyxqD40kSsaJnzQ==", "integrity": "sha512-vGXSKF4HHuCQCIIk0hc5sgHD+u7mhAZmksMTmT52ghks2lrLvD+DFUwnRCxMVaJoEilQu3TMXIVS4wwzf3hB1g==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
"@babel/types", "@babel/types",
"@lingui/conf@6.0.0-next.2", "@lingui/conf@6.0.0-next.3",
"@lingui/message-utils@6.0.0-next.2" "@lingui/message-utils@6.0.0-next.3"
] ]
}, },
"@lingui/cli@5.9.4_typescript@5.9.3": { "@lingui/cli@5.9.4_typescript@5.9.3": {
@@ -597,19 +598,19 @@
], ],
"bin": true "bin": true
}, },
"@lingui/cli@6.0.0-next.2": { "@lingui/cli@6.0.0-next.3": {
"integrity": "sha512-nZj7EaLUC4YJqgR8GJDOq1hGjwxuDoVGRd6C3qXlJY/yaPDY333CIW9VXFm2RSGozFlnppTOp48ESZo1k0mRZA==", "integrity": "sha512-HbVS5nkqR3/GPZyDAp3d2Km/YdQE9YIn5iKAy/WDOMpVMyR34Mi+O3uFyFoMugnB17qVqjmoAUVVxFlTc6CIKw==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
"@babel/generator", "@babel/generator",
"@babel/parser", "@babel/parser",
"@babel/types", "@babel/types",
"@lingui/babel-plugin-extract-messages@6.0.0-next.2", "@lingui/babel-plugin-extract-messages@6.0.0-next.3",
"@lingui/babel-plugin-lingui-macro@6.0.0-next.2", "@lingui/babel-plugin-lingui-macro@6.0.0-next.3",
"@lingui/conf@6.0.0-next.2", "@lingui/conf@6.0.0-next.3",
"@lingui/core@6.0.0-next.2", "@lingui/core@6.0.0-next.3",
"@lingui/format-po@6.0.0-next.2", "@lingui/format-po@6.0.0-next.3",
"@lingui/message-utils@6.0.0-next.2", "@lingui/message-utils@6.0.0-next.3",
"chokidar@5.0.0", "chokidar@5.0.0",
"cli-table3", "cli-table3",
"commander@14.0.3", "commander@14.0.3",
@@ -635,8 +636,8 @@
"picocolors" "picocolors"
] ]
}, },
"@lingui/conf@6.0.0-next.2": { "@lingui/conf@6.0.0-next.3": {
"integrity": "sha512-DZGvd0yVOP6bhG+wZei0nPIHiyO3LlgG/u/DFFyeCPT+hr4j9hFRH6efsN5hRgCZ4qf8sxiKWwvOAhfLIcbKsQ==", "integrity": "sha512-GZjVN4sP+WOp9+SRdjvYPn7kOXe2jBUNNETYFxN7hb7pEXS1TmehgruNjDr/td4TRDKBxXLI+OaJMlZWaDQE8g==",
"dependencies": [ "dependencies": [
"jest-validate", "jest-validate",
"jiti", "jiti",
@@ -655,11 +656,11 @@
"@lingui/babel-plugin-lingui-macro@5.9.4_typescript@5.9.3" "@lingui/babel-plugin-lingui-macro@5.9.4_typescript@5.9.3"
] ]
}, },
"@lingui/core@6.0.0-next.2": { "@lingui/core@6.0.0-next.3": {
"integrity": "sha512-JuGvuIlfW4HyCK2cvagqN9o5AG9TwJQ9wP36scxtobufFC0wNCxh7lC5sfsYz+KJwY62duCdYEiXfbMhBvmW1Q==", "integrity": "sha512-ugIBl1cwxlHg7tY98qT9h1M5v6CurDBre/Vjzfv3F2RXC4J7iB4LGJ2QSbVIYAQVIK/H0pgrv1Lh1L8IwgEPZQ==",
"dependencies": [ "dependencies": [
"@lingui/babel-plugin-lingui-macro@6.0.0-next.2", "@lingui/babel-plugin-lingui-macro@6.0.0-next.3",
"@lingui/message-utils@6.0.0-next.2" "@lingui/message-utils@6.0.0-next.3"
] ]
}, },
"@lingui/format-po@5.9.4_typescript@5.9.3": { "@lingui/format-po@5.9.4_typescript@5.9.3": {
@@ -671,11 +672,11 @@
"pofile" "pofile"
] ]
}, },
"@lingui/format-po@6.0.0-next.2": { "@lingui/format-po@6.0.0-next.3": {
"integrity": "sha512-vlQCy0tFYKGLT2VS8rlYjh36CBAcQAdzezo6IphtIeg5E3jQYWukrVsAVKqurUHe3/kKVNyyWEMODeH84pIJiA==", "integrity": "sha512-fFtNFzVaSAefPIJHuoCUSTXy/up9OfJTed3gJm9CgpovxaGVBQfuDQtS2lSghaWiLtGo9UqdkROPrSzYdTqulQ==",
"dependencies": [ "dependencies": [
"@lingui/conf@6.0.0-next.2", "@lingui/conf@6.0.0-next.3",
"@lingui/message-utils@6.0.0-next.2", "@lingui/message-utils@6.0.0-next.3",
"pofile" "pofile"
] ]
}, },
@@ -686,33 +687,33 @@
"js-sha256" "js-sha256"
] ]
}, },
"@lingui/message-utils@6.0.0-next.2": { "@lingui/message-utils@6.0.0-next.3": {
"integrity": "sha512-3vI2La2XLD/mrsdkghMmuuHuCS9+pvGXOKofCNjDqiayvCPV9q5YeOGa3jb+frOLfIuJ/IiHplNO4wIq3y2kuw==", "integrity": "sha512-oXTGhXyFoZua0ahVNDwhs7/3tXVAL8NPz7SOTRYORyxDREQwHYTqYXHijHRGquBvL5QEHy28uYSQvy5Szojn8A==",
"dependencies": [ "dependencies": [
"@messageformat/date-skeleton", "@messageformat/date-skeleton",
"@messageformat/parser", "@messageformat/parser",
"js-sha256" "js-sha256"
] ]
}, },
"@lingui/react@6.0.0-next.2_react@19.2.4": { "@lingui/react@6.0.0-next.3_react@19.2.4": {
"integrity": "sha512-EdzP+8a2UDLwzWPDG2+ctoFrU4oAyWFvijimTm6m6o39s/WHci+yH/eD7tNFr+bOUaynwoTI7azxfJJzbaDZNQ==", "integrity": "sha512-tSu+Y8Xsi5oHdjXZaa5o76B4UHdVmrLdQUVDxgMXx+E4s/D30cTkuVtqoKr3kdY/StiEU7aKb08qUdfE52CWyA==",
"dependencies": [ "dependencies": [
"@lingui/babel-plugin-lingui-macro@6.0.0-next.2", "@lingui/babel-plugin-lingui-macro@6.0.0-next.3",
"@lingui/core@6.0.0-next.2", "@lingui/core@6.0.0-next.3",
"react" "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==", "integrity": "sha512-rD20Y7gpquPUw7e/V33lOcsTa28kuuP2GpIK74iQ46KFWm067DKNUkGfcfeEHG6ORBJck6W+KvqeOZ/B4vID1Q==",
"dependencies": [ "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": { "@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-dK+imM95tZ7LoJoh8VOp7P20cjgXoC2oWx9dW7zoYz40jZsvIssRYQ4a4jiYM4UNp+fMdaAo5t0GDbqIJ9EZGw==", "integrity": "sha512-Ewmg3jcBTp8VEfqkgsr6/YJJ7cphLtLSW+RGkCJREko6pa/a4V3U7hA/xKo2Z2Q1u95E8ngfWJO3qDXLGDb+DA==",
"dependencies": [ "dependencies": [
"@lingui/cli@6.0.0-next.2", "@lingui/cli@6.0.0-next.3",
"@lingui/conf@6.0.0-next.2", "@lingui/conf@6.0.0-next.3",
"vite" "vite"
] ]
}, },
@@ -1000,7 +1001,7 @@
"@types/yargs-parser" "@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==", "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
"dependencies": [ "dependencies": [
"@eslint-community/regexpp", "@eslint-community/regexpp",
@@ -1016,7 +1017,7 @@
"typescript" "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==", "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dependencies": [ "dependencies": [
"@typescript-eslint/scope-manager", "@typescript-eslint/scope-manager",
@@ -1050,7 +1051,7 @@
"typescript" "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==", "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
"dependencies": [ "dependencies": [
"@typescript-eslint/types", "@typescript-eslint/types",
@@ -1080,7 +1081,7 @@
"typescript" "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==", "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
"dependencies": [ "dependencies": [
"@eslint-community/eslint-utils", "@eslint-community/eslint-utils",
@@ -1101,7 +1102,7 @@
"@ungap/structured-clone@1.3.0": { "@ungap/structured-clone@1.3.0": {
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" "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==", "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==",
"dependencies": [ "dependencies": [
"@rolldown/pluginutils@1.0.0-rc.7", "@rolldown/pluginutils@1.0.0-rc.7",
@@ -1165,8 +1166,8 @@
"base64-js@1.5.1": { "base64-js@1.5.1": {
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
}, },
"baseline-browser-mapping@2.10.10": { "baseline-browser-mapping@2.10.16": {
"integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
"bin": true "bin": true
}, },
"binary-extensions@2.3.0": { "binary-extensions@2.3.0": {
@@ -1199,8 +1200,8 @@
"fill-range" "fill-range"
] ]
}, },
"browserslist@4.28.1": { "browserslist@4.28.2": {
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dependencies": [ "dependencies": [
"baseline-browser-mapping", "baseline-browser-mapping",
"caniuse-lite", "caniuse-lite",
@@ -1223,8 +1224,8 @@
"camelcase@6.3.0": { "camelcase@6.3.0": {
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="
}, },
"caniuse-lite@1.0.30001780": { "caniuse-lite@1.0.30001786": {
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==" "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="
}, },
"ccount@2.0.1": { "ccount@2.0.1": {
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
@@ -1398,8 +1399,8 @@
"dequal" "dequal"
] ]
}, },
"electron-to-chromium@1.5.321": { "electron-to-chromium@1.5.332": {
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ=="
}, },
"emoji-regex@8.0.0": { "emoji-regex@8.0.0": {
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
@@ -1452,7 +1453,7 @@
"escape-string-regexp@5.0.0": { "escape-string-regexp@5.0.0": {
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" "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==", "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
@@ -1463,7 +1464,7 @@
"zod-validation-error" "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==", "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
"dependencies": [ "dependencies": [
"eslint" "eslint"
@@ -1485,7 +1486,7 @@
"eslint-visitor-keys@5.0.1": { "eslint-visitor-keys@5.0.1": {
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==" "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="
}, },
"eslint@9.39.4": { "eslint@9.39.4_jiti@2.6.1": {
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dependencies": [ "dependencies": [
"@eslint-community/eslint-utils", "@eslint-community/eslint-utils",
@@ -1517,12 +1518,16 @@
"ignore@5.3.2", "ignore@5.3.2",
"imurmurhash", "imurmurhash",
"is-glob", "is-glob",
"jiti",
"json-stable-stringify-without-jsonify", "json-stable-stringify-without-jsonify",
"lodash.merge", "lodash.merge",
"minimatch@3.1.5", "minimatch@3.1.5",
"natural-compare", "natural-compare",
"optionator" "optionator"
], ],
"optionalPeers": [
"jiti"
],
"bin": true "bin": true
}, },
"esm@3.2.25": { "esm@3.2.25": {
@@ -2902,7 +2907,7 @@
"prelude-ls" "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==", "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
"dependencies": [ "dependencies": [
"@typescript-eslint/eslint-plugin", "@typescript-eslint/eslint-plugin",
@@ -2965,7 +2970,7 @@
"unist-util-visit-parents" "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==", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dependencies": [ "dependencies": [
"browserslist", "browserslist",
@@ -2997,10 +3002,11 @@
"vfile-message" "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==", "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
"dependencies": [ "dependencies": [
"@types/node", "@types/node",
"jiti",
"lightningcss", "lightningcss",
"picomatch@4.0.3", "picomatch@4.0.3",
"postcss", "postcss",
@@ -3011,7 +3017,8 @@
"fsevents" "fsevents"
], ],
"optionalPeers": [ "optionalPeers": [
"@types/node" "@types/node",
"jiti"
], ],
"bin": true "bin": true
}, },
@@ -3065,12 +3072,13 @@
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@eslint/js@^9.39.4", "npm:@eslint/js@^9.39.4",
"npm:@lingui/cli@6.0.0-next.2", "npm:@lingui/cli@6.0.0-next.3",
"npm:@lingui/core@6.0.0-next.2", "npm:@lingui/conf@6.0.0-next.3",
"npm:@lingui/format-po@6.0.0-next.2", "npm:@lingui/core@6.0.0-next.3",
"npm:@lingui/react@6.0.0-next.2", "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/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/node@^24.12.0",
"npm:@types/react-dom@^19.2.3", "npm:@types/react-dom@^19.2.3",
"npm:@types/react@^19.2.14", "npm:@types/react@^19.2.14",
@@ -3080,6 +3088,7 @@
"npm:eslint@^9.39.4", "npm:eslint@^9.39.4",
"npm:frimousse@0.3", "npm:frimousse@0.3",
"npm:globals@^17.4.0", "npm:globals@^17.4.0",
"npm:jiti@^2.6.1",
"npm:react-dom@^19.2.4", "npm:react-dom@^19.2.4",
"npm:react-markdown@^10.1.0", "npm:react-markdown@^10.1.0",
"npm:react-router@^7.13.1", "npm:react-router@^7.13.1",

View File

@@ -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: '^_',
},
],
},
},
])

32
eslint.config.ts Normal file
View File

@@ -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: "^_",
},
],
},
},
]);

View File

@@ -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} */ const config: LinguiConfig = {
module.exports = {
locales: ["en", "fr"], locales: ["en", "fr"],
sourceLocale: "en", sourceLocale: "en",
catalogs: [ catalogs: [
@@ -12,3 +12,5 @@ module.exports = {
], ],
format: formatter(), format: formatter(),
}; };
export default config;

View File

@@ -10,14 +10,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lingui/cli": "6.0.0-next.2", "@lingui/cli": "6.0.0-next.3",
"@lingui/core": "6.0.0-next.2", "@lingui/conf": "6.0.0-next.3",
"@lingui/format-po": "6.0.0-next.2", "@lingui/core": "6.0.0-next.3",
"@lingui/react": "6.0.0-next.2", "@lingui/format-po": "6.0.0-next.3",
"@lingui/react": "6.0.0-next.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@lingui/swc-plugin": "6.0.0-next.2", "@lingui/swc-plugin": "6.0.0-next.2",
"@vitejs/plugin-react-swc": "^4.3.0", "@vitejs/plugin-react-swc": "^4.3.0",
"frimousse": "^0.3.0", "frimousse": "^0.3.0",
"jiti": "^2.6.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -25,7 +27,7 @@
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@lingui/vite-plugin": "6.0.0-next.2", "@lingui/vite-plugin": "6.0.0-next.3",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View File

@@ -1807,6 +1807,10 @@ body.has-player .fab-new {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
} }
.app-header--has-center .app-header-nav {
grid-column: 3;
}
} }
.app-header-brand { .app-header-brand {

View File

@@ -1,20 +1,8 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import { Index } from "./pages/Index.tsx";
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.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 { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
@@ -25,8 +13,59 @@ import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
import "./App.css"; 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() { function AppRoutes() {
return ( return (
<Suspense>
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/" element={<Index />} />
<Route path="/dumps/:selectedDump" element={<Dump />} /> <Route path="/dumps/:selectedDump" element={<Dump />} />
@@ -73,6 +112,7 @@ function AppRoutes() {
} }
/> />
</Routes> </Routes>
</Suspense>
); );
} }

View File

@@ -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 { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
import { NotificationBell } from "./NotificationBell.tsx"; import { NotificationBell } from "./NotificationBell.tsx";
import { UserMenu } from "./UserMenu.tsx"; import { UserMenu } from "./UserMenu.tsx";
const DumpCreateModal = lazy(() =>
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
);
export function AppHeader( export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: { { centerSlot, disableNew, initialDumpUrl }: {
centerSlot?: ReactNode; centerSlot?: ReactNode;
@@ -88,10 +91,12 @@ export function AppHeader(
)} )}
{createModalOpen && ( {createModalOpen && (
<Suspense>
<DumpCreateModal <DumpCreateModal
onClose={() => setCreateModalOpen(false)} onClose={() => setCreateModalOpen(false)}
initialUrl={initialDumpUrl} initialUrl={initialDumpUrl}
/> />
</Suspense>
)} )}
</> </>
); );

View File

@@ -118,6 +118,21 @@ function AudioFilePreview(
); );
} }
function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <span className="rich-content-compact-icon">{fallback}</span>;
}
return (
<img
src={src}
alt=""
className="rich-content-compact-thumbnail"
onError={() => setFailed(true)}
/>
);
}
function mimeIcon(mime: string): string { function mimeIcon(mime: string): string {
if (mime.startsWith("video/")) return "🎬"; if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵"; if (mime.startsWith("audio/")) return "🎵";
@@ -148,6 +163,7 @@ export default function FilePreview(
); );
} }
if (mime.startsWith("video/")) { if (mime.startsWith("video/")) {
const thumbUrl = `${API_URL}/api/thumbnails/${dump.id}`;
return ( return (
<button <button
type="button" type="button"
@@ -160,15 +176,7 @@ export default function FilePreview(
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title }); play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}} }}
> >
<video <VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
src={fileUrl}
preload="metadata"
className="rich-content-compact-thumbnail"
muted
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = 0.1;
}}
/>
<span className="rich-content-play-overlay"></span> <span className="rich-content-play-overlay"></span>
</button> </button>
); );
@@ -217,12 +225,9 @@ export default function FilePreview(
> >
<video <video
src={fileUrl} src={fileUrl}
preload="metadata" preload="none"
className="file-preview-video-thumb" className="file-preview-video-thumb"
muted muted
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = 0.1;
}}
/> />
<span className="rich-content-play-overlay"> <span className="rich-content-play-overlay">
{videoPlaying ? "⏸" : "▶"} {videoPlaying ? "⏸" : "▶"}

View File

@@ -74,7 +74,7 @@ msgstr "← Back to profile"
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Invite someone" msgstr "+ Invite someone"
#: src/components/AppHeader.tsx:67 #: src/components/AppHeader.tsx:70
msgid "+ New" msgid "+ New"
msgstr "+ New" msgstr "+ New"
@@ -176,8 +176,8 @@ msgid "Appearance"
msgstr "Appearance" msgstr "Appearance"
#. placeholder {0}: VALIDATION.PASSWORD_MIN #. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/ChangePasswordModal.tsx:101 #: src/components/ChangePasswordModal.tsx:123
#: src/pages/ResetPassword.tsx:113 #: src/pages/ResetPassword.tsx:125
msgid "At least {0} characters" msgid "At least {0} characters"
msgstr "At least {0} characters" msgstr "At least {0} characters"
@@ -185,8 +185,8 @@ msgstr "At least {0} characters"
msgid "Auto" msgid "Auto"
msgstr "Auto" msgstr "Auto"
#: src/pages/ResetPassword.tsx:36 #: src/pages/ResetPassword.tsx:44
#: src/pages/ResetPassword.tsx:146 #: src/pages/ResetPassword.tsx:159
msgid "Back to login" msgid "Back to login"
msgstr "Back to login" msgstr "Back to login"
@@ -195,7 +195,7 @@ msgstr "Back to login"
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
#: src/components/ChangePasswordModal.tsx:132 #: src/components/ChangePasswordModal.tsx:168
#: src/components/CommentThread.tsx:281 #: src/components/CommentThread.tsx:281
#: src/components/CommentThread.tsx:373 #: src/components/CommentThread.tsx:373
#: src/components/CommentThread.tsx:510 #: src/components/CommentThread.tsx:510
@@ -221,8 +221,8 @@ msgstr "Cancel removal"
msgid "Change avatar" msgid "Change avatar"
msgstr "Change avatar" msgstr "Change avatar"
#: src/components/ChangePasswordModal.tsx:55 #: src/components/ChangePasswordModal.tsx:56
#: src/components/ChangePasswordModal.tsx:142 #: src/components/ChangePasswordModal.tsx:178
msgid "Change password" msgid "Change password"
msgstr "Change password" msgstr "Change password"
@@ -234,7 +234,7 @@ msgstr "Change password…"
msgid "Checking invite…" msgid "Checking invite…"
msgstr "Checking invite…" msgstr "Checking invite…"
#: src/components/ChangePasswordModal.tsx:65 #: src/components/ChangePasswordModal.tsx:66
#: src/components/Modal.tsx:45 #: src/components/Modal.tsx:45
msgid "Close" msgid "Close"
msgstr "Close" msgstr "Close"
@@ -247,8 +247,8 @@ msgstr "Color scheme"
#~ msgid "Comment not found" #~ msgid "Comment not found"
#~ msgstr "Comment not found" #~ msgstr "Comment not found"
#: src/components/ChangePasswordModal.tsx:107 #: src/components/ChangePasswordModal.tsx:136
#: src/pages/ResetPassword.tsx:120 #: src/pages/ResetPassword.tsx:132
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirm new password" msgstr "Confirm new password"
@@ -260,11 +260,11 @@ msgstr "Copied!"
msgid "Copy" msgid "Copy"
msgstr "Copy" msgstr "Copy"
#: src/components/ChangePasswordModal.tsx:123 #: src/components/ChangePasswordModal.tsx:159
msgid "Could not change password" msgid "Could not change password"
msgstr "Could not change password" msgstr "Could not change password"
#: src/pages/ResetPassword.tsx:84 #: src/pages/ResetPassword.tsx:94
#: src/pages/UserLogin.tsx:79 #: src/pages/UserLogin.tsx:79
msgid "Could not connect to server" msgid "Could not connect to server"
msgstr "Could not connect to server" msgstr "Could not connect to server"
@@ -293,7 +293,7 @@ msgstr "Created ({0}{1})"
msgid "Creating…" msgid "Creating…"
msgstr "Creating…" msgstr "Creating…"
#: src/components/ChangePasswordModal.tsx:75 #: src/components/ChangePasswordModal.tsx:83
msgid "Current password" msgid "Current password"
msgstr "Current password" msgstr "Current password"
@@ -413,7 +413,7 @@ msgstr "Email address"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Enter a query to search." msgstr "Enter a query to search."
#: src/components/ChangePasswordModal.tsx:48 #: src/components/ChangePasswordModal.tsx:49
msgid "Failed to change password" msgid "Failed to change password"
msgstr "Failed to change password" msgstr "Failed to change password"
@@ -432,7 +432,6 @@ msgstr "Failed to generate invite"
#: src/pages/index/HotFeed.tsx:36 #: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48 #: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/NewFeed.tsx:36 #: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:323
#: src/pages/UserPublicProfile.tsx:1106 #: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1148 #: src/pages/UserPublicProfile.tsx:1148
#: src/pages/UserPublicProfile.tsx:1193 #: src/pages/UserPublicProfile.tsx:1193
@@ -554,7 +553,7 @@ msgstr "From people"
msgid "From playlists" msgid "From playlists"
msgstr "From playlists" msgstr "From playlists"
#: src/pages/ResetPassword.tsx:56 #: src/pages/ResetPassword.tsx:66
msgid "Go to login" msgid "Go to login"
msgstr "Go to login" msgstr "Go to login"
@@ -574,7 +573,7 @@ msgstr "If that address is registered you'll receive a reset link shortly."
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invalid invite" msgstr "Invalid invite"
#: src/pages/ResetPassword.tsx:33 #: src/pages/ResetPassword.tsx:34
msgid "Invalid link" msgid "Invalid link"
msgstr "Invalid link" msgstr "Invalid link"
@@ -620,11 +619,11 @@ msgstr "Light"
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/AppHeader.tsx:84 #: src/components/AppHeader.tsx:87
msgid "Live updates unavailable." msgid "Live updates unavailable."
msgstr "Live updates unavailable." msgstr "Live updates unavailable."
#: src/pages/Notifications.tsx:396 #: src/pages/Notifications.tsx:390
msgid "Load more" msgid "Load more"
msgstr "Load more" msgstr "Load more"
@@ -659,8 +658,8 @@ msgstr "Loading profile…"
#: src/pages/index/HotFeed.tsx:32 #: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44 #: src/pages/index/JournalFeed.tsx:44
#: src/pages/index/NewFeed.tsx:32 #: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:319 #: src/pages/Notifications.tsx:313
#: src/pages/Notifications.tsx:395 #: src/pages/Notifications.tsx:389
#: src/pages/UserDumps.tsx:51 #: src/pages/UserDumps.tsx:51
#: src/pages/UserPlaylists.tsx:342 #: src/pages/UserPlaylists.tsx:342
#: src/pages/UserPublicProfile.tsx:1100 #: src/pages/UserPublicProfile.tsx:1100
@@ -670,7 +669,7 @@ msgstr "Loading profile…"
msgid "Loading…" msgid "Loading…"
msgstr "Loading…" msgstr "Loading…"
#: src/components/AppHeader.tsx:74 #: src/components/AppHeader.tsx:77
#: src/pages/UserLogin.tsx:87 #: src/pages/UserLogin.tsx:87
#: src/pages/UserLogin.tsx:117 #: src/pages/UserLogin.tsx:117
msgid "Log in" msgid "Log in"
@@ -693,7 +692,7 @@ msgstr "Login failed"
msgid "Max 50 MB" msgid "Max 50 MB"
msgstr "Max 50 MB" msgstr "Max 50 MB"
#: src/pages/Notifications.tsx:312 #: src/pages/Notifications.tsx:306
msgid "new" msgid "new"
msgstr "new" msgstr "new"
@@ -705,8 +704,8 @@ msgstr "New"
msgid "New dump" msgid "New dump"
msgstr "New dump" msgstr "New dump"
#: src/components/ChangePasswordModal.tsx:88 #: src/components/ChangePasswordModal.tsx:103
#: src/pages/ResetPassword.tsx:101 #: src/pages/ResetPassword.tsx:113
msgid "New password" msgid "New password"
msgstr "New password" msgstr "New password"
@@ -763,7 +762,7 @@ msgstr "No users match \"{q}\"."
msgid "Not following anyone yet." msgid "Not following anyone yet."
msgstr "Not following anyone yet." msgstr "Not following anyone yet."
#: src/pages/Notifications.tsx:330 #: src/pages/Notifications.tsx:324
#: src/pages/UserDumps.tsx:123 #: src/pages/UserDumps.tsx:123
#: src/pages/UserPublicProfile.tsx:1340 #: src/pages/UserPublicProfile.tsx:1340
#: src/pages/UserPublicProfile.tsx:1463 #: src/pages/UserPublicProfile.tsx:1463
@@ -772,7 +771,7 @@ msgid "Nothing here yet."
msgstr "Nothing here yet." msgstr "Nothing here yet."
#: src/components/NotificationBell.tsx:42 #: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:308 #: src/pages/Notifications.tsx:302
msgid "Notifications" msgid "Notifications"
msgstr "Notifications" msgstr "Notifications"
@@ -798,7 +797,7 @@ msgstr "Password"
msgid "Password (min. {0} characters)" msgid "Password (min. {0} characters)"
msgstr "Password (min. {0} characters)" msgstr "Password (min. {0} characters)"
#: src/components/ChangePasswordModal.tsx:60 #: src/components/ChangePasswordModal.tsx:61
msgid "Password changed successfully." msgid "Password changed successfully."
msgstr "Password changed successfully." msgstr "Password changed successfully."
@@ -810,12 +809,12 @@ msgstr "Password changed successfully."
#~ msgid "Password must be at most 128 characters" #~ msgid "Password must be at most 128 characters"
#~ msgstr "Password must be at most 128 characters" #~ msgstr "Password must be at most 128 characters"
#: src/pages/ResetPassword.tsx:47 #: src/pages/ResetPassword.tsx:56
msgid "Password updated" msgid "Password updated"
msgstr "Password updated" msgstr "Password updated"
#: src/components/ChangePasswordModal.tsx:118 #: src/components/ChangePasswordModal.tsx:154
#: src/pages/ResetPassword.tsx:129 #: src/pages/ResetPassword.tsx:141
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Passwords do not match" msgstr "Passwords do not match"
@@ -823,7 +822,7 @@ msgstr "Passwords do not match"
#~ msgid "Playlist not found" #~ msgid "Playlist not found"
#~ msgstr "Playlist not found" #~ msgstr "Playlist not found"
#: src/components/AppHeader.tsx:50 #: src/components/AppHeader.tsx:53
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175 #: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:368 #: src/pages/UserPlaylists.tsx:368
@@ -922,7 +921,7 @@ msgstr "Reply"
msgid "Request failed" msgid "Request failed"
msgstr "Request failed" msgstr "Request failed"
#: src/pages/ResetPassword.tsx:94 #: src/pages/ResetPassword.tsx:106
msgid "Reset failed" msgid "Reset failed"
msgstr "Reset failed" msgstr "Reset failed"
@@ -939,10 +938,10 @@ msgstr "Retry"
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
#: src/components/ChangePasswordModal.tsx:141 #: src/components/ChangePasswordModal.tsx:177
#: src/components/CommentThread.tsx:269 #: src/components/CommentThread.tsx:269
#: src/pages/PlaylistDetail.tsx:673 #: src/pages/PlaylistDetail.tsx:673
#: src/pages/ResetPassword.tsx:140 #: src/pages/ResetPassword.tsx:152
#: src/pages/UserPublicProfile.tsx:832 #: src/pages/UserPublicProfile.tsx:832
#: src/pages/UserPublicProfile.tsx:911 #: src/pages/UserPublicProfile.tsx:911
msgid "Saving…" msgid "Saving…"
@@ -972,12 +971,12 @@ msgstr "Send reset link"
msgid "Sending…" msgid "Sending…"
msgstr "Sending…" msgstr "Sending…"
#: src/components/AppHeader.tsx:65 #: src/components/AppHeader.tsx:68
msgid "Server unreachable" msgid "Server unreachable"
msgstr "Server unreachable" msgstr "Server unreachable"
#: src/pages/ResetPassword.tsx:91 #: src/pages/ResetPassword.tsx:102
#: src/pages/ResetPassword.tsx:141 #: src/pages/ResetPassword.tsx:153
msgid "Set new password" msgid "Set new password"
msgstr "Set new password" msgstr "Set new password"
@@ -1014,7 +1013,7 @@ msgstr "This invite link is missing, expired, or already used."
msgid "This is a mirage." msgid "This is a mirage."
msgstr "This is a mirage." msgstr "This is a mirage."
#: src/pages/ResetPassword.tsx:34 #: src/pages/ResetPassword.tsx:37
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed." msgstr "This reset link is missing or malformed."
@@ -1042,8 +1041,8 @@ msgstr "Unfollow {targetUsername}"
msgid "Unfollow playlist" msgid "Unfollow playlist"
msgstr "Unfollow playlist" msgstr "Unfollow playlist"
#: src/components/ChangePasswordModal.tsx:43 #: src/components/ChangePasswordModal.tsx:44
#: src/pages/ResetPassword.tsx:80 #: src/pages/ResetPassword.tsx:90
msgid "Unknown error" msgid "Unknown error"
msgstr "Unknown error" msgstr "Unknown error"
@@ -1120,7 +1119,7 @@ msgstr "Write a reply…"
msgid "Yesterday" msgid "Yesterday"
msgstr "Yesterday" msgstr "Yesterday"
#: src/pages/Notifications.tsx:333 #: src/pages/Notifications.tsx:327
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
@@ -1140,6 +1139,6 @@ msgstr "You've reached the end."
msgid "Your email address" msgid "Your email address"
msgstr "Your email address" msgstr "Your email address"
#: src/pages/ResetPassword.tsx:49 #: src/pages/ResetPassword.tsx:59
msgid "Your password has been changed. You can now log in." msgid "Your password has been changed. You can now log in."
msgstr "Your password has been changed. You can now log in." msgstr "Your password has been changed. You can now log in."

View File

@@ -74,7 +74,7 @@ msgstr "← Retour au profil"
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un" msgstr "+ Inviter quelqu'un"
#: src/components/AppHeader.tsx:67 #: src/components/AppHeader.tsx:70
msgid "+ New" msgid "+ New"
msgstr "+ Nouveau" msgstr "+ Nouveau"
@@ -172,8 +172,8 @@ msgid "Appearance"
msgstr "Apparence" msgstr "Apparence"
#. placeholder {0}: VALIDATION.PASSWORD_MIN #. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/ChangePasswordModal.tsx:101 #: src/components/ChangePasswordModal.tsx:123
#: src/pages/ResetPassword.tsx:113 #: src/pages/ResetPassword.tsx:125
msgid "At least {0} characters" msgid "At least {0} characters"
msgstr "Au moins {0} caractères" msgstr "Au moins {0} caractères"
@@ -181,8 +181,8 @@ msgstr "Au moins {0} caractères"
msgid "Auto" msgid "Auto"
msgstr "Auto" msgstr "Auto"
#: src/pages/ResetPassword.tsx:36 #: src/pages/ResetPassword.tsx:44
#: src/pages/ResetPassword.tsx:146 #: src/pages/ResetPassword.tsx:159
msgid "Back to login" msgid "Back to login"
msgstr "Retour à la connexion" msgstr "Retour à la connexion"
@@ -191,7 +191,7 @@ msgstr "Retour à la connexion"
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
#: src/components/ChangePasswordModal.tsx:132 #: src/components/ChangePasswordModal.tsx:168
#: src/components/CommentThread.tsx:281 #: src/components/CommentThread.tsx:281
#: src/components/CommentThread.tsx:373 #: src/components/CommentThread.tsx:373
#: src/components/CommentThread.tsx:510 #: src/components/CommentThread.tsx:510
@@ -213,8 +213,8 @@ msgstr "Annuler la suppression"
msgid "Change avatar" msgid "Change avatar"
msgstr "Changer l'avatar" msgstr "Changer l'avatar"
#: src/components/ChangePasswordModal.tsx:55 #: src/components/ChangePasswordModal.tsx:56
#: src/components/ChangePasswordModal.tsx:142 #: src/components/ChangePasswordModal.tsx:178
msgid "Change password" msgid "Change password"
msgstr "Changer le mot de passe" msgstr "Changer le mot de passe"
@@ -226,7 +226,7 @@ msgstr "Changer le mot de passe…"
msgid "Checking invite…" msgid "Checking invite…"
msgstr "Vérification de l'invitation…" msgstr "Vérification de l'invitation…"
#: src/components/ChangePasswordModal.tsx:65 #: src/components/ChangePasswordModal.tsx:66
#: src/components/Modal.tsx:45 #: src/components/Modal.tsx:45
msgid "Close" msgid "Close"
msgstr "Fermer" msgstr "Fermer"
@@ -235,8 +235,8 @@ msgstr "Fermer"
msgid "Color scheme" msgid "Color scheme"
msgstr "Thème de couleur" msgstr "Thème de couleur"
#: src/components/ChangePasswordModal.tsx:107 #: src/components/ChangePasswordModal.tsx:136
#: src/pages/ResetPassword.tsx:120 #: src/pages/ResetPassword.tsx:132
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe" msgstr "Confirmer le nouveau mot de passe"
@@ -248,11 +248,11 @@ msgstr "Copié !"
msgid "Copy" msgid "Copy"
msgstr "Copier" msgstr "Copier"
#: src/components/ChangePasswordModal.tsx:123 #: src/components/ChangePasswordModal.tsx:159
msgid "Could not change password" msgid "Could not change password"
msgstr "Impossible de changer le mot de passe" msgstr "Impossible de changer le mot de passe"
#: src/pages/ResetPassword.tsx:84 #: src/pages/ResetPassword.tsx:94
#: src/pages/UserLogin.tsx:79 #: src/pages/UserLogin.tsx:79
msgid "Could not connect to server" msgid "Could not connect to server"
msgstr "Impossible de contacter le serveur" msgstr "Impossible de contacter le serveur"
@@ -281,7 +281,7 @@ msgstr "Créées ({0}{1})"
msgid "Creating…" msgid "Creating…"
msgstr "Création…" msgstr "Création…"
#: src/components/ChangePasswordModal.tsx:75 #: src/components/ChangePasswordModal.tsx:83
msgid "Current password" msgid "Current password"
msgstr "Mot de passe actuel" msgstr "Mot de passe actuel"
@@ -397,7 +397,7 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Saisissez une recherche." msgstr "Saisissez une recherche."
#: src/components/ChangePasswordModal.tsx:48 #: src/components/ChangePasswordModal.tsx:49
msgid "Failed to change password" msgid "Failed to change password"
msgstr "Impossible de changer le mot de passe" msgstr "Impossible de changer le mot de passe"
@@ -416,7 +416,6 @@ msgstr "Impossible de générer une invitation"
#: src/pages/index/HotFeed.tsx:36 #: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48 #: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/NewFeed.tsx:36 #: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:323
#: src/pages/UserPublicProfile.tsx:1106 #: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1148 #: src/pages/UserPublicProfile.tsx:1148
#: src/pages/UserPublicProfile.tsx:1193 #: src/pages/UserPublicProfile.tsx:1193
@@ -522,7 +521,7 @@ msgstr "De personnes"
msgid "From playlists" msgid "From playlists"
msgstr "De collections" msgstr "De collections"
#: src/pages/ResetPassword.tsx:56 #: src/pages/ResetPassword.tsx:66
msgid "Go to login" msgid "Go to login"
msgstr "Aller à la connexion" msgstr "Aller à la connexion"
@@ -538,7 +537,7 @@ msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialis
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invitation invalide" msgstr "Invitation invalide"
#: src/pages/ResetPassword.tsx:33 #: src/pages/ResetPassword.tsx:34
msgid "Invalid link" msgid "Invalid link"
msgstr "Lien invalide" msgstr "Lien invalide"
@@ -567,11 +566,11 @@ msgstr "Clair"
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/AppHeader.tsx:84 #: src/components/AppHeader.tsx:87
msgid "Live updates unavailable." msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles." msgstr "Mises à jour en direct indisponibles."
#: src/pages/Notifications.tsx:396 #: src/pages/Notifications.tsx:390
msgid "Load more" msgid "Load more"
msgstr "Charger plus" msgstr "Charger plus"
@@ -606,8 +605,8 @@ msgstr "Chargement du profil…"
#: src/pages/index/HotFeed.tsx:32 #: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44 #: src/pages/index/JournalFeed.tsx:44
#: src/pages/index/NewFeed.tsx:32 #: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:319 #: src/pages/Notifications.tsx:313
#: src/pages/Notifications.tsx:395 #: src/pages/Notifications.tsx:389
#: src/pages/UserDumps.tsx:51 #: src/pages/UserDumps.tsx:51
#: src/pages/UserPlaylists.tsx:342 #: src/pages/UserPlaylists.tsx:342
#: src/pages/UserPublicProfile.tsx:1100 #: src/pages/UserPublicProfile.tsx:1100
@@ -617,7 +616,7 @@ msgstr "Chargement du profil…"
msgid "Loading…" msgid "Loading…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/components/AppHeader.tsx:74 #: src/components/AppHeader.tsx:77
#: src/pages/UserLogin.tsx:87 #: src/pages/UserLogin.tsx:87
#: src/pages/UserLogin.tsx:117 #: src/pages/UserLogin.tsx:117
msgid "Log in" msgid "Log in"
@@ -640,7 +639,7 @@ msgstr "Connexion échouée"
msgid "Max 50 MB" msgid "Max 50 MB"
msgstr "Max 50 Mo" msgstr "Max 50 Mo"
#: src/pages/Notifications.tsx:312 #: src/pages/Notifications.tsx:306
msgid "new" msgid "new"
msgstr "nouveau" msgstr "nouveau"
@@ -652,8 +651,8 @@ msgstr "Nouveau"
msgid "New dump" msgid "New dump"
msgstr "Nouvelle reco" msgstr "Nouvelle reco"
#: src/components/ChangePasswordModal.tsx:88 #: src/components/ChangePasswordModal.tsx:103
#: src/pages/ResetPassword.tsx:101 #: src/pages/ResetPassword.tsx:113
msgid "New password" msgid "New password"
msgstr "Nouveau mot de passe" msgstr "Nouveau mot de passe"
@@ -706,7 +705,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
msgid "Not following anyone yet." msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment." msgstr "Aucun abonnement pour le moment."
#: src/pages/Notifications.tsx:330 #: src/pages/Notifications.tsx:324
#: src/pages/UserDumps.tsx:123 #: src/pages/UserDumps.tsx:123
#: src/pages/UserPublicProfile.tsx:1340 #: src/pages/UserPublicProfile.tsx:1340
#: src/pages/UserPublicProfile.tsx:1463 #: src/pages/UserPublicProfile.tsx:1463
@@ -715,7 +714,7 @@ msgid "Nothing here yet."
msgstr "Rien ici pour l'instant." msgstr "Rien ici pour l'instant."
#: src/components/NotificationBell.tsx:42 #: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:308 #: src/pages/Notifications.tsx:302
msgid "Notifications" msgid "Notifications"
msgstr "Notifications" msgstr "Notifications"
@@ -741,20 +740,20 @@ msgstr "Mot de passe"
msgid "Password (min. {0} characters)" msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)" msgstr "Mot de passe (min. {0} caractères)"
#: src/components/ChangePasswordModal.tsx:60 #: src/components/ChangePasswordModal.tsx:61
msgid "Password changed successfully." msgid "Password changed successfully."
msgstr "Mot de passe modifié avec succès." msgstr "Mot de passe modifié avec succès."
#: src/pages/ResetPassword.tsx:47 #: src/pages/ResetPassword.tsx:56
msgid "Password updated" msgid "Password updated"
msgstr "Mot de passe mis à jour" msgstr "Mot de passe mis à jour"
#: src/components/ChangePasswordModal.tsx:118 #: src/components/ChangePasswordModal.tsx:154
#: src/pages/ResetPassword.tsx:129 #: src/pages/ResetPassword.tsx:141
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas" msgstr "Les mots de passe ne correspondent pas"
#: src/components/AppHeader.tsx:50 #: src/components/AppHeader.tsx:53
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175 #: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:368 #: src/pages/UserPlaylists.tsx:368
@@ -853,7 +852,7 @@ msgstr "Répondre"
msgid "Request failed" msgid "Request failed"
msgstr "Échec de la demande" msgstr "Échec de la demande"
#: src/pages/ResetPassword.tsx:94 #: src/pages/ResetPassword.tsx:106
msgid "Reset failed" msgid "Reset failed"
msgstr "Échec de la réinitialisation" msgstr "Échec de la réinitialisation"
@@ -870,10 +869,10 @@ msgstr "Réessayer"
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:141 #: src/components/ChangePasswordModal.tsx:177
#: src/components/CommentThread.tsx:269 #: src/components/CommentThread.tsx:269
#: src/pages/PlaylistDetail.tsx:673 #: src/pages/PlaylistDetail.tsx:673
#: src/pages/ResetPassword.tsx:140 #: src/pages/ResetPassword.tsx:152
#: src/pages/UserPublicProfile.tsx:832 #: src/pages/UserPublicProfile.tsx:832
#: src/pages/UserPublicProfile.tsx:911 #: src/pages/UserPublicProfile.tsx:911
msgid "Saving…" msgid "Saving…"
@@ -903,12 +902,12 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…" msgid "Sending…"
msgstr "Envoi…" msgstr "Envoi…"
#: src/components/AppHeader.tsx:65 #: src/components/AppHeader.tsx:68
msgid "Server unreachable" msgid "Server unreachable"
msgstr "Serveur inaccessible" msgstr "Serveur inaccessible"
#: src/pages/ResetPassword.tsx:91 #: src/pages/ResetPassword.tsx:102
#: src/pages/ResetPassword.tsx:141 #: src/pages/ResetPassword.tsx:153
msgid "Set new password" msgid "Set new password"
msgstr "Définir un nouveau mot de passe" msgstr "Définir un nouveau mot de passe"
@@ -945,7 +944,7 @@ msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
msgid "This is a mirage." msgid "This is a mirage."
msgstr "C'est un mirage." msgstr "C'est un mirage."
#: src/pages/ResetPassword.tsx:34 #: src/pages/ResetPassword.tsx:37
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé." msgstr "Ce lien de réinitialisation est absent ou malformé."
@@ -969,8 +968,8 @@ msgstr "Ne plus suivre {targetUsername}"
msgid "Unfollow playlist" msgid "Unfollow playlist"
msgstr "Ne plus suivre la collection" msgstr "Ne plus suivre la collection"
#: src/components/ChangePasswordModal.tsx:43 #: src/components/ChangePasswordModal.tsx:44
#: src/pages/ResetPassword.tsx:80 #: src/pages/ResetPassword.tsx:90
msgid "Unknown error" msgid "Unknown error"
msgstr "Erreur inconnue" msgstr "Erreur inconnue"
@@ -1039,7 +1038,7 @@ msgstr "Écrire une réponse…"
msgid "Yesterday" msgid "Yesterday"
msgstr "Hier" msgstr "Hier"
#: src/pages/Notifications.tsx:333 #: src/pages/Notifications.tsx:327
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
@@ -1059,6 +1058,6 @@ msgstr "Vous avez tout lu, tout vu, tout bu."
msgid "Your email address" msgid "Your email address"
msgstr "Votre adresse e-mail" msgstr "Votre adresse e-mail"
#: src/pages/ResetPassword.tsx:49 #: src/pages/ResetPassword.tsx:59
msgid "Your password has been changed. You can now log in." msgid "Your password has been changed. You can now log in."
msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter." msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter."

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { relativeTime } from "../utils/relativeTime.ts";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts"; import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -174,14 +175,8 @@ function notificationContent(n: Notification): React.ReactNode {
} }
function timeAgo(date: Date): string { function timeAgo(date: Date): string {
const secs = Math.floor((Date.now() - date.getTime()) / 1000); const abs = Math.abs(Date.now() - date.getTime()) / 1000;
if (secs < 60) return t`just now`; if (abs < 7 * 86400) return relativeTime(date);
const mins = Math.floor(secs / 60);
if (mins < 60) return t`${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return t`${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return t`${days}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }

View File

@@ -1,6 +1,7 @@
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); import { i18n } from "../i18n.ts";
export function relativeTime(date: Date): string { export function relativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const diff = date.getTime() - Date.now(); // negative = past const diff = date.getTime() - Date.now(); // negative = past
const abs = Math.abs(diff) / 1000; const abs = Math.abs(diff) / 1000;

View File

@@ -57,6 +57,20 @@ export default defineConfig({
ignored: ["**/api/**"], ignored: ["**/api/**"],
}, },
}, },
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("react-markdown") || id.includes("remark-gfm") || id.includes("remark-parse") || id.includes("mdast") || id.includes("micromark") || id.includes("unist")) {
return "vendor-markdown";
}
if (id.includes("node_modules")) {
return "vendor";
}
},
},
},
},
plugins: [ plugins: [
manifestPlugin(), manifestPlugin(),
lingui(), lingui(),