diff --git a/web/src/styles/clips.css b/web/src/styles/clips.css new file mode 100644 index 0000000..ca987fa --- /dev/null +++ b/web/src/styles/clips.css @@ -0,0 +1,174 @@ +.clips-view { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; +} +.clip-filters { + display: flex; + gap: 6px; + padding: 0 28px 16px; + flex-wrap: wrap; +} +.clip-filters button { + display: flex; + gap: 8px; + align-items: center; + border-radius: 999px; + background: transparent; +} +.clip-filters button[aria-pressed="true"] { + background: var(--sel); +} +.clip-filters span { + color: var(--t3); + font-size: 11px; +} +.clip-list { + padding: 0 28px 28px; +} +.clip-row { + width: 100%; + display: flex; + text-align: left; + gap: 16px; + align-items: center; + padding: 16px 0; + border-top: 1px solid var(--line); + border-radius: 0; + background: transparent; +} +.clip-row:last-child { + border-bottom: 1px solid var(--line); +} +.clip-row > span:first-child { + flex: 1; + min-width: 0; +} +.clip-row strong { + display: block; + font-size: 14px; + font-weight: 500; + overflow-wrap: anywhere; +} +.clip-row small { + display: block; + color: var(--t3); + font-size: 12px; + margin-top: 5px; + overflow-wrap: anywhere; +} +.clip-row-state { + font-size: 11px; + color: var(--t3); + max-width: 120px; +} +.clip-reader { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} +.clip-reader > header { + display: flex; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + padding: 0 28px 16px; + border-bottom: 1px solid var(--line); +} +.clip-reader-content { + padding: 32px 40px; + max-width: calc(var(--notes-editor-width, 640px) + 80px); + margin: 0 auto; + width: 100%; + overflow-wrap: anywhere; +} +.clip-reader-content h2 { + font-size: 28px; + line-height: 1.25; + letter-spacing: -0.035em; +} +.clip-provenance { + font-size: 12px; + color: var(--t3); +} +.clip-original { + font-size: 12px; + user-select: text; + overflow-wrap: anywhere; + color: var(--t2); +} +.clip-text { + white-space: pre-wrap; + font-size: var(--notes-editor-size, 14px); + line-height: 1.7; + margin-top: 28px; +} +.clip-link-only { + margin-top: 32px; + color: var(--t3); + line-height: 1.7; +} +.clip-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.clip-form > label { + display: flex; + flex-direction: column; + gap: 6px; +} +.clip-form .clip-checkbox { + flex-direction: row; + align-items: center; + gap: 8px; +} +.clip-checkbox input { + width: auto; +} +.clip-form textarea { + resize: vertical; + min-height: 120px; +} +.clip-form-help { + font-size: 12px; + color: var(--t3); + margin: 0; +} +.clips-view .page-heading h1 { + margin-bottom: 4px; +} +@media (max-width: 650px) { + .clip-filters { + padding: 0 16px 14px; + } + .clip-list { + padding: 0 16px 20px; + } + .clip-row { + gap: 10px; + } + .clip-row-state { + max-width: 80px; + } + .clip-reader > header { + padding: 0 16px 14px; + } + .clip-reader-content { + padding: 24px 20px; + } + .clip-reader-content h2 { + font-size: 24px; + } + .clips-view .page-heading { + gap: 12px; + flex-wrap: wrap; + } + .clips-view .page-heading .actions { + flex-wrap: wrap; + } +} diff --git a/web/src/ui/ClipsView.tsx b/web/src/ui/ClipsView.tsx new file mode 100644 index 0000000..7c57cb2 --- /dev/null +++ b/web/src/ui/ClipsView.tsx @@ -0,0 +1,1170 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { zipSync, strToU8 } from "fflate"; +import type { + Clip, + ClipActionInput, + ClipIntent, + ClipMutationResult, + ClipReceipt, + ClipSnapshot, + ClipsStore, +} from "../clips/contracts"; +import { ClipDuplicateError } from "../clips/contracts"; +import { exportClipsFiles } from "../clips/transfer"; +import { registerCloseGuard, trackOperation } from "../desktop/closeGuard"; +import { Dialog } from "./primitives"; +import { message } from "./model"; +import { useEditorPreferences } from "./editorPreferences"; +import "../styles/clips.css"; +import type { + ClipSearchOpenRequest, + SearchReaderResult, +} from "./searchNavigation"; + +type Filter = "all" | "unread" | "later" | "archived"; +type Draft = { url: string; title: string; text: string; readLater: boolean }; +type Operation = { + input: ClipActionInput; + expected: { epoch: string; revision: number }; + intent: ClipIntent | null; + committing: boolean; +}; +type Notice = { text: string; openId?: string; undo?: ClipReceipt }; +const emptyDraft = (): Draft => ({ + url: "", + title: "", + text: "", + readLater: false, +}); +const host = (url: string) => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; +const isConflict = (error: unknown) => { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : ""; + return [ + "clip_conflict", + "collections_revision_conflict", + "clip_stale", + "clip_intent_revoked", + "clip_undo_unavailable", + "clip_operation_reused", + ].includes(code); +}; +export function ClipsView({ + store, + onOpenExternal, + openClip, + onSearchReaderResult, + accountId, + holdSearchOrigin = false, + restoreSearchOrigin = 0, +}: { + store: ClipsStore; + onOpenExternal?: (url: string) => Promise; + openClip?: ClipSearchOpenRequest | null; + onSearchReaderResult?: SearchReaderResult; + accountId?: string; + holdSearchOrigin?: boolean; + restoreSearchOrigin?: number; +}) { + useEditorPreferences(); + const [snapshot, setSnapshot] = useState(null), + [error, setError] = useState(""), + [closed, setClosed] = useState(false), + [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("all"), + [selected, setSelected] = useState(null), + [form, setForm] = useState(false), + [draft, setDraft] = useState(emptyDraft), + [discard, setDiscard] = useState<{ openId?: string } | null>(null); + const [duplicate, setDuplicate] = useState< + ClipDuplicateError["existing"] | null + >(null), + [busy, setBusy] = useState(false), + [operationError, setOperationError] = useState(""), + [conflict, setConflict] = useState(false), + [reloaded, setReloaded] = useState(false), + [notice, setNotice] = useState(null); + const [exportOpen, setExportOpen] = useState(false), + [exportBusy, setExportBusy] = useState(false), + [exportError, setExportError] = useState(""), + [download, setDownload] = useState<{ url: string; name: string } | null>( + null, + ), + [sourceError, setSourceError] = useState(""), + [offline, setOffline] = useState(!navigator.onLine), + [clock, setClock] = useState(Date.now()); + const alive = useRef(false), + generation = useRef(0), + readSequence = useRef(0), + current = useRef(null), + dirty = useRef(false), + draftRef = useRef(emptyDraft()), + operation = useRef(null), + pending = useRef(false), + exportPending = useRef(false), + downloadUrl = useRef(null); + const selectionScope = useRef(0), + selectedId = useRef(null); + function selectClip(id: string | null) { + selectionScope.current++; + selectedId.current = id; + setSelected(id); + setSourceError(""); + } + const heldSearchOrigin = useRef<{ + id: string | null; + filter: Filter; + epoch: string; + } | null>(null), + restoredSearchOrigin = useRef(restoreSearchOrigin); + useLayoutEffect(() => { + if (restoredSearchOrigin.current !== restoreSearchOrigin) { + const origin = heldSearchOrigin.current; + if (origin && current.current?.epoch === origin.epoch) { + selectClip( + origin.id && + current.current.clips.items.some((item) => item.id === origin.id) + ? origin.id + : null, + ); + setFilter(origin.filter); + } + heldSearchOrigin.current = null; + restoredSearchOrigin.current = restoreSearchOrigin; + } + if (holdSearchOrigin && !heldSearchOrigin.current && current.current) + heldSearchOrigin.current = { + id: selectedId.current, + filter, + epoch: current.current.epoch, + }; + if (!holdSearchOrigin) heldSearchOrigin.current = null; + }, [holdSearchOrigin, restoreSearchOrigin, snapshot?.epoch]); + const searchAdmission = useRef(0), + readerResult = useRef(onSearchReaderResult); + readerResult.current = onSearchReaderResult; + useEffect(() => { + const ticket = ++searchAdmission.current; + if (!openClip) return; + const request = openClip, + scope = generation.current; + if ( + dirty.current || + operation.current || + pending.current || + exportPending.current + ) { + readerResult.current?.(request.requestId, "blocked"); + return; + } + void trackOperation(() => store.read()) + .then((value) => { + if ( + !alive.current || + ticket !== searchAdmission.current || + scope !== generation.current + ) + return; + if ( + dirty.current || + operation.current || + pending.current || + exportPending.current + ) { + readerResult.current?.(request.requestId, "blocked"); + return; + } + if ( + !accountId || + accountId !== request.target.accountId || + value.epoch !== request.target.collectionsEpoch || + !value.clips.items.some((item) => item.id === request.target.clipId) + ) { + readerResult.current?.(request.requestId, "unavailable"); + return; + } + adopt(value); + selectClip(request.target.clipId); + readerResult.current?.(request.requestId, "opened"); + }) + .catch(() => { + if ( + alive.current && + ticket === searchAdmission.current && + scope === generation.current + ) + readerResult.current?.(request.requestId, "unavailable"); + }); + return () => { + searchAdmission.current++; + }; + }, [store, accountId, openClip?.requestId]); + function clearDownload() { + if (downloadUrl.current) URL.revokeObjectURL(downloadUrl.current); + downloadUrl.current = null; + setDownload(null); + } + function adopt(value: ClipSnapshot) { + current.current = value; + setSnapshot(value); + setLoading(false); + setError(""); + } + async function read(): Promise { + const invocation = generation.current, + sequence = ++readSequence.current; + const value = await store.read(); + if (!alive.current || invocation !== generation.current) + throw new Error("Clips is no longer available."); + if (sequence === readSequence.current) adopt(value); + return value; + } + useLayoutEffect(() => { + alive.current = true; + const stopInvalidation = store.onInvalidate((reason) => { + heldSearchOrigin.current = null; + generation.current++; + readSequence.current++; + current.current = null; + dirty.current = false; + draftRef.current = emptyDraft(); + operation.current = null; + pending.current = false; + exportPending.current = false; + const clear = () => { + clearDownload(); + setSnapshot(null); + selectClip(null); + setForm(false); + setDraft(emptyDraft()); + setDiscard(null); + setDuplicate(null); + setOperationError(""); + setConflict(false); + setReloaded(false); + setBusy(false); + setNotice(null); + setExportOpen(false); + setExportBusy(false); + setExportError(""); + setSourceError(""); + setClosed(reason === "closed"); + setLoading(reason !== "closed"); + }; + if (alive.current) flushSync(clear); + const invalidationGeneration = generation.current; + if (reason === "epoch") + queueMicrotask(() => { + if (alive.current && invalidationGeneration === generation.current) + refreshQuietly(); + }); + }); + function refreshQuietly() { + const invocation = generation.current; + void read().catch((failure) => { + if (alive.current && invocation === generation.current) { + setError(message(failure)); + setLoading(false); + } + }); + } + const stop = store.subscribe(refreshQuietly); + refreshQuietly(); + return () => { + alive.current = false; + generation.current++; + stopInvalidation(); + stop(); + if (downloadUrl.current) URL.revokeObjectURL(downloadUrl.current); + downloadUrl.current = null; + }; + }, [store]); + useLayoutEffect( + () => + registerCloseGuard(() => { + if ( + dirty.current || + operation.current || + pending.current || + exportPending.current + ) { + setNotice({ + text: "Finish or discard your clip draft, and wait for pending actions before leaving.", + }); + return false; + } + return true; + }), + [], + ); + useEffect(() => { + const unload = (event: BeforeUnloadEvent) => { + if ( + dirty.current || + operation.current || + pending.current || + exportPending.current + ) + event.preventDefault(); + }; + window.addEventListener("beforeunload", unload); + return () => window.removeEventListener("beforeunload", unload); + }, []); + useEffect(() => { + const update = () => setOffline(!navigator.onLine); + window.addEventListener("online", update); + window.addEventListener("offline", update); + return () => { + window.removeEventListener("online", update); + window.removeEventListener("offline", update); + }; + }, []); + useEffect(() => { + if (!notice?.undo) return; + const end = Date.parse(notice.undo.at) + 5000; + setClock(Date.now()); + const timer = setTimeout( + () => setClock(Date.now()), + Math.max(0, end - Date.now() + 1), + ); + return () => clearTimeout(timer); + }, [notice]); + function change(patch: Partial) { + if (operation.current || pending.current) return; + const next = { ...draftRef.current, ...patch }; + dirty.current = !!(next.url || next.title || next.text || next.readLater); + draftRef.current = next; + setDraft(next); + setOperationError(""); + setDuplicate(null); + } + function openForm() { + if (operation.current || pending.current) return; + draftRef.current = emptyDraft(); + dirty.current = false; + setDraft(emptyDraft()); + setDuplicate(null); + setForm(true); + setOperationError(""); + } + function closeForm() { + if (pending.current) return; + if (dirty.current || operation.current) setDiscard({}); + else { + setForm(false); + setDuplicate(null); + } + } + async function discardForm(openId?: string) { + if (pending.current) return; + const invocation = generation.current, + clickedDraft = draftRef.current; + pending.current = true; + setBusy(true); + try { + if (openId) { + const value = await trackOperation(() => read()); + if ( + !alive.current || + invocation !== generation.current || + draftRef.current !== clickedDraft + ) + return; + if (!value.clips.items.some((item) => item.id === openId)) { + setOperationError( + "This clip is no longer available. Your draft is still here.", + ); + setDiscard(null); + return; + } + selectClip(openId); + } + operation.current = null; + dirty.current = false; + draftRef.current = emptyDraft(); + setDraft(emptyDraft()); + setForm(false); + setDuplicate(null); + setDiscard(null); + setOperationError(""); + setConflict(false); + setNotice(null); + } catch (failure) { + if (alive.current && invocation === generation.current) { + setOperationError(message(failure)); + setDiscard(null); + } + } finally { + if (alive.current && invocation === generation.current) { + pending.current = false; + setBusy(false); + } + } + } + function success(value: ClipMutationResult, action: ClipActionInput) { + readSequence.current++; + adopt(value.snapshot); + operation.current = null; + setOperationError(""); + setConflict(false); + setReloaded(false); + setClock(Date.now()); + if (action.kind === "create") { + dirty.current = false; + draftRef.current = emptyDraft(); + setDraft(emptyDraft()); + setForm(false); + setDuplicate(null); + const item = value.snapshot.clips.items.find( + (item) => item.id === value.receipt.clipId, + ); + setNotice( + item + ? { + text: item.text === null ? "Link saved" : "Text clip saved", + openId: item.id, + undo: value.receipt, + } + : { + text: "The save was confirmed, but this clip is no longer available.", + }, + ); + } else if (action.kind === "undo-create") { + setNotice({ text: "Clip save undone" }); + if (selectedId.current === value.receipt.clipId) selectClip(null); + } else { + setNotice({ + text: !value.changed + ? "No changes needed" + : action.kind === "set-read" + ? action.read + ? "Marked read" + : "Marked unread" + : action.kind === "set-read-later" + ? action.readLater + ? "Added to read later" + : "Removed from read later" + : action.archived + ? "Clip archived" + : "Clip unarchived", + }); + } + } + async function execute(value: Operation) { + if (pending.current) return; + const invocation = generation.current; + pending.current = true; + setBusy(true); + setOperationError(""); + try { + const result = await trackOperation(async () => { + if (!value.intent) + value.intent = await store.prepare(value.expected, value.input); + if (invocation !== generation.current || !alive.current) + throw new Error("Clips is no longer available."); + value.committing = true; + return store.commit(value.intent); + }); + if (invocation === generation.current && alive.current) + success(result, value.input); + } catch (failure) { + if (invocation !== generation.current || !alive.current) return; + if ( + failure instanceof ClipDuplicateError || + (failure && + typeof failure === "object" && + "code" in failure && + failure.code === "clip_duplicate") + ) { + operation.current = null; + setDuplicate((failure as ClipDuplicateError).existing); + setOperationError(""); + } else { + const knownConflict = isConflict(failure); + setConflict(knownConflict); + setReloaded(false); + setOperationError(message(failure)); + if (!value.committing) operation.current = null; + } + } finally { + if (invocation === generation.current && alive.current) { + pending.current = false; + setBusy(false); + } + } + } + function start(input: ClipActionInput, expected = current.current) { + if (operation.current || pending.current || !expected) return; + const value: Operation = { + input: structuredClone(input), + expected: { epoch: expected.epoch, revision: expected.revision }, + intent: null, + committing: false, + }; + operation.current = value; + setConflict(false); + setReloaded(false); + void execute(value); + } + async function save(allowDuplicate = false) { + if (!current.current || pending.current || operation.current) return; + const input: ClipActionInput = { + kind: "create", + ...structuredClone(draftRef.current), + allowDuplicate, + }; + if (!allowDuplicate) { + start(input); + return; + } + const invocation = generation.current, + clickedDraft = draftRef.current; + pending.current = true; + setBusy(true); + let handedOff = false; + try { + const fresh = await trackOperation(() => read()); + if ( + !alive.current || + invocation !== generation.current || + draftRef.current !== clickedDraft + ) + return; + setDuplicate(null); + // One synchronous handoff: no edit/click can enter between the read and intent admission. + pending.current = false; + handedOff = true; + start(input, fresh); + } catch (failure) { + if (alive.current && invocation === generation.current) + setOperationError(message(failure)); + } finally { + if (!handedOff && alive.current && invocation === generation.current) { + pending.current = false; + setBusy(false); + } + } + } + async function reloadAction() { + const invocation = generation.current, + value = operation.current; + try { + await read(); + if ( + alive.current && + invocation === generation.current && + value === operation.current + ) + setReloaded(true); + } catch (failure) { + if (invocation === generation.current) + setOperationError(message(failure)); + } + } + function clearConflict() { + if (pending.current || !reloaded || !conflict) return; + operation.current = null; + setOperationError(""); + setConflict(false); + setReloaded(false); + } + async function copy(url: string) { + const invocation = generation.current, + selection = selectionScope.current; + const ownsFeedback = () => + alive.current && + invocation === generation.current && + selection === selectionScope.current; + try { + await navigator.clipboard.writeText(url); + if (ownsFeedback()) { + setSourceError(""); + setNotice({ text: "Link copied" }); + } + } catch { + if (ownsFeedback()) + setSourceError("Couldn’t copy the link. Select the URL and copy it."); + } + } + async function openOriginal(url: string) { + if (!onOpenExternal) return; + const invocation = generation.current, + selection = selectionScope.current; + const ownsFeedback = () => + alive.current && + invocation === generation.current && + selection === selectionScope.current; + try { + await trackOperation(() => onOpenExternal(url)); + if (ownsFeedback()) setSourceError(""); + } catch { + if (ownsFeedback()) + setSourceError( + "Couldn’t open the original page. Copy the link and open it in your browser.", + ); + } + } + async function prepareExport() { + if (exportPending.current) return; + const invocation = generation.current; + exportPending.current = true; + setExportBusy(true); + setExportError(""); + try { + await trackOperation(async () => { + const value = await read(); + const files = exportClipsFiles(value.clips); + if ( + invocation !== generation.current || + !alive.current || + value.epoch !== current.current?.epoch + ) + return; + if (!files.length) { + setExportError("There are no clips to export."); + return; + } + const archive: Record = {}; + for (const file of files) + archive[file.filename] = strToU8(file.content); + const blob = new Blob([zipSync(archive) as BlobPart], { + type: "application/zip", + }); + if (invocation !== generation.current || !alive.current) return; + clearDownload(); + const url = URL.createObjectURL(blob); + downloadUrl.current = url; + setDownload({ url, name: "orbyn-clips.zip" }); + setExportOpen(false); + setNotice({ text: "Clips export prepared" }); + }); + } catch (failure) { + if (invocation === generation.current) + setExportError(`Couldn’t export clips. ${message(failure)}`); + } finally { + if (invocation === generation.current) { + exportPending.current = false; + setExportBusy(false); + } + } + } + const { items, active, unread, visible } = useMemo(() => { + const items = snapshot?.clips.items ?? [], + active = items.filter((item) => !item.archivedAt), + unread = active.filter((item) => !item.readAt).length; + const visible = items + .filter((item) => + filter === "archived" + ? !!item.archivedAt + : !item.archivedAt && + (filter !== "unread" || !item.readAt) && + (filter !== "later" || item.readLater), + ) + .sort( + (a, b) => + Date.parse(b.createdAt) - Date.parse(a.createdAt) || + a.id.localeCompare(b.id), + ); + return { items, active, unread, visible }; + }, [snapshot, filter]); + const item = items.find((item) => item.id === selected), + undoItem = + notice?.undo && items.find((item) => item.id === notice.undo!.clipId), + canUndo = !!( + notice?.undo && + notice.undo.kind === "create" && + undoItem?.revision === 1 && + clock >= Date.parse(notice.undo.at) && + clock < Date.parse(notice.undo.at) + 5000 + ); + const recovery = ( + <> + {operationError && ( +
+

{operationError}

+ {operation.current && ( + <> +

+ {conflict + ? "Reload saved clips, then review your action before applying it again." + : "The result could not be confirmed. Reload, then retry the same action."} +

+ + {conflict ? ( + + ) : ( + + )} + + )} +
+ )} + + ); + if (closed) + return ( +
+

Clips is no longer available

+

+ Your account or workspace changed. Reopen Clips before making changes. +

+
+ ); + if (!snapshot) + return ( +
+

{error ? "Clips couldn’t open" : "Opening Clips"}

+ {error && ( + <> +

{error}

+ + + )} +
+ ); + return ( +
+
+
+

Clips

+ {unread} unread +
+
+ + +
+
+ {offline && ( +

+ You’re offline. Links and pasted text still save on this device. +

+ )} + {error && ( +

+ Couldn’t refresh clips. {error} +

+ )} + {!form && recovery} + {notice && ( +
+ {notice.text} + {notice.openId && items.some((item) => item.id === notice.openId) && ( + + )} + {canUndo && ( + + )} + +
+ )} + {download && ( +

+ + Download Clips export + + +

+ )} +
+ {( + [ + ["all", "All", active.length], + ["unread", "Unread", unread], + [ + "later", + "Read later", + active.filter((item) => item.readLater).length, + ], + [ + "archived", + "Archived", + items.filter((item) => item.archivedAt).length, + ], + ] as const + ).map(([key, label, count]) => ( + + ))} +
+ {item ? ( +
+
+ +
+ + + +
+
+
+

+ {host(item.originalUrl)} ·{" "} + {item.text === null ? "Link only" : "Pasted text"} · Saved{" "} + {new Date(item.createdAt).toLocaleDateString()} +

+

{item.title}

+

{item.originalUrl}

+
+ {onOpenExternal ? ( + + ) : ( + + Open original + + )} + +
+ {sourceError && ( +

+ {sourceError} +

+ )} + {item.text === null ? ( +

+ Only the link is saved. Open the original page to read it. +

+ ) : ( +
{item.text}
+ )} +
+
+ ) : visible.length ? ( +
+ {visible.map((clip) => ( + + ))} +
+ ) : ( +
+

+ {filter === "archived" + ? "No archived clips" + : filter === "unread" + ? "No unread clips" + : filter === "later" + ? "Nothing in read later" + : "No clips yet"} +

+

+ Save a link and any text you want to keep. Orbyn does not fetch the + page in this version. +

+ +
+ )} + {form && ( + +
+

+ The link and any text you paste are saved on this device. Orbyn + does not fetch the page in this version. +

+ + +