v3: fixed authorization bug with stale tokens, fixed private dump access bug, various small fixes across the app
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 20s

This commit is contained in:
khannurien
2026-06-27 10:40:07 +00:00
parent 73e0114bf7
commit 76de798faf
24 changed files with 236 additions and 194 deletions

View File

@@ -1,3 +1,3 @@
FROM denoland/deno:bin-2.7.5 AS deno FROM denoland/deno:bin-2.9.0 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:20 FROM mcr.microsoft.com/devcontainers/typescript-node:20
COPY --from=deno /deno /usr/local/bin/deno COPY --from=deno /deno /usr/local/bin/deno

View File

@@ -1,5 +1,5 @@
# ── Stage 1: build Vite frontend ───────────────────────────────────────────── # ── Stage 1: build Vite frontend ─────────────────────────────────────────────
FROM denoland/deno:2.8.3 AS builder FROM denoland/deno:2.9.0 AS builder
WORKDIR /app WORKDIR /app
@@ -23,7 +23,7 @@ ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \
RUN deno task build RUN deno task build
# ── Stage 2: runtime ────────────────────────────────────────────────────────── # ── Stage 2: runtime ──────────────────────────────────────────────────────────
FROM denoland/deno:alpine-2.8.3 FROM denoland/deno:alpine-2.9.0
RUN apk add --no-cache ffmpeg curl RUN apk add --no-cache ffmpeg curl

View File

@@ -1,10 +1,28 @@
import type { Context } from "@oak/oak"; import type { Context } from "@oak/oak";
import { verifyJWT } from "./jwt.ts"; import { verifyJWT } from "./jwt.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
/** Extracts the userId from an optional Bearer token. Returns null if absent or invalid. */ /**
* Extracts the userId from an optional Bearer token.
*
* Distinguishes *missing* credentials from *invalid* ones:
* - No `Authorization: Bearer …` header (or a placeholder value such as the
* `Bearer undefined` that the client emits when logged out) → anonymous (null).
* - A real token that fails verification (expired / bad signature) → throws 401,
* so a stale client session surfaces as an auth error it can react to, instead
* of being silently downgraded to anonymous (which yields a misleading 404 on
* private resources).
*/
export async function parseOptionalAuth(ctx: Context): Promise<string | null> { export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
const authHeader = ctx.request.headers.get("Authorization"); const authHeader = ctx.request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return null; if (!authHeader?.startsWith("Bearer ")) return null;
const payload = await verifyJWT(authHeader.substring(7));
return payload?.userId ?? null; const token = authHeader.substring(7).trim();
if (!token || token === "undefined" || token === "null") return null;
const payload = await verifyJWT(token);
if (!payload) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
}
return payload.userId;
} }

View File

@@ -71,7 +71,7 @@ export async function createJWT(
): Promise<string> { ): Promise<string> {
return await new SignJWT(payload) return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h") .setExpirationTime("7d")
.sign(JWT_KEY); .sign(JWT_KEY);
} }

View File

