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 up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
||||||
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.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 up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
|
||||||
|
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,6 +37,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
name: "0012_fix_favicon_reclassification",
|
name: "0012_fix_favicon_reclassification",
|
||||||
up: up0012FixFaviconReclassification,
|
up: up0012FixFaviconReclassification,
|
||||||
},
|
},
|
||||||
|
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
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,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
|
-- Lossy lookup key for "has this been dumped already?" — see api/lib/canonical-url.ts
|
||||||
|
url_canonical TEXT,
|
||||||
slug TEXT,
|
slug TEXT,
|
||||||
rich_content TEXT,
|
rich_content TEXT,
|
||||||
file_name 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_user ON dumps(user_id);
|
||||||
CREATE INDEX idx_dumps_url ON dumps(url);
|
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_votes_user ON votes(user_id);
|
||||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
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 {
|
export interface CreateUrlDumpRequest {
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||||
|
title?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
categoryIds?: string[];
|
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(
|
export function isCreateUrlDumpRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is CreateUrlDumpRequest {
|
): obj is CreateUrlDumpRequest {
|
||||||
@@ -499,6 +515,13 @@ export function isCreateUrlDumpRequest(
|
|||||||
typeof o.comment === "string" &&
|
typeof o.comment === "string" &&
|
||||||
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
||||||
) return false;
|
) 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 ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
|
||||||
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
|
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Dump,
|
type Dump,
|
||||||
|
type DumpUrlMatch,
|
||||||
isCreateUrlDumpRequest,
|
isCreateUrlDumpRequest,
|
||||||
isUpdateDumpRequest,
|
isUpdateDumpRequest,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
createFileDump,
|
createFileDump,
|
||||||
createUrlDump,
|
createUrlDump,
|
||||||
deleteDump,
|
deleteDump,
|
||||||
|
findDumpsByUrl,
|
||||||
getDump,
|
getDump,
|
||||||
listDumps,
|
listDumps,
|
||||||
refreshDumpMetadata,
|
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) => {
|
router.get("/:dumpId", async (ctx) => {
|
||||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import {
|
import {
|
||||||
fetchRichContent,
|
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
|
tryFetchRichContent,
|
||||||
} from "../services/rich-content-service.ts";
|
} from "../services/rich-content-service.ts";
|
||||||
import { APIErrorCode } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../model/interfaces.ts";
|
||||||
|
|
||||||
@@ -18,8 +18,15 @@ previewRouter.get("/api/preview", async (ctx) => {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await fetchRichContent(url);
|
// `reached` is reported separately because a failed fetch still yields a
|
||||||
ctx.response.body = { success: true, data: data ?? null };
|
// 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,
|
APIException,
|
||||||
type CreateUrlDumpRequest,
|
type CreateUrlDumpRequest,
|
||||||
type Dump,
|
type Dump,
|
||||||
|
type DumpUrlMatch,
|
||||||
type UpdateDumpRequest,
|
type UpdateDumpRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
notifyUserFollowersNewDump,
|
notifyUserFollowersNewDump,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||||
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||||
import {
|
import {
|
||||||
DUMP_ALLOWED_MIME_PREFIXES,
|
DUMP_ALLOWED_MIME_PREFIXES,
|
||||||
DUMP_ALLOWED_MIME_TYPES,
|
DUMP_ALLOWED_MIME_TYPES,
|
||||||
@@ -63,13 +65,16 @@ export async function createUrlDump(
|
|||||||
const dumpId = crypto.randomUUID();
|
const dumpId = crypto.randomUUID();
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
const richContent = await fetchRichContent(request.url);
|
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 isPrivate = request.isPrivate ?? false;
|
||||||
const slug = makeSlug(title, dumpId);
|
const slug = makeSlug(title, dumpId);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private)
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
"url",
|
"url",
|
||||||
@@ -79,6 +84,7 @@ export async function createUrlDump(
|
|||||||
userId,
|
userId,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
request.url,
|
request.url,
|
||||||
|
canonicalizeUrl(request.url),
|
||||||
richContent ? JSON.stringify(richContent) : null,
|
richContent ? JSON.stringify(richContent) : null,
|
||||||
isPrivate ? 1 : 0,
|
isPrivate ? 1 : 0,
|
||||||
);
|
);
|
||||||
@@ -294,6 +300,54 @@ export function listDumps(
|
|||||||
return { items, total: totalRow?.count ?? 0 };
|
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(
|
export async function updateDump(
|
||||||
dumpId: string,
|
dumpId: string,
|
||||||
request: UpdateDumpRequest,
|
request: UpdateDumpRequest,
|
||||||
@@ -387,12 +441,13 @@ export async function updateDump(
|
|||||||
|
|
||||||
const row = dumpApiToRow(updatedDump);
|
const row = dumpApiToRow(updatedDump);
|
||||||
const result = db.prepare(
|
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(
|
).run(
|
||||||
row.title,
|
row.title,
|
||||||
row.slug,
|
row.slug,
|
||||||
row.comment,
|
row.comment,
|
||||||
row.url,
|
row.url,
|
||||||
|
row.url ? canonicalizeUrl(row.url) : null,
|
||||||
row.rich_content,
|
row.rich_content,
|
||||||
row.is_private,
|
row.is_private,
|
||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
|
|||||||
251
src/App.css
251
src/App.css
@@ -438,6 +438,222 @@
|
|||||||
font-weight: 700;
|
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 ── */
|
/* ── Mode toggle — segmented control ── */
|
||||||
.visibility-toggle {
|
.visibility-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -592,6 +808,41 @@
|
|||||||
background: var(--color-border-subtle);
|
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 file / URL preview (DumpCreate) ── */
|
||||||
.local-preview-image {
|
.local-preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -4,6 +4,7 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
Routes,
|
Routes,
|
||||||
|
useLocation,
|
||||||
useParams,
|
useParams,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
@@ -85,14 +86,21 @@ function useResolvedDefaultTab() {
|
|||||||
return preferredTab === "followed" && !user ? "hot" : preferredTab;
|
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() {
|
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.
|
// Bare `/<slug>` lands on that category's default feed tab.
|
||||||
function CategoryRedirect() {
|
function CategoryRedirect() {
|
||||||
const { categorySlug } = useParams();
|
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.
|
// 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 { createPortal } from "react-dom";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
@@ -7,11 +7,24 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
wide?: boolean;
|
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 backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
if (onBeforeClose?.() === false) return;
|
||||||
|
onClose();
|
||||||
|
}, [onBeforeClose, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
@@ -21,18 +34,18 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
if (e.key === "Escape" && !e.defaultPrevented) requestClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handler);
|
document.addEventListener("keydown", handler);
|
||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [onClose]);
|
}, [requestClose]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal-backdrop"
|
||||||
ref={backdropRef}
|
ref={backdropRef}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === backdropRef.current) onClose();
|
if (e.target === backdropRef.current) requestClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
||||||
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="modal-close-btn"
|
className="modal-close-btn"
|
||||||
onClick={onClose}
|
onClick={requestClose}
|
||||||
aria-label={t`Close`}
|
aria-label={t`Close`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
@@ -64,14 +64,17 @@ export function SegmentedField<T extends FieldValues, V>({
|
|||||||
*/
|
*/
|
||||||
export function VisibilityToggle<T extends FieldValues>({
|
export function VisibilityToggle<T extends FieldValues>({
|
||||||
name,
|
name,
|
||||||
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
name: Path<T>;
|
name: Path<T>;
|
||||||
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SegmentedField<T, boolean>
|
<SegmentedField<T, boolean>
|
||||||
name={name}
|
name={name}
|
||||||
|
label={label}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
options={[
|
options={[
|
||||||
{ value: true, label: <Trans>Public</Trans> },
|
{ value: true, label: <Trans>Public</Trans> },
|
||||||
|
|||||||
@@ -59,22 +59,77 @@ export const useAuth = () => {
|
|||||||
return res;
|
return res;
|
||||||
}, [authResponse?.token, logout]);
|
}, [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 {
|
return {
|
||||||
user: authResponse?.user ?? null,
|
user: authResponse?.user ?? null,
|
||||||
token: authResponse?.token ?? null,
|
token: authResponse?.token ?? null,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
authFetch,
|
authFetch,
|
||||||
|
authUpload,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRequiredAuth = () => {
|
export const useRequiredAuth = () => {
|
||||||
const { user, token, login, logout, authFetch } = useAuth();
|
const { user, token, login, logout, authFetch, authUpload } = useAuth();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invariant: useRequiredAuth called outside a protected route",
|
"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
|
#. placeholder {0}: dump.commentCount
|
||||||
#: src/components/DumpCard.tsx:112
|
#: src/components/DumpCard.tsx:112
|
||||||
#: src/components/JournalCard.tsx:107
|
#: src/components/JournalCard.tsx:114
|
||||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
|
|||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{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 {0}: names[0]
|
||||||
#. placeholder {1}: names[1]
|
#. placeholder {1}: names[1]
|
||||||
#: src/components/ChatModal.tsx:531
|
#: src/components/ChatModal.tsx:531
|
||||||
@@ -53,6 +58,7 @@ msgstr "{label} ({count})"
|
|||||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:935
|
||||||
#: src/pages/PlaylistDetail.tsx:570
|
#: src/pages/PlaylistDetail.tsx:570
|
||||||
#: src/pages/UserPublicProfile.tsx:749
|
#: src/pages/UserPublicProfile.tsx:749
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
@@ -154,7 +160,7 @@ msgid "Add email…"
|
|||||||
msgstr "Add email…"
|
msgstr "Add email…"
|
||||||
|
|
||||||
#: src/components/AddToPlaylistModal.tsx:64
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
msgid "Add to playlist"
|
msgid "Add to playlist"
|
||||||
msgstr "Add to playlist"
|
msgstr "Add to playlist"
|
||||||
|
|
||||||
@@ -167,6 +173,12 @@ msgstr "Admin"
|
|||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "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
|
#: src/pages/UserRegister.tsx:156
|
||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Already have an account? <0>Log in</0>"
|
msgstr "Already have an account? <0>Log in</0>"
|
||||||
@@ -201,6 +213,7 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
|||||||
#: src/components/ChatModal.tsx:229
|
#: src/components/ChatModal.tsx:229
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
|
#: src/components/DumpCreateModal.tsx:945
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:404
|
#: src/pages/Dump.tsx:404
|
||||||
#: src/pages/DumpEdit.tsx:469
|
#: src/pages/DumpEdit.tsx:469
|
||||||
@@ -262,14 +275,22 @@ msgid "Checking invite…"
|
|||||||
msgstr "Checking invite…"
|
msgstr "Checking invite…"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:56
|
#: src/components/ChangePasswordModal.tsx:56
|
||||||
#: src/components/Modal.tsx:45
|
#: src/components/Modal.tsx:58
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Close"
|
msgstr "Close"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:690
|
||||||
|
msgid "Close anyway"
|
||||||
|
msgstr "Close anyway"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:184
|
#: src/components/GlobalPlayer.tsx:184
|
||||||
msgid "Close player"
|
msgid "Close player"
|
||||||
msgstr "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
|
#: src/components/GlobalPlayer.tsx:176
|
||||||
msgid "Collapse player"
|
msgid "Collapse player"
|
||||||
msgstr "Collapse player"
|
msgstr "Collapse player"
|
||||||
@@ -303,6 +324,10 @@ msgstr "Could not load."
|
|||||||
msgid "Could not save"
|
msgid "Could not save"
|
||||||
msgstr "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
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Create"
|
msgstr "Create"
|
||||||
@@ -391,7 +416,7 @@ msgstr "deleted message"
|
|||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (optional)"
|
msgstr "Description (optional)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:488
|
#: src/components/DumpCreateModal.tsx:741
|
||||||
msgid "Done"
|
msgid "Done"
|
||||||
msgstr "Done"
|
msgstr "Done"
|
||||||
|
|
||||||
@@ -399,6 +424,10 @@ msgstr "Done"
|
|||||||
msgid "Drop a file here"
|
msgid "Drop a file here"
|
||||||
msgstr "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
|
#: src/pages/DumpEdit.tsx:437
|
||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
@@ -407,11 +436,11 @@ msgstr "Drop a replacement here"
|
|||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Dump"
|
msgstr "Dump"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:458
|
#: src/components/DumpCreateModal.tsx:958
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Dump it"
|
msgstr "Dump it"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:469
|
#: src/components/DumpCreateModal.tsx:718
|
||||||
msgid "Dumped!"
|
msgid "Dumped!"
|
||||||
msgstr "Dumped!"
|
msgstr "Dumped!"
|
||||||
|
|
||||||
@@ -444,7 +473,7 @@ msgstr "Edit"
|
|||||||
msgid "Edit {0}"
|
msgid "Edit {0}"
|
||||||
msgstr "Edit {0}"
|
msgstr "Edit {0}"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:420
|
#: src/components/DumpCreateModal.tsx:197
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Edit title"
|
msgstr "Edit title"
|
||||||
|
|
||||||
@@ -518,7 +547,7 @@ msgstr "Failed to generate invite"
|
|||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Failed to load"
|
msgstr "Failed to load"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:337
|
#: src/components/DumpCreateModal.tsx:750
|
||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Failed to post"
|
msgstr "Failed to post"
|
||||||
|
|
||||||
@@ -556,19 +585,11 @@ msgstr "Failed to update role"
|
|||||||
msgid "Feeds"
|
msgid "Feeds"
|
||||||
msgstr "Feeds"
|
msgstr "Feeds"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:369
|
#: src/components/DumpCreateModal.tsx:789
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Fetching preview…"
|
msgstr "Fetching preview…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:455
|
#: src/components/DumpCreateModal.tsx:571
|
||||||
msgid "Fetching…"
|
|
||||||
msgstr "Fetching…"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:331
|
|
||||||
msgid "File"
|
|
||||||
msgstr "File"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:239
|
|
||||||
msgid "File too large (max 50 MB)."
|
msgid "File too large (max 50 MB)."
|
||||||
msgstr "File too large (max 50 MB)."
|
msgstr "File too large (max 50 MB)."
|
||||||
|
|
||||||
@@ -670,6 +691,10 @@ msgstr "Invitees"
|
|||||||
msgid "Journal"
|
msgid "Journal"
|
||||||
msgstr "Journal"
|
msgstr "Journal"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:687
|
||||||
|
msgid "Keep editing"
|
||||||
|
msgstr "Keep editing"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1205
|
#: src/pages/UserPublicProfile.tsx:1205
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Light"
|
msgstr "Light"
|
||||||
@@ -678,6 +703,14 @@ msgstr "Light"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "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
|
#: src/contexts/WSProvider.tsx:575
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
@@ -795,7 +828,7 @@ msgstr "new"
|
|||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
#: src/components/DumpFab.tsx:65
|
#: src/components/DumpFab.tsx:65
|
||||||
#: src/components/DumpFab.tsx:66
|
#: src/components/DumpFab.tsx:66
|
||||||
#: src/pages/UserDumps.tsx:88
|
#: src/pages/UserDumps.tsx:88
|
||||||
@@ -813,6 +846,10 @@ msgstr "New password"
|
|||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "New playlist"
|
msgstr "New playlist"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:957
|
||||||
|
msgid "Next →"
|
||||||
|
msgstr "Next →"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:166
|
#: src/components/GlobalPlayer.tsx:166
|
||||||
msgid "Next track"
|
msgid "Next track"
|
||||||
msgstr "Next track"
|
msgstr "Next track"
|
||||||
@@ -923,11 +960,20 @@ msgstr "Password updated"
|
|||||||
msgid "Passwords do not match"
|
msgid "Passwords do not match"
|
||||||
msgstr "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
|
#: src/pages/PlaylistDetail.tsx:863
|
||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Playlist title"
|
msgstr "Playlist title"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:86
|
#: src/components/AppHeader.tsx:86
|
||||||
|
#: src/components/DumpCreateModal.tsx:68
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPlaylists.tsx:371
|
#: src/pages/UserPlaylists.tsx:371
|
||||||
@@ -941,10 +987,6 @@ msgstr "Playlists"
|
|||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "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
|
#: src/components/CommentThread.tsx:472
|
||||||
msgid "Post comment"
|
msgid "Post comment"
|
||||||
msgstr "Post comment"
|
msgstr "Post comment"
|
||||||
@@ -955,6 +997,7 @@ msgstr "Post reply"
|
|||||||
|
|
||||||
#: src/components/CommentThread.tsx:396
|
#: src/components/CommentThread.tsx:396
|
||||||
#: src/components/CommentThread.tsx:473
|
#: src/components/CommentThread.tsx:473
|
||||||
|
#: src/components/DumpCreateModal.tsx:951
|
||||||
msgid "Posting…"
|
msgid "Posting…"
|
||||||
msgstr "Posting…"
|
msgstr "Posting…"
|
||||||
|
|
||||||
@@ -963,7 +1006,7 @@ msgid "Previous track"
|
|||||||
msgstr "Previous track"
|
msgstr "Previous track"
|
||||||
|
|
||||||
#: src/components/DumpCard.tsx:121
|
#: src/components/DumpCard.tsx:121
|
||||||
#: src/components/JournalCard.tsx:116
|
#: src/components/JournalCard.tsx:123
|
||||||
#: src/components/PlaylistCard.tsx:73
|
#: src/components/PlaylistCard.tsx:73
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||||
#: src/pages/Dump.tsx:463
|
#: src/pages/Dump.tsx:463
|
||||||
@@ -971,7 +1014,7 @@ msgstr "Previous track"
|
|||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "private"
|
msgstr "private"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:78
|
#: src/components/form/SegmentedField.tsx:81
|
||||||
#: src/pages/PlaylistDetail.tsx:902
|
#: src/pages/PlaylistDetail.tsx:902
|
||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Private"
|
msgstr "Private"
|
||||||
@@ -981,7 +1024,7 @@ msgstr "Private"
|
|||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:77
|
#: src/components/form/SegmentedField.tsx:80
|
||||||
#: src/pages/PlaylistDetail.tsx:895
|
#: src/pages/PlaylistDetail.tsx:895
|
||||||
msgid "Public"
|
msgid "Public"
|
||||||
msgstr "Public"
|
msgstr "Public"
|
||||||
@@ -1063,6 +1106,11 @@ msgstr "Reset password"
|
|||||||
msgid "Reset to default"
|
msgid "Reset to default"
|
||||||
msgstr "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/Dump.tsx:285
|
||||||
#: src/pages/DumpEdit.tsx:180
|
#: src/pages/DumpEdit.tsx:180
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
@@ -1148,6 +1196,10 @@ msgstr "slug"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:706
|
||||||
|
msgid "Start fresh"
|
||||||
|
msgstr "Start fresh"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1155
|
#: src/pages/UserPublicProfile.tsx:1155
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
@@ -1180,13 +1232,13 @@ msgstr "This reset link is missing or malformed."
|
|||||||
msgid "Thumbnail"
|
msgid "Thumbnail"
|
||||||
msgstr "Thumbnail"
|
msgstr "Thumbnail"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:389
|
#: src/components/DumpCreateModal.tsx:174
|
||||||
#: src/components/PlaylistCreateForm.tsx:70
|
#: src/components/PlaylistCreateForm.tsx:70
|
||||||
#: src/pages/DumpEdit.tsx:398
|
#: src/pages/DumpEdit.tsx:398
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr "Title"
|
msgstr "Title"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:237
|
#: src/components/DumpCreateModal.tsx:569
|
||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Title is required."
|
msgstr "Title is required."
|
||||||
|
|
||||||
@@ -1215,7 +1267,12 @@ msgstr "Unfollow playlist"
|
|||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "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…"
|
msgid "Uploading…"
|
||||||
msgstr "Uploading…"
|
msgstr "Uploading…"
|
||||||
|
|
||||||
@@ -1233,12 +1290,11 @@ msgstr "Upvoted"
|
|||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Upvoted ({0}{1})"
|
msgstr "Upvoted ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:344
|
|
||||||
#: src/pages/DumpEdit.tsx:421
|
#: src/pages/DumpEdit.tsx:421
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:223
|
#: src/components/DumpCreateModal.tsx:553
|
||||||
msgid "URL is required."
|
msgid "URL is required."
|
||||||
msgstr "URL is required."
|
msgstr "URL is required."
|
||||||
|
|
||||||
@@ -1275,11 +1331,11 @@ msgstr "Users"
|
|||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "View all →"
|
msgstr "View all →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:471
|
#: src/components/DumpCreateModal.tsx:720
|
||||||
msgid "View dump →"
|
msgid "View dump →"
|
||||||
msgstr "View dump →"
|
msgstr "View dump →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:434
|
#: src/components/DumpCreateModal.tsx:904
|
||||||
#: src/pages/DumpEdit.tsx:446
|
#: src/pages/DumpEdit.tsx:446
|
||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "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?"
|
msgid "Who am I?"
|
||||||
msgstr "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
|
#: src/pages/DumpEdit.tsx:445
|
||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Why?"
|
msgstr "Why?"
|
||||||
@@ -1303,6 +1367,10 @@ msgstr "Write a reply…"
|
|||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "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
|
#: src/pages/Notifications.tsx:384
|
||||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ msgstr "[supprimé]"
|
|||||||
|
|
||||||
#. placeholder {0}: dump.commentCount
|
#. placeholder {0}: dump.commentCount
|
||||||
#: src/components/DumpCard.tsx:112
|
#: src/components/DumpCard.tsx:112
|
||||||
#: src/components/JournalCard.tsx:107
|
#: src/components/JournalCard.tsx:114
|
||||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
|||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
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 {0}: names[0]
|
||||||
#. placeholder {1}: names[1]
|
#. placeholder {1}: names[1]
|
||||||
#: src/components/ChatModal.tsx:531
|
#: src/components/ChatModal.tsx:531
|
||||||
@@ -53,6 +58,7 @@ msgstr "{label} ({count})"
|
|||||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:935
|
||||||
#: src/pages/PlaylistDetail.tsx:570
|
#: src/pages/PlaylistDetail.tsx:570
|
||||||
#: src/pages/UserPublicProfile.tsx:749
|
#: src/pages/UserPublicProfile.tsx:749
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
@@ -154,7 +160,7 @@ msgid "Add email…"
|
|||||||
msgstr "Ajouter un e-mail…"
|
msgstr "Ajouter un e-mail…"
|
||||||
|
|
||||||
#: src/components/AddToPlaylistModal.tsx:64
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
msgid "Add to playlist"
|
msgid "Add to playlist"
|
||||||
msgstr "Ajouter à la collection"
|
msgstr "Ajouter à la collection"
|
||||||
|
|
||||||
@@ -167,6 +173,12 @@ msgstr "Administrateur"
|
|||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "Tout"
|
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
|
#: src/pages/UserRegister.tsx:156
|
||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||||
@@ -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/ChatModal.tsx:229
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
|
#: src/components/DumpCreateModal.tsx:945
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:404
|
#: src/pages/Dump.tsx:404
|
||||||
#: src/pages/DumpEdit.tsx:469
|
#: src/pages/DumpEdit.tsx:469
|
||||||
@@ -262,14 +275,22 @@ msgid "Checking invite…"
|
|||||||
msgstr "Vérification de l'invitation…"
|
msgstr "Vérification de l'invitation…"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:56
|
#: src/components/ChangePasswordModal.tsx:56
|
||||||
#: src/components/Modal.tsx:45
|
#: src/components/Modal.tsx:58
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:690
|
||||||
|
msgid "Close anyway"
|
||||||
|
msgstr "Fermer quand même"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:184
|
#: src/components/GlobalPlayer.tsx:184
|
||||||
msgid "Close player"
|
msgid "Close player"
|
||||||
msgstr "Fermer le lecteur"
|
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
|
#: src/components/GlobalPlayer.tsx:176
|
||||||
msgid "Collapse player"
|
msgid "Collapse player"
|
||||||
msgstr "Réduire le lecteur"
|
msgstr "Réduire le lecteur"
|
||||||
@@ -303,6 +324,10 @@ msgstr "Impossible de charger."
|
|||||||
msgid "Could not save"
|
msgid "Could not save"
|
||||||
msgstr "Sauvegarde impossible"
|
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
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Créer"
|
msgstr "Créer"
|
||||||
@@ -391,7 +416,7 @@ msgstr "message supprimé"
|
|||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (facultatif)"
|
msgstr "Description (facultatif)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:488
|
#: src/components/DumpCreateModal.tsx:741
|
||||||
msgid "Done"
|
msgid "Done"
|
||||||
msgstr "Terminé"
|
msgstr "Terminé"
|
||||||
|
|
||||||
@@ -399,6 +424,10 @@ msgstr "Terminé"
|
|||||||
msgid "Drop a file here"
|
msgid "Drop a file here"
|
||||||
msgstr "Déposez un fichier ici"
|
msgstr "Déposez un fichier ici"
|
||||||
|
|
||||||
|
#: src/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
|
#: src/pages/DumpEdit.tsx:437
|
||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Déposez un fichier de remplacement ici"
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
@@ -407,11 +436,11 @@ msgstr "Déposez un fichier de remplacement ici"
|
|||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Reco"
|
msgstr "Reco"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:458
|
#: src/components/DumpCreateModal.tsx:958
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Recommander"
|
msgstr "Recommander"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:469
|
#: src/components/DumpCreateModal.tsx:718
|
||||||
msgid "Dumped!"
|
msgid "Dumped!"
|
||||||
msgstr "Recommandé !"
|
msgstr "Recommandé !"
|
||||||
|
|
||||||
@@ -444,7 +473,7 @@ msgstr "Modifier"
|
|||||||
msgid "Edit {0}"
|
msgid "Edit {0}"
|
||||||
msgstr "Modifier {0}"
|
msgstr "Modifier {0}"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:420
|
#: src/components/DumpCreateModal.tsx:197
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Modifier le titre"
|
msgstr "Modifier le titre"
|
||||||
|
|
||||||
@@ -518,7 +547,7 @@ msgstr "Impossible de générer une invitation"
|
|||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Chargement échoué"
|
msgstr "Chargement échoué"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:337
|
#: src/components/DumpCreateModal.tsx:750
|
||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Publication échouée"
|
msgstr "Publication échouée"
|
||||||
|
|
||||||
@@ -556,19 +585,11 @@ msgstr "Erreur lors de la mise à jour du rôle"
|
|||||||
msgid "Feeds"
|
msgid "Feeds"
|
||||||
msgstr "Flux"
|
msgstr "Flux"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:369
|
#: src/components/DumpCreateModal.tsx:789
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Récupération de l'aperçu…"
|
msgstr "Récupération de l'aperçu…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:455
|
#: src/components/DumpCreateModal.tsx:571
|
||||||
msgid "Fetching…"
|
|
||||||
msgstr "Récupération…"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:331
|
|
||||||
msgid "File"
|
|
||||||
msgstr "Fichier"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:239
|
|
||||||
msgid "File too large (max 50 MB)."
|
msgid "File too large (max 50 MB)."
|
||||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||||
|
|
||||||
@@ -670,6 +691,10 @@ msgstr "Invités"
|
|||||||
msgid "Journal"
|
msgid "Journal"
|
||||||
msgstr "Journal"
|
msgstr "Journal"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:687
|
||||||
|
msgid "Keep editing"
|
||||||
|
msgstr "Continuer l'édition"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1205
|
#: src/pages/UserPublicProfile.tsx:1205
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Clair"
|
msgstr "Clair"
|
||||||
@@ -678,6 +703,14 @@ msgstr "Clair"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Aimer"
|
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
|
#: src/contexts/WSProvider.tsx:575
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
@@ -795,7 +828,7 @@ msgstr "nouveau"
|
|||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
#: src/components/DumpFab.tsx:65
|
#: src/components/DumpFab.tsx:65
|
||||||
#: src/components/DumpFab.tsx:66
|
#: src/components/DumpFab.tsx:66
|
||||||
#: src/pages/UserDumps.tsx:88
|
#: src/pages/UserDumps.tsx:88
|
||||||
@@ -813,6 +846,10 @@ msgstr "Nouveau mot de passe"
|
|||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "Nouvelle collection"
|
msgstr "Nouvelle collection"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:957
|
||||||
|
msgid "Next →"
|
||||||
|
msgstr "Suivant →"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:166
|
#: src/components/GlobalPlayer.tsx:166
|
||||||
msgid "Next track"
|
msgid "Next track"
|
||||||
msgstr "Piste suivante"
|
msgstr "Piste suivante"
|
||||||
@@ -923,11 +960,20 @@ msgstr "Mot de passe mis à jour"
|
|||||||
msgid "Passwords do not match"
|
msgid "Passwords do not match"
|
||||||
msgstr "Les mots de passe ne correspondent pas"
|
msgstr "Les mots de passe ne correspondent pas"
|
||||||
|
|
||||||
|
#: src/components/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
|
#: src/pages/PlaylistDetail.tsx:863
|
||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Titre de la collection"
|
msgstr "Titre de la collection"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:86
|
#: src/components/AppHeader.tsx:86
|
||||||
|
#: src/components/DumpCreateModal.tsx:68
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPlaylists.tsx:371
|
#: src/pages/UserPlaylists.tsx:371
|
||||||
@@ -941,10 +987,6 @@ msgstr "Collections"
|
|||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Collections ({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
|
#: src/components/CommentThread.tsx:472
|
||||||
msgid "Post comment"
|
msgid "Post comment"
|
||||||
msgstr "Publier le commentaire"
|
msgstr "Publier le commentaire"
|
||||||
@@ -955,6 +997,7 @@ msgstr "Publier la réponse"
|
|||||||
|
|
||||||
#: src/components/CommentThread.tsx:396
|
#: src/components/CommentThread.tsx:396
|
||||||
#: src/components/CommentThread.tsx:473
|
#: src/components/CommentThread.tsx:473
|
||||||
|
#: src/components/DumpCreateModal.tsx:951
|
||||||
msgid "Posting…"
|
msgid "Posting…"
|
||||||
msgstr "Publication…"
|
msgstr "Publication…"
|
||||||
|
|
||||||
@@ -963,7 +1006,7 @@ msgid "Previous track"
|
|||||||
msgstr "Piste précédente"
|
msgstr "Piste précédente"
|
||||||
|
|
||||||
#: src/components/DumpCard.tsx:121
|
#: src/components/DumpCard.tsx:121
|
||||||
#: src/components/JournalCard.tsx:116
|
#: src/components/JournalCard.tsx:123
|
||||||
#: src/components/PlaylistCard.tsx:73
|
#: src/components/PlaylistCard.tsx:73
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||||
#: src/pages/Dump.tsx:463
|
#: src/pages/Dump.tsx:463
|
||||||
@@ -971,7 +1014,7 @@ msgstr "Piste précédente"
|
|||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:78
|
#: src/components/form/SegmentedField.tsx:81
|
||||||
#: src/pages/PlaylistDetail.tsx:902
|
#: src/pages/PlaylistDetail.tsx:902
|
||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Privé"
|
msgstr "Privé"
|
||||||
@@ -981,7 +1024,7 @@ msgstr "Privé"
|
|||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:77
|
#: src/components/form/SegmentedField.tsx:80
|
||||||
#: src/pages/PlaylistDetail.tsx:895
|
#: src/pages/PlaylistDetail.tsx:895
|
||||||
msgid "Public"
|
msgid "Public"
|
||||||
msgstr "Public"
|
msgstr "Public"
|
||||||
@@ -1063,6 +1106,11 @@ msgstr "Réinitialiser le mot de passe"
|
|||||||
msgid "Reset to default"
|
msgid "Reset to default"
|
||||||
msgstr "Réinitialiser par défaut"
|
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/Dump.tsx:285
|
||||||
#: src/pages/DumpEdit.tsx:180
|
#: src/pages/DumpEdit.tsx:180
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
@@ -1148,6 +1196,10 @@ msgstr "identifiant"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:706
|
||||||
|
msgid "Start fresh"
|
||||||
|
msgstr "Repartir de zéro"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1155
|
#: src/pages/UserPublicProfile.tsx:1155
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
@@ -1180,13 +1232,13 @@ msgstr "Ce lien de réinitialisation est absent ou malformé."
|
|||||||
msgid "Thumbnail"
|
msgid "Thumbnail"
|
||||||
msgstr "Miniature"
|
msgstr "Miniature"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:389
|
#: src/components/DumpCreateModal.tsx:174
|
||||||
#: src/components/PlaylistCreateForm.tsx:70
|
#: src/components/PlaylistCreateForm.tsx:70
|
||||||
#: src/pages/DumpEdit.tsx:398
|
#: src/pages/DumpEdit.tsx:398
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:237
|
#: src/components/DumpCreateModal.tsx:569
|
||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Un titre est requis."
|
msgstr "Un titre est requis."
|
||||||
|
|
||||||
@@ -1215,7 +1267,12 @@ msgstr "Ne plus suivre la collection"
|
|||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Envoi échoué"
|
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…"
|
msgid "Uploading…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
@@ -1233,12 +1290,11 @@ msgstr "Voté"
|
|||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Votés ({0}{1})"
|
msgstr "Votés ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:344
|
|
||||||
#: src/pages/DumpEdit.tsx:421
|
#: src/pages/DumpEdit.tsx:421
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:223
|
#: src/components/DumpCreateModal.tsx:553
|
||||||
msgid "URL is required."
|
msgid "URL is required."
|
||||||
msgstr "L'URL est obligatoire."
|
msgstr "L'URL est obligatoire."
|
||||||
|
|
||||||
@@ -1275,11 +1331,11 @@ msgstr "Utilisateurs"
|
|||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "Tout voir →"
|
msgstr "Tout voir →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:471
|
#: src/components/DumpCreateModal.tsx:720
|
||||||
msgid "View dump →"
|
msgid "View dump →"
|
||||||
msgstr "Voir la reco →"
|
msgstr "Voir la reco →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:434
|
#: src/components/DumpCreateModal.tsx:904
|
||||||
#: src/pages/DumpEdit.tsx:446
|
#: src/pages/DumpEdit.tsx:446
|
||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "Pourquoi on en voudrait ?"
|
msgstr "Pourquoi on en voudrait ?"
|
||||||
@@ -1289,7 +1345,15 @@ msgstr "Pourquoi on en voudrait ?"
|
|||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Qui suis-je ?"
|
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
|
#: src/pages/DumpEdit.tsx:445
|
||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Pourquoi ?"
|
msgstr "Pourquoi ?"
|
||||||
@@ -1303,6 +1367,10 @@ msgstr "Écrire une réponse…"
|
|||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Hier"
|
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
|
#: src/pages/Notifications.tsx:384
|
||||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||||
|
|||||||
28
src/model.ts
28
src/model.ts
@@ -74,6 +74,32 @@ export interface Dump {
|
|||||||
|
|
||||||
export type RawDump = WithStringDate<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 {
|
export function deserializeDump(raw: RawDump): Dump {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
@@ -671,6 +697,8 @@ export interface RegisterRequest {
|
|||||||
|
|
||||||
export interface CreateUrlDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||||
|
title?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
categoryIds?: string[];
|
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`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
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