1"use client";
2
3
14import { useCallback, useEffect, useRef } from "react";
15import type { ReactNode } from "react";
16import { AlertTriangle, Loader2, X } from "lucide-react";
17
18export interface DbAiDialogProps {
19 open: boolean;
20
21 title: string;
22 testId: string;
23 onClose: () => void;
24
25 children: ReactNode;
26
27 footer?: ReactNode;
28
29 loadingText?: string;
30
31 live: string;
32}
33
34export function DbAiDialog({ open, title, testId, onClose, children, footer, loadingText, live }: DbAiDialogProps) {
35 const dialogRef = useRef<HTMLDivElement>(null);
36 const triggerRef = useRef<HTMLElement | null>(null);
37
38 const close = useCallback(() => {
39 onClose();
40 requestAnimationFrame(() => {
41 triggerRef.current?.focus();
42 triggerRef.current = null;
43 });
44 }, [onClose]);
45
46 useEffect(() => {
47 if (!open) return;
48 const prev = document.activeElement as HTMLElement | null;
49 triggerRef.current = prev;
50 requestAnimationFrame(() => dialogRef.current?.querySelector<HTMLElement>("button, [tabindex], textarea, input")?.focus());
51 const onKey = (e: KeyboardEvent) => {
52 if (e.key === "Escape") {
53 e.stopPropagation();
54 close();
55 } else if (e.key === "Tab" && dialogRef.current) {
56 const focusables = dialogRef.current.querySelectorAll<HTMLElement>('button, [tabindex]:not([tabindex="-1"]), textarea, input');
57 if (focusables.length === 0) return;
58 const first = focusables[0];
59 const last = focusables[focusables.length - 1];
60 if (!first || !last) return;
61 if (e.shiftKey && document.activeElement === first) {
62 e.preventDefault();
63 last.focus();
64 } else if (!e.shiftKey && document.activeElement === last) {
65 e.preventDefault();
66 first.focus();
67 }
68 }
69 };
70 window.addEventListener("keydown", onKey);
71 return () => window.removeEventListener("keydown", onKey);
72 }, [open, close]);
73
74 if (!open) return null;
75
76 return (
77 <div className="fixed inset-0 z-[85] flex items-end justify-center sm:items-center" role="presentation">
78 <div className="absolute inset-0 bg-black/30" aria-hidden onClick={close} />
79 <div
80 ref={dialogRef}
81 data-testid={testId}
82 role="dialog"
83 aria-modal="true"
84 aria-label={title}
85 className="relative flex max-h-[85vh] w-full flex-col rounded-t-xl border border-border bg-background shadow-2xl sm:max-w-2xl sm:rounded-xl"
86 >
87 <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
88 <h2 className="text-sm font-semibold">{title}</h2>
89 <button
90 type="button"
91 onClick={close}
92 className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
93 aria-label="Close"
94 >
95 <X className="size-4" aria-hidden />
96 </button>
97 </div>
98
99 <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">{children}</div>
100
101 {footer ? <div className="flex shrink-0 items-center justify-between gap-2 border-t border-border p-3">{footer}</div> : null}
102
103 <div className="sr-only" role="status" aria-live="polite">
104 {live}
105 </div>
106 </div>
107 </div>
108 );
109}
110
111
112export function DbAiErrorBanner({ code, message, budget, retryable, onRetry }: { code: string; message: string; budget?: boolean; retryable?: boolean; onRetry?: () => void }) {
113 const heading = budget
114 ? "Monthly AI budget reached"
115 : code === "RATE_LIMITED"
116 ? "Sending too fast"
117 : code === "FORBIDDEN"
118 ? "You don't have permission"
119 : code === "AI_BUDGET_EXCEEDED" || code === "AI_BUDGET_MONTHLY_EXCEEDED"
120 ? "Monthly AI budget reached"
121 : "Something went wrong";
122 return (
123 <div
124 data-testid="db-ai-error-banner"
125 role="alert"
126 className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
127 >
128 <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
129 <div className="min-w-0 flex-1">
130 <p className="font-medium">{heading}</p>
131 <p className="mt-0.5 break-words">{message}</p>
132 {budget ? (
133 <p className="mt-1 text-[11px] opacity-80">
134 Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.
135 </p>
136 ) : null}
137 {code === "FORBIDDEN" ? (
138 <p className="mt-1 text-[11px] opacity-80">You need edit access to this workspace to use AI. Ask an admin for access.</p>
139 ) : null}
140 </div>
141 {retryable && onRetry ? (
142 <button
143 type="button"
144 onClick={onRetry}
145 className="inline-flex shrink-0 items-center gap-1 rounded border border-amber-400 px-2 py-1 font-medium hover:bg-amber-100 dark:hover:bg-amber-900/40"
146 >
147 Retry
148 </button>
149 ) : null}
150 </div>
151 );
152}
153
154
155export function DbAiLoading({ text, rows = 3 }: { text?: string; rows?: number }) {
156 return (
157 <div data-testid="db-ai-loading" className="space-y-2" role="status">
158 <p className="flex items-center gap-2 text-xs text-muted-foreground">
159 <Loader2 className="size-3.5 animate-spin" aria-hidden /> {text ?? "Generating…"}
160 </p>
161 {Array.from({ length: rows }).map((_, i) => (
162 <div key={i} className="animate-pulse rounded-md border border-border bg-muted/40 px-3 py-2.5">
163 <div className="mb-2 h-2.5 w-1/3 rounded bg-foreground/10" />
164 <div className="h-2.5 w-full rounded bg-foreground/10" />
165 <div className="mt-1.5 h-2.5 w-2/3 rounded bg-foreground/10" />
166 </div>
167 ))}
168 </div>
169 );
170}
171