Files
gerbeur/src/utils/dumpDraft.ts
khannurien fb8364e24d
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
v3: reworked posting — three panels, drafts, duplicate detection, upload progress
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
2026-09-08 14:40:37 +00:00

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 */ }
}