v3: reworked posting — three panels, drafts, duplicate detection, upload progress
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

Posting a dump was a single form where the important choices were the easiest
to miss. It is now three panels: link or file, why & where, playlists.

Composition:
- No more URL/File toggle. An empty panel offers both ways in at once and the
  kind follows what you actually did; a file dropped anywhere in the modal is
  accepted, not just on the zone.
- Categories and visibility get their own panel instead of a disclosure that
  read as optional, and the primary button stays "Next" until they've been
  seen. Visibility carries a real label now.
- The draft (link, title, why, categories, visibility) is mirrored to
  localStorage on every change and restored on reopen, so Escape or a stray
  backdrop click costs nothing. Only an attached file can't be restored, so
  that is the one case that asks before closing.
- URL dumps can carry a poster-supplied title instead of being stuck with
  whatever the page scraped, editable right under the preview.
- Multipart uploads go through XHR so there is a real progress bar and a
  percentage on the button, rather than 50 MB of silence.

Duplicates:
- New dumps.url_canonical column (+ index, backfilled by 0013) holding a lossy
  key that ignores scheme, www., trailing slashes, tracking parameters and
  YouTube share shapes. GET /api/dumps/by-url reads it, and the create form
  warns "already dumped by X" while it fetches the preview. Never blocking.

Fixes:
- /api/preview now reports whether the page was actually reached: a failed
  fetch still yields a hostname-only stub, so a dead link and a page without
  metadata used to render identically.
- The Web Share Target never worked. The manifest posts to "/", but the index
  redirect dropped the query string, so every Android share landed on the feed
  with nothing pre-filled.
- File dumps no longer take the extension into their title.
- The link field no longer autofocuses on touch, where it raised a keyboard
  over the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
This commit is contained in:
khannurien
2026-09-08 14:40:37 +00:00
parent d76154d15d
commit fb8364e24d
21 changed files with 1688 additions and 304 deletions

View File

@@ -11,6 +11,7 @@ import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
interface Migration {
name: string;
@@ -36,6 +37,7 @@ const MIGRATIONS: Migration[] = [
name: "0012_fix_favicon_reclassification",
up: up0012FixFaviconReclassification,
},
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,119 @@
import type { DatabaseSync } from "node:sqlite";
// Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
// check on the create form — and backfills it for every existing URL dump.
//
// Purely local: it re-derives the key from the URL already stored on each row
// and makes no network calls.
//
// The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts`
// as it stood when this migration shipped, not an import of it. A migration
// runs exactly once per database, so importing the live version would mean two
// databases migrating at different times end up with keys computed under
// different rules. Improving the shared canonicalizer therefore calls for a new
// backfill migration rather than an edit here.
//
// Idempotent: the column is only added when missing and only rows whose key is
// still NULL are touched, so a fresh database built from schema.sql is a no-op.
const TRACKING_PARAMS = new Set([
"fbclid",
"gclid",
"dclid",
"msclkid",
"twclid",
"yclid",
"mc_cid",
"mc_eid",
"igshid",
"igsh",
"si",
"spm",
"ref_src",
"ref_url",
"_ga",
"_gl",
"__twitter_impression",
]);
function isTrackingParam(key: string): boolean {
const k = key.toLowerCase();
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
}
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtube-nocookie.com",
]);
function youtubeVideoId(
host: string,
pathname: string,
params: URLSearchParams,
): string | null {
if (host === "youtu.be") return pathname.split("/")[1] || null;
if (!YOUTUBE_HOSTS.has(host)) return null;
if (pathname === "/watch") return params.get("v");
if (/^\/(embed|shorts|live)\//.test(pathname)) {
return pathname.split("/")[2] || null;
}
return null;
}
function canonicalizeUrl(raw: string): string | null {
let u: URL;
try {
u = new URL(raw);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const host = u.hostname.toLowerCase().replace(/^www\./, "");
if (!host) return null;
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
if (videoId) return `https://youtube.com/watch?v=${videoId}`;
const listId = YOUTUBE_HOSTS.has(host) && u.pathname === "/playlist"
? u.searchParams.get("list")
: null;
if (listId) return `https://youtube.com/playlist?list=${listId}`;
const path = u.pathname.replace(/\/+$/, "");
const params = [...u.searchParams.entries()]
.filter(([key]) => !isTrackingParam(key))
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
const query = new URLSearchParams(params).toString();
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
}
export function up(db: DatabaseSync): void {
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;
}[];
if (!columns.some((c) => c.name === "url_canonical")) {
db.exec(`ALTER TABLE dumps ADD COLUMN url_canonical TEXT;`);
}
db.exec(
`CREATE INDEX IF NOT EXISTS idx_dumps_url_canonical ON dumps(url_canonical);`,
);
const rows = db.prepare(
`SELECT id, url FROM dumps WHERE url IS NOT NULL AND url_canonical IS NULL;`,
).all() as { id: string; url: string }[];
const update = db.prepare(
`UPDATE dumps SET url_canonical = ? WHERE id = ?;`,
);
for (const row of rows) {
const canonical = canonicalizeUrl(row.url);
if (canonical) update.run(canonical, row.id);
}
}

View File

