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
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:
@@ -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 {
|
||||
|
||||
119
api/db/migrations/0013_dump_url_canonical.ts
Normal file
119
api/db/migrations/0013_dump_url_canonical.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
104
api/lib/canonical-url.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
251
src/App.css
251
src/App.css
@@ -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%;
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -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
@@ -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`}
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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 0–1 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
@@ -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
@@ -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."
|
||||
|
||||
28
src/model.ts
28
src/model.ts
@@ -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
85
src/utils/dumpDraft.ts
Normal 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 */ }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user