All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
970 lines
33 KiB
TypeScript
970 lines
33 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { UseFormRegisterReturn } from "react-hook-form";
|
|
import { Link } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Plural, Trans } from "@lingui/react/macro";
|
|
|
|
import { API_URL, VALIDATION } from "../config/api.ts";
|
|
import type {
|
|
CreateUrlDumpRequest,
|
|
Dump,
|
|
DumpUrlMatch,
|
|
PlaylistMembership,
|
|
RawDump,
|
|
RawDumpUrlMatch,
|
|
RawPlaylistMembership,
|
|
UrlPreviewResponse,
|
|
} from "../model.ts";
|
|
import {
|
|
deserializeDump,
|
|
deserializeDumpUrlMatch,
|
|
deserializePlaylistMembership,
|
|
} from "../model.ts";
|
|
import { useAuth } from "../hooks/useAuth.ts";
|
|
import { useWS } from "../hooks/useWS.ts";
|
|
import { dumpUrl, normalizeUrl } from "../utils/urls.ts";
|
|
import { relativeTime } from "../utils/relativeTime.ts";
|
|
import { titleFromFileName } from "../utils/format.ts";
|
|
import {
|
|
clearDumpDraft,
|
|
type DumpDraft,
|
|
EMPTY_DUMP_DRAFT,
|
|
loadDumpDraft,
|
|
saveDumpDraft,
|
|
} from "../utils/dumpDraft.ts";
|
|
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
|
import RichContentCard from "./RichContentCard.tsx";
|
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
|
import type { RichContent } from "../model.ts";
|
|
import { Modal } from "./Modal.tsx";
|
|
import type { TextEditorHandle } from "./TextEditor.tsx";
|
|
import { CategorySelect } from "./CategorySelect.tsx";
|
|
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
|
import {
|
|
expectOk,
|
|
FileField,
|
|
FormError,
|
|
FormProvider,
|
|
RichTextField,
|
|
SubmitButton,
|
|
useApiForm,
|
|
VisibilityToggle,
|
|
} from "./form/index.ts";
|
|
|
|
/**
|
|
* Posting a dump walks three panels. The split is deliberate: categories and
|
|
* visibility used to sit behind a disclosure, where they read as optional and
|
|
* went untouched — they are neither, so "why & where" is a stop on the way to
|
|
* posting rather than something to go looking for.
|
|
*/
|
|
type Step = "what" | "details" | "playlist";
|
|
|
|
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
|
|
const NO_DUPLICATES: DumpUrlMatch[] = [];
|
|
|
|
const STEPS: { id: Step; label: () => string }[] = [
|
|
{ id: "what", label: () => t`Link or file` },
|
|
{ id: "details", label: () => t`Why & where` },
|
|
{ id: "playlist", label: () => t`Playlists` },
|
|
];
|
|
|
|
/** Where you are in the three panels — and, past the first, how far you got. */
|
|
function StepIndicator({ current }: { current: Step }) {
|
|
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
|
|
|
return (
|
|
<ol className="dump-steps">
|
|
{STEPS.map((step, i) => {
|
|
const state = i < currentIndex
|
|
? "done"
|
|
: i === currentIndex
|
|
? "current"
|
|
: "todo";
|
|
return (
|
|
<li
|
|
key={step.id}
|
|
className={`dump-step dump-step--${state}`}
|
|
aria-current={state === "current" ? "step" : undefined}
|
|
>
|
|
<span className="dump-step-marker" aria-hidden="true">
|
|
{state === "done" ? "✓" : i + 1}
|
|
</span>
|
|
<span className="dump-step-label">{step.label()}</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
type UrlPreview =
|
|
| { status: "idle" }
|
|
| { status: "loading" }
|
|
| { status: "done"; reached: boolean; richContent: RichContent | null };
|
|
|
|
interface CreateValues {
|
|
url: string;
|
|
file: File | null;
|
|
title: string;
|
|
comment: string;
|
|
isPublic: boolean;
|
|
}
|
|
|
|
/**
|
|
* Whether the pointer is coarse — a touch device, where autofocusing the URL
|
|
* input raises a keyboard that covers most of the modal before the user has
|
|
* seen it.
|
|
*/
|
|
function prefersNoAutoFocus(): boolean {
|
|
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
|
|
}
|
|
|
|
function LocalFilePreview({ file }: { file: File }) {
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const url = URL.createObjectURL(file);
|
|
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setSrc(url);
|
|
return () => {
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
}, [file]);
|
|
|
|
if (!src) return null;
|
|
const mime = file.type;
|
|
if (mime.startsWith("image/")) {
|
|
return <img src={src} alt={file.name} className="local-preview-image" />;
|
|
}
|
|
if (mime.startsWith("video/")) {
|
|
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
|
}
|
|
if (mime.startsWith("audio/")) {
|
|
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
|
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
|
* which is enough to stop stray typing while keeping a correction one click
|
|
* away. Once edited, a reset offers the suggestion back.
|
|
*/
|
|
function TitleField({
|
|
registration,
|
|
editing,
|
|
onEdit,
|
|
onReset,
|
|
disabled,
|
|
}: {
|
|
registration: UseFormRegisterReturn<"title">;
|
|
editing: boolean;
|
|
onEdit: () => void;
|
|
onReset?: () => void;
|
|
disabled: boolean;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const { ref, ...titleReg } = registration;
|
|
|
|
return (
|
|
<div className="form-field">
|
|
<label className="form-label" htmlFor="dc-title">
|
|
<Trans>Title</Trans>
|
|
</label>
|
|
<div className="dump-create-title-row">
|
|
<input
|
|
id="dc-title"
|
|
type="text"
|
|
{...titleReg}
|
|
ref={(el) => {
|
|
ref(el);
|
|
inputRef.current = el;
|
|
}}
|
|
disabled={disabled || !editing}
|
|
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
|
/>
|
|
{!editing && (
|
|
<button
|
|
type="button"
|
|
className="dump-create-title-edit-btn"
|
|
onClick={() => {
|
|
onEdit();
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
}}
|
|
disabled={disabled}
|
|
aria-label={t`Edit title`}
|
|
>
|
|
✎
|
|
</button>
|
|
)}
|
|
{editing && onReset && (
|
|
<button
|
|
type="button"
|
|
className="dump-create-title-edit-btn"
|
|
onClick={onReset}
|
|
disabled={disabled}
|
|
aria-label={t`Reset to the suggested title`}
|
|
title={t`Reset to the suggested title`}
|
|
>
|
|
↺
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
|
function DuplicateNotice(
|
|
{ matches, onNavigate }: {
|
|
matches: DumpUrlMatch[];
|
|
onNavigate: () => void;
|
|
},
|
|
) {
|
|
const [first, ...rest] = matches;
|
|
|
|
return (
|
|
<div className="dump-duplicate" role="status">
|
|
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
|
|
<div className="dump-duplicate-body">
|
|
<p className="dump-duplicate-lead">
|
|
<Trans>
|
|
Already dumped by {first.username} {relativeTime(first.createdAt)}
|
|
</Trans>
|
|
</p>
|
|
<Link
|
|
to={dumpUrl(first)}
|
|
className="dump-duplicate-link"
|
|
onClick={onNavigate}
|
|
>
|
|
{first.title}
|
|
</Link>
|
|
{rest.length > 0 && (
|
|
<p className="dump-duplicate-more">
|
|
<Plural
|
|
value={rest.length}
|
|
one="and # earlier dump"
|
|
other="and # earlier dumps"
|
|
/>
|
|
</p>
|
|
)}
|
|
<p className="dump-duplicate-hint">
|
|
<Trans>You can post it anyway.</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DumpCreateModalProps {
|
|
onClose: () => void;
|
|
initialUrl?: string;
|
|
}
|
|
|
|
export function DumpCreateModal(
|
|
{ onClose, initialUrl = "" }: DumpCreateModalProps,
|
|
) {
|
|
const { authFetch, authUpload } = useAuth();
|
|
const { injectDump } = useWS();
|
|
|
|
const [step, setStep] = useState<Step>("what");
|
|
// Which way the next panel should come in from. Purely presentational.
|
|
const [direction, setDirection] = useState<"forward" | "back">("forward");
|
|
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
|
|
|
|
const goToStep = (next: Step, dir: "forward" | "back") => {
|
|
setDirection(dir);
|
|
setStep(next);
|
|
};
|
|
|
|
// Arriving with a URL (the Android share sheet) is a request to post *that*,
|
|
// so an abandoned draft is dropped whole rather than half-merged: pairing
|
|
// someone's old "why" with a link they just shared from another app would be
|
|
// more confusing than starting them clean.
|
|
const [restoredDraft] = useState<DumpDraft | null>(() =>
|
|
initialUrl ? null : loadDumpDraft()
|
|
);
|
|
const draft = restoredDraft ?? EMPTY_DUMP_DRAFT;
|
|
const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft);
|
|
|
|
// Composition state
|
|
const [titleEditing, setTitleEditing] = useState(false);
|
|
const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser);
|
|
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
|
|
status: "idle",
|
|
});
|
|
// Tagged with the URL they describe, so a result never lingers over the next
|
|
// link during the debounce (and so clearing them needs no effect).
|
|
const [duplicateState, setDuplicateState] = useState<
|
|
{ url: string; matches: DumpUrlMatch[] }
|
|
>({ url: "", matches: [] });
|
|
const [uploadProgress, setUploadProgress] = useState<number | null>();
|
|
const [confirmClose, setConfirmClose] = useState(false);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const commentEditorRef = useRef<TextEditorHandle | null>(null);
|
|
|
|
const form = useApiForm<CreateValues>({
|
|
defaultValues: {
|
|
url: initialUrl || draft.url,
|
|
file: null,
|
|
title: draft.title,
|
|
comment: draft.comment,
|
|
isPublic: draft.isPublic,
|
|
},
|
|
});
|
|
const { register, setValue, watch, clearErrors, reset } = form;
|
|
const submitting = form.formState.isSubmitting;
|
|
const url = watch("url");
|
|
const file = watch("file");
|
|
const title = watch("title");
|
|
const comment = watch("comment");
|
|
const isPublic = watch("isPublic");
|
|
|
|
// The dump's kind is whatever the user actually provided, so there is no mode
|
|
// to pick: attaching a file makes it a file dump, typing a link makes it a
|
|
// link dump, and clearing either offers both again.
|
|
const mode: "url" | "file" = file ? "file" : "url";
|
|
|
|
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
|
|
() => new Set(draft.categoryIds),
|
|
);
|
|
const toggleCategory = (id: string) =>
|
|
setSelectedCategoryIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
|
|
// Playlist step state
|
|
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
|
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
|
|
|
const selectFile = useCallback((f: File | null) => {
|
|
setValue("file", f);
|
|
if (f) {
|
|
setValue("title", titleFromFileName(f.name));
|
|
setTitleFromUser(false);
|
|
}
|
|
setTitleEditing(false);
|
|
}, [setValue]);
|
|
|
|
// Canonical form of `url`, used (rather than the raw input text) to decide
|
|
// whether the preview actually needs to re-fetch — e.g. "example.com" and
|
|
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
|
|
// to the same value, so they shouldn't trigger two separate fetches.
|
|
let canonicalUrl: string | null;
|
|
try {
|
|
const u = new URL(normalizeUrl(url));
|
|
canonicalUrl = (u.protocol === "http:" || u.protocol === "https:")
|
|
? u.toString()
|
|
: null;
|
|
} catch {
|
|
canonicalUrl = null;
|
|
}
|
|
|
|
// Idle state is derived below from canonicalUrl rather than set here, so
|
|
// this effect only ever needs to set loading/done (inside the timeout).
|
|
const urlPreview: UrlPreview = canonicalUrl
|
|
? urlFetchState
|
|
: { status: "idle" };
|
|
|
|
const duplicates = canonicalUrl && duplicateState.url === canonicalUrl
|
|
? duplicateState.matches
|
|
: NO_DUPLICATES;
|
|
|
|
// Debounced preview + "already dumped?" lookup. Both answer questions about
|
|
// the same link and settle at the same time, so they share one debounce.
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (!canonicalUrl) return;
|
|
|
|
// Deferred to a microtask (rather than called directly) so the immediate
|
|
// "loading" feedback doesn't fire synchronously within the effect body —
|
|
// same timing for the user, just not in the same tick.
|
|
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
|
|
|
|
debounceRef.current = setTimeout(() => {
|
|
const encoded = encodeURIComponent(canonicalUrl);
|
|
|
|
fetch(`${API_URL}/api/preview?url=${encoded}`)
|
|
.then((res) => res.json())
|
|
.then((body) => {
|
|
const data = body.success
|
|
? body.data as UrlPreviewResponse
|
|
: undefined;
|
|
setUrlFetchState({
|
|
status: "done",
|
|
reached: data?.reached ?? false,
|
|
richContent: data?.richContent ?? null,
|
|
});
|
|
})
|
|
.catch(() => {
|
|
setUrlFetchState({ status: "done", reached: false, richContent: null });
|
|
});
|
|
|
|
// Signed in, so that a private dump of the poster's own counts as a
|
|
// duplicate for them and for nobody else.
|
|
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`)
|
|
.then((res) => res.json())
|
|
.then((body) => {
|
|
setDuplicateState({
|
|
url: canonicalUrl,
|
|
matches: body.success
|
|
? (body.data as RawDumpUrlMatch[]).map(deserializeDumpUrlMatch)
|
|
: [],
|
|
});
|
|
})
|
|
.catch(() =>
|
|
setDuplicateState({ url: canonicalUrl, matches: [] })
|
|
);
|
|
}, 600);
|
|
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, [canonicalUrl, authFetch]);
|
|
|
|
// Until the poster takes the field over, the title *is* the current page's
|
|
// title — including when the new link suggests nothing, which is why this
|
|
// writes an empty suggestion too rather than leaving the previous link's.
|
|
const suggestedTitle = urlPreview.status === "done"
|
|
? urlPreview.richContent?.title ?? ""
|
|
: "";
|
|
useEffect(() => {
|
|
if (mode !== "url" || titleFromUser) return;
|
|
setValue("title", suggestedTitle);
|
|
}, [mode, titleFromUser, suggestedTitle, setValue]);
|
|
|
|
// Mirror the draft on every change so a dismissal — deliberate or not — never
|
|
// costs more than the attached file, which cannot be stored.
|
|
useEffect(() => {
|
|
if (step === "playlist") return;
|
|
saveDumpDraft({
|
|
url,
|
|
title,
|
|
titleFromUser,
|
|
comment,
|
|
isPublic,
|
|
categoryIds: [...selectedCategoryIds],
|
|
});
|
|
}, [
|
|
step,
|
|
url,
|
|
title,
|
|
titleFromUser,
|
|
comment,
|
|
isPublic,
|
|
selectedCategoryIds,
|
|
]);
|
|
|
|
const discardDraft = () => {
|
|
clearDumpDraft();
|
|
reset({
|
|
url: "",
|
|
file: null,
|
|
title: "",
|
|
comment: "",
|
|
isPublic: true,
|
|
});
|
|
setSelectedCategoryIds(new Set());
|
|
setTitleFromUser(false);
|
|
setTitleEditing(false);
|
|
setDraftNoticeVisible(false);
|
|
};
|
|
|
|
// Land the caret in the "why" box on arrival, so the second panel is
|
|
// immediately typeable — except on touch, where it would raise a keyboard
|
|
// over the categories this panel exists to show.
|
|
useEffect(() => {
|
|
if (step !== "details" || prefersNoAutoFocus()) return;
|
|
commentEditorRef.current?.focus();
|
|
}, [step]);
|
|
|
|
// Only the attached file is unrecoverable, so it is the only thing worth
|
|
// interrupting a dismissal for — the text comes back on its own.
|
|
const handleBeforeClose = useCallback(() => {
|
|
if (step === "playlist" || submitting || !file || confirmClose) return true;
|
|
setConfirmClose(true);
|
|
return false;
|
|
}, [step, submitting, file, confirmClose]);
|
|
|
|
/** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */
|
|
const requestClose = () => {
|
|
if (handleBeforeClose()) onClose();
|
|
};
|
|
|
|
// Paste handler
|
|
useEffect(() => {
|
|
const handler = (e: ClipboardEvent) => {
|
|
// Fields own their paste: the comment editor uploads pasted images as
|
|
// inline attachments, and the URL input has its own file handler below.
|
|
// Without this guard, pasting an image into the description would flip
|
|
// the whole modal over to a file dump.
|
|
const tag = (e.target as HTMLElement).tagName;
|
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
const pastedFile = e.clipboardData?.files[0];
|
|
if (pastedFile) {
|
|
setValue("url", "");
|
|
selectFile(pastedFile);
|
|
clearErrors("root");
|
|
return;
|
|
}
|
|
const text = e.clipboardData?.getData("text") ?? "";
|
|
try {
|
|
const u = new URL(normalizeUrl(text));
|
|
if (u.protocol === "http:" || u.protocol === "https:") {
|
|
selectFile(null);
|
|
setValue("url", u.toString());
|
|
clearErrors("root");
|
|
}
|
|
} catch { /* not a URL */ }
|
|
};
|
|
globalThis.addEventListener("paste", handler);
|
|
return () => globalThis.removeEventListener("paste", handler);
|
|
}, [selectFile, setValue, clearErrors]);
|
|
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
// Dropping is accepted over the whole modal, not just the drop zone: once a
|
|
// link is being typed the zone is hidden, and aiming at it was never the
|
|
// point of dragging a file into a "new dump" dialog.
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragging(false);
|
|
if (submitting) return;
|
|
const dropped = e.dataTransfer.files[0];
|
|
if (dropped) {
|
|
setValue("url", "");
|
|
selectFile(dropped);
|
|
clearErrors("root");
|
|
}
|
|
};
|
|
|
|
const onSubmit = form.submit(async (
|
|
{ url, file, title, comment, isPublic },
|
|
) => {
|
|
const isPrivate = !isPublic;
|
|
let res: Response;
|
|
|
|
if (!file) {
|
|
const normalizedUrl = normalizeUrl(url);
|
|
if (!normalizedUrl) throw new Error(t`URL is required.`);
|
|
setValue("url", normalizedUrl);
|
|
const body: CreateUrlDumpRequest = {
|
|
url: normalizedUrl,
|
|
// Only when the poster typed it: otherwise the server would be pinned
|
|
// to whatever the preview happened to scrape a moment ago.
|
|
title: titleFromUser && title.trim() ? title.trim() : undefined,
|
|
comment: comment.trim() || undefined,
|
|
isPrivate,
|
|
categoryIds: [...selectedCategoryIds],
|
|
};
|
|
res = await authFetch(`${API_URL}/api/dumps`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
} else {
|
|
if (!title.trim()) throw new Error(t`Title is required.`);
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
throw new Error(t`File too large (max 50 MB).`);
|
|
}
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
if (comment.trim()) formData.append("comment", comment.trim());
|
|
if (title.trim()) formData.append("title", title.trim());
|
|
formData.append("isPrivate", String(isPrivate));
|
|
formData.append("categoryIds", JSON.stringify([...selectedCategoryIds]));
|
|
setUploadProgress(0);
|
|
try {
|
|
res = await authUpload(
|
|
`${API_URL}/api/dumps`,
|
|
formData,
|
|
setUploadProgress,
|
|
);
|
|
} finally {
|
|
setUploadProgress(undefined);
|
|
}
|
|
}
|
|
|
|
const dump = deserializeDump(await expectOk<RawDump>(res));
|
|
clearDumpDraft();
|
|
injectDump(dump);
|
|
setCreatedDump(dump);
|
|
goToStep("playlist", "forward");
|
|
setPlaylistsLoading(true);
|
|
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
if (body.success) {
|
|
setMemberships(
|
|
(body.data as RawPlaylistMembership[]).map(
|
|
deserializePlaylistMembership,
|
|
),
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setPlaylistsLoading(false));
|
|
});
|
|
|
|
const toggleMembership = async (membership: PlaylistMembership) => {
|
|
if (!createdDump) return;
|
|
const { playlist, hasDump } = membership;
|
|
if (hasDump) {
|
|
await authFetch(
|
|
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
setMemberships((prev) =>
|
|
prev.map((m) =>
|
|
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
|
|
)
|
|
);
|
|
} else {
|
|
await authFetch(
|
|
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
|
{ method: "POST" },
|
|
);
|
|
setMemberships((prev) =>
|
|
prev.map((m) =>
|
|
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
const canProceed = !!file || !!canonicalUrl;
|
|
|
|
// What the second panel's choices apply to, in one line.
|
|
const recap = title.trim() || file?.name || normalizeUrl(url);
|
|
const uploadPercentLabel = t`Uploading ${
|
|
Math.round((uploadProgress ?? 0) * 100)
|
|
}%`;
|
|
|
|
// Enter in the link field advances rather than posts, so the panels behave
|
|
// like a form the whole way through.
|
|
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
if (step === "what") {
|
|
e.preventDefault();
|
|
if (canProceed) goToStep("details", "forward");
|
|
return;
|
|
}
|
|
void onSubmit(e);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
title={step === "playlist" ? t`Add to playlist` : t`New dump`}
|
|
onClose={onClose}
|
|
onBeforeClose={handleBeforeClose}
|
|
wide
|
|
>
|
|
<div
|
|
className={`dump-create${dragging ? " dump-create--drag" : ""}`}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (!submitting && step !== "playlist") setDragging(true);
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
setDragging(false);
|
|
}
|
|
}}
|
|
onDrop={handleDrop}
|
|
>
|
|
{confirmClose && (
|
|
<div className="dump-create-confirm" role="alert">
|
|
<p>
|
|
<Trans>
|
|
Close without posting? The attached file won't be kept —
|
|
everything you typed will.
|
|
</Trans>
|
|
</p>
|
|
<div className="dump-create-confirm-actions">
|
|
<button type="button" onClick={() => setConfirmClose(false)}>
|
|
<Trans>Keep editing</Trans>
|
|
</button>
|
|
<button type="button" className="btn-danger" onClick={onClose}>
|
|
<Trans>Close anyway</Trans>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{draftNoticeVisible && step === "what" && (
|
|
<div className="dump-create-draft" role="status">
|
|
<span>
|
|
<Trans>Picked up where you left off.</Trans>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="dump-create-draft-discard"
|
|
onClick={discardDraft}
|
|
>
|
|
<Trans>Start fresh</Trans>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<StepIndicator current={step} />
|
|
|
|
{step === "playlist"
|
|
? (
|
|
<div className="dump-panel dump-panel--forward">
|
|
{createdDump && (
|
|
<p className="dump-create-success">
|
|
<Trans>Dumped!</Trans>{" "}
|
|
<Link to={dumpUrl(createdDump)} onClick={onClose}>
|
|
<Trans>View dump →</Trans>
|
|
</Link>
|
|
</p>
|
|
)}
|
|
|
|
<PlaylistMembershipPanel
|
|
dumpId={createdDump?.id ?? ""}
|
|
memberships={memberships}
|
|
loading={playlistsLoading}
|
|
onToggle={toggleMembership}
|
|
onPlaylistCreated={(membership) =>
|
|
setMemberships((prev) => [membership, ...prev])}
|
|
/>
|
|
|
|
<div className="form-actions">
|
|
<div className="form-actions-right">
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
onClick={onClose}
|
|
>
|
|
<Trans>Done</Trans>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
: (
|
|
<FormProvider {...form}>
|
|
<form onSubmit={onFormSubmit} className="form">
|
|
<FormError title={t`Failed to post`} />
|
|
|
|
{/* Keyed so each panel re-enters from the side it came from. */}
|
|
<div
|
|
key={step}
|
|
className={`dump-panel dump-panel--${direction}`}
|
|
>
|
|
{step === "what"
|
|
? (
|
|
<>
|
|
{mode === "url" && (
|
|
<>
|
|
<div className="form-field">
|
|
<label className="form-label" htmlFor="dc-url">
|
|
<Trans>Link</Trans>
|
|
</label>
|
|
<input
|
|
id="dc-url"
|
|
type="url"
|
|
{...register("url")}
|
|
onBlur={(e) =>
|
|
setValue("url", normalizeUrl(e.target.value))}
|
|
onPaste={(e) => {
|
|
const pastedFile = e.clipboardData.files[0];
|
|
if (pastedFile) {
|
|
e.preventDefault();
|
|
setValue("url", "");
|
|
selectFile(pastedFile);
|
|
clearErrors("root");
|
|
}
|
|
}}
|
|
disabled={submitting}
|
|
placeholder={t`Paste a link…`}
|
|
autoFocus={!prefersNoAutoFocus()}
|
|
/>
|
|
</div>
|
|
|
|
{urlPreview.status === "loading" && (
|
|
<p className="preview-loading">
|
|
<Trans>Fetching preview…</Trans>
|
|
</p>
|
|
)}
|
|
|
|
{urlPreview.status === "done" && (
|
|
<>
|
|
{urlPreview.richContent && (
|
|
<RichContentCard
|
|
richContent={urlPreview.richContent}
|
|
/>
|
|
)}
|
|
{!urlPreview.reached && (
|
|
<p className="preview-note">
|
|
<Trans>
|
|
Couldn't load a preview for this link —
|
|
check it, or post it as-is and refresh the
|
|
preview later.
|
|
</Trans>
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{duplicates.length > 0 && (
|
|
<DuplicateNotice
|
|
matches={duplicates}
|
|
onNavigate={onClose}
|
|
/>
|
|
)}
|
|
|
|
{urlPreview.status === "done" && (
|
|
<TitleField
|
|
registration={register("title")}
|
|
editing={titleEditing}
|
|
onEdit={() => {
|
|
setTitleEditing(true);
|
|
setTitleFromUser(true);
|
|
}}
|
|
onReset={titleFromUser && suggestedTitle
|
|
? () => {
|
|
setTitleFromUser(false);
|
|
setTitleEditing(false);
|
|
setValue("title", suggestedTitle);
|
|
}
|
|
: undefined}
|
|
disabled={submitting}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Hidden only while a link is being written: an empty
|
|
panel offers both ways in at once, no mode to pick. */}
|
|
{!(mode === "url" && url.trim()) && (
|
|
<FileField<CreateValues>
|
|
name="file"
|
|
onValueChange={selectFile}
|
|
disabled={submitting}
|
|
hint={t`Drop a file here, or paste one`}
|
|
/>
|
|
)}
|
|
|
|
{file && (
|
|
<>
|
|
<LocalFilePreview file={file} />
|
|
<TitleField
|
|
registration={register("title")}
|
|
editing={titleEditing}
|
|
onEdit={() => {
|
|
setTitleEditing(true);
|
|
setTitleFromUser(true);
|
|
}}
|
|
disabled={submitting}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
: (
|
|
<>
|
|
{/* The link itself is a panel back, so name the thing
|
|
these choices are about. */}
|
|
<p className="dump-panel-recap" title={recap}>
|
|
<span aria-hidden="true">
|
|
{mode === "url" ? "🔗" : "📎"}
|
|
</span>{" "}
|
|
{recap}
|
|
</p>
|
|
|
|
{uploadProgress !== undefined && (
|
|
<div
|
|
className={`dump-upload-progress${
|
|
typeof uploadProgress === "number"
|
|
? ""
|
|
: " dump-upload-progress--indeterminate"
|
|
}`}
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={typeof uploadProgress === "number"
|
|
? Math.round(uploadProgress * 100)
|
|
: undefined}
|
|
>
|
|
<div
|
|
className="dump-upload-progress-fill"
|
|
style={typeof uploadProgress === "number"
|
|
? { width: `${uploadProgress * 100}%` }
|
|
: undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<RichTextField<CreateValues>
|
|
name="comment"
|
|
label={t`Why?`}
|
|
placeholder={t`What makes it worth it?`}
|
|
rows={3}
|
|
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
|
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
|
disabled={submitting}
|
|
editorRef={commentEditorRef}
|
|
/>
|
|
|
|
<CategorySelect
|
|
selected={selectedCategoryIds}
|
|
onToggle={toggleCategory}
|
|
disabled={submitting}
|
|
/>
|
|
|
|
<VisibilityToggle<CreateValues>
|
|
name="isPublic"
|
|
label={t`Who can see it`}
|
|
disabled={submitting}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-actions">
|
|
{step === "details" && (
|
|
<button
|
|
type="button"
|
|
className="form-cancel"
|
|
onClick={() => goToStep("what", "back")}
|
|
disabled={submitting}
|
|
>
|
|
<Trans>← Back</Trans>
|
|
</button>
|
|
)}
|
|
<div className="form-actions-right">
|
|
{step === "what" && (
|
|
<button
|
|
type="button"
|
|
className="form-cancel"
|
|
onClick={requestClose}
|
|
>
|
|
<Trans>Cancel</Trans>
|
|
</button>
|
|
)}
|
|
<SubmitButton
|
|
disabled={step === "what" && !canProceed}
|
|
pendingLabel={mode === "url"
|
|
? <Trans>Posting…</Trans>
|
|
: typeof uploadProgress === "number"
|
|
? uploadPercentLabel
|
|
: <Trans>Uploading…</Trans>}
|
|
>
|
|
{step === "what"
|
|
? <Trans>Next →</Trans>
|
|
: <Trans>Dump it</Trans>}
|
|
</SubmitButton>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</FormProvider>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|