@@ -7,6 +7,8 @@ CREATE TABLE dumps (
created_at TEXT NOT NULL,
updated_at TEXT,
url TEXT,
-- Lossy lookup key for "has this been dumped already?" — see api/lib/canonical-url.ts
url_canonical TEXT,
slug TEXT,
rich_content TEXT,
file_name TEXT,
@@ -117,6 +119,7 @@ CREATE TABLE dump_backlinks (
CREATE INDEX idx_dumps_user ON dumps(user_id);
CREATE INDEX idx_dumps_url ON dumps(url);
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
CREATE INDEX idx_votes_user ON votes(user_id);
CREATE INDEX idx_playlists_user ON playlists(user_id);
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);

104
api/lib/canonical-url.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Canonical form of a URL, used *only* to recognise that two links point at the
* same thing — the duplicate check the create form runs while it fetches a
* preview. It is a lookup key, never something we display or fetch: `dumps.url`
* keeps the exact string the poster submitted.
*
* Because it is only ever compared against other canonical forms, it may be
* lossy in ways a real URL never could be — it forces `https`, drops `www.`,
* and throws away share/tracking parameters and timestamps, so
* `http://www.example.com/a/?utm_source=x` and `https://example.com/a` collapse
* to one key.
*
* Stored in `dumps.url_canonical`. Migration 0013 carries a frozen copy of this
* logic to backfill existing rows; changing the rules here therefore needs a
* new backfill migration, or old rows keep keys computed under the old rules.
*/
const TRACKING_PARAMS = new Set([
"fbclid",
"gclid",
"dclid",
"msclkid",
"twclid",
"yclid",
"mc_cid",
"mc_eid",
"igshid",
"igsh",
"si",
"spm",
"ref_src",
"ref_url",
"_ga",
"_gl",
"__twitter_impression",
]);
function isTrackingParam(key: string): boolean {
const k = key.toLowerCase();
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
}
// Hosts already stripped of a leading "www.".
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtube-nocookie.com",
]);
/**
* The video a YouTube URL points at, in any of the shapes people paste
* (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`).
* Timestamps are deliberately ignored: the same video linked at 2:30 is still
* the same video for duplicate purposes.
*/
function youtubeVideoId(
host: string,
pathname: string,
params: URLSearchParams,
): string | null {
if (host === "youtu.be") return pathname.split("/")[1] || null;
if (!YOUTUBE_HOSTS.has(host)) return null;
if (pathname === "/watch") return params.get("v");
if (/^\/(embed|shorts|live)\//.test(pathname)) {
return pathname.split("/")[2] || null;
}
return null;
}
export function canonicalizeUrl(raw: string): string | null {
let u: URL;
try {
u = new URL(raw);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const host = u.hostname.toLowerCase().replace(/^www\./, "");
if (!host) return null;
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
if (videoId) return `https://youtube.com/watch?v=${videoId}`;
const listId = YOUTUBE_HOSTS.has(host) && u.pathname === "/playlist"
? u.searchParams.get("list")
: null;
if (listId) return `https://youtube.com/playlist?list=${listId}`;
// A single trailing slash is never meaningful; "/" itself becomes "".
const path = u.pathname.replace(/\/+$/, "");
const params = [...u.searchParams.entries()]
.filter(([key]) => !isTrackingParam(key))
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
const query = new URLSearchParams(params).toString();
// A bare "#section" anchor points into the same page, so it is dropped —
// but "#/route" and "#!/route" address distinct pages of a hash-routed app.
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
}

View File

@@ -479,11 +479,27 @@ function isStringArray(value: unknown): value is string[] {
export interface CreateUrlDumpRequest {
url: string;
/** Overrides the title scraped from the page — the poster edited the preview. */
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
/**
* An existing dump pointing at the same URL, as shown by the create form's
* duplicate hint. Display-only projection — see `findDumpsByUrl`.
*/
export interface DumpUrlMatch {
id: string;
slug?: string;
title: string;
username: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
export function isCreateUrlDumpRequest(
obj: unknown,
): obj is CreateUrlDumpRequest {
@@ -499,6 +515,13 @@ export function isCreateUrlDumpRequest(
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("title" in o && typeof o.title !== "string" && o.title !== null) {
return false;
}
if (
typeof o.title === "string" &&
(o.title as string).length > VALIDATION.DUMP_TITLE_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;

View File

@@ -5,6 +5,7 @@ import {
APIException,
type APIResponse,
type Dump,
type DumpUrlMatch,
isCreateUrlDumpRequest,
isUpdateDumpRequest,
type PaginatedData,
@@ -21,6 +22,7 @@ import {
createFileDump,
createUrlDump,
deleteDump,
findDumpsByUrl,
getDump,
listDumps,
refreshDumpMetadata,
@@ -100,6 +102,17 @@ router.post(
},
);
// Registered ahead of "/:dumpId" so the literal path wins over the parameter.
router.get("/by-url", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const url = ctx.request.url.searchParams.get("url") ?? "";
const responseBody: APIResponse<DumpUrlMatch[]> = {
success: true,
data: findDumpsByUrl(url, requestingUserId),
};
ctx.response.body = responseBody;
});
router.get("/:dumpId", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId);

View File

@@ -1,8 +1,8 @@
import { Router } from "@oak/oak";
import {
fetchRichContent,
fetchWithTimeout,
isValidHttpUrl,
tryFetchRichContent,
} from "../services/rich-content-service.ts";
import { APIErrorCode } from "../model/interfaces.ts";
@@ -18,8 +18,15 @@ previewRouter.get("/api/preview", async (ctx) => {
};
return;
}
const data = await fetchRichContent(url);
ctx.response.body = { success: true, data: data ?? null };
// `reached` is reported separately because a failed fetch still yields a
// usable stub (hostname only). Without the flag the create form cannot tell
// "this page has no preview" from "this link is dead", and shows the same
// bare card for both.
const { ok, content } = await tryFetchRichContent(url);
ctx.response.body = {
success: true,
data: { reached: ok, richContent: content ?? null },
};
});
/**

View File

@@ -3,6 +3,7 @@ import {
APIException,
type CreateUrlDumpRequest,
type Dump,
type DumpUrlMatch,
type UpdateDumpRequest,
} from "../model/interfaces.ts";
import {
@@ -28,6 +29,7 @@ import {
notifyUserFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
@@ -63,13 +65,16 @@ export async function createUrlDump(
const dumpId = crypto.randomUUID();
const createdAt = new Date();
const richContent = await fetchRichContent(request.url);
const title = richContent?.title ?? titleFromUrl(request.url);
// A title typed on the create form wins over the one scraped from the page:
// the poster has seen the preview and is correcting it.
const title = request.title?.trim() || richContent?.title ||
titleFromUrl(request.url);
const isPrivate = request.isPrivate ?? false;
const slug = makeSlug(title, dumpId);
db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
).run(
dumpId,
"url",
@@ -79,6 +84,7 @@ export async function createUrlDump(
userId,
createdAt.toISOString(),
request.url,
canonicalizeUrl(request.url),
richContent ? JSON.stringify(richContent) : null,
isPrivate ? 1 : 0,
);
@@ -294,6 +300,54 @@ export function listDumps(
return { items, total: totalRow?.count ?? 0 };
}
/**
* Dumps that already point at the same thing as `url`, newest first.
*
* Matched on the canonical key rather than the raw URL, so a link reposted with
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
* different share shape still counts as the same dump. Private dumps are
* visible only to their owner, exactly as everywhere else.
*
* Returns a small display-only projection: this feeds a hint on the create
* form, not a feed.
*/
export function findDumpsByUrl(
url: string,
requestingUserId?: string,
limit = 3,
): DumpUrlMatch[] {
const canonical = canonicalizeUrl(url);
if (!canonical) return [];
const rows = db.prepare(
`SELECT d.id, d.slug, d.title, d.created_at, d.vote_count, u.username,
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
FROM dumps d
JOIN users u ON u.id = d.user_id
WHERE d.url_canonical = ? AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY d.created_at DESC
LIMIT ?;`,
).all(canonical, requestingUserId ?? null, limit) as {
id: string;
slug: string | null;
title: string;
created_at: string;
vote_count: number;
username: string;
comment_count: number;
}[];
return rows.map((row) => ({
id: row.id,
slug: row.slug ?? undefined,
title: row.title,
username: row.username,
createdAt: new Date(row.created_at),
voteCount: row.vote_count,
commentCount: row.comment_count,
}));
}
export async function updateDump(
dumpId: string,
request: UpdateDumpRequest,
@@ -387,12 +441,13 @@ export async function updateDump(
const row = dumpApiToRow(updatedDump);
const result = db.prepare(
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
row.title,
row.slug,
row.comment,
row.url,
row.url ? canonicalizeUrl(row.url) : null,
row.rich_content,
row.is_private,
now.toISOString(),

View File

@@ -438,6 +438,222 @@
font-weight: 700;
}
/* ── Create-dump modal ── */
/* A file dropped anywhere in the modal is accepted, so the whole body lights up. */
.dump-create--drag {
outline: 2px dashed var(--color-accent);
outline-offset: 6px;
border-radius: 10px;
}
.dump-create-draft,
.dump-create-confirm {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.9rem;
padding: 0.55rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
}
.dump-create-draft {
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-text-secondary);
}
.dump-create-draft-discard {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-weight: 600;
color: var(--color-link);
text-decoration: underline;
}
.dump-create-confirm {
background: var(--color-danger-bg);
border: 1px solid var(--color-danger);
}
.dump-create-confirm p {
margin: 0;
flex: 1 1 14rem;
}
.dump-create-confirm-actions {
display: flex;
gap: 0.5rem;
}
/* Not reached, or reached with nothing worth showing. */
.preview-note {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
/* ── "Already dumped" hint ── */
.dump-duplicate {
display: flex;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: 9px;
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.dump-duplicate-icon {
font-size: 1rem;
line-height: 1.3;
opacity: 0.8;
}
.dump-duplicate-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.dump-duplicate-lead {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
}
.dump-duplicate-link {
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dump-duplicate-more,
.dump-duplicate-hint {
margin: 0;
font-size: 0.78rem;
color: var(--color-text-secondary);
}
/* ── Step indicator ── */
.dump-steps {
display: flex;
align-items: center;
gap: 0.5rem;
list-style: none;
margin: 0 0 1.1rem;
padding: 0;
}
.dump-step {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
font-size: 0.82rem;
color: var(--color-text-muted);
}
/* Connector between steps — drawn on the gap, not on the labels. */
.dump-step + .dump-step::before {
content: "";
width: 1.1rem;
height: 1px;
background: var(--color-border-subtle);
flex-shrink: 0;
}
.dump-step-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
flex-shrink: 0;
border-radius: 999px;
border: 1.5px solid var(--color-border-subtle);
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
}
.dump-step-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dump-step--current {
color: var(--color-text);
font-weight: 600;
}
.dump-step--current .dump-step-marker {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
.dump-step--done {
color: var(--color-text-secondary);
}
.dump-step--done .dump-step-marker {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Only the current step keeps its label once the modal gets narrow. */
@media (max-width: 30rem) {
.dump-step:not(.dump-step--current) .dump-step-label {
display: none;
}
}
/* ── Panels ── */
.dump-panel {
display: flex;
flex-direction: column;
gap: 1rem; /* matches .form, so panels and plain forms space identically */
animation: dump-panel-in 0.18s ease-out;
}
.dump-panel--back {
animation-name: dump-panel-in-back;
}
@keyframes dump-panel-in {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: none; }
}
@keyframes dump-panel-in-back {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.dump-panel {
animation: none;
}
}
/* One line naming the thing the second panel's choices apply to. */
.dump-panel-recap {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Mode toggle — segmented control ── */
.visibility-toggle {
display: flex;
@@ -592,6 +808,41 @@
background: var(--color-border-subtle);
}
/* ── Upload progress (second panel, while the file is being sent) ── */
.dump-upload-progress {
height: 4px;
border-radius: 999px;
background: var(--color-border-subtle);
overflow: hidden;
}
.dump-upload-progress-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: var(--color-accent);
transition: width 0.2s ease-out;
}
/* Length unknown, or the body is sent and the server is still thinking. */
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
width: 40%;
animation: dump-upload-slide 1.1s ease-in-out infinite;
}
@keyframes dump-upload-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
@media (prefers-reduced-motion: reduce) {
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
width: 100%;
animation: none;
opacity: 0.5;
}
}
/* ── Local file / URL preview (DumpCreate) ── */
.local-preview-image {
width: 100%;

View File

@@ -4,6 +4,7 @@ import {
Navigate,
Route,
Routes,
useLocation,
useParams,
} from "react-router";
@@ -85,14 +86,21 @@ function useResolvedDefaultTab() {
return preferredTab === "followed" && !user ? "hot" : preferredTab;
}
// The query string has to survive the hop: the Web Share Target posts to "/",
// so an Android share arrives here as `/?share_url=…` and the feed below is the
// only thing that can act on it.
function IndexRedirect() {
return <Navigate to={`/~/${useResolvedDefaultTab()}`} replace />;
const { search } = useLocation();
return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
}
// Bare `/<slug>` lands on that category's default feed tab.
function CategoryRedirect() {
const { categorySlug } = useParams();
return <Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}`} replace />;
const { search } = useLocation();
return (
<Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`} replace />
);
}
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed.

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useRef } from "react";
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
@@ -7,11 +7,24 @@ interface ModalProps {
onClose: () => void;
children: ReactNode;
wide?: boolean;
/**
* Runs before every dismissal the user did not aim at the content — Escape,
* the backdrop, the ✕. Returning `false` cancels it, which lets a modal
* holding an unsaved draft ask first instead of throwing the work away.
*/
onBeforeClose?: () => boolean;
}
export function Modal({ title, onClose, children, wide = false }: ModalProps) {
export function Modal(
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
) {
const backdropRef = useRef<HTMLDivElement>(null);
const requestClose = useCallback(() => {
if (onBeforeClose?.() === false) return;
onClose();
}, [onBeforeClose, onClose]);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
@@ -21,18 +34,18 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
if (e.key === "Escape" && !e.defaultPrevented) requestClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
}, [requestClose]);
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
if (e.target === backdropRef.current) requestClose();
}}
>
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
<button
type="button"
className="modal-close-btn"
onClick={onClose}
onClick={requestClose}
aria-label={t`Close`}
>

View File

@@ -64,14 +64,17 @@ export function SegmentedField<T extends FieldValues, V>({
*/
export function VisibilityToggle<T extends FieldValues>({
name,
label,
disabled,
}: {
name: Path<T>;
label?: string;
disabled?: boolean;
}) {
return (
<SegmentedField<T, boolean>
name={name}
label={label}
disabled={disabled}
options={[
{ value: true, label: <Trans>Public</Trans> },

View File

@@ -59,22 +59,77 @@ export const useAuth = () => {
return res;
}, [authResponse?.token, logout]);
/**
* `authFetch` for multipart uploads that need a progress bar.
*
* `fetch` reports nothing until the whole body is on the wire, which for a
* 50 MB dump on a phone is a long silence — so this goes through XHR, whose
* `upload.progress` events fire as bytes leave. The result is wrapped back
* into a real `Response` so callers keep using `expectOk` unchanged.
*
* `onProgress` receives a 01 fraction, or `null` once the body is fully sent
* and we are waiting on the server (the length is unknown for chunked bodies,
* and "100%, still waiting" reads as a stall).
*/
const authUpload = useCallback((
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method = "POST",
): Promise<Response> => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
return Promise.resolve(new Response(null, { status: 401 }));
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
// Content-Type is left to the browser so the multipart boundary is set.
xhr.upload.addEventListener("progress", (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : null);
});
xhr.upload.addEventListener("load", () => onProgress(null));
xhr.addEventListener("load", () => {
if (xhr.status === 401) logout();
resolve(
new Response(xhr.responseText, {
status: xhr.status,
statusText: xhr.statusText,
}),
);
});
// Mirrors what `fetch` throws on a network failure, so the shared
// `friendlyFetchError` path renders it the same way.
xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch")));
xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch")));
xhr.send(body);
});
}, [authResponse?.token, logout]);
return {
user: authResponse?.user ?? null,
token: authResponse?.token ?? null,
login,
logout,
authFetch,
authUpload,
};
};
export const useRequiredAuth = () => {
const { user, token, login, logout, authFetch } = useAuth();
const { user, token, login, logout, authFetch, authUpload } = useAuth();
if (!user) {
throw new Error(
"Invariant: useRequiredAuth called outside a protected route",
);
}
return { user, token, login, logout, authFetch };
return { user, token, login, logout, authFetch, authUpload };
};

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:107
#: src/components/JournalCard.tsx:114
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
#. placeholder {0}: rest.length
#: src/components/DumpCreateModal.tsx:246
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: names[0]
#. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:531
@@ -53,6 +58,7 @@ msgstr "{label} ({count})"
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/components/DumpCreateModal.tsx:935
#: src/pages/PlaylistDetail.tsx:570
#: src/pages/UserPublicProfile.tsx:749
msgid "← Back"
@@ -154,7 +160,7 @@ msgid "Add email…"
msgstr "Add email…"
#: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:301
#: src/components/DumpCreateModal.tsx:659
msgid "Add to playlist"
msgstr "Add to playlist"
@@ -167,6 +173,12 @@ msgstr "Admin"
msgid "All"
msgstr "All"
#. placeholder {0}: first.username
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/DumpCreateModal.tsx:233
msgid "Already dumped by {0} {1}"
msgstr "Already dumped by {0} {1}"
#: src/pages/UserRegister.tsx:156
msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>"
@@ -201,6 +213,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/ChatModal.tsx:229
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:945
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:469
@@ -262,14 +275,22 @@ msgid "Checking invite…"
msgstr "Checking invite…"
#: src/components/ChangePasswordModal.tsx:56
#: src/components/Modal.tsx:45
#: src/components/Modal.tsx:58
msgid "Close"
msgstr "Close"
#: src/components/DumpCreateModal.tsx:690
msgid "Close anyway"
msgstr "Close anyway"
#: src/components/GlobalPlayer.tsx:184
msgid "Close player"
msgstr "Close player"
#: src/components/DumpCreateModal.tsx:680
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
#: src/components/GlobalPlayer.tsx:176
msgid "Collapse player"
msgstr "Collapse player"
@@ -303,6 +324,10 @@ msgstr "Could not load."
msgid "Could not save"
msgstr "Could not save"
#: src/components/DumpCreateModal.tsx:802
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
#: src/components/PlaylistCreateForm.tsx:87
msgid "Create"
msgstr "Create"
@@ -391,7 +416,7 @@ msgstr "deleted message"
msgid "Description (optional)"
msgstr "Description (optional)"
#: src/components/DumpCreateModal.tsx:488
#: src/components/DumpCreateModal.tsx:741
msgid "Done"
msgstr "Done"
@@ -399,6 +424,10 @@ msgstr "Done"
msgid "Drop a file here"
msgstr "Drop a file here"
#: src/components/DumpCreateModal.tsx:847
msgid "Drop a file here, or paste one"
msgstr "Drop a file here, or paste one"
#: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
@@ -407,11 +436,11 @@ msgstr "Drop a replacement here"
msgid "Dump"
msgstr "Dump"
#: src/components/DumpCreateModal.tsx:458
#: src/components/DumpCreateModal.tsx:958
msgid "Dump it"
msgstr "Dump it"
#: src/components/DumpCreateModal.tsx:469
#: src/components/DumpCreateModal.tsx:718
msgid "Dumped!"
msgstr "Dumped!"
@@ -444,7 +473,7 @@ msgstr "Edit"
msgid "Edit {0}"
msgstr "Edit {0}"
#: src/components/DumpCreateModal.tsx:420
#: src/components/DumpCreateModal.tsx:197
msgid "Edit title"
msgstr "Edit title"
@@ -518,7 +547,7 @@ msgstr "Failed to generate invite"
msgid "Failed to load"
msgstr "Failed to load"
#: src/components/DumpCreateModal.tsx:337
#: src/components/DumpCreateModal.tsx:750
msgid "Failed to post"
msgstr "Failed to post"
@@ -556,19 +585,11 @@ msgstr "Failed to update role"
msgid "Feeds"
msgstr "Feeds"
#: src/components/DumpCreateModal.tsx:369
#: src/components/DumpCreateModal.tsx:789
msgid "Fetching preview…"
msgstr "Fetching preview…"
#: src/components/DumpCreateModal.tsx:455
msgid "Fetching…"
msgstr "Fetching…"
#: src/components/DumpCreateModal.tsx:331
msgid "File"
msgstr "File"
#: src/components/DumpCreateModal.tsx:239
#: src/components/DumpCreateModal.tsx:571
msgid "File too large (max 50 MB)."
msgstr "File too large (max 50 MB)."
@@ -670,6 +691,10 @@ msgstr "Invitees"
msgid "Journal"
msgstr "Journal"
#: src/components/DumpCreateModal.tsx:687
msgid "Keep editing"
msgstr "Keep editing"
#: src/pages/UserPublicProfile.tsx:1205
msgid "Light"
msgstr "Light"
@@ -678,6 +703,14 @@ msgstr "Light"
msgid "Like"
msgstr "Like"
#: src/components/DumpCreateModal.tsx:764
msgid "Link"
msgstr "Link"
#: src/components/DumpCreateModal.tsx:66
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/WSProvider.tsx:575
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -795,7 +828,7 @@ msgstr "new"
msgid "New"
msgstr "New"
#: src/components/DumpCreateModal.tsx:301
#: src/components/DumpCreateModal.tsx:659
#: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88
@@ -813,6 +846,10 @@ msgstr "New password"
msgid "New playlist"
msgstr "New playlist"
#: src/components/DumpCreateModal.tsx:957
msgid "Next →"
msgstr "Next →"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Next track"
@@ -923,11 +960,20 @@ msgstr "Password updated"
msgid "Passwords do not match"
msgstr "Passwords do not match"
#: src/components/DumpCreateModal.tsx:782
msgid "Paste a link…"
msgstr "Paste a link…"
#: src/components/DumpCreateModal.tsx:699
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
#: src/pages/PlaylistDetail.tsx:863
msgid "Playlist title"
msgstr "Playlist title"
#: src/components/AppHeader.tsx:86
#: src/components/DumpCreateModal.tsx:68
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371
@@ -941,10 +987,6 @@ msgstr "Playlists"
msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})"
#: src/components/DumpCreateModal.tsx:236
msgid "Please select a file."
msgstr "Please select a file."
#: src/components/CommentThread.tsx:472
msgid "Post comment"
msgstr "Post comment"
@@ -955,6 +997,7 @@ msgstr "Post reply"
#: src/components/CommentThread.tsx:396
#: src/components/CommentThread.tsx:473
#: src/components/DumpCreateModal.tsx:951
msgid "Posting…"
msgstr "Posting…"
@@ -963,7 +1006,7 @@ msgid "Previous track"
msgstr "Previous track"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:116
#: src/components/JournalCard.tsx:123
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:463
@@ -971,7 +1014,7 @@ msgstr "Previous track"
msgid "private"
msgstr "private"
#: src/components/form/SegmentedField.tsx:78
#: src/components/form/SegmentedField.tsx:81
#: src/pages/PlaylistDetail.tsx:902
msgid "Private"
msgstr "Private"
@@ -981,7 +1024,7 @@ msgstr "Private"
msgid "public"
msgstr "public"
#: src/components/form/SegmentedField.tsx:77
#: src/components/form/SegmentedField.tsx:80
#: src/pages/PlaylistDetail.tsx:895
msgid "Public"
msgstr "Public"
@@ -1063,6 +1106,11 @@ msgstr "Reset password"
msgid "Reset to default"
msgstr "Reset to default"
#: src/components/DumpCreateModal.tsx:208
#: src/components/DumpCreateModal.tsx:209
msgid "Reset to the suggested title"
msgstr "Reset to the suggested title"
#: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
@@ -1148,6 +1196,10 @@ msgstr "slug"
msgid "Something went wrong"
msgstr "Something went wrong"
#: src/components/DumpCreateModal.tsx:706
msgid "Start fresh"
msgstr "Start fresh"
#: src/pages/UserPublicProfile.tsx:1155
msgid "Style"
msgstr "Style"
@@ -1180,13 +1232,13 @@ msgstr "This reset link is missing or malformed."
msgid "Thumbnail"
msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:389
#: src/components/DumpCreateModal.tsx:174
#: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:398
msgid "Title"
msgstr "Title"
#: src/components/DumpCreateModal.tsx:237
#: src/components/DumpCreateModal.tsx:569
msgid "Title is required."
msgstr "Title is required."
@@ -1215,7 +1267,12 @@ msgstr "Unfollow playlist"
msgid "Upload failed"
msgstr "Upload failed"
#: src/components/DumpCreateModal.tsx:456
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
#: src/components/DumpCreateModal.tsx:642
msgid "Uploading {0}%"
msgstr "Uploading {0}%"
#: src/components/DumpCreateModal.tsx:954
msgid "Uploading…"
msgstr "Uploading…"
@@ -1233,12 +1290,11 @@ msgstr "Upvoted"
msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:344
#: src/pages/DumpEdit.tsx:421
msgid "URL"
msgstr "URL"
#: src/components/DumpCreateModal.tsx:223
#: src/components/DumpCreateModal.tsx:553
msgid "URL is required."
msgstr "URL is required."
@@ -1275,11 +1331,11 @@ msgstr "Users"
msgid "View all →"
msgstr "View all →"
#: src/components/DumpCreateModal.tsx:471
#: src/components/DumpCreateModal.tsx:720
msgid "View dump →"
msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:434
#: src/components/DumpCreateModal.tsx:904
#: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
@@ -1289,7 +1345,15 @@ msgstr "What makes it worth it?"
msgid "Who am I?"
msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:433
#: src/components/DumpCreateModal.tsx:920
msgid "Who can see it"
msgstr "Who can see it"
#: src/components/DumpCreateModal.tsx:67
msgid "Why & where"
msgstr "Why & where"
#: src/components/DumpCreateModal.tsx:903
#: src/pages/DumpEdit.tsx:445
msgid "Why?"
msgstr "Why?"
@@ -1303,6 +1367,10 @@ msgstr "Write a reply…"
msgid "Yesterday"
msgstr "Yesterday"
#: src/components/DumpCreateModal.tsx:254
msgid "You can post it anyway."
msgstr "You can post it anyway."
#: src/pages/Notifications.tsx:384
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."

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:107
#: src/components/JournalCard.tsx:114
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
#. placeholder {0}: rest.length
#: src/components/DumpCreateModal.tsx:246
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus anciennes}}"
#. placeholder {0}: names[0]
#. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:531
@@ -53,6 +58,7 @@ msgstr "{label} ({count})"
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/components/DumpCreateModal.tsx:935
#: src/pages/PlaylistDetail.tsx:570
#: src/pages/UserPublicProfile.tsx:749
msgid "← Back"
@@ -154,7 +160,7 @@ msgid "Add email…"
msgstr "Ajouter un e-mail…"
#: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:301
#: src/components/DumpCreateModal.tsx:659
msgid "Add to playlist"
msgstr "Ajouter à la collection"
@@ -167,6 +173,12 @@ msgstr "Administrateur"
msgid "All"
msgstr "Tout"
#. placeholder {0}: first.username
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/DumpCreateModal.tsx:233
msgid "Already dumped by {0} {1}"
msgstr "Déjà recommandé par {0} {1}"
#: src/pages/UserRegister.tsx:156
msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
@@ -201,6 +213,7 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/ChatModal.tsx:229
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:945
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:469
@@ -262,14 +275,22 @@ msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
#: src/components/ChangePasswordModal.tsx:56
#: src/components/Modal.tsx:45
#: src/components/Modal.tsx:58
msgid "Close"
msgstr "Fermer"
#: src/components/DumpCreateModal.tsx:690
msgid "Close anyway"
msgstr "Fermer quand même"
#: src/components/GlobalPlayer.tsx:184
msgid "Close player"
msgstr "Fermer le lecteur"
#: src/components/DumpCreateModal.tsx:680
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
#: src/components/GlobalPlayer.tsx:176
msgid "Collapse player"
msgstr "Réduire le lecteur"
@@ -303,6 +324,10 @@ msgstr "Impossible de charger."
msgid "Could not save"
msgstr "Sauvegarde impossible"
#: src/components/DumpCreateModal.tsx:802
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
#: src/components/PlaylistCreateForm.tsx:87
msgid "Create"
msgstr "Créer"
@@ -391,7 +416,7 @@ msgstr "message supprimé"
msgid "Description (optional)"
msgstr "Description (facultatif)"
#: src/components/DumpCreateModal.tsx:488
#: src/components/DumpCreateModal.tsx:741
msgid "Done"
msgstr "Terminé"
@@ -399,6 +424,10 @@ msgstr "Terminé"
msgid "Drop a file here"
msgstr "Déposez un fichier ici"
#: src/components/DumpCreateModal.tsx:847
msgid "Drop a file here, or paste one"
msgstr "Déposez un fichier ici, ou collez-en un"
#: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
@@ -407,11 +436,11 @@ msgstr "Déposez un fichier de remplacement ici"
msgid "Dump"
msgstr "Reco"
#: src/components/DumpCreateModal.tsx:458
#: src/components/DumpCreateModal.tsx:958
msgid "Dump it"
msgstr "Recommander"
#: src/components/DumpCreateModal.tsx:469
#: src/components/DumpCreateModal.tsx:718
msgid "Dumped!"
msgstr "Recommandé !"
@@ -444,7 +473,7 @@ msgstr "Modifier"
msgid "Edit {0}"
msgstr "Modifier {0}"
#: src/components/DumpCreateModal.tsx:420
#: src/components/DumpCreateModal.tsx:197
msgid "Edit title"
msgstr "Modifier le titre"
@@ -518,7 +547,7 @@ msgstr "Impossible de générer une invitation"
msgid "Failed to load"
msgstr "Chargement échoué"
#: src/components/DumpCreateModal.tsx:337
#: src/components/DumpCreateModal.tsx:750
msgid "Failed to post"
msgstr "Publication échouée"
@@ -556,19 +585,11 @@ msgstr "Erreur lors de la mise à jour du rôle"
msgid "Feeds"
msgstr "Flux"
#: src/components/DumpCreateModal.tsx:369
#: src/components/DumpCreateModal.tsx:789
msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…"
#: src/components/DumpCreateModal.tsx:455
msgid "Fetching…"
msgstr "Récupération…"
#: src/components/DumpCreateModal.tsx:331
msgid "File"
msgstr "Fichier"
#: src/components/DumpCreateModal.tsx:239
#: src/components/DumpCreateModal.tsx:571
msgid "File too large (max 50 MB)."
msgstr "Fichier trop volumineux (max 50 Mo)."
@@ -670,6 +691,10 @@ msgstr "Invités"
msgid "Journal"
msgstr "Journal"
#: src/components/DumpCreateModal.tsx:687
msgid "Keep editing"
msgstr "Continuer l'édition"
#: src/pages/UserPublicProfile.tsx:1205
msgid "Light"
msgstr "Clair"
@@ -678,6 +703,14 @@ msgstr "Clair"
msgid "Like"
msgstr "Aimer"
#: src/components/DumpCreateModal.tsx:764
msgid "Link"
msgstr "Lien"
#: src/components/DumpCreateModal.tsx:66
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/WSProvider.tsx:575
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -795,7 +828,7 @@ msgstr "nouveau"
msgid "New"
msgstr "Nouveau"
#: src/components/DumpCreateModal.tsx:301
#: src/components/DumpCreateModal.tsx:659
#: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88
@@ -813,6 +846,10 @@ msgstr "Nouveau mot de passe"
msgid "New playlist"
msgstr "Nouvelle collection"
#: src/components/DumpCreateModal.tsx:957
msgid "Next →"
msgstr "Suivant →"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Piste suivante"
@@ -923,11 +960,20 @@ msgstr "Mot de passe mis à jour"
msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas"
#: src/components/DumpCreateModal.tsx:782
msgid "Paste a link…"
msgstr "Collez un lien…"
#: src/components/DumpCreateModal.tsx:699
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
#: src/pages/PlaylistDetail.tsx:863
msgid "Playlist title"
msgstr "Titre de la collection"
#: src/components/AppHeader.tsx:86
#: src/components/DumpCreateModal.tsx:68
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371
@@ -941,10 +987,6 @@ msgstr "Collections"
msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})"
#: src/components/DumpCreateModal.tsx:236
msgid "Please select a file."
msgstr "Veuillez sélectionner un fichier."
#: src/components/CommentThread.tsx:472
msgid "Post comment"
msgstr "Publier le commentaire"
@@ -955,6 +997,7 @@ msgstr "Publier la réponse"
#: src/components/CommentThread.tsx:396
#: src/components/CommentThread.tsx:473
#: src/components/DumpCreateModal.tsx:951
msgid "Posting…"
msgstr "Publication…"
@@ -963,7 +1006,7 @@ msgid "Previous track"
msgstr "Piste précédente"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:116
#: src/components/JournalCard.tsx:123
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:463
@@ -971,7 +1014,7 @@ msgstr "Piste précédente"
msgid "private"
msgstr "privé"
#: src/components/form/SegmentedField.tsx:78
#: src/components/form/SegmentedField.tsx:81
#: src/pages/PlaylistDetail.tsx:902
msgid "Private"
msgstr "Privé"
@@ -981,7 +1024,7 @@ msgstr "Privé"
msgid "public"
msgstr "public"
#: src/components/form/SegmentedField.tsx:77
#: src/components/form/SegmentedField.tsx:80
#: src/pages/PlaylistDetail.tsx:895
msgid "Public"
msgstr "Public"
@@ -1063,6 +1106,11 @@ msgstr "Réinitialiser le mot de passe"
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
#: src/components/DumpCreateModal.tsx:208
#: src/components/DumpCreateModal.tsx:209
msgid "Reset to the suggested title"
msgstr "Rétablir le titre suggéré"
#: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
@@ -1148,6 +1196,10 @@ msgstr "identifiant"
msgid "Something went wrong"
msgstr "Une erreur est survenue"
#: src/components/DumpCreateModal.tsx:706
msgid "Start fresh"
msgstr "Repartir de zéro"
#: src/pages/UserPublicProfile.tsx:1155
msgid "Style"
msgstr "Style"
@@ -1180,13 +1232,13 @@ msgstr "Ce lien de réinitialisation est absent ou malformé."
msgid "Thumbnail"
msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:389
#: src/components/DumpCreateModal.tsx:174
#: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:398
msgid "Title"
msgstr "Titre"
#: src/components/DumpCreateModal.tsx:237
#: src/components/DumpCreateModal.tsx:569
msgid "Title is required."
msgstr "Un titre est requis."
@@ -1215,7 +1267,12 @@ msgstr "Ne plus suivre la collection"
msgid "Upload failed"
msgstr "Envoi échoué"
#: src/components/DumpCreateModal.tsx:456
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
#: src/components/DumpCreateModal.tsx:642
msgid "Uploading {0}%"
msgstr "Envoi {0} %"
#: src/components/DumpCreateModal.tsx:954
msgid "Uploading…"
msgstr "Envoi…"
@@ -1233,12 +1290,11 @@ msgstr "Voté"
msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:344
#: src/pages/DumpEdit.tsx:421
msgid "URL"
msgstr "URL"
#: src/components/DumpCreateModal.tsx:223
#: src/components/DumpCreateModal.tsx:553
msgid "URL is required."
msgstr "L'URL est obligatoire."
@@ -1275,11 +1331,11 @@ msgstr "Utilisateurs"
msgid "View all →"
msgstr "Tout voir →"
#: src/components/DumpCreateModal.tsx:471
#: src/components/DumpCreateModal.tsx:720
msgid "View dump →"
msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:434
#: src/components/DumpCreateModal.tsx:904
#: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
@@ -1289,7 +1345,15 @@ msgstr "Pourquoi on en voudrait ?"
msgid "Who am I?"
msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:433
#: src/components/DumpCreateModal.tsx:920
msgid "Who can see it"
msgstr "Qui peut la voir"
#: src/components/DumpCreateModal.tsx:67
msgid "Why & where"
msgstr "Pourquoi & où"
#: src/components/DumpCreateModal.tsx:903
#: src/pages/DumpEdit.tsx:445
msgid "Why?"
msgstr "Pourquoi ?"
@@ -1303,6 +1367,10 @@ msgstr "Écrire une réponse…"
msgid "Yesterday"
msgstr "Hier"
#: src/components/DumpCreateModal.tsx:254
msgid "You can post it anyway."
msgstr "Vous pouvez la publier quand même."
#: src/pages/Notifications.tsx:384
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."