@@ -2,6 +2,7 @@ import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump } from "../services/dump-service.ts"; import { getDump } from "../services/dump-service.ts";
import { DUMPS_DIR } from "../lib/upload.ts"; import { DUMPS_DIR } from "../lib/upload.ts";
import { verifyJWT } from "../lib/jwt.ts";
const router = new Router({ prefix: "/api/files" }); const router = new Router({ prefix: "/api/files" });
@@ -30,7 +31,12 @@ router.get("/:dumpId", async (ctx) => {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
} }
const dump = getDump(dumpId); // Media elements can't send an Authorization header, so the viewer's token
// arrives as a query param (same as the WebSocket route). Needed to authorize
// access to private dumps the requester owns.
const token = ctx.request.url.searchParams.get("token");
const payload = token ? await verifyJWT(token) : null;
const dump = getDump(dumpId, payload?.userId);
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
throw new APIException( throw new APIException(

View File

@@ -3,6 +3,7 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts"; import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
import { serveUploadedFile } from "../lib/upload.ts"; import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts";
const router = new Router({ prefix: "/api/thumbnails" }); const router = new Router({ prefix: "/api/thumbnails" });
@@ -54,6 +55,12 @@ router.get("/:dumpId", async (ctx) => {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
} }
// Token via query param (media elements can't send headers) — authorizes the
// owner's private dumps and gates the custom-thumbnail branch below too.
const token = ctx.request.url.searchParams.get("token");
const payload = token ? await verifyJWT(token) : null;
const dump = getDump(dumpId, payload?.userId);
const customThumb = getDumpThumbnailInfo(dumpId); const customThumb = getDumpThumbnailInfo(dumpId);
if (customThumb) { if (customThumb) {
const served = await serveUploadedFile( const served = await serveUploadedFile(
@@ -64,8 +71,6 @@ router.get("/:dumpId", async (ctx) => {
if (served) return; if (served) return;
} }
const dump = getDump(dumpId);
if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) { if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) {
throw new APIException( throw new APIException(
APIErrorCode.NOT_FOUND, APIErrorCode.NOT_FOUND,

View File

@@ -4,9 +4,9 @@
"build": "deno task i18n:extract && deno task i18n:compile && 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 task server:start", "serve": "deno task build && deno task server:start",
"i18n:extract": "deno run -A scripts/lingui-extract.ts", "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
"i18n:compile": "deno run -A scripts/lingui-compile.ts", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1",
"start": "deno run -A api/db/init.ts && deno run -A api/main.ts" "start": "deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts"
}, },
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"compilerOptions": { "compilerOptions": {

View File

@@ -1,11 +0,0 @@
import { command as compile } from "../node_modules/@lingui/cli/dist/lingui-compile.js";
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
const config = getConfig({ cwd: Deno.cwd() });
await compile(config, {
watch: false,
namespace: undefined,
typescript: false,
allowEmpty: true,
workersOptions: { poolSize: 0 },
});

View File

@@ -1,11 +0,0 @@
import extract from "../node_modules/@lingui/cli/dist/lingui-extract.js";
import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs";
const config = getConfig({ cwd: Deno.cwd() });
await extract(config, {
verbose: false,
watch: false,
files: [],
clean: true,
workersOptions: { poolSize: 0 },
});

View File

@@ -256,6 +256,34 @@
opacity: 0.6; opacity: 0.6;
} }
/* Label paired with an inline action (e.g. a "reset to default" button). */
.form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.form-label-action {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
opacity: 0.6;
}
.form-label-action:hover:not(:disabled) {
opacity: 1;
}
.form-label-action:disabled {
opacity: 0.3;
cursor: default;
}
.form input, .form input,
.form textarea { .form textarea {
width: 100%; width: 100%;
@@ -654,8 +682,11 @@
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
} }
.audio-file-preview--active .waveform-bar { /* When active, brighten only the *un-played* bars. Excluding played bars keeps
fill: var(--color-accent); this rule from out-specifying `.waveform-bar--played` (which would paint every
bar the played colour and make the waveform look stuck at 100%). */
.audio-file-preview--active .waveform-bar:not(.waveform-bar--played) {
fill: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%);
} }
.audio-file-preview--active .audio-player-btn { .audio-file-preview--active .audio-player-btn {

View File

@@ -1,9 +1,9 @@
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
import { API_URL } from "../config/api.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { dumpUrl } from "../utils/urls.ts"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import FilePreview from "./FilePreview.tsx"; import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx"; import RichContentCard from "./RichContentCard.tsx";
@@ -27,6 +27,7 @@ export function DumpCard(
DumpCardProps, DumpCardProps,
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth();
const unread = !isOwner && isRecent(dump.createdAt) && const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id); !isDumpVisited(dump.id);
@@ -54,7 +55,7 @@ export function DumpCard(
richContent={dump.richContent} richContent={dump.richContent}
compact compact
thumbnailOverrideUrl={dump.thumbnailMime thumbnailOverrideUrl={dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}` ? dumpThumbnailUrl(dump, token)
: undefined} : undefined}
/> />
) )

View File

@@ -1,7 +1,8 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
import { formatBytes } from "../utils/format.ts"; import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx"; import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext } from "../contexts/PlayerContext.ts";
import { import {
@@ -145,11 +146,12 @@ export default function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps, { dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) { ) {
const { current, playing, play, togglePlay } = useContext(PlayerContext); const { current, playing, play, togglePlay } = useContext(PlayerContext);
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`; const { token } = useAuth();
const fileUrl = dumpFileUrl(dump, token);
const mime = dump.fileMime ?? ""; const mime = dump.fileMime ?? "";
const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl; const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl;
const thumbOverride = dump.thumbnailMime const thumbOverride = dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}` ? dumpThumbnailUrl(dump, token)
: null; : null;
if (compact) { if (compact) {
@@ -166,7 +168,7 @@ export default function FilePreview(
); );
} }
if (mime.startsWith("video/")) { if (mime.startsWith("video/")) {
const thumbUrl = `${API_URL}/api/thumbnails/${dump.id}`; const thumbUrl = dumpThumbnailUrl(dump, token);
return ( return (
<button <button
type="button" type="button"

View File

@@ -4,7 +4,8 @@ import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import { VoteButton } from "./VoteButton.tsx"; import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
@@ -29,6 +30,7 @@ export function JournalCard(
JournalCardProps, JournalCardProps,
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth();
const { play } = useContext(PlayerContext); const { play } = useContext(PlayerContext);
const unread = !isOwner && isRecent(dump.createdAt) && const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id); !isDumpVisited(dump.id);
@@ -40,7 +42,7 @@ export function JournalCard(
const rawThumbnail = const rawThumbnail =
dump.kind === "file" && dump.fileMime?.startsWith("image/") dump.kind === "file" && dump.fileMime?.startsWith("image/")
? `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}` ? dumpFileUrl(dump, token)
: (dump.richContent?.thumbnailUrl ?? null); : (dump.richContent?.thumbnailUrl ?? null);
// Route external HTTP thumbnails through the server proxy to avoid // Route external HTTP thumbnails through the server proxy to avoid

View File

@@ -1,4 +1,4 @@
import { useId } from "react"; import { type ReactNode, useId } from "react";
import { import {
type FieldValues, type FieldValues,
type Path, type Path,
@@ -12,6 +12,8 @@ import { charCountClass } from "../../utils/charCount.ts";
interface TextFieldProps<T extends FieldValues> { interface TextFieldProps<T extends FieldValues> {
name: Path<T>; name: Path<T>;
label?: string; label?: string;
/** Optional control rendered to the right of the label (e.g. a reset button). */
labelAction?: ReactNode;
type?: "text" | "email" | "password" | "url" | "search"; type?: "text" | "email" | "password" | "url" | "search";
placeholder?: string; placeholder?: string;
autoComplete?: string; autoComplete?: string;
@@ -45,6 +47,7 @@ function CharCounter<T extends FieldValues>(
export function TextField<T extends FieldValues>({ export function TextField<T extends FieldValues>({
name, name,
label, label,
labelAction,
type = "text", type = "text",
placeholder, placeholder,
autoComplete, autoComplete,
@@ -61,10 +64,17 @@ export function TextField<T extends FieldValues>({
return ( return (
<div className="form-field"> <div className="form-field">
{label && ( {(label || labelAction) && (
<label className="form-label" htmlFor={id}> <div className="form-label-row">
{label} {label
</label> ? (
<label className="form-label" htmlFor={id}>
{label}
</label>
)
: <span />}
{labelAction}
</div>
)} )}
<div className={counted ? "input-with-count" : undefined}> <div className={counted ? "input-with-count" : undefined}>
<input <input

View File

@@ -1,8 +1,9 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
import { type AuthResponse, deserializeAuthResponse } from "../model.ts"; import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
import { API_URL } from "../config/api.ts";
function isTokenExpired(token: string): boolean { function isTokenExpired(token: string): boolean {
try { try {
@@ -33,6 +34,43 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return parsed; return parsed;
}); });
// The `exp` pre-check above only catches expiry; it cannot detect a token the
// server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which
// leaves the signature invalid but the token unexpired). Ask the server — the
// only authority on validity — and clear the session if it returns 401, so the
// UI never shows a false "logged in" state. Re-check when the tab regains
// focus to catch tokens invalidated by a server restart while we were away.
const token = authResponse?.token;
useEffect(() => {
if (!token) return;
const validate = async () => {
try {
const res = await fetch(`${API_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (res.status === 401) {
localStorage.removeItem("authResponse");
setAuthResponse(null);
}
} catch {
// Network error — don't log out an offline user; only an explicit 401 clears the session.
}
};
validate();
const onVisible = () => {
if (document.visibilityState === "visible") validate();
};
globalThis.addEventListener("focus", validate);
document.addEventListener("visibilitychange", onVisible);
return () => {
globalThis.removeEventListener("focus", validate);
document.removeEventListener("visibilitychange", onVisible);
};
}, [token]);
const value: AuthContextValue = { authResponse, setAuthResponse }; const value: AuthContextValue = { authResponse, setAuthResponse };
return ( return (

View File

@@ -41,7 +41,9 @@ export const useAuth = () => {
...init, ...init,
headers: { headers: {
...(init.headers ?? {}), ...(init.headers ?? {}),
Authorization: `Bearer ${token}`, // Only attach a token when we actually have one — avoids sending a
// `Bearer undefined` header that the server would reject as invalid.
...(token ? { Authorization: `Bearer ${token}` } : {}),
// Let the browser set Content-Type for FormData (it includes the boundary) // Let the browser set Content-Type for FormData (it includes the boundary)
...(isFormData ? {} : { "Content-Type": "application/json" }), ...(isFormData ? {} : { "Content-Type": "application/json" }),
}, },

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[deleted]" msgstr "[deleted]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:104 #: src/components/DumpCard.tsx:105
#: src/components/JournalCard.tsx:96 #: src/components/JournalCard.tsx:98
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -34,22 +34,10 @@ msgstr "{0, plural, one {# dump} other {# dumps}}"
msgid "{0}{1} characters: letters, numbers, or underscores" msgid "{0}{1} characters: letters, numbers, or underscores"
msgstr "{0}{1} characters: letters, numbers, or underscores" msgstr "{0}{1} characters: letters, numbers, or underscores"
#: src/pages/Notifications.tsx:184
msgid "{days}d ago"
msgstr "{days}d ago"
#: src/pages/Notifications.tsx:182
msgid "{hrs}h ago"
msgstr "{hrs}h ago"
#: src/pages/Search.tsx:176 #: src/pages/Search.tsx:176
msgid "{label} ({count})" msgid "{label} ({count})"
msgstr "{label} ({count})" msgstr "{label} ({count})"
#: src/pages/Notifications.tsx:180
msgid "{mins}m ago"
msgstr "{mins}m ago"
#: src/components/CommentThread.tsx:453 #: src/components/CommentThread.tsx:453
msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
@@ -75,11 +63,6 @@ msgstr "← Back to profile"
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Invite someone" msgstr "+ Invite someone"
#: src/pages/UserDumps.tsx:114
#: src/pages/UserPublicProfile.tsx:1332
msgid "+ New dump"
msgstr "+ New dump"
#: src/components/PlaylistMembershipPanel.tsx:80 #: src/components/PlaylistMembershipPanel.tsx:80
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
@@ -159,14 +142,6 @@ msgstr "Add email…"
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Add to playlist" msgstr "Add to playlist"
#: src/pages/UserDumps.tsx:114
msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
msgstr "All {0, plural, one {# dump} other {# dumps}} loaded."
#: src/pages/UserUpvoted.tsx:187
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
msgstr "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
#: src/pages/UserRegister.tsx:154 #: src/pages/UserRegister.tsx:154
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>" msgstr "Already have an account? <0>Log in</0>"
@@ -200,7 +175,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:357 #: src/pages/Dump.tsx:357
#: src/pages/DumpEdit.tsx:416 #: src/pages/DumpEdit.tsx:433
#: src/pages/PlaylistDetail.tsx:908 #: src/pages/PlaylistDetail.tsx:908
#: src/pages/UserPublicProfile.tsx:1482 #: src/pages/UserPublicProfile.tsx:1482
#: src/pages/UserPublicProfile.tsx:1552 #: src/pages/UserPublicProfile.tsx:1552
@@ -258,22 +233,11 @@ msgstr "Copy"
msgid "Could not change password" msgid "Could not change password"
msgstr "Could not change password" msgstr "Could not change password"
#: src/pages/ResetPassword.tsx:94
#: src/pages/UserLogin.tsx:79
msgid "Could not connect to server"
msgstr "Could not connect to server"
#: src/components/UserListPopover.tsx:141 #: src/components/UserListPopover.tsx:141
msgid "Could not load." msgid "Could not load."
msgstr "Could not load." msgstr "Could not load."
#: src/components/CommentThread.tsx:116 #: src/pages/DumpEdit.tsx:334
#: src/components/CommentThread.tsx:158
#: src/components/CommentThread.tsx:463
msgid "Could not reach the server. Please try again."
msgstr "Could not reach the server. Please try again."
#: src/pages/DumpEdit.tsx:330
msgid "Could not save" msgid "Could not save"
msgstr "Could not save" msgstr "Could not save"
@@ -311,7 +275,7 @@ msgid "Delete"
msgstr "Delete" msgstr "Delete"
#: src/pages/DumpEdit.tsx:242 #: src/pages/DumpEdit.tsx:242
#: src/pages/DumpEdit.tsx:412 #: src/pages/DumpEdit.tsx:429
msgid "Delete dump" msgid "Delete dump"
msgstr "Delete dump" msgstr "Delete dump"
@@ -347,7 +311,7 @@ msgstr "Done"
msgid "Drop a file here" msgid "Drop a file here"
msgstr "Drop a file here" msgstr "Drop a file here"
#: src/pages/DumpEdit.tsx:387 #: src/pages/DumpEdit.tsx:404
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Drop a replacement here" msgstr "Drop a replacement here"
@@ -419,10 +383,6 @@ 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:49
msgid "Failed to change password"
msgstr "Failed to change password"
#: src/components/PlaylistCreateForm.tsx:84 #: src/components/PlaylistCreateForm.tsx:84
msgid "Failed to create playlist" msgid "Failed to create playlist"
msgstr "Failed to create playlist" msgstr "Failed to create playlist"
@@ -578,10 +538,6 @@ msgstr "Invitees"
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
#: src/pages/Notifications.tsx:178
msgid "just now"
msgstr "just now"
#: src/pages/UserPublicProfile.tsx:1153 #: src/pages/UserPublicProfile.tsx:1153
msgid "Light" msgid "Light"
msgstr "Light" msgstr "Light"
@@ -834,8 +790,8 @@ msgstr "Post reply"
msgid "Posting…" msgid "Posting…"
msgstr "Posting…" msgstr "Posting…"
#: src/components/DumpCard.tsx:113 #: src/components/DumpCard.tsx:114
#: src/components/JournalCard.tsx:105 #: src/components/JournalCard.tsx:107
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:416 #: src/pages/Dump.tsx:416
@@ -899,7 +855,7 @@ msgstr "Remove like"
msgid "Remove vote" msgid "Remove vote"
msgstr "Remove vote" msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:386 #: src/pages/DumpEdit.tsx:403
msgid "Replace file" msgid "Replace file"
msgstr "Replace file" msgstr "Replace file"
@@ -915,7 +871,8 @@ msgstr "Request failed"
msgid "Reset failed" msgid "Reset failed"
msgstr "Reset failed" msgstr "Reset failed"
#: src/pages/DumpEdit.tsx:350 #: src/pages/DumpEdit.tsx:354
#: src/pages/DumpEdit.tsx:372
msgid "Reset to default" msgid "Reset to default"
msgstr "Reset to default" msgstr "Reset to default"
@@ -926,7 +883,7 @@ msgstr "Retry"
#: src/components/CommentThread.tsx:326 #: src/components/CommentThread.tsx:326
#: src/pages/Dump.tsx:349 #: src/pages/Dump.tsx:349
#: src/pages/DumpEdit.tsx:419 #: src/pages/DumpEdit.tsx:436
#: src/pages/PlaylistDetail.tsx:915 #: src/pages/PlaylistDetail.tsx:915
#: src/pages/UserPublicProfile.tsx:1474 #: src/pages/UserPublicProfile.tsx:1474
#: src/pages/UserPublicProfile.tsx:1544 #: src/pages/UserPublicProfile.tsx:1544
@@ -1010,13 +967,13 @@ msgstr "This page does not exist."
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."
#: src/pages/DumpEdit.tsx:334 #: src/pages/DumpEdit.tsx:338
msgid "Thumbnail" msgid "Thumbnail"
msgstr "Thumbnail" msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:386 #: src/components/DumpCreateModal.tsx:386
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:358 #: src/pages/DumpEdit.tsx:362
msgid "Title" msgid "Title"
msgstr "Title" msgstr "Title"
@@ -1040,11 +997,6 @@ msgstr "Unfollow {targetUsername}"
msgid "Unfollow playlist" msgid "Unfollow playlist"
msgstr "Unfollow playlist" msgstr "Unfollow playlist"
#: src/components/ChangePasswordModal.tsx:44
#: src/pages/ResetPassword.tsx:90
msgid "Unknown error"
msgstr "Unknown error"
#: src/pages/UserPublicProfile.tsx:671 #: src/pages/UserPublicProfile.tsx:671
msgid "Upload failed" msgid "Upload failed"
msgstr "Upload failed" msgstr "Upload failed"
@@ -1068,7 +1020,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:335 #: src/components/DumpCreateModal.tsx:335
#: src/pages/DumpEdit.tsx:368 #: src/pages/DumpEdit.tsx:385
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
@@ -1102,7 +1054,7 @@ msgid "View dump →"
msgstr "View dump →" msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:431 #: src/components/DumpCreateModal.tsx:431
#: src/pages/DumpEdit.tsx:398 #: src/pages/DumpEdit.tsx:415
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
@@ -1112,7 +1064,7 @@ msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:430 #: src/components/DumpCreateModal.tsx:430
#: src/pages/DumpEdit.tsx:397 #: src/pages/DumpEdit.tsx:414
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[supprimé]" msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:104 #: src/components/DumpCard.tsx:105
#: src/components/JournalCard.tsx:96 #: src/components/JournalCard.tsx:98
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -34,22 +34,10 @@ msgstr "{0, plural, one {# reco} other {# recos}}"
msgid "{0}{1} characters: letters, numbers, or underscores" msgid "{0}{1} characters: letters, numbers, or underscores"
msgstr "{0}{1} caractères : lettres, chiffres ou tirets bas" msgstr "{0}{1} caractères : lettres, chiffres ou tirets bas"
#: src/pages/Notifications.tsx:184
msgid "{days}d ago"
msgstr "il y a {days}j"
#: src/pages/Notifications.tsx:182
msgid "{hrs}h ago"
msgstr "il y a {hrs}h"
#: src/pages/Search.tsx:176 #: src/pages/Search.tsx:176
msgid "{label} ({count})" msgid "{label} ({count})"
msgstr "{label} ({count})" msgstr "{label} ({count})"
#: src/pages/Notifications.tsx:180
msgid "{mins}m ago"
msgstr "il y a {mins}min"
#: src/components/CommentThread.tsx:453 #: src/components/CommentThread.tsx:453
msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
@@ -75,11 +63,6 @@ msgstr "← Retour au profil"
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un" msgstr "+ Inviter quelqu'un"
#: src/pages/UserDumps.tsx:114
#: src/pages/UserPublicProfile.tsx:1332
msgid "+ New dump"
msgstr "+ Nouvelle reco"
#: src/components/PlaylistMembershipPanel.tsx:80 #: src/components/PlaylistMembershipPanel.tsx:80
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
@@ -159,14 +142,6 @@ msgstr "Ajouter un e-mail…"
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Ajouter à la collection" msgstr "Ajouter à la collection"
#: src/pages/UserDumps.tsx:114
msgid "All {0, plural, one {# dump} other {# dumps}} loaded."
msgstr "Toutes les {0, plural, one {# reco} other {# recos}} chargées."
#: src/pages/UserUpvoted.tsx:187
msgid "All {0, plural, one {# upvoted dump} other {# upvoted dumps}} loaded."
msgstr "Toutes les {0, plural, one {# reco votée} other {# recos votées}} chargées."
#: src/pages/UserRegister.tsx:154 #: src/pages/UserRegister.tsx:154
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>" msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
@@ -200,7 +175,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:357 #: src/pages/Dump.tsx:357
#: src/pages/DumpEdit.tsx:416 #: src/pages/DumpEdit.tsx:433
#: src/pages/PlaylistDetail.tsx:908 #: src/pages/PlaylistDetail.tsx:908
#: src/pages/UserPublicProfile.tsx:1482 #: src/pages/UserPublicProfile.tsx:1482
#: src/pages/UserPublicProfile.tsx:1552 #: src/pages/UserPublicProfile.tsx:1552
@@ -258,22 +233,11 @@ msgstr "Copier"
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:94
#: src/pages/UserLogin.tsx:79
msgid "Could not connect to server"
msgstr "Impossible de contacter le serveur"
#: src/components/UserListPopover.tsx:141 #: src/components/UserListPopover.tsx:141
msgid "Could not load." msgid "Could not load."
msgstr "Impossible de charger." msgstr "Impossible de charger."
#: src/components/CommentThread.tsx:116 #: src/pages/DumpEdit.tsx:334
#: src/components/CommentThread.tsx:158
#: src/components/CommentThread.tsx:463
msgid "Could not reach the server. Please try again."
msgstr "Impossible de contacter le serveur. Veuillez réessayer."
#: src/pages/DumpEdit.tsx:330
msgid "Could not save" msgid "Could not save"
msgstr "Sauvegarde impossible" msgstr "Sauvegarde impossible"
@@ -311,7 +275,7 @@ msgid "Delete"
msgstr "Supprimer" msgstr "Supprimer"
#: src/pages/DumpEdit.tsx:242 #: src/pages/DumpEdit.tsx:242
#: src/pages/DumpEdit.tsx:412 #: src/pages/DumpEdit.tsx:429
msgid "Delete dump" msgid "Delete dump"
msgstr "Supprimer la reco" msgstr "Supprimer la reco"
@@ -347,7 +311,7 @@ msgstr "Terminé"
msgid "Drop a file here" msgid "Drop a file here"
msgstr "Déposez un fichier ici" msgstr "Déposez un fichier ici"
#: src/pages/DumpEdit.tsx:387 #: src/pages/DumpEdit.tsx:404
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici" msgstr "Déposez un fichier de remplacement ici"
@@ -419,10 +383,6 @@ 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:49
msgid "Failed to change password"
msgstr "Impossible de changer le mot de passe"
#: src/components/PlaylistCreateForm.tsx:84 #: src/components/PlaylistCreateForm.tsx:84
msgid "Failed to create playlist" msgid "Failed to create playlist"
msgstr "Impossible de créer la collection" msgstr "Impossible de créer la collection"
@@ -578,10 +538,6 @@ msgstr "Invités"
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
#: src/pages/Notifications.tsx:178
msgid "just now"
msgstr "à l'instant"
#: src/pages/UserPublicProfile.tsx:1153 #: src/pages/UserPublicProfile.tsx:1153
msgid "Light" msgid "Light"
msgstr "Clair" msgstr "Clair"
@@ -834,8 +790,8 @@ msgstr "Publier la réponse"
msgid "Posting…" msgid "Posting…"
msgstr "Publication…" msgstr "Publication…"
#: src/components/DumpCard.tsx:113 #: src/components/DumpCard.tsx:114
#: src/components/JournalCard.tsx:105 #: src/components/JournalCard.tsx:107
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:416 #: src/pages/Dump.tsx:416
@@ -899,7 +855,7 @@ msgstr "Retirer le j'aime"
msgid "Remove vote" msgid "Remove vote"
msgstr "Retirer le vote" msgstr "Retirer le vote"
#: src/pages/DumpEdit.tsx:386 #: src/pages/DumpEdit.tsx:403
msgid "Replace file" msgid "Replace file"
msgstr "Remplacer le fichier" msgstr "Remplacer le fichier"
@@ -915,7 +871,8 @@ msgstr "Échec de la demande"
msgid "Reset failed" msgid "Reset failed"
msgstr "Échec de la réinitialisation" msgstr "Échec de la réinitialisation"
#: src/pages/DumpEdit.tsx:350 #: src/pages/DumpEdit.tsx:354
#: src/pages/DumpEdit.tsx:372
msgid "Reset to default" msgid "Reset to default"
msgstr "Réinitialiser par défaut" msgstr "Réinitialiser par défaut"
@@ -926,7 +883,7 @@ msgstr "Réessayer"
#: src/components/CommentThread.tsx:326 #: src/components/CommentThread.tsx:326
#: src/pages/Dump.tsx:349 #: src/pages/Dump.tsx:349
#: src/pages/DumpEdit.tsx:419 #: src/pages/DumpEdit.tsx:436
#: src/pages/PlaylistDetail.tsx:915 #: src/pages/PlaylistDetail.tsx:915
#: src/pages/UserPublicProfile.tsx:1474 #: src/pages/UserPublicProfile.tsx:1474
#: src/pages/UserPublicProfile.tsx:1544 #: src/pages/UserPublicProfile.tsx:1544
@@ -1010,13 +967,13 @@ msgstr "Rien à voir, circulez."
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é."
#: src/pages/DumpEdit.tsx:334 #: src/pages/DumpEdit.tsx:338
msgid "Thumbnail" msgid "Thumbnail"
msgstr "Miniature" msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:386 #: src/components/DumpCreateModal.tsx:386
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:358 #: src/pages/DumpEdit.tsx:362
msgid "Title" msgid "Title"
msgstr "Titre" msgstr "Titre"
@@ -1040,11 +997,6 @@ 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:44
#: src/pages/ResetPassword.tsx:90
msgid "Unknown error"
msgstr "Erreur inconnue"
#: src/pages/UserPublicProfile.tsx:671 #: src/pages/UserPublicProfile.tsx:671
msgid "Upload failed" msgid "Upload failed"
msgstr "Envoi échoué" msgstr "Envoi échoué"
@@ -1068,7 +1020,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})" msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:335 #: src/components/DumpCreateModal.tsx:335
#: src/pages/DumpEdit.tsx:368 #: src/pages/DumpEdit.tsx:385
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
@@ -1102,7 +1054,7 @@ msgid "View dump →"
msgstr "Voir la reco →" msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:431 #: src/components/DumpCreateModal.tsx:431
#: src/pages/DumpEdit.tsx:398 #: src/pages/DumpEdit.tsx:415
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?" msgstr "Pourquoi on en voudrait ?"
@@ -1112,7 +1064,7 @@ msgid "Who am I?"
msgstr "Qui suis-je ?" msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:430 #: src/components/DumpCreateModal.tsx:430
#: src/pages/DumpEdit.tsx:397 #: src/pages/DumpEdit.tsx:414
msgid "Why?" msgid "Why?"
msgstr "Pourquoi ?" msgstr "Pourquoi ?"

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router"; import { Link, useLocation, useNavigate, useParams } 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 { dumpUrl } from "../utils/urls.ts"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
@@ -350,7 +350,7 @@ export function Dump() {
</button> </button>
<button <button
type="button" type="button"
className="btn-border" className="form-cancel"
onClick={() => setTitleEditing(false)} onClick={() => setTitleEditing(false)}
disabled={titleSaving} disabled={titleSaving}
> >
@@ -434,7 +434,7 @@ export function Dump() {
<RichContentCard <RichContentCard
richContent={dump.richContent} richContent={dump.richContent}
thumbnailOverrideUrl={dump.thumbnailMime thumbnailOverrideUrl={dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}` ? dumpThumbnailUrl(dump, token)
: undefined} : undefined}
/> />
) )

View File

@@ -8,7 +8,7 @@ import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts"; import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts"; import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts"; import { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
@@ -176,12 +176,12 @@ export function DumpEdit() {
const { dump } = state; const { dump } = state;
const currentThumbnailSrc = dump.thumbnailMime const currentThumbnailSrc = dump.thumbnailMime
? `${API_URL}/api/thumbnails/${dump.id}` ? dumpThumbnailUrl(dump, token)
: dump.kind === "file" : dump.kind === "file"
? (dump.fileMime?.startsWith("image/") ? (dump.fileMime?.startsWith("image/")
? `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}` ? dumpFileUrl(dump, token)
: dump.fileMime?.startsWith("video/") : dump.fileMime?.startsWith("video/")
? `${API_URL}/api/thumbnails/${dump.id}` ? dumpThumbnailUrl(dump, token)
: null) : null)
: dump.richContent?.thumbnailUrl ?? null; : dump.richContent?.thumbnailUrl ?? null;
@@ -286,6 +286,10 @@ function DumpEditForm({
}, },
}); });
// Metadata-extracted title for URL dumps; the source for "Reset to default".
const metaTitle = dump.richContent?.title?.trim() ?? "";
const currentTitle = form.watch("title");
const onSubmit = form.submit( const onSubmit = form.submit(
async ({ title, url, comment, isPublic, newFile }) => { async ({ title, url, comment, isPublic, newFile }) => {
const trimmedTitle = title.trim(); const trimmedTitle = title.trim();
@@ -356,6 +360,19 @@ function DumpEditForm({
<TextField<EditValues> <TextField<EditValues>
name="title" name="title"
label={t`Title`} label={t`Title`}
labelAction={metaTitle
? (
<button
type="button"
className="form-label-action"
onClick={() =>
form.setValue("title", metaTitle, { shouldDirty: true })}
disabled={currentTitle.trim() === metaTitle}
>
<Trans>Reset to default</Trans>
</button>
)
: undefined}
maxLength={VALIDATION.DUMP_TITLE_MAX} maxLength={VALIDATION.DUMP_TITLE_MAX}
rules={{ required: true }} rules={{ required: true }}
/> />

View File

@@ -177,7 +177,8 @@
[data-style="brutalist"] .user-menu-trigger, [data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .nav-search-btn, [data-style="brutalist"] .nav-search-btn,
[data-style="brutalist"] .auth-link-btn, [data-style="brutalist"] .auth-link-btn,
[data-style="brutalist"] .form-cancel { [data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action {
border: none; border: none;
box-shadow: none; box-shadow: none;
} }
@@ -189,7 +190,9 @@
[data-style="brutalist"] .auth-link-btn:hover:not(:disabled), [data-style="brutalist"] .auth-link-btn:hover:not(:disabled),
[data-style="brutalist"] .auth-link-btn:active, [data-style="brutalist"] .auth-link-btn:active,
[data-style="brutalist"] .form-cancel:hover:not(:disabled), [data-style="brutalist"] .form-cancel:hover:not(:disabled),
[data-style="brutalist"] .form-cancel:active { [data-style="brutalist"] .form-cancel:active,
[data-style="brutalist"] .form-label-action:hover:not(:disabled),
[data-style="brutalist"] .form-label-action:active {
transform: none; transform: none;
box-shadow: none; box-shadow: none;
} }

View File

@@ -1,3 +1,28 @@
import { API_URL } from "../config/api.ts";
/**
* Direct URL to a dump's uploaded file. The viewer's token is passed as a query
* param — media elements (`<audio>`, `<img>`, …) can't send an `Authorization`
* header — so the server can authorize access to private dumps. Same approach as
* the WebSocket connection (`?token=`).
*/
export function dumpFileUrl(
dump: { id: string; fileSize?: number },
token?: string | null,
): string {
const auth = token ? `&token=${encodeURIComponent(token)}` : "";
return `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}${auth}`;
}
/** Thumbnail URL for a dump, with the viewer's token (see {@link dumpFileUrl}). */
export function dumpThumbnailUrl(
dump: { id: string },
token?: string | null,
): string {
const auth = token ? `?token=${encodeURIComponent(token)}` : "";
return `${API_URL}/api/thumbnails/${dump.id}${auth}`;
}
export function normalizeUrl(input: string): string { export function normalizeUrl(input: string): string {
const s = input.trim(); const s = input.trim();
if (!s || /^https?:\/\//i.test(s)) return s; if (!s || /^https?:\/\//i.test(s)) return s;