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
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
/**
|
|
* 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 */ }
|
|
}
|