View File

@@ -74,6 +74,32 @@ export interface Dump {
export type RawDump = WithStringDate<Dump>;
/**
* `GET /api/preview`. `reached` is false when the page could not be fetched, in
* which case `richContent` is a hostname-only stub rather than real metadata.
*/
export interface UrlPreviewResponse {
reached: boolean;
richContent: RichContent | null;
}
/** An existing dump on the same URL, as returned by `GET /api/dumps/by-url`. */
export interface DumpUrlMatch {
id: string;
slug?: string;
title: string;
username: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
export type RawDumpUrlMatch = WithStringDate<DumpUrlMatch>;
export function deserializeDumpUrlMatch(raw: RawDumpUrlMatch): DumpUrlMatch {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
export function deserializeDump(raw: RawDump): Dump {
return {
...raw,
@@ -671,6 +697,8 @@ export interface RegisterRequest {
export interface CreateUrlDumpRequest {
url: string;
/** Overrides the title scraped from the page — the poster edited the preview. */
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];

85
src/utils/dumpDraft.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Persistence for the half-written dump in the create modal.
*
* The modal is dismissable by Escape, a backdrop click and the ✕, and it holds
* a URL, a title, a rich-text "why", categories and a visibility choice — so a
* stray keystroke used to destroy a few minutes of writing. Everything except
* the attached file (a `File` cannot survive a reload) is mirrored here on each
* change and restored the next time the modal opens; a successful post clears
* it.
*
* Deliberately a single draft rather than one per tab: someone who closed the
* modal by accident wants the thing they were just writing, wherever they
* reopen it.
*/
const STORAGE_KEY = "gerbeur.dumpDraft";
export interface DumpDraft {
url: string;
title: string;
/** Whether `title` was typed by the poster, rather than taken from the preview. */
titleFromUser: boolean;
comment: string;
isPublic: boolean;
categoryIds: string[];
}
export const EMPTY_DUMP_DRAFT: DumpDraft = {
url: "",
title: "",
titleFromUser: false,
comment: "",
isPublic: true,
categoryIds: [],
};
/** Whether a draft holds anything worth restoring or warning about. */
export function isDumpDraftEmpty(draft: DumpDraft): boolean {
return !draft.url.trim() && !draft.comment.trim() &&
!(draft.titleFromUser && draft.title.trim()) &&
draft.categoryIds.length === 0 && draft.isPublic;
}
export function loadDumpDraft(): DumpDraft | null {
let raw: string | null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return null; // Storage disabled (private mode, blocked cookies) — no drafts.
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DumpDraft>;
const draft: DumpDraft = {
url: typeof parsed.url === "string" ? parsed.url : "",
title: typeof parsed.title === "string" ? parsed.title : "",
titleFromUser: parsed.titleFromUser === true,
comment: typeof parsed.comment === "string" ? parsed.comment : "",
isPublic: parsed.isPublic !== false,
categoryIds: Array.isArray(parsed.categoryIds)
? parsed.categoryIds.filter((id): id is string => typeof id === "string")
: [],
};
return isDumpDraftEmpty(draft) ? null : draft;
} catch {
return null;
}
}
export function saveDumpDraft(draft: DumpDraft): void {
try {
if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
} catch {
// Storage full or unavailable — the draft is a convenience, never a
// precondition for posting, so a failure here is silent by design.
}
}
export function clearDumpDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch { /* see saveDumpDraft */ }
}

View File

@@ -3,3 +3,14 @@ export function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* A file's name as a readable dump title: the extension goes (it is noise in a
* feed, and the dump already carries its MIME type), and separator runs become
* spaces, so `holiday_photo-01.final.jpg` reads as `holiday photo-01.final`.
* Names that are *only* an extension (`.gitignore`) are left alone.
*/
export function titleFromFileName(name: string): string {
const withoutExt = name.replace(/(?!^)\.[^.]+$/, "");
return withoutExt.replace(/_+/g, " ").trim() || name;
}