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:
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 { t } from "@lingui/core/macro";
|
||||
|
||||
@@ -7,11 +7,24 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
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 requestClose = useCallback(() => {
|
||||
if (onBeforeClose?.() === false) return;
|
||||
onClose();
|
||||
}, [onBeforeClose, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
@@ -21,18 +34,18 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
if (e.key === "Escape" && !e.defaultPrevented) requestClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
}, [requestClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
ref={backdropRef}
|
||||
onClick={(e) => {
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
if (e.target === backdropRef.current) requestClose();
|
||||
}}
|
||||
>
|
||||
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
||||
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-btn"
|
||||
onClick={onClose}
|
||||
onClick={requestClose}
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -64,14 +64,17 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
*/
|
||||
export function VisibilityToggle<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SegmentedField<T, boolean>
|
||||
name={name}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
|
||||
Reference in New Issue
Block a user