Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | 5x 5x 5x 5x 41x 41x 41x 41x 41x 41x 41x 41x 41x 41x 41x 41x 18x 41x 22x 9x 9x 9x 9x 9x 9x 9x 9x 2x 2x 1x 1x 1x 1x 1x 7x 6x 6x 6x 1x 9x 41x 6x 35x 13x 9x 22x 1x 1x 1x | "use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button, Input } from "@/components/ui";
import { quietLink } from "@/lib/design/quiet-link";
import { listPendingKeysForCurator, newClientKey } from "@/features/sorting/decisionQueue";
import { drain } from "@/features/sorting/decisionFlusher";
interface SaveAndShareProps {
/** ULID collection.id (the API never takes the slug). */
collectionId: string;
collectionSlug: string;
curatorId: string;
/** "<collection name>, <date>" — computed on the server so it never mismatches on hydrate. */
defaultName: string;
}
const STUMBLE = "That didn't save just now — nothing was lost. Try again whenever you like.";
// 409 save_key_conflict: this save's link was turned off since. Retrying the same key can never succeed.
const LINK_GONE = "The link from that save is no longer active — save again for a fresh one.";
// 409 nothing_to_save: nothing is ranked yet; the key was never used, so it stays.
const NOTHING_YET = "Nothing to save yet — sort a few first, then come back.";
/**
* Spec §4.4: one quiet button → name it → one server transaction that saves and
* returns the share link. The key is minted once per mount and reused on retry,
* so a double tap or a retried request returns the same link, never a second save
* (only a 409 save_key_conflict — that key's link is gone — mints a fresh key).
* Answers still queued on this device land first, so the save includes them.
*/
export function SaveAndShare({ collectionId, collectionSlug, curatorId, defaultName }: SaveAndShareProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState(defaultName);
const [saveKey, setSaveKey] = useState(newClientKey);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState<{ url: string; resultNumber: number } | null>(null);
const [copied, setCopied] = useState(false);
const opener = useRef<HTMLButtonElement>(null);
const savedHeading = useRef<HTMLHeadingElement>(null);
// Each step replaces the last one's controls; move focus with it (SC 2.4.3).
const [returnFocus, setReturnFocus] = useState(false);
useEffect(() => {
if (saved) savedHeading.current?.focus();
}, [saved]);
useEffect(() => {
if (returnFocus && !open) opener.current?.focus();
}, [returnFocus, open]);
async function save(event: FormEvent) {
event.preventDefault();
Iif (busy || name.trim() === "") return;
setBusy(true);
setError("");
try {
await drain(listPendingKeysForCurator(curatorId));
const res = await fetch(`/api/collections/${collectionId}/ranking/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), saveKey }),
});
if (res.status === 409) {
const { code } = (await res.json().catch(() => ({}))) as { code?: string };
if (code === "nothing_to_save") {
setError(NOTHING_YET);
return;
}
setSaveKey(newClientKey());
setError(LINK_GONE);
return;
}
if (!res.ok) throw new Error("save");
const body = (await res.json()) as { resultNumber: number; shareToken: string };
setSaved({ url: `${window.location.origin}/shared-results/${body.shareToken}`, resultNumber: body.resultNumber });
// The save carried the pins into a fresh draft; let the Ranking read it again.
router.refresh();
} catch {
setError(STUMBLE);
} finally {
setBusy(false);
}
}
async function copy(url: string) {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
setCopied(false); // the field stays selectable; copying by hand still works
}
}
if (saved) {
return (
<section aria-labelledby="saved-heading" className="w-full space-y-3 rounded-card border border-rim bg-glass p-4 sm:w-96">
<h2 id="saved-heading" ref={savedHeading} tabIndex={-1} className="font-display text-xl text-ink focus:outline-hidden">
Saved
</h2>
<Input label="Link to share" readOnly value={saved.url} onFocus={(e) => e.currentTarget.select()} />
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="secondary" onClick={() => void copy(saved.url)}>
{copied ? "Copied" : "Copy link"}
</Button>
<Link href={`/collections/${collectionSlug}/results/${saved.resultNumber}`} className={quietLink}>
See it saved
</Link>
</div>
</section>
);
}
if (!open) {
return (
<Button ref={opener} type="button" onClick={() => setOpen(true)}>
Save and share
</Button>
);
}
return (
<form onSubmit={save} className="w-full space-y-3 rounded-card border border-rim bg-glass p-4 sm:w-96">
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- the person just asked to name it */}
<Input label="Name" autoFocus value={name} maxLength={200} onChange={(e) => setName(e.target.value)} />
<div className="flex flex-wrap gap-3">
{/* aria-disabled while saving, not disabled: disabling the focused button drops focus to <body>. */}
<Button type="submit" disabled={name.trim() === ""} aria-disabled={busy || undefined}>
{busy ? "Saving…" : "Save"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
setReturnFocus(true);
}}
>
Not now
</Button>
</div>
{/* Always mounted (empty when idle) so the text is announced when it lands. */}
<p role="status" className="text-sm text-ink-soft">
{error}
</p>
</form>
);
}
|