kasuti: stage 2 replay — shareable URL + copy/tweet buttons

A completed tour can now be serialised into a URL-safe base64 blob:
pattern id (with the custom pattern's edges inlined when the motif was
made in the editor), the start vertex, and the sequence of to-points.
Sides alternate so they don't need encoding. The payload lands in
?replay=<...> and is read back on mount — a hit on the URL hydrates
state, marks the tour complete, and the existing completion effect
picks up from there (confetti + autoplay).

Two new buttons in the replay panel:
- "Copy link" writes the URL to the clipboard with a sonner toast,
- "Share" opens an X intent pre-filled with the motif title, stitch
  count, and the URL.

Mounts a local SonnerToaster on the component so the toast actually
shows (sonner's Toaster was never wired up at the layout level).

https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw
This commit is contained in:
Claude 2026-04-22 11:36:37 +00:00
parent 38a312d214
commit bf20155423
No known key found for this signature in database

View file

@ -1,6 +1,7 @@
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { ChevronLeft, ChevronRight, Download, Pause, Play, Plus, RotateCcw, SkipBack, SkipForward, Undo2, Upload } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, Link as LinkIcon, Pause, Play, Plus, RotateCcw, Share2, SkipBack, SkipForward, Undo2, Upload } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -14,6 +15,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Toaster as SonnerToaster } from '@/components/ui/sonner';
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@ -74,6 +76,78 @@ function vEqual(a: V | null, b: V | null): boolean {
return a[0] === b[0] && a[1] === b[1]; return a[0] === b[0] && a[1] === b[1];
} }
// ─── Replay URL codec ───────────────────────────────────────────────────────
// The payload captures just enough to reconstruct a completed tour: which
// pattern (preset id, or "custom" with its edge list), the start vertex, and
// the sequence of to-points. Sides alternate front/back so they don't need
// encoding. Coordinates are placed coords on the 20×20 grid; placement is
// deterministic (bbox centering) so a snowflake reload lands on the same
// vertices that were recorded.
interface ReplayPayload {
v: 1;
p: string;
e?: Array<[[number, number], [number, number]]>;
s: [number, number];
t: Array<[number, number]>;
}
function b64urlEncode(s: string): string {
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function b64urlDecode(s: string): string {
const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice(0, (4 - (s.length % 4)) % 4);
return atob(padded);
}
function encodeReplay(
patternId: string,
customPattern: Pattern | null,
startVertex: V,
stitches: Stitch[]
): string {
const payload: ReplayPayload = {
v: 1,
p: patternId,
s: [startVertex[0], startVertex[1]],
t: stitches.map((st) => [st.to[0], st.to[1]]),
};
if (patternId === 'custom' && customPattern) {
payload.e = customPattern.edges.map(([a, b]) => [
[a[0], a[1]],
[b[0], b[1]],
]);
}
return b64urlEncode(JSON.stringify(payload));
}
function decodeReplay(raw: string): ReplayPayload | null {
try {
const obj = JSON.parse(b64urlDecode(raw)) as ReplayPayload;
if (!obj || obj.v !== 1) return null;
if (typeof obj.p !== 'string') return null;
if (!Array.isArray(obj.s) || obj.s.length !== 2) return null;
if (!Array.isArray(obj.t)) return null;
for (const pt of obj.t) {
if (!Array.isArray(pt) || pt.length !== 2) return null;
if (typeof pt[0] !== 'number' || typeof pt[1] !== 'number') return null;
}
if (obj.e !== undefined) {
if (!Array.isArray(obj.e)) return null;
for (const e of obj.e) {
if (!Array.isArray(e) || e.length !== 2) return null;
const [a, b] = e;
if (!Array.isArray(a) || !Array.isArray(b)) return null;
if (a.length !== 2 || b.length !== 2) return null;
}
}
return obj;
} catch {
return null;
}
}
// ─── Patterns ─────────────────────────────────────────────────────────────── // ─── Patterns ───────────────────────────────────────────────────────────────
const PATTERNS: Pattern[] = [ const PATTERNS: Pattern[] = [
@ -736,6 +810,37 @@ const KasutiEmbroidery: React.FC = () => {
celebratedRef.current = false; celebratedRef.current = false;
}, [stitches]); }, [stitches]);
// Build a shareable URL that hydrates to this tour on arrival.
const buildReplayUrl = useCallback((): string | null => {
if (!completed || !startVertex || typeof window === 'undefined') return null;
const encoded = encodeReplay(patternId, customPattern, startVertex, stitches);
const url = new URL(window.location.href);
url.search = '';
url.searchParams.set('replay', encoded);
return url.toString();
}, [completed, startVertex, patternId, customPattern, stitches]);
const copyReplayLink = useCallback(async () => {
const url = buildReplayUrl();
if (!url) return;
try {
await navigator.clipboard.writeText(url);
toast.success('Replay link copied to clipboard.');
} catch {
toast.error('Could not copy — your browser blocked clipboard access.');
}
}, [buildReplayUrl]);
const shareReplayOnX = useCallback(() => {
const url = buildReplayUrl();
if (!url) return;
const patternName =
visiblePatterns.find((p) => p.id === patternId)?.title ?? 'kasuti';
const text = `I just traced a ${patternName} kasuti tour in ${stitches.length} stitches — watch it replay:`;
const tweet = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
window.open(tweet, '_blank', 'noopener,noreferrer');
}, [buildReplayUrl, patternId, stitches.length, visiblePatterns]);
// On first completion: confetti + kick off an autoplay replay from step 0. // On first completion: confetti + kick off an autoplay replay from step 0.
// When the tour is un-completed (undo/reset/pattern change), tear down replay. // When the tour is un-completed (undo/reset/pattern change), tear down replay.
useEffect(() => { useEffect(() => {
@ -750,6 +855,66 @@ const KasutiEmbroidery: React.FC = () => {
} }
}, [completed]); }, [completed]);
// Hydrate from ?replay=... on mount. Runs once client-side; if the payload
// parses and the pattern is known (preset or carried inline), rebuild the
// stitch sequence and mark the tour complete — the completion effect above
// then takes care of confetti + autoplay.
const hydratedRef = useRef(false);
useEffect(() => {
if (hydratedRef.current) return;
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const raw = params.get('replay');
if (!raw) return;
const payload = decodeReplay(raw);
if (!payload) return;
let targetPattern: Pattern | null = null;
if (payload.p === 'custom' && payload.e) {
const customEdges: Array<readonly [V, V]> = payload.e.map(([a, b]) => [
[a[0], a[1]] as V,
[b[0], b[1]] as V,
]);
// Build a Pattern from the raw (already-placed) edges. bbox should be 0..max.
let maxR = 0;
let maxC = 0;
for (const [a, b] of customEdges) {
maxR = Math.max(maxR, a[0], b[0]);
maxC = Math.max(maxC, a[1], b[1]);
}
targetPattern = {
id: 'custom',
title: 'Custom',
description: `${customEdges.length} stitches`,
bbox: [maxR, maxC],
edges: customEdges,
};
setCustomPattern(targetPattern);
} else {
targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null;
}
if (!targetPattern) return;
setPatternId(payload.p);
// Reconstruct stitches: start at payload.s, alternate sides, each entry's
// `to` is the corresponding tour point.
const start: V = [payload.s[0], payload.s[1]];
const rebuilt: Stitch[] = [];
let from: V = start;
payload.t.forEach((pt, i) => {
const to: V = [pt[0], pt[1]];
rebuilt.push({ side: i % 2 === 0 ? 'front' : 'back', from, to });
from = to;
});
setStartVertex(start);
setCurrentVertex(from);
setActiveSide(rebuilt.length % 2 === 0 ? 'front' : 'back');
setStitches(rebuilt);
setCompleted(true);
hydratedRef.current = true;
}, []);
// Replay autoplay tick. // Replay autoplay tick.
useEffect(() => { useEffect(() => {
if (!completed || !replayPlaying) return; if (!completed || !replayPlaying) return;
@ -968,6 +1133,8 @@ const KasutiEmbroidery: React.FC = () => {
onIdxChange={setReplayIdx} onIdxChange={setReplayIdx}
onPlayingChange={setReplayPlaying} onPlayingChange={setReplayPlaying}
onSpeedChange={setReplaySpeed} onSpeedChange={setReplaySpeed}
onCopyLink={copyReplayLink}
onShareTweet={shareReplayOnX}
/> />
)} )}
@ -1027,6 +1194,7 @@ const KasutiEmbroidery: React.FC = () => {
</a> </a>
! !
</footer> </footer>
<SonnerToaster richColors position="bottom-center" />
</div> </div>
); );
}; };
@ -1041,6 +1209,8 @@ interface ReplayControlsProps {
onIdxChange: (idx: number) => void; onIdxChange: (idx: number) => void;
onPlayingChange: (playing: boolean) => void; onPlayingChange: (playing: boolean) => void;
onSpeedChange: (speed: number) => void; onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onShareTweet: () => void;
} }
const REPLAY_SPEEDS = [0.5, 1, 2] as const; const REPLAY_SPEEDS = [0.5, 1, 2] as const;
@ -1053,6 +1223,8 @@ const ReplayControls: React.FC<ReplayControlsProps> = ({
onIdxChange, onIdxChange,
onPlayingChange, onPlayingChange,
onSpeedChange, onSpeedChange,
onCopyLink,
onShareTweet,
}) => { }) => {
const atStart = replayIdx <= 0; const atStart = replayIdx <= 0;
const atEnd = replayIdx >= totalStitches; const atEnd = replayIdx >= totalStitches;
@ -1073,22 +1245,32 @@ const ReplayControls: React.FC<ReplayControlsProps> = ({
<p className="text-sm font-semibold text-green-800 dark:text-green-200"> <p className="text-sm font-semibold text-green-800 dark:text-green-200">
Tour complete in {totalStitches} stitches replay below. Tour complete in {totalStitches} stitches replay below.
</p> </p>
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="flex items-center gap-2 flex-wrap">
speed <Button variant="outline" size="sm" onClick={onCopyLink}>
{REPLAY_SPEEDS.map((s) => ( <LinkIcon className="w-4 h-4 mr-1" />
<button Copy link
key={s} </Button>
type="button" <Button variant="outline" size="sm" onClick={onShareTweet}>
onClick={() => onSpeedChange(s)} <Share2 className="w-4 h-4 mr-1" />
className={`px-2 py-0.5 rounded-md font-mono ${ Share
s === speed </Button>
? 'bg-foreground text-background' <div className="flex items-center gap-1 text-xs text-muted-foreground">
: 'hover:bg-muted' speed
}`} {REPLAY_SPEEDS.map((s) => (
> <button
{s === 1 ? '1×' : `${s}×`} key={s}
</button> type="button"
))} onClick={() => onSpeedChange(s)}
className={`px-2 py-0.5 rounded-md font-mono ${
s === speed
? 'bg-foreground text-background'
: 'hover:bg-muted'
}`}
>
{s === 1 ? '1×' : `${s}×`}
</button>
))}
</div>
</div> </div>
</div> </div>