"use client"; import { CheckIcon, Loader2Icon, RefreshCwIcon } from "lucide-react"; import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type UnpluggedHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; export type UnpluggedStatus = "offline" | "connecting" | "online"; const hues: Record = { sky: "[--hue-hi:var(--color-sky-300)] [--hue:var(--color-sky-500)] [--hue-deep:var(--color-sky-700)]", emerald: "[--hue-hi:var(--color-emerald-300)] [--hue:var(--color-emerald-500)] [--hue-deep:var(--color-emerald-700)]", amber: "[--hue-hi:var(--color-amber-300)] [--hue:var(--color-amber-500)] [--hue-deep:var(--color-amber-700)]", violet: "[--hue-hi:var(--color-violet-300)] [--hue:var(--color-violet-500)] [--hue-deep:var(--color-violet-700)]", rose: "[--hue-hi:var(--color-rose-300)] [--hue:var(--color-rose-500)] [--hue-deep:var(--color-rose-700)]", orange: "[--hue-hi:var(--color-orange-300)] [--hue:var(--color-orange-500)] [--hue-deep:var(--color-orange-700)]", indigo: "[--hue-hi:var(--color-indigo-300)] [--hue:var(--color-indigo-500)] [--hue-deep:var(--color-indigo-700)]", teal: "[--hue-hi:var(--color-teal-300)] [--hue:var(--color-teal-500)] [--hue-deep:var(--color-teal-700)]", }; export interface UnpluggedProps { status?: UnpluggedStatus; onReconnect?: () => void; title?: string; description?: string; onlineTitle?: string; onlineDescription?: string; actionLabel?: string; hue?: UnpluggedHue; className?: string; } const W = 400; const H = 170; const CY = 80; const FACE = 250; const REACH = 72; const poses = { offline: { x: 130, y: CY + 8, r: 9 }, connecting: { x: FACE - REACH - 14, y: CY + 2, r: 2 }, online: { x: FACE - REACH, y: CY, r: 0 }, }; const pill: Record = { offline: { label: "Offline", dot: "bg-destructive" }, connecting: { label: "Reconnecting", dot: "bg-warning" }, online: { label: "Connected", dot: "bg-success" }, }; function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function Plug({ gradient }: { gradient: string }) { return ( ); } export function Unplugged({ status: statusProp, onReconnect, title = "You're offline", description = "Your edits are saved on this device and will sync once you're back.", onlineTitle = "You're back online", onlineDescription = "All 12 changes synced. Pick up right where you left off.", actionLabel = "Reconnect", hue = "sky", className, }: UnpluggedProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const inView = useInView(root); const [own, setOwn] = useState("offline"); const status = statusProp ?? own; const [clicks, setClicks] = useState(0); const settled = useRef(false); useEffect(() => { if (statusProp !== undefined || own !== "connecting") return; const t = setTimeout(() => setOwn("online"), 1100); return () => clearTimeout(t); }, [statusProp, own]); const px = useMotionValue(poses.offline.x); const py = useMotionValue(poses.offline.y); const rot = useMotionValue(poses.offline.r); const sway = useMotionValue(0); const angle = useTransform(() => rot.get() + sway.get()); const cable = useTransform(() => { const a = (angle.get() * Math.PI) / 180; const x = px.get(); const y = py.get(); return `M-80 ${CY + 74} C 20 ${CY + 76} ${x - 50 * Math.cos(a)} ${y - 50 * Math.sin(a)} ${x} ${y}`; }); useEffect(() => { const pose = poses[status]; if (reduce || !settled.current) { px.set(pose.x); py.set(pose.y); rot.set(pose.r); settled.current = true; return; } const spring = status === "online" ? { type: "spring" as const, stiffness: 520, damping: 34 } : status === "connecting" ? { type: "spring" as const, stiffness: 160, damping: 22 } : { type: "spring" as const, stiffness: 140, damping: 11, mass: 0.9 }; const runs = [animate(px, pose.x, spring), animate(py, pose.y, spring), animate(rot, pose.r, spring)]; if (status === "online") setClicks((c) => c + 1); return () => runs.forEach((r) => r.stop()); }, [status, reduce, px, py, rot]); useEffect(() => { if (reduce || status !== "offline" || !inView) { const c = animate(sway, 0, { duration: 0.3, ease: "easeOut" }); return () => c.stop(); } const c = animate(sway, [0, -4, 0], { delay: 0.9, duration: 3.4, ease: "easeInOut", repeat: Number.POSITIVE_INFINITY }); return () => c.stop(); }, [status, reduce, inView, sway]); const reconnect = () => { if (status !== "offline") return; if (statusProp === undefined) setOwn("connecting"); onReconnect?.(); }; const sparking = status === "offline" && inView && !reduce; const online = status === "online"; return (
{status === "connecting" && !reduce && ( )} {pill[status].label}
0 && !reduce ? { x: [0, 3, 0] } : { x: 0 }} key={`socket-${clicks}`} transition={{ duration: 0.22, ease: "easeOut", delay: 0.08 }} > {clicks > 0 && !reduce && ( {[-1, 1].map((side) => ( ))} )} {sparking && ( )}
{[ { key: "offline", show: !online, t: title, d: description }, { key: "online", show: online, t: onlineTitle, d: onlineDescription }, ].map((copy) => (

{copy.t}

{copy.d}

))}
); }