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

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:
khannurien
2026-09-08 14:40:37 +00:00
parent d76154d15d
commit fb8364e24d
21 changed files with 1688 additions and 304 deletions

View File

@@ -59,22 +59,77 @@ export const useAuth = () => {
return res;
}, [authResponse?.token, logout]);
/**
* `authFetch` for multipart uploads that need a progress bar.
*
* `fetch` reports nothing until the whole body is on the wire, which for a
* 50 MB dump on a phone is a long silence — so this goes through XHR, whose
* `upload.progress` events fire as bytes leave. The result is wrapped back
* into a real `Response` so callers keep using `expectOk` unchanged.
*
* `onProgress` receives a 0–1 fraction, or `null` once the body is fully sent
* and we are waiting on the server (the length is unknown for chunked bodies,
* and "100%, still waiting" reads as a stall).
*/
const authUpload = useCallback((
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method = "POST",
): Promise<Response> => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
return Promise.resolve(new Response(null, { status: 401 }));
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
// Content-Type is left to the browser so the multipart boundary is set.
xhr.upload.addEventListener("progress", (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : null);
});
xhr.upload.addEventListener("load", () => onProgress(null));
xhr.addEventListener("load", () => {
if (xhr.status === 401) logout();
resolve(
new Response(xhr.responseText, {
status: xhr.status,
statusText: xhr.statusText,
}),
);
});
// Mirrors what `fetch` throws on a network failure, so the shared
// `friendlyFetchError` path renders it the same way.
xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch")));
xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch")));
xhr.send(body);
});
}, [authResponse?.token, logout]);
return {
user: authResponse?.user ?? null,
token: authResponse?.token ?? null,
login,
logout,
authFetch,
authUpload,
};
};
export const useRequiredAuth = () => {
const { user, token, login, logout, authFetch } = useAuth();
const { user, token, login, logout, authFetch, authUpload } = useAuth();
if (!user) {
throw new Error(
"Invariant: useRequiredAuth called outside a protected route",
);
}
return { user, token, login, logout, authFetch };
return { user, token, login, logout, authFetch, authUpload };
};