1"use client";
2
3import { useEffect, useRef, useState } from "react";
4import { X } from "lucide-react";
5import { useOutboxStore } from "@/stores/offlineQueue";
6
7
8function describeItem(item: { method: string; path: string }): string {
9 const segs = item.path.split("?")[0]?.split("/").filter(Boolean) ?? [];
10 const root = segs[0] ?? "";
11 const id = segs[1] ?? "";
12 const noun = root === "comments" ? "a comment" : root === "blocks" ? "a block" : root === "records" ? "a record" : root === "pages" ? "a page" : `a ${root}`;
13 return `${item.method} ${id ? `to ${noun} ${id.slice(0, 8)}…` : `to create ${noun}`}`;
14}
15
16
22export function ReviewFailedDialog({ onClose }: { onClose: () => void }) {
23 const failedItems = useOutboxStore((s) => s.failedItems);
24 const retry = useOutboxStore((s) => s.retry);
25 const discard = useOutboxStore((s) => s.discard);
26 const [confirmKey, setConfirmKey] = useState<string | null>(null);
27 const closeRef = useRef<HTMLButtonElement>(null);
28
29 const items = failedItems();
30
31
32 useEffect(() => {
33 closeRef.current?.focus();
34 const onKey = (e: KeyboardEvent) => {
35 if (e.key === "Escape") onClose();
36 };
37 document.addEventListener("keydown", onKey);
38 return () => document.removeEventListener("keydown", onKey);
39 }, [onClose]);
40
41 return (
42 <div
43 className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
44 onMouseDown={(e) => {
45 if (e.target === e.currentTarget) onClose();
46 }}
47 >
48 <div
49 role="dialog"
50 aria-modal="true"
51 aria-label="Failed offline changes"
52 data-testid="review-failed-dialog"
53 className="w-full max-w-md rounded-lg border border-border bg-background p-4 shadow-lg"
54 >
55 <div className="mb-3 flex items-center justify-between">
56 <h2 className="text-sm font-semibold">
57 {items.length} change{items.length === 1 ? "" : "s"} couldn't be synced — Review
58 </h2>
59 <button
60 ref={closeRef}
61 type="button"
62 onClick={onClose}
63 aria-label="Close"
64 className="rounded p-1 text-foreground/60 hover:bg-accent hover:text-foreground"
65 >
66 <X className="size-4" aria-hidden />
67 </button>
68 </div>
69
70 <ul className="max-h-72 space-y-2 overflow-y-auto">
71 {items.length === 0 ? (
72 <li className="py-4 text-center text-sm text-muted-foreground">No failed changes.</li>
73 ) : (
74 items.map((item) => (
75 <li
76 key={item.key}
77 className="rounded-md border border-border p-2.5 text-sm"
78 >
79 <div className="font-medium">{describeItem(item)}</div>
80 {item.lastError ? (
81 <div className="mt-0.5 text-xs text-destructive">{item.lastError}</div>
82 ) : null}
83 <div className="mt-2 flex gap-2">
84 <button
85 type="button"
86 data-testid="failed-item-retry"
87 onClick={() => {
88 retry(item.key);
89
90 if (items.length === 1) onClose();
91 }}
92 className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
93 >
94 Retry
95 </button>
96 {confirmKey === item.key ? (
97 <button
98 type="button"
99 data-testid="failed-item-discard-confirm"
100 onClick={() => {
101 discard(item.key);
102 setConfirmKey(null);
103 if (items.length === 1) onClose();
104 }}
105 className="rounded-md border border-destructive px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10"
106 >
107 Confirm discard?
108 </button>
109 ) : (
110 <button
111 type="button"
112 data-testid="failed-item-discard"
113 onClick={() => setConfirmKey(item.key)}
114 className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground/70 hover:bg-accent"
115 >
116 Discard
117 </button>
118 )}
119 </div>
120 </li>
121 ))
122 )}
123 </ul>
124 </div>
125 </div>
126 );
127}
128