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": {
"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.

View File

@@ -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"]

View File

@@ -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([

View File

@@ -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(),

View File

@@ -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');`,

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": {
"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": {

161
deno.lock generated
View File

@@ -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",

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

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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 (
<Routes>
<Route path="/" element={<Index />} />
<Route path="/dumps/:selectedDump" element={<Dump />} />
<Route
path="/dumps/:selectedDump/edit"
element={
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>
}
/>
<Route
path="/register"
element={
<RestrictedGuest>
<UserRegister />
</RestrictedGuest>
}
/>
<Route
path="/login"
element={
<RestrictedGuest>
<UserLogin />
</RestrictedGuest>
}
/>
<Route path="/users/:username" element={<UserPublicProfile />} />
<Route path="/users/:username/dumps" element={<UserDumps />} />
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
<Route
path="/users/:username/playlists"
element={<UserPlaylists />}
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
<Route path="/search" element={<Search />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/notifications"
element={
<RestrictedLoggedIn>
<Notifications />
</RestrictedLoggedIn>
}
/>
</Routes>
<Suspense>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/dumps/:selectedDump" element={<Dump />} />
<Route
path="/dumps/:selectedDump/edit"
element={
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>
}
/>
<Route
path="/register"
element={
<RestrictedGuest>
<UserRegister />
</RestrictedGuest>
}
/>
<Route
path="/login"
element={
<RestrictedGuest>
<UserLogin />
</RestrictedGuest>
}
/>
<Route path="/users/:username" element={<UserPublicProfile />} />
<Route path="/users/:username/dumps" element={<UserDumps />} />
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
<Route
path="/users/:username/playlists"
element={<UserPlaylists />}
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
<Route path="/search" element={<Search />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/notifications"
element={
<RestrictedLoggedIn>
<Notifications />
</RestrictedLoggedIn>
}
/>
</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 { 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 && (
<DumpCreateModal
onClose={() => setCreateModalOpen(false)}
initialUrl={initialDumpUrl}
/>
<Suspense>
<DumpCreateModal
onClose={() => setCreateModalOpen(false)}
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 {
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 (
<button
type="button"
@@ -160,15 +176,7 @@ export default function FilePreview(
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title });
}}
>
<video
src={fileUrl}
preload="metadata"
className="rich-content-compact-thumbnail"
muted
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = 0.1;
}}
/>
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
<span className="rich-content-play-overlay"></span>
</button>
);
@@ -217,12 +225,9 @@ export default function FilePreview(
>
<video
src={fileUrl}
preload="metadata"
preload="none"
className="file-preview-video-thumb"
muted
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = 0.1;
}}
/>
<span className="rich-content-play-overlay">
{videoPlaying ? "⏸" : "▶"}

View File

@@ -74,7 +74,7 @@ msgstr "← Back to profile"
msgid "+ Invite someone"
msgstr "+ Invite someone"
#: src/components/AppHeader.tsx:67
#: src/components/AppHeader.tsx:70
msgid "+ New"
msgstr "+ New"
@@ -176,8 +176,8 @@ msgid "Appearance"
msgstr "Appearance"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:113
#: src/components/ChangePasswordModal.tsx:123
#: src/pages/ResetPassword.tsx:125
msgid "At least {0} characters"
msgstr "At least {0} characters"
@@ -185,8 +185,8 @@ msgstr "At least {0} characters"
msgid "Auto"
msgstr "Auto"
#: src/pages/ResetPassword.tsx:36
#: src/pages/ResetPassword.tsx:146
#: src/pages/ResetPassword.tsx:44
#: src/pages/ResetPassword.tsx:159
msgid "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."
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:373
#: src/components/CommentThread.tsx:510
@@ -221,8 +221,8 @@ msgstr "Cancel removal"
msgid "Change avatar"
msgstr "Change avatar"
#: src/components/ChangePasswordModal.tsx:55
#: src/components/ChangePasswordModal.tsx:142
#: src/components/ChangePasswordModal.tsx:56
#: src/components/ChangePasswordModal.tsx:178
msgid "Change password"
msgstr "Change password"
@@ -234,7 +234,7 @@ msgstr "Change password…"
msgid "Checking invite…"
msgstr "Checking invite…"
#: src/components/ChangePasswordModal.tsx:65
#: src/components/ChangePasswordModal.tsx:66
#: src/components/Modal.tsx:45
msgid "Close"
msgstr "Close"
@@ -247,8 +247,8 @@ msgstr "Color scheme"
#~ msgid "Comment not found"
#~ msgstr "Comment not found"
#: src/components/ChangePasswordModal.tsx:107
#: src/pages/ResetPassword.tsx:120
#: src/components/ChangePasswordModal.tsx:136
#: src/pages/ResetPassword.tsx:132
msgid "Confirm new password"
msgstr "Confirm new password"
@@ -260,11 +260,11 @@ msgstr "Copied!"
msgid "Copy"
msgstr "Copy"
#: src/components/ChangePasswordModal.tsx:123
#: src/components/ChangePasswordModal.tsx:159
msgid "Could not change password"
msgstr "Could not change password"
#: src/pages/ResetPassword.tsx:84
#: src/pages/ResetPassword.tsx:94
#: src/pages/UserLogin.tsx:79
msgid "Could not connect to server"
msgstr "Could not connect to server"
@@ -293,7 +293,7 @@ msgstr "Created ({0}{1})"
msgid "Creating…"
msgstr "Creating…"
#: src/components/ChangePasswordModal.tsx:75
#: src/components/ChangePasswordModal.tsx:83
msgid "Current password"
msgstr "Current password"
@@ -413,7 +413,7 @@ msgstr "Email address"
msgid "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"
msgstr "Failed to change password"
@@ -432,7 +432,6 @@ msgstr "Failed to generate invite"
#: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:323
#: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1148
#: src/pages/UserPublicProfile.tsx:1193
@@ -554,7 +553,7 @@ msgstr "From people"
msgid "From playlists"
msgstr "From playlists"
#: src/pages/ResetPassword.tsx:56
#: src/pages/ResetPassword.tsx:66
msgid "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"
msgstr "Invalid invite"
#: src/pages/ResetPassword.tsx:33
#: src/pages/ResetPassword.tsx:34
msgid "Invalid link"
msgstr "Invalid link"
@@ -620,11 +619,11 @@ msgstr "Light"
msgid "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."
msgstr "Live updates unavailable."
#: src/pages/Notifications.tsx:396
#: src/pages/Notifications.tsx:390
msgid "Load more"
msgstr "Load more"
@@ -659,8 +658,8 @@ msgstr "Loading profile…"
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44
#: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:319
#: src/pages/Notifications.tsx:395
#: src/pages/Notifications.tsx:313
#: src/pages/Notifications.tsx:389
#: src/pages/UserDumps.tsx:51
#: src/pages/UserPlaylists.tsx:342
#: src/pages/UserPublicProfile.tsx:1100
@@ -670,7 +669,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/AppHeader.tsx:74
#: src/components/AppHeader.tsx:77
#: src/pages/UserLogin.tsx:87
#: src/pages/UserLogin.tsx:117
msgid "Log in"
@@ -693,7 +692,7 @@ msgstr "Login failed"
msgid "Max 50 MB"
msgstr "Max 50 MB"
#: src/pages/Notifications.tsx:312
#: src/pages/Notifications.tsx:306
msgid "new"
msgstr "new"
@@ -705,8 +704,8 @@ msgstr "New"
msgid "New dump"
msgstr "New dump"
#: src/components/ChangePasswordModal.tsx:88
#: src/pages/ResetPassword.tsx:101
#: src/components/ChangePasswordModal.tsx:103
#: src/pages/ResetPassword.tsx:113
msgid "New password"
msgstr "New password"
@@ -763,7 +762,7 @@ msgstr "No users match \"{q}\"."
msgid "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/UserPublicProfile.tsx:1340
#: src/pages/UserPublicProfile.tsx:1463
@@ -772,7 +771,7 @@ msgid "Nothing here yet."
msgstr "Nothing here yet."
#: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:308
#: src/pages/Notifications.tsx:302
msgid "Notifications"
msgstr "Notifications"
@@ -798,7 +797,7 @@ msgstr "Password"
msgid "Password (min. {0} characters)"
msgstr "Password (min. {0} characters)"
#: src/components/ChangePasswordModal.tsx:60
#: src/components/ChangePasswordModal.tsx:61
msgid "Password changed successfully."
msgstr "Password changed successfully."
@@ -810,12 +809,12 @@ msgstr "Password changed successfully."
#~ msgid "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"
msgstr "Password updated"
#: src/components/ChangePasswordModal.tsx:118
#: src/pages/ResetPassword.tsx:129
#: src/components/ChangePasswordModal.tsx:154
#: src/pages/ResetPassword.tsx:141
msgid "Passwords do not match"
msgstr "Passwords do not match"
@@ -823,7 +822,7 @@ msgstr "Passwords do not match"
#~ msgid "Playlist not found"
#~ msgstr "Playlist not found"
#: src/components/AppHeader.tsx:50
#: src/components/AppHeader.tsx:53
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:368
@@ -922,7 +921,7 @@ msgstr "Reply"
msgid "Request failed"
msgstr "Request failed"
#: src/pages/ResetPassword.tsx:94
#: src/pages/ResetPassword.tsx:106
msgid "Reset failed"
msgstr "Reset failed"
@@ -939,10 +938,10 @@ msgstr "Retry"
msgid "Save"
msgstr "Save"
#: src/components/ChangePasswordModal.tsx:141
#: src/components/ChangePasswordModal.tsx:177
#: src/components/CommentThread.tsx:269
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/ResetPassword.tsx:140
#: src/pages/ResetPassword.tsx:152
#: src/pages/UserPublicProfile.tsx:832
#: src/pages/UserPublicProfile.tsx:911
msgid "Saving…"
@@ -972,12 +971,12 @@ msgstr "Send reset link"
msgid "Sending…"
msgstr "Sending…"
#: src/components/AppHeader.tsx:65
#: src/components/AppHeader.tsx:68
msgid "Server unreachable"
msgstr "Server unreachable"
#: src/pages/ResetPassword.tsx:91
#: src/pages/ResetPassword.tsx:141
#: src/pages/ResetPassword.tsx:102
#: src/pages/ResetPassword.tsx:153
msgid "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."
msgstr "This is a mirage."
#: src/pages/ResetPassword.tsx:34
#: src/pages/ResetPassword.tsx:37
msgid "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"
msgstr "Unfollow playlist"
#: src/components/ChangePasswordModal.tsx:43
#: src/pages/ResetPassword.tsx:80
#: src/components/ChangePasswordModal.tsx:44
#: src/pages/ResetPassword.tsx:90
msgid "Unknown error"
msgstr "Unknown error"
@@ -1120,7 +1119,7 @@ msgstr "Write a reply…"
msgid "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."
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"
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."
msgstr "Your password has been changed. You can now log in."

View File

@@ -74,7 +74,7 @@ msgstr "← Retour au profil"
msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un"
#: src/components/AppHeader.tsx:67
#: src/components/AppHeader.tsx:70
msgid "+ New"
msgstr "+ Nouveau"
@@ -172,8 +172,8 @@ msgid "Appearance"
msgstr "Apparence"
#. placeholder {0}: VALIDATION.PASSWORD_MIN
#: src/components/ChangePasswordModal.tsx:101
#: src/pages/ResetPassword.tsx:113
#: src/components/ChangePasswordModal.tsx:123
#: src/pages/ResetPassword.tsx:125
msgid "At least {0} characters"
msgstr "Au moins {0} caractères"
@@ -181,8 +181,8 @@ msgstr "Au moins {0} caractères"
msgid "Auto"
msgstr "Auto"
#: src/pages/ResetPassword.tsx:36
#: src/pages/ResetPassword.tsx:146
#: src/pages/ResetPassword.tsx:44
#: src/pages/ResetPassword.tsx:159
msgid "Back to login"
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."
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:373
#: src/components/CommentThread.tsx:510
@@ -213,8 +213,8 @@ msgstr "Annuler la suppression"
msgid "Change avatar"
msgstr "Changer l'avatar"
#: src/components/ChangePasswordModal.tsx:55
#: src/components/ChangePasswordModal.tsx:142
#: src/components/ChangePasswordModal.tsx:56
#: src/components/ChangePasswordModal.tsx:178
msgid "Change password"
msgstr "Changer le mot de passe"
@@ -226,7 +226,7 @@ msgstr "Changer le mot de passe…"
msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
#: src/components/ChangePasswordModal.tsx:65
#: src/components/ChangePasswordModal.tsx:66
#: src/components/Modal.tsx:45
msgid "Close"
msgstr "Fermer"
@@ -235,8 +235,8 @@ msgstr "Fermer"
msgid "Color scheme"
msgstr "Thème de couleur"
#: src/components/ChangePasswordModal.tsx:107
#: src/pages/ResetPassword.tsx:120
#: src/components/ChangePasswordModal.tsx:136
#: src/pages/ResetPassword.tsx:132
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
@@ -248,11 +248,11 @@ msgstr "Copié !"
msgid "Copy"
msgstr "Copier"
#: src/components/ChangePasswordModal.tsx:123
#: src/components/ChangePasswordModal.tsx:159
msgid "Could not change password"
msgstr "Impossible de changer le mot de passe"
#: src/pages/ResetPassword.tsx:84
#: src/pages/ResetPassword.tsx:94
#: src/pages/UserLogin.tsx:79
msgid "Could not connect to server"
msgstr "Impossible de contacter le serveur"
@@ -281,7 +281,7 @@ msgstr "Créées ({0}{1})"
msgid "Creating…"
msgstr "Création…"
#: src/components/ChangePasswordModal.tsx:75
#: src/components/ChangePasswordModal.tsx:83
msgid "Current password"
msgstr "Mot de passe actuel"
@@ -397,7 +397,7 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
#: src/components/ChangePasswordModal.tsx:48
#: src/components/ChangePasswordModal.tsx:49
msgid "Failed to change password"
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/JournalFeed.tsx:48
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:323
#: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1148
#: src/pages/UserPublicProfile.tsx:1193
@@ -522,7 +521,7 @@ msgstr "De personnes"
msgid "From playlists"
msgstr "De collections"
#: src/pages/ResetPassword.tsx:56
#: src/pages/ResetPassword.tsx:66
msgid "Go to login"
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"
msgstr "Invitation invalide"
#: src/pages/ResetPassword.tsx:33
#: src/pages/ResetPassword.tsx:34
msgid "Invalid link"
msgstr "Lien invalide"
@@ -567,11 +566,11 @@ msgstr "Clair"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
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."
msgstr "Mises à jour en direct indisponibles."
#: src/pages/Notifications.tsx:396
#: src/pages/Notifications.tsx:390
msgid "Load more"
msgstr "Charger plus"
@@ -606,8 +605,8 @@ msgstr "Chargement du profil…"
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44
#: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:319
#: src/pages/Notifications.tsx:395
#: src/pages/Notifications.tsx:313
#: src/pages/Notifications.tsx:389
#: src/pages/UserDumps.tsx:51
#: src/pages/UserPlaylists.tsx:342
#: src/pages/UserPublicProfile.tsx:1100
@@ -617,7 +616,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/AppHeader.tsx:74
#: src/components/AppHeader.tsx:77
#: src/pages/UserLogin.tsx:87
#: src/pages/UserLogin.tsx:117
msgid "Log in"
@@ -640,7 +639,7 @@ msgstr "Connexion échouée"
msgid "Max 50 MB"
msgstr "Max 50 Mo"
#: src/pages/Notifications.tsx:312
#: src/pages/Notifications.tsx:306
msgid "new"
msgstr "nouveau"
@@ -652,8 +651,8 @@ msgstr "Nouveau"
msgid "New dump"
msgstr "Nouvelle reco"
#: src/components/ChangePasswordModal.tsx:88
#: src/pages/ResetPassword.tsx:101
#: src/components/ChangePasswordModal.tsx:103
#: src/pages/ResetPassword.tsx:113
msgid "New password"
msgstr "Nouveau mot de passe"
@@ -706,7 +705,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment."
#: src/pages/Notifications.tsx:330
#: src/pages/Notifications.tsx:324
#: src/pages/UserDumps.tsx:123
#: src/pages/UserPublicProfile.tsx:1340
#: src/pages/UserPublicProfile.tsx:1463
@@ -715,7 +714,7 @@ msgid "Nothing here yet."
msgstr "Rien ici pour l'instant."
#: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:308
#: src/pages/Notifications.tsx:302
msgid "Notifications"
msgstr "Notifications"
@@ -741,20 +740,20 @@ msgstr "Mot de passe"
msgid "Password (min. {0} characters)"
msgstr "Mot de passe (min. {0} caractères)"
#: src/components/ChangePasswordModal.tsx:60
#: src/components/ChangePasswordModal.tsx:61
msgid "Password changed successfully."
msgstr "Mot de passe modifié avec succès."
#: src/pages/ResetPassword.tsx:47
#: src/pages/ResetPassword.tsx:56
msgid "Password updated"
msgstr "Mot de passe mis à jour"
#: src/components/ChangePasswordModal.tsx:118
#: src/pages/ResetPassword.tsx:129
#: src/components/ChangePasswordModal.tsx:154
#: src/pages/ResetPassword.tsx:141
msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas"
#: src/components/AppHeader.tsx:50
#: src/components/AppHeader.tsx:53
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:175
#: src/pages/UserPlaylists.tsx:368
@@ -853,7 +852,7 @@ msgstr "Répondre"
msgid "Request failed"
msgstr "Échec de la demande"
#: src/pages/ResetPassword.tsx:94
#: src/pages/ResetPassword.tsx:106
msgid "Reset failed"
msgstr "Échec de la réinitialisation"
@@ -870,10 +869,10 @@ msgstr "Réessayer"
msgid "Save"
msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:141
#: src/components/ChangePasswordModal.tsx:177
#: src/components/CommentThread.tsx:269
#: src/pages/PlaylistDetail.tsx:673
#: src/pages/ResetPassword.tsx:140
#: src/pages/ResetPassword.tsx:152
#: src/pages/UserPublicProfile.tsx:832
#: src/pages/UserPublicProfile.tsx:911
msgid "Saving…"
@@ -903,12 +902,12 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…"
msgstr "Envoi…"
#: src/components/AppHeader.tsx:65
#: src/components/AppHeader.tsx:68
msgid "Server unreachable"
msgstr "Serveur inaccessible"
#: src/pages/ResetPassword.tsx:91
#: src/pages/ResetPassword.tsx:141
#: src/pages/ResetPassword.tsx:102
#: src/pages/ResetPassword.tsx:153
msgid "Set new password"
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."
msgstr "C'est un mirage."
#: src/pages/ResetPassword.tsx:34
#: src/pages/ResetPassword.tsx:37
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
@@ -969,8 +968,8 @@ msgstr "Ne plus suivre {targetUsername}"
msgid "Unfollow playlist"
msgstr "Ne plus suivre la collection"
#: src/components/ChangePasswordModal.tsx:43
#: src/pages/ResetPassword.tsx:80
#: src/components/ChangePasswordModal.tsx:44
#: src/pages/ResetPassword.tsx:90
msgid "Unknown error"
msgstr "Erreur inconnue"
@@ -1039,7 +1038,7 @@ msgstr "Écrire une réponse…"
msgid "Yesterday"
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."
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"
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."
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 { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { relativeTime } from "../utils/relativeTime.ts";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -174,14 +175,8 @@ function notificationContent(n: Notification): React.ReactNode {
}
function timeAgo(date: Date): string {
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
if (secs < 60) return t`just now`;
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`;
const abs = Math.abs(Date.now() - date.getTime()) / 1000;
if (abs < 7 * 86400) return relativeTime(date);
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 {
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const diff = date.getTime() - Date.now(); // negative = past
const abs = Math.abs(diff) / 1000;

View File

@@ -57,6 +57,20 @@ export default defineConfig({
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: [
manifestPlugin(),
lingui(),