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:
85
src/utils/dumpDraft.ts
Normal file
85
src/utils/dumpDraft.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Persistence for the half-written dump in the create modal.
|
||||
*
|
||||
* The modal is dismissable by Escape, a backdrop click and the ✕, and it holds
|
||||
* a URL, a title, a rich-text "why", categories and a visibility choice — so a
|
||||
* stray keystroke used to destroy a few minutes of writing. Everything except
|
||||
* the attached file (a `File` cannot survive a reload) is mirrored here on each
|
||||
* change and restored the next time the modal opens; a successful post clears
|
||||
* it.
|
||||
*
|
||||
* Deliberately a single draft rather than one per tab: someone who closed the
|
||||
* modal by accident wants the thing they were just writing, wherever they
|
||||
* reopen it.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "gerbeur.dumpDraft";
|
||||
|
||||
export interface DumpDraft {
|
||||
url: string;
|
||||
title: string;
|
||||
/** Whether `title` was typed by the poster, rather than taken from the preview. */
|
||||
titleFromUser: boolean;
|
||||
comment: string;
|
||||
isPublic: boolean;
|
||||
categoryIds: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_DUMP_DRAFT: DumpDraft = {
|
||||
url: "",
|
||||
title: "",
|
||||
titleFromUser: false,
|
||||
comment: "",
|
||||
isPublic: true,
|
||||
categoryIds: [],
|
||||
};
|
||||
|
||||
/** Whether a draft holds anything worth restoring or warning about. */
|
||||
export function isDumpDraftEmpty(draft: DumpDraft): boolean {
|
||||
return !draft.url.trim() && !draft.comment.trim() &&
|
||||
!(draft.titleFromUser && draft.title.trim()) &&
|
||||
draft.categoryIds.length === 0 && draft.isPublic;
|
||||
}
|
||||
|
||||
export function loadDumpDraft(): DumpDraft | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null; // Storage disabled (private mode, blocked cookies) — no drafts.
|
||||
}
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<DumpDraft>;
|
||||
const draft: DumpDraft = {
|
||||
url: typeof parsed.url === "string" ? parsed.url : "",
|
||||
title: typeof parsed.title === "string" ? parsed.title : "",
|
||||
titleFromUser: parsed.titleFromUser === true,
|
||||
comment: typeof parsed.comment === "string" ? parsed.comment : "",
|
||||
isPublic: parsed.isPublic !== false,
|
||||
categoryIds: Array.isArray(parsed.categoryIds)
|
||||
? parsed.categoryIds.filter((id): id is string => typeof id === "string")
|
||||
: [],
|
||||
};
|
||||
return isDumpDraftEmpty(draft) ? null : draft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDumpDraft(draft: DumpDraft): void {
|
||||
try {
|
||||
if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
|
||||
} catch {
|
||||
// Storage full or unavailable — the draft is a convenience, never a
|
||||
// precondition for posting, so a failure here is silent by design.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDumpDraft(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch { /* see saveDumpDraft */ }
|
||||
}
|
||||
@@ -3,3 +3,14 @@ export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's name as a readable dump title: the extension goes (it is noise in a
|
||||
* feed, and the dump already carries its MIME type), and separator runs become
|
||||
* spaces, so `holiday_photo-01.final.jpg` reads as `holiday photo-01.final`.
|
||||
* Names that are *only* an extension (`.gitignore`) are left alone.
|
||||
*/
|
||||
export function titleFromFileName(name: string): string {
|
||||
const withoutExt = name.replace(/(?!^)\.[^.]+$/, "");
|
||||
return withoutExt.replace(/_+/g, " ").trim() || name;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user