TrikozikGames commited on
Commit
eddc354
·
1 Parent(s): b9ca8ef

Add source files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. components/studio/AdminPanel.tsx +355 -0
  2. components/studio/FeedbackDialog.tsx +52 -0
  3. components/studio/LoginDialog.tsx +155 -0
  4. components/studio/ProfileDialog.tsx +159 -0
  5. components/studio/ProjectsDialog.tsx +159 -0
  6. components/studio/Shape.tsx +58 -0
  7. components/studio/TattingStudio.tsx +1198 -0
  8. components/ui/accordion.tsx +51 -0
  9. components/ui/alert-dialog.tsx +115 -0
  10. components/ui/alert.tsx +49 -0
  11. components/ui/aspect-ratio.tsx +5 -0
  12. components/ui/avatar.tsx +47 -0
  13. components/ui/badge.tsx +32 -0
  14. components/ui/breadcrumb.tsx +101 -0
  15. components/ui/button.tsx +49 -0
  16. components/ui/calendar.tsx +177 -0
  17. components/ui/card.tsx +55 -0
  18. components/ui/carousel.tsx +240 -0
  19. components/ui/chart.tsx +331 -0
  20. components/ui/checkbox.tsx +26 -0
  21. components/ui/collapsible.tsx +11 -0
  22. components/ui/command.tsx +143 -0
  23. components/ui/context-menu.tsx +186 -0
  24. components/ui/dialog.tsx +104 -0
  25. components/ui/drawer.tsx +98 -0
  26. components/ui/dropdown-menu.tsx +187 -0
  27. components/ui/form.tsx +171 -0
  28. components/ui/hover-card.tsx +27 -0
  29. components/ui/input-otp.tsx +73 -0
  30. components/ui/input.tsx +22 -0
  31. components/ui/label.tsx +21 -0
  32. components/ui/menubar.tsx +228 -0
  33. components/ui/navigation-menu.tsx +120 -0
  34. components/ui/pagination.tsx +98 -0
  35. components/ui/popover.tsx +31 -0
  36. components/ui/progress.tsx +25 -0
  37. components/ui/radio-group.tsx +36 -0
  38. components/ui/resizable.tsx +37 -0
  39. components/ui/scroll-area.tsx +44 -0
  40. components/ui/select.tsx +152 -0
  41. components/ui/separator.tsx +24 -0
  42. components/ui/sheet.tsx +122 -0
  43. components/ui/sidebar.tsx +744 -0
  44. components/ui/skeleton.tsx +7 -0
  45. components/ui/slider.tsx +23 -0
  46. components/ui/sonner.tsx +23 -0
  47. components/ui/switch.tsx +27 -0
  48. components/ui/table.tsx +94 -0
  49. components/ui/tabs.tsx +53 -0
  50. components/ui/textarea.tsx +21 -0
components/studio/AdminPanel.tsx ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { Check, Copy, FolderOpen, KeyRound, MessageSquare, RotateCcw, Trash2, UserPlus, Users } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from "@/components/ui/alert-dialog";
14
+ import { Badge } from "@/components/ui/badge";
15
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
16
+ import { Input } from "@/components/ui/input";
17
+ import { Label } from "@/components/ui/label";
18
+ import {
19
+ Table,
20
+ TableBody,
21
+ TableCell,
22
+ TableHead,
23
+ TableHeader,
24
+ TableRow,
25
+ } from "@/components/ui/table";
26
+ import {
27
+ createUser,
28
+ deleteUser,
29
+ listUsers,
30
+ resetPassword,
31
+ roleLabel,
32
+ statusLabel,
33
+ type StoredUser,
34
+ } from "@/lib/auth-service";
35
+ import {
36
+ countProjectsByOwner,
37
+ formatStamp,
38
+ listProjects,
39
+ removeProjectsOfOwner,
40
+ type StoredProject,
41
+ } from "@/lib/studio-storage";
42
+ import { listFeedback, markFeedbackRead, removeFeedback, type FeedbackItem } from "@/lib/feedback-service";
43
+
44
+ type Props = {
45
+ open: boolean;
46
+ onOpenChange: (v: boolean) => void;
47
+ };
48
+
49
+ export default function AdminPanel({ open, onOpenChange }: Props) {
50
+ const [users, setUsers] = useState<StoredUser[]>([]);
51
+ const [counts, setCounts] = useState<Record<string, number>>({});
52
+ const [createOpen, setCreateOpen] = useState(false);
53
+ const [newLogin, setNewLogin] = useState("");
54
+ const [createError, setCreateError] = useState<string | null>(null);
55
+ const [credentials, setCredentials] = useState<{ username: string; password: string } | null>(null);
56
+ const [toDelete, setToDelete] = useState<StoredUser | null>(null);
57
+ const [viewing, setViewing] = useState<{ user: StoredUser; items: StoredProject[] } | null>(null);
58
+ const [feedback, setFeedback] = useState<FeedbackItem[]>([]);
59
+
60
+ const refresh = useCallback(() => {
61
+ setUsers(listUsers());
62
+ setCounts(countProjectsByOwner());
63
+ setFeedback(listFeedback());
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ if (open) refresh();
68
+ }, [open, refresh]);
69
+
70
+ const totalProjects = Object.values(counts).reduce((a, b) => a + b, 0);
71
+ const activeUsers = users.filter((u) => u.status === "active").length;
72
+ const pendingUsers = users.length - activeUsers;
73
+
74
+ const copy = (value: string) => {
75
+ void navigator.clipboard?.writeText(value);
76
+ };
77
+
78
+ const submitCreate = () => {
79
+ const result = createUser(newLogin);
80
+ if (!result.ok) {
81
+ setCreateError(result.error);
82
+ return;
83
+ }
84
+ setCreateError(null);
85
+ setCreateOpen(false);
86
+ setNewLogin("");
87
+ setCredentials({ username: result.user.username, password: result.tempPassword });
88
+ refresh();
89
+ };
90
+
91
+ return (
92
+ <>
93
+ <Dialog open={open} onOpenChange={onOpenChange}>
94
+ <DialogContent className="max-h-[88vh] max-w-5xl overflow-y-auto">
95
+ <DialogHeader>
96
+ <DialogTitle className="flex items-center gap-2 font-display text-2xl">
97
+ <Users className="h-6 w-6 text-accent" /> Панель админа
98
+ </DialogTitle>
99
+ <DialogDescription>Управление пользователями студии и обзор сохранённых схем.</DialogDescription>
100
+ </DialogHeader>
101
+
102
+ <div className="grid gap-3 sm:grid-cols-5">
103
+ {[
104
+ { label: "Пользователей", value: users.length },
105
+ { label: "Активных", value: activeUsers },
106
+ { label: "Ожидают входа", value: pendingUsers },
107
+ { label: "Всего схем", value: totalProjects },
108
+ { label: "Новых пожеланий", value: feedback.filter((f) => !f.read).length },
109
+ ].map((s) => (
110
+ <div key={s.label} className="rounded-2xl border border-border bg-secondary p-4">
111
+ <p className="text-xs text-muted-foreground">{s.label}</p>
112
+ <p className="font-display text-3xl leading-tight">{s.value}</p>
113
+ </div>
114
+ ))}
115
+ </div>
116
+
117
+ <section className="rounded-2xl border border-border">
118
+ <h3 className="flex items-center gap-2 border-b border-border px-4 py-2 font-display text-lg">
119
+ <MessageSquare className="h-5 w-5 text-accent" /> Пожелания пользователей
120
+ </h3>
121
+ {feedback.length === 0 ? (
122
+ <p className="px-4 py-6 text-center text-sm text-muted-foreground">Пока нет сообщений.</p>
123
+ ) : (
124
+ <ul className="max-h-64 divide-y divide-border overflow-y-auto">
125
+ {feedback.map((f) => (
126
+ <li key={f.id} className="flex items-start gap-3 px-4 py-2.5">
127
+ <div className="min-w-0 flex-1">
128
+ <p className="text-xs text-muted-foreground">
129
+ {f.username} · {formatStamp(f.createdAt)}
130
+ {!f.read && <Badge className="ml-2 align-middle">новое</Badge>}
131
+ </p>
132
+ <p className="whitespace-pre-wrap text-sm">{f.text}</p>
133
+ </div>
134
+ <div className="flex shrink-0 gap-1">
135
+ {!f.read && (
136
+ <Button
137
+ variant="outline"
138
+ size="icon"
139
+ className="h-8 w-8"
140
+ title="Отметить прочитанным"
141
+ onClick={() => setFeedback(markFeedbackRead(f.id))}
142
+ >
143
+ <Check className="h-4 w-4" />
144
+ </Button>
145
+ )}
146
+ <Button
147
+ variant="outline"
148
+ size="icon"
149
+ className="h-8 w-8 text-destructive"
150
+ title="Удалить сообщение"
151
+ onClick={() => setFeedback(removeFeedback(f.id))}
152
+ >
153
+ <Trash2 className="h-4 w-4" />
154
+ </Button>
155
+ </div>
156
+ </li>
157
+ ))}
158
+ </ul>
159
+ )}
160
+ </section>
161
+
162
+
163
+ <div>
164
+ <Button size="lg" className="h-12 gap-2" onClick={() => setCreateOpen(true)}>
165
+ <UserPlus className="h-5 w-5" /> Создать пользователя
166
+ </Button>
167
+ </div>
168
+
169
+ <div className="overflow-x-auto rounded-2xl border border-border">
170
+ <Table>
171
+ <TableHeader>
172
+ <TableRow>
173
+ <TableHead>Логин</TableHead>
174
+ <TableHead>Роль</TableHead>
175
+ <TableHead>Создан</TableHead>
176
+ <TableHead>Статус</TableHead>
177
+ <TableHead>Схемы</TableHead>
178
+ <TableHead className="text-right">Действия</TableHead>
179
+ </TableRow>
180
+ </TableHeader>
181
+ <TableBody>
182
+ {users.map((u) => (
183
+ <TableRow key={u.id}>
184
+ <TableCell className="font-medium">{u.username}</TableCell>
185
+ <TableCell>{roleLabel(u.role)}</TableCell>
186
+ <TableCell className="text-muted-foreground">{formatStamp(u.createdAt)}</TableCell>
187
+ <TableCell>
188
+ <Badge variant={u.status === "active" ? "secondary" : "outline"}>{statusLabel(u.status)}</Badge>
189
+ </TableCell>
190
+ <TableCell>{counts[u.id] ?? 0}</TableCell>
191
+ <TableCell>
192
+ <div className="flex flex-wrap justify-end gap-2">
193
+ <Button
194
+ variant="outline"
195
+ size="sm"
196
+ className="h-10 gap-1"
197
+ title="Сбросить пароль и выдать временный"
198
+ onClick={() => {
199
+ const password = resetPassword(u.id);
200
+ setCredentials({ username: u.username, password });
201
+ refresh();
202
+ }}
203
+ >
204
+ <RotateCcw className="h-4 w-4" /> Сбросить пароль
205
+ </Button>
206
+ <Button
207
+ variant="outline"
208
+ size="sm"
209
+ className="h-10 gap-1"
210
+ title="Просмотреть проекты пользователя"
211
+ onClick={() => setViewing({ user: u, items: listProjects(u.id) })}
212
+ >
213
+ <FolderOpen className="h-4 w-4" /> Просмотреть проекты
214
+ </Button>
215
+ <Button
216
+ variant="destructive"
217
+ size="sm"
218
+ className="h-10 gap-1"
219
+ disabled={u.role === "admin"}
220
+ title={
221
+ u.role === "admin" ? "Администратора удалить нельзя" : "Удалить пользователя и его схемы"
222
+ }
223
+ onClick={() => setToDelete(u)}
224
+ >
225
+ <Trash2 className="h-4 w-4" /> Удалить
226
+ </Button>
227
+ </div>
228
+ </TableCell>
229
+ </TableRow>
230
+ ))}
231
+ </TableBody>
232
+ </Table>
233
+ </div>
234
+ </DialogContent>
235
+ </Dialog>
236
+
237
+ {/* Создание пользователя */}
238
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
239
+ <DialogContent className="max-w-sm">
240
+ <DialogHeader>
241
+ <DialogTitle className="font-display text-2xl">Создать пользователя</DialogTitle>
242
+ <DialogDescription>
243
+ Укажите логин — система выдаст одноразовый временный пароль для первого входа.
244
+ </DialogDescription>
245
+ </DialogHeader>
246
+ <div className="space-y-2">
247
+ <Label htmlFor="new-user-login">Логин</Label>
248
+ <Input
249
+ id="new-user-login"
250
+ autoFocus
251
+ value={newLogin}
252
+ onChange={(e) => {
253
+ setNewLogin(e.target.value);
254
+ setCreateError(null);
255
+ }}
256
+ className="h-12"
257
+ />
258
+ {createError && <p className="text-sm text-destructive">{createError}</p>}
259
+ </div>
260
+ <Button size="lg" className="h-12 w-full" onClick={submitCreate}>
261
+ <UserPlus className="h-5 w-5" /> Создать и выдать пароль
262
+ </Button>
263
+ </DialogContent>
264
+ </Dialog>
265
+
266
+ {/* Показ временного пароля */}
267
+ <Dialog open={!!credentials} onOpenChange={(v) => !v && setCredentials(null)}>
268
+ <DialogContent className="max-w-sm">
269
+ <DialogHeader>
270
+ <DialogTitle className="flex items-center gap-2 font-display text-2xl">
271
+ <KeyRound className="h-6 w-6 text-accent" /> Временный пароль
272
+ </DialogTitle>
273
+ <DialogDescription>
274
+ Передайте пароль пользователю «{credentials?.username}». При первом входе он задаст постоянный пароль.
275
+ </DialogDescription>
276
+ </DialogHeader>
277
+ <div className="flex items-center gap-2">
278
+ <code className="flex-1 rounded-xl border border-border bg-secondary px-4 py-3 text-center text-lg tracking-widest">
279
+ {credentials?.password}
280
+ </code>
281
+ <Button
282
+ variant="outline"
283
+ size="lg"
284
+ className="h-12 gap-2"
285
+ title="Скопировать пароль"
286
+ onClick={() => credentials && copy(credentials.password)}
287
+ >
288
+ <Copy className="h-5 w-5" />
289
+ </Button>
290
+ </div>
291
+ <Button size="lg" className="h-12 w-full" onClick={() => setCredentials(null)}>
292
+ Готово
293
+ </Button>
294
+ </DialogContent>
295
+ </Dialog>
296
+
297
+ {/* Проекты пользователя */}
298
+ <Dialog open={!!viewing} onOpenChange={(v) => !v && setViewing(null)}>
299
+ <DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
300
+ <DialogHeader>
301
+ <DialogTitle className="font-display text-2xl">Проекты «{viewing?.user.username}»</DialogTitle>
302
+ <DialogDescription>Схемы, сохранённые этим пользователем.</DialogDescription>
303
+ </DialogHeader>
304
+ {viewing && viewing.items.length === 0 ? (
305
+ <p className="py-8 text-center text-sm text-muted-foreground">У пользователя пока нет схем.</p>
306
+ ) : (
307
+ <div className="grid gap-4 sm:grid-cols-3">
308
+ {viewing?.items.map((p) => (
309
+ <article key={p.id} className="overflow-hidden rounded-2xl border border-border bg-card">
310
+ <img
311
+ src={p.thumb}
312
+ alt={`Превью схемы «${p.title}»`}
313
+ className="aspect-[4/3] w-full bg-secondary object-cover"
314
+ />
315
+ <div className="space-y-1 p-3">
316
+ <h3 className="truncate font-medium">{p.title}</h3>
317
+ <p className="text-xs text-muted-foreground">
318
+ {formatStamp(p.updatedAt)} · элементов: {p.elements}
319
+ </p>
320
+ </div>
321
+ </article>
322
+ ))}
323
+ </div>
324
+ )}
325
+ </DialogContent>
326
+ </Dialog>
327
+
328
+ <AlertDialog open={!!toDelete} onOpenChange={(v) => !v && setToDelete(null)}>
329
+ <AlertDialogContent>
330
+ <AlertDialogHeader>
331
+ <AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
332
+ <AlertDialogDescription>
333
+ Пользователь «{toDelete?.username}» и все его схемы будут удалены безвозвратно.
334
+ </AlertDialogDescription>
335
+ </AlertDialogHeader>
336
+ <AlertDialogFooter>
337
+ <AlertDialogCancel>Отмена</AlertDialogCancel>
338
+ <AlertDialogAction
339
+ onClick={() => {
340
+ if (toDelete) {
341
+ removeProjectsOfOwner(toDelete.id);
342
+ deleteUser(toDelete.id);
343
+ }
344
+ setToDelete(null);
345
+ refresh();
346
+ }}
347
+ >
348
+ Удалить
349
+ </AlertDialogAction>
350
+ </AlertDialogFooter>
351
+ </AlertDialogContent>
352
+ </AlertDialog>
353
+ </>
354
+ );
355
+ }
components/studio/FeedbackDialog.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { MessageSquarePlus, Send } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
5
+ import { Textarea } from "@/components/ui/textarea";
6
+ import { addFeedback } from "@/lib/feedback-service";
7
+ import type { StoredUser } from "@/lib/auth-service";
8
+
9
+ type Props = {
10
+ open: boolean;
11
+ onOpenChange: (v: boolean) => void;
12
+ user: StoredUser;
13
+ };
14
+
15
+ export default function FeedbackDialog({ open, onOpenChange, user }: Props) {
16
+ const [text, setText] = useState("");
17
+ const [sent, setSent] = useState(false);
18
+
19
+ const submit = () => {
20
+ if (text.trim().length < 3) return;
21
+ addFeedback(user.id, user.username, text);
22
+ setText("");
23
+ setSent(true);
24
+ window.setTimeout(() => {
25
+ setSent(false);
26
+ onOpenChange(false);
27
+ }, 1200);
28
+ };
29
+
30
+ return (
31
+ <Dialog open={open} onOpenChange={onOpenChange}>
32
+ <DialogContent className="max-w-sm">
33
+ <DialogHeader>
34
+ <DialogTitle className="flex items-center gap-2 font-display text-2xl">
35
+ <MessageSquarePlus className="h-5 w-5 text-accent" /> Чего не хватает?
36
+ </DialogTitle>
37
+ <DialogDescription>Напишите пожелание — оно придёт администратору студии.</DialogDescription>
38
+ </DialogHeader>
39
+ <Textarea
40
+ rows={4}
41
+ autoFocus
42
+ value={text}
43
+ placeholder="Например: добавьте элемент «трилистник» и печать на A3"
44
+ onChange={(e) => setText(e.target.value)}
45
+ />
46
+ <Button className="h-11 w-full gap-2" disabled={text.trim().length < 3} onClick={submit}>
47
+ <Send className="h-4 w-4" /> {sent ? "Отправлено!" : "Отправить"}
48
+ </Button>
49
+ </DialogContent>
50
+ </Dialog>
51
+ );
52
+ }
components/studio/LoginDialog.tsx ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, type FormEvent } from "react";
2
+ import { KeyRound, ShieldCheck } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Checkbox } from "@/components/ui/checkbox";
5
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import { login, setPermanentPassword, type StoredUser } from "@/lib/auth-service";
9
+
10
+ export default function LoginDialog({ onSignedIn }: { onSignedIn: (user: StoredUser) => void }) {
11
+ const [username, setUsername] = useState("");
12
+ const [password, setPassword] = useState("");
13
+ const [remember, setRemember] = useState(true);
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ /** Пользователь вошёл по временному паролю — требуется постоянный. */
17
+ const [pending, setPending] = useState<StoredUser | null>(null);
18
+ const [next, setNext] = useState("");
19
+ const [confirm, setConfirm] = useState("");
20
+
21
+ const submitLogin = (e: FormEvent) => {
22
+ e.preventDefault();
23
+ const result = login(username, password, remember);
24
+ if (!result.ok) {
25
+ setError(result.error);
26
+ return;
27
+ }
28
+ setError(null);
29
+ if (result.mustChangePassword) {
30
+ setPending(result.user);
31
+ return;
32
+ }
33
+ onSignedIn(result.user);
34
+ };
35
+
36
+ const submitNewPassword = (e: FormEvent) => {
37
+ e.preventDefault();
38
+ if (!pending) return;
39
+ if (next !== confirm) {
40
+ setError("Пароли не совпадают.");
41
+ return;
42
+ }
43
+ const result = setPermanentPassword(pending.id, next);
44
+ if (!result.ok) {
45
+ setError(result.error ?? "Не удалось сохранить пароль.");
46
+ return;
47
+ }
48
+ onSignedIn({ ...pending, temporary: false, status: "active" });
49
+ };
50
+
51
+ if (pending) {
52
+ return (
53
+ <Dialog open>
54
+ <DialogContent className="max-w-sm [&>button]:hidden">
55
+ <DialogHeader>
56
+ <DialogTitle className="flex items-center gap-2 font-display text-2xl">
57
+ <ShieldCheck className="h-6 w-6 text-accent" /> Новый пароль
58
+ </DialogTitle>
59
+ <DialogDescription>
60
+ Вы вошли по временному паролю. Задайте постоянный пароль, чтобы продолжить работу.
61
+ </DialogDescription>
62
+ </DialogHeader>
63
+ <form onSubmit={submitNewPassword} className="space-y-4">
64
+ <div className="space-y-2">
65
+ <Label htmlFor="new-password">Новый пароль</Label>
66
+ <Input
67
+ id="new-password"
68
+ autoFocus
69
+ type="password"
70
+ value={next}
71
+ onChange={(e) => {
72
+ setNext(e.target.value);
73
+ setError(null);
74
+ }}
75
+ className="h-12"
76
+ />
77
+ </div>
78
+ <div className="space-y-2">
79
+ <Label htmlFor="confirm-password">Повторите пароль</Label>
80
+ <Input
81
+ id="confirm-password"
82
+ type="password"
83
+ value={confirm}
84
+ onChange={(e) => {
85
+ setConfirm(e.target.value);
86
+ setError(null);
87
+ }}
88
+ className="h-12"
89
+ />
90
+ </div>
91
+ {error && <p className="text-sm text-destructive">{error}</p>}
92
+ <Button type="submit" size="lg" className="h-12 w-full">
93
+ Сохранить и войти
94
+ </Button>
95
+ </form>
96
+ </DialogContent>
97
+ </Dialog>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <Dialog open>
103
+ <DialogContent className="max-w-sm [&>button]:hidden">
104
+ <DialogHeader>
105
+ <DialogTitle className="flex items-center gap-2 font-display text-2xl">
106
+ <KeyRound className="h-6 w-6 text-accent" /> RealKnot Studio
107
+ </DialogTitle>
108
+ <DialogDescription>Войдите в аккаунт, чтобы открыть студию и свои проекты.</DialogDescription>
109
+ </DialogHeader>
110
+ <form onSubmit={submitLogin} className="space-y-4">
111
+ <div className="space-y-2">
112
+ <Label htmlFor="login">Логин</Label>
113
+ <Input
114
+ id="login"
115
+ autoFocus
116
+ autoComplete="username"
117
+ placeholder="admin"
118
+ value={username}
119
+ onChange={(e) => {
120
+ setUsername(e.target.value);
121
+ setError(null);
122
+ }}
123
+ className="h-12"
124
+ />
125
+ </div>
126
+ <div className="space-y-2">
127
+ <Label htmlFor="password">Пароль</Label>
128
+ <Input
129
+ id="password"
130
+ type="password"
131
+ autoComplete="current-password"
132
+ value={password}
133
+ onChange={(e) => {
134
+ setPassword(e.target.value);
135
+ setError(null);
136
+ }}
137
+ className="h-12"
138
+ />
139
+ </div>
140
+ {error && <p className="text-sm text-destructive">{error}</p>}
141
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
142
+ <Checkbox checked={remember} onCheckedChange={(v) => setRemember(v === true)} />
143
+ Запомнить меня
144
+ </label>
145
+ <Button type="submit" size="lg" className="h-12 w-full">
146
+ Войти в студию
147
+ </Button>
148
+ <p className="text-center text-xs text-muted-foreground">
149
+ Аккаунт создаёт администратор в «Панели админа».
150
+ </p>
151
+ </form>
152
+ </DialogContent>
153
+ </Dialog>
154
+ );
155
+ }
components/studio/ProfileDialog.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Camera, FolderOpen, Save, UserRound } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import { Textarea } from "@/components/ui/textarea";
8
+ import { roleLabel, statusLabel, updateProfile, type StoredUser } from "@/lib/auth-service";
9
+ import { formatStamp, listProjects, type StoredProject } from "@/lib/studio-storage";
10
+
11
+ type Props = {
12
+ open: boolean;
13
+ onOpenChange: (v: boolean) => void;
14
+ user: StoredUser;
15
+ onUserChange: (u: StoredUser) => void;
16
+ onOpenProject: (p: StoredProject) => void;
17
+ };
18
+
19
+ export default function ProfileDialog({ open, onOpenChange, user, onUserChange, onOpenProject }: Props) {
20
+ const fileRef = useRef<HTMLInputElement | null>(null);
21
+ const [displayName, setDisplayName] = useState(user.displayName ?? user.username);
22
+ const [about, setAbout] = useState(user.about ?? "");
23
+ const [avatar, setAvatar] = useState(user.avatar ?? "");
24
+ const [items, setItems] = useState<StoredProject[]>([]);
25
+ const [savedAt, setSavedAt] = useState(0);
26
+
27
+ useEffect(() => {
28
+ if (!open) return;
29
+ setDisplayName(user.displayName ?? user.username);
30
+ setAbout(user.about ?? "");
31
+ setAvatar(user.avatar ?? "");
32
+ setItems(listProjects(user.id));
33
+ }, [open, user]);
34
+
35
+ const pickAvatar = (file: File) => {
36
+ const reader = new FileReader();
37
+ reader.onload = () => setAvatar(String(reader.result ?? ""));
38
+ reader.readAsDataURL(file);
39
+ };
40
+
41
+ const save = () => {
42
+ const next = updateProfile(user.id, { displayName: displayName.trim(), about: about.trim(), avatar });
43
+ if (next) onUserChange(next);
44
+ setSavedAt(Date.now());
45
+ };
46
+
47
+ return (
48
+ <Dialog open={open} onOpenChange={onOpenChange}>
49
+ <DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto">
50
+ <DialogHeader>
51
+ <DialogTitle className="font-display text-2xl">Профиль</DialogTitle>
52
+ <DialogDescription>Данные аккаунта, аватар и сохранённые работы.</DialogDescription>
53
+ </DialogHeader>
54
+
55
+ <input
56
+ ref={fileRef}
57
+ type="file"
58
+ accept="image/*"
59
+ className="hidden"
60
+ onChange={(e) => {
61
+ const f = e.target.files?.[0];
62
+ if (f) pickAvatar(f);
63
+ e.target.value = "";
64
+ }}
65
+ />
66
+
67
+ <div className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)]">
68
+ <div className="flex flex-col items-center gap-2">
69
+ <div className="grid h-24 w-24 place-items-center overflow-hidden rounded-full border border-border bg-secondary">
70
+ {avatar ? (
71
+ <img src={avatar} alt="Аватар пользователя" className="h-full w-full object-cover" />
72
+ ) : (
73
+ <UserRound className="h-10 w-10 text-muted-foreground" />
74
+ )}
75
+ </div>
76
+ <Button variant="outline" size="sm" className="h-9 gap-1.5" onClick={() => fileRef.current?.click()}>
77
+ <Camera className="h-4 w-4" /> Аватар
78
+ </Button>
79
+ </div>
80
+
81
+ <div className="space-y-3">
82
+ <div className="grid gap-2 sm:grid-cols-2">
83
+ <div className="space-y-1.5">
84
+ <Label htmlFor="pf-name">Отображаемое имя</Label>
85
+ <Input id="pf-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
86
+ </div>
87
+ <div className="space-y-1.5">
88
+ <Label>Логин</Label>
89
+ <Input value={user.username} readOnly className="text-muted-foreground" />
90
+ </div>
91
+ </div>
92
+ <div className="space-y-1.5">
93
+ <Label htmlFor="pf-about">О себе</Label>
94
+ <Textarea
95
+ id="pf-about"
96
+ rows={3}
97
+ value={about}
98
+ placeholder="Например: плету салфетки и мандалы"
99
+ onChange={(e) => setAbout(e.target.value)}
100
+ />
101
+ </div>
102
+ <dl className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
103
+ {[
104
+ { k: "Роль", v: roleLabel(user.role) },
105
+ { k: "Статус", v: statusLabel(user.status) },
106
+ { k: "Создан", v: formatStamp(user.createdAt) },
107
+ { k: "Схем", v: String(items.length) },
108
+ ].map((s) => (
109
+ <div key={s.k} className="rounded-lg border border-border bg-secondary px-2 py-1.5">
110
+ <dt className="text-muted-foreground">{s.k}</dt>
111
+ <dd className="truncate font-medium">{s.v}</dd>
112
+ </div>
113
+ ))}
114
+ </dl>
115
+ <div className="flex items-center gap-2">
116
+ <Button size="sm" className="h-10 gap-1.5" onClick={save}>
117
+ <Save className="h-4 w-4" /> Сохранить профиль
118
+ </Button>
119
+ {savedAt > 0 && <span className="text-xs text-muted-foreground">Изменения сохранены</span>}
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <div className="border-t border-border pt-3">
125
+ <h3 className="font-display text-lg">Мои работы</h3>
126
+ {items.length === 0 ? (
127
+ <p className="py-6 text-center text-sm text-muted-foreground">Пока нет сохранённых схем.</p>
128
+ ) : (
129
+ <div className="mt-2 grid gap-3 sm:grid-cols-3">
130
+ {items.map((p) => (
131
+ <article key={p.id} className="overflow-hidden rounded-xl border border-border bg-card">
132
+ <img
133
+ src={p.thumb}
134
+ alt={`Превью схемы «${p.title}»`}
135
+ className="aspect-[4/3] w-full bg-secondary object-cover"
136
+ />
137
+ <div className="space-y-1.5 p-2">
138
+ <h4 className="truncate text-sm font-medium">{p.title}</h4>
139
+ <p className="text-[11px] text-muted-foreground">{formatStamp(p.updatedAt)}</p>
140
+ <Button
141
+ size="sm"
142
+ className="h-8 w-full gap-1.5"
143
+ onClick={() => {
144
+ onOpenProject(p);
145
+ onOpenChange(false);
146
+ }}
147
+ >
148
+ <FolderOpen className="h-4 w-4" /> Открыть
149
+ </Button>
150
+ </div>
151
+ </article>
152
+ ))}
153
+ </div>
154
+ )}
155
+ </div>
156
+ </DialogContent>
157
+ </Dialog>
158
+ );
159
+ }
components/studio/ProjectsDialog.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Download, FolderOpen, Pencil, Trash2, Upload } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from "@/components/ui/alert-dialog";
14
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
15
+ import {
16
+ formatStamp,
17
+ listProjects,
18
+ removeProject,
19
+ renameProject,
20
+ type StoredProject,
21
+ } from "@/lib/studio-storage";
22
+
23
+ type Props = {
24
+ open: boolean;
25
+ onOpenChange: (v: boolean) => void;
26
+ onOpenProject: (p: StoredProject) => void;
27
+ onImportFile: () => void;
28
+ onSaveCurrent: () => void;
29
+ ownerId: string;
30
+ };
31
+
32
+ export default function ProjectsDialog({ open, onOpenChange, onOpenProject, onImportFile, onSaveCurrent, ownerId }: Props) {
33
+ const [items, setItems] = useState<StoredProject[]>([]);
34
+ const [toDelete, setToDelete] = useState<StoredProject | null>(null);
35
+
36
+ useEffect(() => {
37
+ if (open) setItems(listProjects(ownerId));
38
+ }, [open, ownerId]);
39
+
40
+ const download = (p: StoredProject) => {
41
+ const blob = new Blob([JSON.stringify(p.data, null, 2)], { type: "application/json" });
42
+ const a = document.createElement("a");
43
+ a.href = URL.createObjectURL(blob);
44
+ a.download = `${p.title || "proekt"}.json`;
45
+ a.click();
46
+ URL.revokeObjectURL(a.href);
47
+ };
48
+
49
+ const rename = (p: StoredProject) => {
50
+ const title = window.prompt("Новое название проекта", p.title);
51
+ if (title && title.trim()) setItems(renameProject(p.id, title.trim(), ownerId));
52
+ };
53
+
54
+ return (
55
+ <>
56
+ <Dialog open={open} onOpenChange={onOpenChange}>
57
+ <DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
58
+ <DialogHeader>
59
+ <DialogTitle className="font-display text-2xl">Мои проекты</DialogTitle>
60
+ <DialogDescription>Схемы, сохранённые в памяти этого браузера.</DialogDescription>
61
+ </DialogHeader>
62
+
63
+ <div className="flex flex-wrap gap-2">
64
+ <Button size="lg" className="h-12 gap-2" onClick={onSaveCurrent} title="Сохранить текущую схему в проекты">
65
+ <FolderOpen className="h-5 w-5" /> Сохранить текущую схему
66
+ </Button>
67
+ <Button
68
+ variant="outline"
69
+ size="lg"
70
+ className="h-12 gap-2"
71
+ onClick={onImportFile}
72
+ title="Загрузить проект из файла .json"
73
+ >
74
+ <Upload className="h-5 w-5" /> Загрузить .json
75
+ </Button>
76
+ </div>
77
+
78
+ {items.length === 0 ? (
79
+ <p className="py-8 text-center text-sm text-muted-foreground">
80
+ Пока нет сохранённых проектов. Создайте схему и нажмите «Сохранить текущую схему».
81
+ </p>
82
+ ) : (
83
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
84
+ {items.map((p) => (
85
+ <article key={p.id} className="overflow-hidden rounded-2xl border border-border bg-card">
86
+ <img
87
+ src={p.thumb}
88
+ alt={`Превью схемы «${p.title}»`}
89
+ className="aspect-[4/3] w-full bg-secondary object-cover"
90
+ />
91
+ <div className="space-y-2 p-3">
92
+ <h3 className="truncate font-medium">{p.title}</h3>
93
+ <p className="text-xs text-muted-foreground">
94
+ {formatStamp(p.updatedAt)} · элементов: {p.elements}
95
+ </p>
96
+ <div className="grid grid-cols-2 gap-2">
97
+ <Button size="sm" className="h-10 gap-1" onClick={() => onOpenProject(p)} title="Открыть проект">
98
+ <FolderOpen className="h-4 w-4" /> Открыть
99
+ </Button>
100
+ <Button
101
+ variant="outline"
102
+ size="sm"
103
+ className="h-10 gap-1"
104
+ onClick={() => rename(p)}
105
+ title="Переименовать проект"
106
+ >
107
+ <Pencil className="h-4 w-4" /> Переименовать
108
+ </Button>
109
+ <Button
110
+ variant="outline"
111
+ size="sm"
112
+ className="h-10 gap-1"
113
+ onClick={() => download(p)}
114
+ title="Скачать проект файлом .json"
115
+ >
116
+ <Download className="h-4 w-4" /> Скачать
117
+ </Button>
118
+ <Button
119
+ variant="destructive"
120
+ size="sm"
121
+ className="h-10 gap-1"
122
+ onClick={() => setToDelete(p)}
123
+ title="Удалить проект"
124
+ >
125
+ <Trash2 className="h-4 w-4" /> Удалить
126
+ </Button>
127
+ </div>
128
+ </div>
129
+ </article>
130
+ ))}
131
+ </div>
132
+ )}
133
+ </DialogContent>
134
+ </Dialog>
135
+
136
+ <AlertDialog open={!!toDelete} onOpenChange={(v) => !v && setToDelete(null)}>
137
+ <AlertDialogContent>
138
+ <AlertDialogHeader>
139
+ <AlertDialogTitle>Удалить проект?</AlertDialogTitle>
140
+ <AlertDialogDescription>
141
+ Вы уверены, что хотите удалить проект «{toDelete?.title}»? Действие нельзя отменить.
142
+ </AlertDialogDescription>
143
+ </AlertDialogHeader>
144
+ <AlertDialogFooter>
145
+ <AlertDialogCancel>Отмена</AlertDialogCancel>
146
+ <AlertDialogAction
147
+ onClick={() => {
148
+ if (toDelete) setItems(removeProject(toDelete.id, ownerId));
149
+ setToDelete(null);
150
+ }}
151
+ >
152
+ Удалить
153
+ </AlertDialogAction>
154
+ </AlertDialogFooter>
155
+ </AlertDialogContent>
156
+ </AlertDialog>
157
+ </>
158
+ );
159
+ }
components/studio/Shape.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ElementKind } from "@/lib/tatting";
2
+
3
+ type Props = {
4
+ kind: ElementKind;
5
+ picots: number;
6
+ radius: number;
7
+ color: string;
8
+ };
9
+
10
+ const r3 = (n: number) => Math.round(n * 1000) / 1000;
11
+
12
+ /** Renders a tatting motif centered at (0,0) in local coordinates. */
13
+ export function Shape({ kind, picots, radius, color }: Props) {
14
+ const picotR = Math.max(3, radius * 0.11);
15
+
16
+ if (kind === "ring") {
17
+ const dots = Array.from({ length: picots }, (_, i) => {
18
+ const a = (i / picots) * Math.PI * 2 - Math.PI / 2;
19
+ return {
20
+ cx: r3(Math.cos(a) * (radius + picotR * 0.7)),
21
+ cy: r3(Math.sin(a) * (radius + picotR * 0.7)),
22
+ };
23
+ });
24
+ return (
25
+ <g fill="none" stroke={color} strokeWidth={2.5} strokeLinecap="round">
26
+ <circle cx={0} cy={0} r={radius} />
27
+ <circle cx={0} cy={0} r={radius - 5} strokeWidth={1} opacity={0.45} />
28
+ {dots.map((d, i) => (
29
+ <circle key={i} cx={d.cx} cy={d.cy} r={picotR} />
30
+ ))}
31
+ </g>
32
+ );
33
+ }
34
+
35
+ // Arc: half-circle bow with picots along the outer edge
36
+ const start = { x: -radius, y: 0 };
37
+ const end = { x: radius, y: 0 };
38
+ const dots = Array.from({ length: picots }, (_, i) => {
39
+ const t = (i + 1) / (picots + 1);
40
+ const a = Math.PI - t * Math.PI;
41
+ const rr = radius + picotR * 0.7;
42
+ return { cx: r3(Math.cos(a) * rr), cy: r3(-Math.sin(a) * rr) };
43
+ });
44
+
45
+ return (
46
+ <g fill="none" stroke={color} strokeWidth={2.5} strokeLinecap="round">
47
+ <path d={`M ${start.x} ${start.y} A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`} />
48
+ <path
49
+ d={`M ${start.x + 5} 0 A ${radius - 5} ${radius - 5} 0 0 1 ${end.x - 5} 0`}
50
+ strokeWidth={1}
51
+ opacity={0.45}
52
+ />
53
+ {dots.map((d, i) => (
54
+ <circle key={i} cx={d.cx} cy={d.cy} r={picotR} />
55
+ ))}
56
+ </g>
57
+ );
58
+ }
components/studio/TattingStudio.tsx ADDED
@@ -0,0 +1,1198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fabric from "fabric";
2
+ import { jsPDF } from "jspdf";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ ArrowDownToLine,
6
+ ArrowUpToLine,
7
+ Cloud,
8
+ Copy,
9
+ Download,
10
+ Eraser,
11
+ FileDown,
12
+ FlipHorizontal,
13
+ FlipVertical,
14
+ FolderOpen,
15
+ Grid3x3,
16
+ Group,
17
+ HelpCircle,
18
+ Maximize,
19
+ MousePointer2,
20
+ Redo2,
21
+ RotateCw,
22
+ Ruler,
23
+ Save,
24
+ Scissors,
25
+ Trash2,
26
+ Undo2,
27
+ Ungroup,
28
+ ZoomIn,
29
+ ZoomOut,
30
+ LogOut,
31
+ Users,
32
+ UserRound,
33
+ MessageSquarePlus,
34
+ } from "lucide-react";
35
+ import { Button } from "@/components/ui/button";
36
+ import {
37
+ Dialog,
38
+ DialogContent,
39
+ DialogDescription,
40
+ DialogHeader,
41
+ DialogTitle,
42
+ } from "@/components/ui/dialog";
43
+ import { Slider } from "@/components/ui/slider";
44
+
45
+ import {
46
+ CATEGORIES,
47
+ LIBRARY,
48
+ SNAP_STEPS,
49
+ SYMMETRY_AXES,
50
+ THREADS,
51
+ threadMeters,
52
+ type ElDef,
53
+ type ThreadColor,
54
+ } from "@/lib/tatting-ru";
55
+ import { applyThread, applyWidth, buildElement, CUSTOM_PROPS, type TattingObject } from "@/lib/fabric-elements";
56
+ import { AUTOSAVE_KEY, newId, upsertProject, type StoredProject } from "@/lib/studio-storage";
57
+ import { currentUser, logout, roleLabel, type StoredUser } from "@/lib/auth-service";
58
+ import LoginDialog from "@/components/studio/LoginDialog";
59
+ import AdminPanel from "@/components/studio/AdminPanel";
60
+ import ProjectsDialog from "@/components/studio/ProjectsDialog";
61
+ import ProfileDialog from "@/components/studio/ProfileDialog";
62
+ import FeedbackDialog from "@/components/studio/FeedbackDialog";
63
+
64
+ const W = 1200;
65
+ const H = 850;
66
+
67
+ type Theme = "light" | "dark" | "grid";
68
+
69
+ const THEME_CFG: Record<Theme, { bg: string; line: string; bold: string; label: string; hint: string }> = {
70
+ light: { bg: "#fdfaf3", line: "#e6dfd0", bold: "#d6cbb4", label: "Светлая", hint: "Светлая тема" },
71
+ dark: { bg: "#14161b", line: "#2a2f3a", bold: "#3f4757", label: "Тёмная", hint: "Тёмная тема с контрастной сеткой" },
72
+ grid: { bg: "#eef5fb", line: "#c3d9ee", bold: "#8fb6dc", label: "Миллиметровка", hint: "Чертёжная миллиметровка" },
73
+ };
74
+
75
+ export default function TattingStudio() {
76
+ const elRef = useRef<HTMLCanvasElement | null>(null);
77
+ const wrapRef = useRef<HTMLDivElement | null>(null);
78
+ const fcRef = useRef<fabric.Canvas | null>(null);
79
+ const history = useRef<{ stack: string[]; index: number; lock: boolean }>({ stack: [], index: -1, lock: false });
80
+ const fileRef = useRef<HTMLInputElement | null>(null);
81
+ const pinch = useRef<{ dist: number; x: number; y: number } | null>(null);
82
+ const drag = useRef<{ x: number; y: number } | null>(null);
83
+ const savedTimer = useRef<number | null>(null);
84
+
85
+ const [unlocked, setUnlocked] = useState(true);
86
+ const [theme, setTheme] = useState<Theme>("light");
87
+ const [snap, setSnap] = useState(10);
88
+ const [showGrid, setShowGrid] = useState(true);
89
+ const [showRulers, setShowRulers] = useState(true);
90
+ const [zoom, setZoom] = useState(1);
91
+ const [pan, setPan] = useState({ x: 0, y: 0 });
92
+ const [panning, setPanning] = useState(false);
93
+ const [booting, setBooting] = useState(true);
94
+ const [symmetry, setSymmetry] = useState(1);
95
+ const [thread, setThread] = useState<ThreadColor>(THREADS[0]!);
96
+ const [width, setWidth] = useState(3);
97
+ const [selected, setSelected] = useState<{ name: string; knots: number; thread: string } | null>(null);
98
+ const [stats, setStats] = useState({ knots: 0, pathPx: 0, count: 0 });
99
+ const [canUndo, setCanUndo] = useState(false);
100
+ const [canRedo, setCanRedo] = useState(false);
101
+ const [category, setCategory] = useState<string>(CATEGORIES[0]!);
102
+ const [helpOpen, setHelpOpen] = useState(false);
103
+ const [projectsOpen, setProjectsOpen] = useState(false);
104
+ const [profileOpen, setProfileOpen] = useState(false);
105
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
106
+ const [saved, setSaved] = useState(false);
107
+ const [user, setUser] = useState<StoredUser | null>(null);
108
+ const [adminOpen, setAdminOpen] = useState(false);
109
+
110
+ useEffect(() => {
111
+ const session = currentUser();
112
+ setUser(session);
113
+ setUnlocked(!!session && !session.temporary);
114
+ }, []);
115
+
116
+ const drawGrid = useCallback((canvas: fabric.Canvas, step: number, th: Theme, visible: boolean) => {
117
+ const cfg = THEME_CFG[th];
118
+ canvas.backgroundColor = cfg.bg;
119
+ canvas.getObjects().forEach((o) => {
120
+ if ((o as TattingObject).isGrid) canvas.remove(o);
121
+ });
122
+ if (visible) {
123
+ const lines: fabric.FabricObject[] = [];
124
+ const mk = (coords: [number, number, number, number], bold: boolean) => {
125
+ const l = new fabric.Line(coords, {
126
+ stroke: bold ? cfg.bold : cfg.line,
127
+ strokeWidth: bold ? 1 : 0.5,
128
+ selectable: false,
129
+ evented: false,
130
+ }) as unknown as TattingObject;
131
+ l.isGrid = true;
132
+ lines.push(l as unknown as fabric.FabricObject);
133
+ };
134
+ for (let x = 0; x <= W; x += step) mk([x, 0, x, H], x % (step * 5) === 0);
135
+ for (let y = 0; y <= H; y += step) mk([0, y, W, y], y % (step * 5) === 0);
136
+ lines.forEach((l) => {
137
+ canvas.add(l);
138
+ canvas.sendObjectToBack(l);
139
+ });
140
+ }
141
+ canvas.requestRenderAll();
142
+ }, []);
143
+
144
+ const recalc = useCallback((canvas: fabric.Canvas) => {
145
+ const objs = canvas.getObjects().filter((o) => !(o as TattingObject).isGrid);
146
+ const knots = objs.reduce((s, o) => s + ((o as TattingObject).knots ?? 0), 0);
147
+ const pathPx = objs.reduce((s, o) => s + ((o as TattingObject).pathPx ?? 0) * (o.scaleX ?? 1), 0);
148
+ setStats({ knots, pathPx, count: objs.length });
149
+ }, []);
150
+
151
+ /** автосохранение текущей схемы в память браузера */
152
+ const persist = useCallback((canvas: fabric.Canvas) => {
153
+ try {
154
+ const data = canvas.toObject(CUSTOM_PROPS) as { objects: { isGrid?: boolean }[] };
155
+ data.objects = data.objects.filter((o) => !o.isGrid);
156
+ localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
157
+ setSaved(true);
158
+ if (savedTimer.current) window.clearTimeout(savedTimer.current);
159
+ savedTimer.current = window.setTimeout(() => setSaved(false), 1400);
160
+ } catch {
161
+ /* приватный режим — просто пропускаем */
162
+ }
163
+ }, []);
164
+
165
+ const snapshot = useCallback(
166
+ (canvas: fabric.Canvas) => {
167
+ if (history.current.lock) return;
168
+ const json = JSON.stringify(canvas.toObject(CUSTOM_PROPS));
169
+ const h = history.current;
170
+ h.stack = h.stack.slice(0, h.index + 1);
171
+ h.stack.push(json);
172
+ if (h.stack.length > 40) h.stack.shift();
173
+ h.index = h.stack.length - 1;
174
+ setCanUndo(h.index > 0);
175
+ setCanRedo(false);
176
+ recalc(canvas);
177
+ persist(canvas);
178
+ },
179
+ [recalc, persist],
180
+ );
181
+
182
+ // init
183
+ useEffect(() => {
184
+ if (!unlocked || !elRef.current) return;
185
+ const canvas = new fabric.Canvas(elRef.current, {
186
+ width: W,
187
+ height: H,
188
+ backgroundColor: THEME_CFG.light.bg,
189
+ preserveObjectStacking: true,
190
+ });
191
+ fcRef.current = canvas;
192
+ drawGrid(canvas, 10, "light", true);
193
+
194
+ const onSelect = () => {
195
+ const a = canvas.getActiveObject() as TattingObject | undefined;
196
+ setSelected(
197
+ a && a.elName
198
+ ? { name: a.elName, knots: a.knots ?? 0, thread: a.threadName ?? "—" }
199
+ : a
200
+ ? { name: "Несколько элементов", knots: 0, thread: "—" }
201
+ : null,
202
+ );
203
+ };
204
+ canvas.on("selection:created", onSelect);
205
+ canvas.on("selection:updated", onSelect);
206
+ canvas.on("selection:cleared", () => setSelected(null));
207
+ canvas.on("object:added", () => snapshot(canvas));
208
+ canvas.on("object:modified", () => snapshot(canvas));
209
+ canvas.on("object:removed", () => snapshot(canvas));
210
+
211
+ const stored = typeof localStorage !== "undefined" ? localStorage.getItem(AUTOSAVE_KEY) : null;
212
+ if (stored) {
213
+ history.current.lock = true;
214
+ void canvas
215
+ .loadFromJSON(stored)
216
+ .then(() => {
217
+ drawGrid(canvas, 10, "light", true);
218
+ canvas.requestRenderAll();
219
+ history.current.lock = false;
220
+ snapshot(canvas);
221
+ })
222
+ .catch(() => {
223
+ history.current.lock = false;
224
+ })
225
+ .finally(() => window.setTimeout(() => setBooting(false), 220));
226
+ } else {
227
+ window.setTimeout(() => setBooting(false), 220);
228
+ }
229
+
230
+
231
+ return () => {
232
+ void canvas.dispose();
233
+ fcRef.current = null;
234
+ };
235
+ }, [unlocked, drawGrid, snapshot]);
236
+
237
+ // привязка к сетке
238
+ useEffect(() => {
239
+ const canvas = fcRef.current;
240
+ if (!canvas) return;
241
+ const handler = (e: { target: fabric.FabricObject }) => {
242
+ const t = e.target;
243
+ t.set({ left: Math.round(t.left / snap) * snap, top: Math.round(t.top / snap) * snap });
244
+ };
245
+ canvas.on("object:moving", handler as never);
246
+ return () => canvas.off("object:moving", handler as never);
247
+ }, [snap]);
248
+
249
+ // тема / шаг сетки
250
+ useEffect(() => {
251
+ const canvas = fcRef.current;
252
+ if (!canvas) return;
253
+ history.current.lock = true;
254
+ drawGrid(canvas, snap, theme, showGrid);
255
+ history.current.lock = false;
256
+ document.documentElement.classList.toggle("dark", theme === "dark");
257
+ }, [theme, snap, showGrid, drawGrid]);
258
+
259
+ /** не даём уехать схеме за пределы окна: сцена всегда перекрывает вид */
260
+ const clampVpt = useCallback((canvas: fabric.Canvas) => {
261
+ const z = canvas.getZoom();
262
+ const vpt = canvas.viewportTransform;
263
+ const limX = Math.max(0, W * z - W);
264
+ const limY = Math.max(0, H * z - H);
265
+ vpt[4] = Math.min(W * 0.25, Math.max(-limX - W * 0.25, vpt[4]));
266
+ vpt[5] = Math.min(H * 0.25, Math.max(-limY - H * 0.25, vpt[5]));
267
+ canvas.setViewportTransform(vpt);
268
+ setZoom(z);
269
+ setPan({ x: vpt[4], y: vpt[5] });
270
+ }, []);
271
+
272
+ const applyZoom = useCallback(
273
+ (next: number, px?: number, py?: number) => {
274
+ const canvas = fcRef.current;
275
+ if (!canvas) return;
276
+ const z = Math.min(6, Math.max(0.3, next));
277
+ canvas.zoomToPoint(new fabric.Point(px ?? W / 2, py ?? H / 2), z);
278
+ clampVpt(canvas);
279
+ canvas.requestRenderAll();
280
+ },
281
+ [clampVpt],
282
+ );
283
+
284
+ const panBy = useCallback(
285
+ (dx: number, dy: number) => {
286
+ const canvas = fcRef.current;
287
+ if (!canvas) return;
288
+ const vpt = canvas.viewportTransform;
289
+ vpt[4] += dx;
290
+ vpt[5] += dy;
291
+ clampVpt(canvas);
292
+ canvas.requestRenderAll();
293
+ },
294
+ [clampVpt],
295
+ );
296
+
297
+ const fitToScreen = useCallback(() => {
298
+ const canvas = fcRef.current;
299
+ if (!canvas) return;
300
+ canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
301
+ setZoom(1);
302
+ setPan({ x: 0, y: 0 });
303
+ canvas.requestRenderAll();
304
+ }, []);
305
+
306
+ // колесо мыши, панорама правой кнопкой и жесты касания
307
+ useEffect(() => {
308
+ const wrap = wrapRef.current;
309
+ if (!wrap || !unlocked) return;
310
+ const scale = () => {
311
+ const r = wrap.getBoundingClientRect();
312
+ return { kx: W / (r.width || 1), ky: H / (r.height || 1), r };
313
+ };
314
+ const toScene = (cx: number, cy: number) => {
315
+ const { kx, ky, r } = scale();
316
+ return { x: (cx - r.left) * kx, y: (cy - r.top) * ky };
317
+ };
318
+
319
+ const onWheel = (e: WheelEvent) => {
320
+ const canvas = fcRef.current;
321
+ if (!canvas) return;
322
+ e.preventDefault();
323
+ const dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1);
324
+ const p = toScene(e.clientX, e.clientY);
325
+ applyZoom(canvas.getZoom() * Math.exp(-dy * 0.0015), p.x, p.y);
326
+ };
327
+
328
+ // правая кнопка мыши — перетаскивание холста
329
+ const onDown = (e: PointerEvent) => {
330
+ if (e.button !== 2 && e.button !== 1) return;
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ drag.current = { x: e.clientX, y: e.clientY };
334
+ setPanning(true);
335
+ wrap.setPointerCapture(e.pointerId);
336
+ };
337
+ const onPointerMove = (e: PointerEvent) => {
338
+ if (!drag.current) return;
339
+ e.preventDefault();
340
+ const { kx, ky } = scale();
341
+ panBy((e.clientX - drag.current.x) * kx, (e.clientY - drag.current.y) * ky);
342
+ drag.current = { x: e.clientX, y: e.clientY };
343
+ };
344
+ const onUp = () => {
345
+ drag.current = null;
346
+ setPanning(false);
347
+ };
348
+ const onContext = (e: MouseEvent) => e.preventDefault();
349
+
350
+ const dist = (t: TouchList) => Math.hypot(t[0]!.clientX - t[1]!.clientX, t[0]!.clientY - t[1]!.clientY);
351
+ const mid = (t: TouchList) => ({
352
+ x: (t[0]!.clientX + t[1]!.clientX) / 2,
353
+ y: (t[0]!.clientY + t[1]!.clientY) / 2,
354
+ });
355
+ const onStart = (e: TouchEvent) => {
356
+ if (e.touches.length === 2) {
357
+ const m = mid(e.touches);
358
+ const c = toScene(m.x, m.y);
359
+ pinch.current = { dist: dist(e.touches), x: c.x, y: c.y };
360
+ drag.current = { x: m.x, y: m.y };
361
+ }
362
+ };
363
+ const onTouchMove = (e: TouchEvent) => {
364
+ const canvas = fcRef.current;
365
+ if (!canvas || e.touches.length !== 2 || !pinch.current || !drag.current) return;
366
+ e.preventDefault();
367
+ const d = dist(e.touches);
368
+ const m = mid(e.touches);
369
+ const c = toScene(m.x, m.y);
370
+ const { kx, ky } = scale();
371
+ applyZoom(canvas.getZoom() * (d / pinch.current.dist), c.x, c.y);
372
+ panBy((m.x - drag.current.x) * kx, (m.y - drag.current.y) * ky);
373
+ pinch.current = { dist: d, x: c.x, y: c.y };
374
+ drag.current = { x: m.x, y: m.y };
375
+ };
376
+ const onEnd = () => {
377
+ pinch.current = null;
378
+ drag.current = null;
379
+ };
380
+
381
+ wrap.addEventListener("wheel", onWheel, { passive: false });
382
+ wrap.addEventListener("pointerdown", onDown, true);
383
+ wrap.addEventListener("pointermove", onPointerMove, true);
384
+ wrap.addEventListener("pointerup", onUp, true);
385
+ wrap.addEventListener("pointercancel", onUp, true);
386
+ wrap.addEventListener("contextmenu", onContext);
387
+ wrap.addEventListener("touchstart", onStart, { passive: false });
388
+ wrap.addEventListener("touchmove", onTouchMove, { passive: false });
389
+ wrap.addEventListener("touchend", onEnd);
390
+ return () => {
391
+ wrap.removeEventListener("wheel", onWheel);
392
+ wrap.removeEventListener("pointerdown", onDown, true);
393
+ wrap.removeEventListener("pointermove", onPointerMove, true);
394
+ wrap.removeEventListener("pointerup", onUp, true);
395
+ wrap.removeEventListener("pointercancel", onUp, true);
396
+ wrap.removeEventListener("contextmenu", onContext);
397
+ wrap.removeEventListener("touchstart", onStart);
398
+ wrap.removeEventListener("touchmove", onTouchMove);
399
+ wrap.removeEventListener("touchend", onEnd);
400
+ };
401
+ }, [unlocked, applyZoom, panBy]);
402
+
403
+
404
+ const addElement = (def: ElDef) => {
405
+ const canvas = fcRef.current;
406
+ if (!canvas) return;
407
+ const cx = W / 2;
408
+ const cy = H / 2;
409
+ const axes = def.kind === "text" ? 1 : symmetry;
410
+ if (axes <= 1) {
411
+ const obj = buildElement(def, thread, width);
412
+ obj.set({ left: cx, top: cy });
413
+ canvas.add(obj);
414
+ canvas.setActiveObject(obj);
415
+ } else {
416
+ const dist = 160;
417
+ for (let i = 0; i < axes; i++) {
418
+ const a = (i / axes) * Math.PI * 2;
419
+ const obj = buildElement(def, thread, width);
420
+ obj.set({
421
+ left: Math.round((cx + Math.cos(a) * dist) / snap) * snap,
422
+ top: Math.round((cy + Math.sin(a) * dist) / snap) * snap,
423
+ angle: (a * 180) / Math.PI + 90,
424
+ });
425
+ canvas.add(obj);
426
+ }
427
+ }
428
+ canvas.requestRenderAll();
429
+ };
430
+
431
+ const withActive = (fn: (obj: TattingObject, canvas: fabric.Canvas) => void) => {
432
+ const canvas = fcRef.current;
433
+ const a = canvas?.getActiveObject() as TattingObject | undefined;
434
+ if (!canvas || !a) return;
435
+ fn(a, canvas);
436
+ canvas.requestRenderAll();
437
+ };
438
+
439
+ const duplicate = () =>
440
+ withActive(async (a, canvas) => {
441
+ const clone = (await a.clone(CUSTOM_PROPS as never)) as TattingObject;
442
+ clone.set({ left: a.left + snap * 3, top: a.top + snap * 3 });
443
+ canvas.add(clone);
444
+ canvas.setActiveObject(clone);
445
+ canvas.requestRenderAll();
446
+ });
447
+
448
+ const groupSelected = () => {
449
+ const canvas = fcRef.current;
450
+ const a = canvas?.getActiveObject();
451
+ if (!canvas || !a || a.type !== "activeselection") return;
452
+ const sel = a as fabric.ActiveSelection;
453
+ const g = new fabric.Group(sel.removeAll()) as TattingObject;
454
+ g.elName = "Группа элементов";
455
+ g.knots = 0;
456
+ canvas.add(g);
457
+ canvas.setActiveObject(g);
458
+ canvas.requestRenderAll();
459
+ snapshot(canvas);
460
+ };
461
+
462
+ const ungroupSelected = () => {
463
+ const canvas = fcRef.current;
464
+ const a = canvas?.getActiveObject() as fabric.Group | undefined;
465
+ if (!canvas || !a || a.type !== "group") return;
466
+ const items = a.removeAll();
467
+ canvas.remove(a);
468
+ items.forEach((o) => canvas.add(o));
469
+ canvas.discardActiveObject();
470
+ canvas.requestRenderAll();
471
+ snapshot(canvas);
472
+ };
473
+
474
+ const restore = (dir: -1 | 1) => {
475
+ const canvas = fcRef.current;
476
+ const h = history.current;
477
+ const next = h.index + dir;
478
+ if (!canvas || next < 0 || next >= h.stack.length) return;
479
+ h.index = next;
480
+ h.lock = true;
481
+ void canvas.loadFromJSON(h.stack[next]!).then(() => {
482
+ drawGrid(canvas, snap, theme, showGrid);
483
+ canvas.requestRenderAll();
484
+ h.lock = false;
485
+ setCanUndo(h.index > 0);
486
+ setCanRedo(h.index < h.stack.length - 1);
487
+ recalc(canvas);
488
+ });
489
+ };
490
+
491
+ useEffect(() => {
492
+ const onKey = (e: KeyboardEvent) => {
493
+ if (!(e.ctrlKey || e.metaKey)) return;
494
+ if (e.key.toLowerCase() === "z") {
495
+ e.preventDefault();
496
+ restore(e.shiftKey ? 1 : -1);
497
+ }
498
+ if (e.key.toLowerCase() === "y") {
499
+ e.preventDefault();
500
+ restore(1);
501
+ }
502
+ };
503
+ window.addEventListener("keydown", onKey);
504
+ return () => window.removeEventListener("keydown", onKey);
505
+ });
506
+
507
+ const exportPng = () => {
508
+ const canvas = fcRef.current;
509
+ if (!canvas) return;
510
+ const url = canvas.toDataURL({ format: "png", multiplier: 3 });
511
+ const a = document.createElement("a");
512
+ a.href = url;
513
+ a.download = "frivolite-uzor.png";
514
+ a.click();
515
+ };
516
+
517
+ const exportPdf = () => {
518
+ const canvas = fcRef.current;
519
+ if (!canvas) return;
520
+ const url = canvas.toDataURL({ format: "png", multiplier: 3 });
521
+ const pdf = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
522
+ const ratio = Math.min((297 - 20) / W, (210 - 24) / H);
523
+ pdf.setFontSize(12);
524
+ pdf.text("Shema frivolite / RealKnot Studio", 10, 12);
525
+ pdf.addImage(url, "PNG", 10, 16, W * ratio, H * ratio);
526
+ pdf.save("frivolite-uzor.pdf");
527
+ };
528
+
529
+ const currentData = () => {
530
+ const canvas = fcRef.current;
531
+ if (!canvas) return null;
532
+ const data = canvas.toObject(CUSTOM_PROPS) as { objects: { isGrid?: boolean }[] };
533
+ data.objects = data.objects.filter((o) => !o.isGrid);
534
+ return data;
535
+ };
536
+
537
+ const saveProjectFile = () => {
538
+ const data = currentData();
539
+ if (!data) return;
540
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
541
+ const a = document.createElement("a");
542
+ a.href = URL.createObjectURL(blob);
543
+ a.download = "frivolite-proekt.json";
544
+ a.click();
545
+ URL.revokeObjectURL(a.href);
546
+ };
547
+
548
+ const saveToGallery = () => {
549
+ const canvas = fcRef.current;
550
+ const data = currentData();
551
+ if (!canvas || !data) return;
552
+ const title = window.prompt("Название проекта", `Схема ${new Date().toLocaleDateString("ru-RU")}`);
553
+ if (!title) return;
554
+ if (!user) return;
555
+ const project: StoredProject = {
556
+ id: newId(),
557
+ ownerId: user.id,
558
+ title: title.trim(),
559
+ updatedAt: Date.now(),
560
+ elements: stats.count,
561
+ thumb: canvas.toDataURL({ format: "png", multiplier: 0.25 }),
562
+ data,
563
+ };
564
+ upsertProject(project);
565
+ setProjectsOpen(false);
566
+ window.setTimeout(() => setProjectsOpen(true), 120);
567
+ };
568
+
569
+ const loadData = (payload: string | object) => {
570
+ const canvas = fcRef.current;
571
+ if (!canvas) return;
572
+ history.current.lock = true;
573
+ void canvas.loadFromJSON(payload as never).then(() => {
574
+ drawGrid(canvas, snap, theme, showGrid);
575
+ canvas.requestRenderAll();
576
+ history.current.lock = false;
577
+ snapshot(canvas);
578
+ });
579
+ };
580
+
581
+ const openFile = (file: File) => void file.text().then((t) => loadData(t));
582
+
583
+ const deleteSelected = () =>
584
+ withActive((_a, c) => {
585
+ c.getActiveObjects().forEach((o) => c.remove(o));
586
+ c.discardActiveObject();
587
+ c.requestRenderAll();
588
+ });
589
+
590
+ const clearCanvas = () => {
591
+ const canvas = fcRef.current;
592
+ if (!canvas) return;
593
+ if (!window.confirm("Очистить холст? Все элементы схемы будут удалены.")) return;
594
+ canvas.discardActiveObject();
595
+ canvas
596
+ .getObjects()
597
+ .filter((o) => !(o as TattingObject).isGrid)
598
+ .forEach((o) => canvas.remove(o));
599
+ canvas.requestRenderAll();
600
+ snapshot(canvas);
601
+ };
602
+
603
+ const meters = threadMeters(stats.pathPx);
604
+
605
+ if (!unlocked || !user)
606
+ return (
607
+ <LoginDialog
608
+ onSignedIn={(signed) => {
609
+ setUser(signed);
610
+ setUnlocked(true);
611
+ }}
612
+ />
613
+ );
614
+
615
+ return (
616
+ <div className="flex h-[100dvh] flex-col overflow-hidden bg-background font-sans text-foreground">
617
+ <input
618
+ ref={fileRef}
619
+ type="file"
620
+ accept="application/json"
621
+ className="hidden"
622
+ onChange={(e) => {
623
+ const f = e.target.files?.[0];
624
+ if (f) openFile(f);
625
+ e.target.value = "";
626
+ }}
627
+ />
628
+
629
+ <header className="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
630
+ <div className="min-w-0 flex-1">
631
+ <h1 className="truncate font-display text-xl leading-none lg:text-2xl">RealKnot Studio</h1>
632
+ <p className="truncate text-[11px] text-muted-foreground">
633
+ Редактор схем плетения · сетка {snap} px · правая кнопка — перемещение, колесо — масштаб
634
+ </p>
635
+ </div>
636
+
637
+ <div className="hidden rounded-lg border border-border p-0.5 md:flex">
638
+ {(Object.keys(THEME_CFG) as Theme[]).map((t) => (
639
+ <button
640
+ key={t}
641
+ onClick={() => setTheme(t)}
642
+ title={THEME_CFG[t].hint}
643
+ className={`h-8 rounded-md px-2 text-xs font-medium transition-colors ${
644
+ theme === t ? "bg-primary text-primary-foreground" : "text-muted-foreground"
645
+ }`}
646
+ >
647
+ {THEME_CFG[t].label}
648
+ </button>
649
+ ))}
650
+ </div>
651
+
652
+ <button
653
+ onClick={() => setProfileOpen(true)}
654
+ title={`Профиль: ${user.username} · ${roleLabel(user.role)}`}
655
+ className="hidden h-9 items-center gap-2 rounded-lg border border-border bg-secondary px-2.5 text-xs font-medium transition-colors hover:border-accent lg:flex"
656
+ >
657
+ {user.avatar ? (
658
+ <img src={user.avatar} alt="" className="h-6 w-6 rounded-full object-cover" />
659
+ ) : (
660
+ <UserRound className="h-4 w-4 text-accent" />
661
+ )}
662
+ {user.displayName?.trim() || user.username}
663
+ </button>
664
+
665
+ <div className="flex items-center gap-1">
666
+ <Button
667
+ variant="outline"
668
+ size="icon"
669
+ className="h-9 w-9 lg:hidden"
670
+ title="Профиль и мои работы"
671
+ onClick={() => setProfileOpen(true)}
672
+ >
673
+ <UserRound className="h-4 w-4" />
674
+ </Button>
675
+ <Button
676
+ variant="outline"
677
+ size="icon"
678
+ className="h-9 w-9"
679
+ title="Написать: чего не хватает"
680
+ onClick={() => setFeedbackOpen(true)}
681
+ >
682
+ <MessageSquarePlus className="h-4 w-4" />
683
+ </Button>
684
+ {user.role === "admin" && (
685
+ <Button
686
+ variant="outline"
687
+ size="icon"
688
+ className="h-9 w-9"
689
+ title="Панель админа: пользователи и пожелания"
690
+ onClick={() => setAdminOpen(true)}
691
+ >
692
+ <Users className="h-4 w-4" />
693
+ </Button>
694
+ )}
695
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Помощь" onClick={() => setHelpOpen(true)}>
696
+ <HelpCircle className="h-4 w-4" />
697
+ </Button>
698
+ <Button
699
+ variant="outline"
700
+ size="icon"
701
+ className="h-9 w-9"
702
+ title="Мои проекты"
703
+ onClick={() => setProjectsOpen(true)}
704
+ >
705
+ <FolderOpen className="h-4 w-4" />
706
+ </Button>
707
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Скачать .json" onClick={saveProjectFile}>
708
+ <Save className="h-4 w-4" />
709
+ </Button>
710
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Экспорт в PDF A4" onClick={exportPdf}>
711
+ <FileDown className="h-4 w-4" />
712
+ </Button>
713
+ <Button size="sm" className="h-9 gap-1.5" title="Экспорт изображения PNG" onClick={exportPng}>
714
+ <Download className="h-4 w-4" /> PNG
715
+ </Button>
716
+ <Button
717
+ variant="outline"
718
+ size="icon"
719
+ className="h-9 w-9"
720
+ title="Выйти из аккаунта"
721
+ onClick={() => {
722
+ logout();
723
+ setUser(null);
724
+ setUnlocked(false);
725
+ }}
726
+ >
727
+ <LogOut className="h-4 w-4" />
728
+ </Button>
729
+ </div>
730
+ </header>
731
+
732
+ <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2 lg:flex-row lg:overflow-hidden lg:gap-3 lg:p-3">
733
+ {/* Библиотека */}
734
+ <aside className="w-full shrink-0 overflow-y-auto rounded-xl border border-border bg-card p-3 lg:w-56 xl:w-64">
735
+ <h2 className="font-display text-base">Библиотека элементов</h2>
736
+
737
+ <div className="mt-2 flex flex-wrap gap-1">
738
+ {CATEGORIES.map((c) => (
739
+ <button
740
+ key={c}
741
+ onClick={() => setCategory(c)}
742
+ title={`Раздел «${c}»`}
743
+ className={`h-8 rounded-md border px-2 text-xs font-medium transition-colors ${
744
+ category === c ? "border-accent bg-secondary" : "border-border text-muted-foreground"
745
+ }`}
746
+ >
747
+ {c}
748
+ </button>
749
+ ))}
750
+ </div>
751
+ <div className="mt-2 grid grid-cols-2 gap-1.5 lg:grid-cols-1">
752
+ {LIBRARY.filter((e) => e.category === category).map((def) => (
753
+ <button
754
+ key={def.id}
755
+ onClick={() => addElement(def)}
756
+ title={`Добавить: ${def.name} · ${def.kind === "text" ? "подпись" : `${def.knots} узлов`}`}
757
+ className="flex w-full items-center gap-2 rounded-lg border border-border bg-background p-1.5 text-left transition-colors hover:border-accent hover:bg-secondary active:scale-[0.98]"
758
+ >
759
+ <ElementIcon def={def} color={thread.value} />
760
+ <span className="min-w-0">
761
+ <span className="block truncate text-xs font-medium">{def.name}</span>
762
+ <span className="block text-[10px] text-muted-foreground">
763
+ {def.kind === "text" ? "подпись" : `${def.knots} узлов`}
764
+ </span>
765
+ </span>
766
+ </button>
767
+ ))}
768
+ </div>
769
+ </aside>
770
+
771
+ {/* Холст */}
772
+ <main className="flex min-h-0 min-w-0 flex-1 flex-col">
773
+
774
+ <div className="flex shrink-0 flex-wrap items-center gap-1 rounded-xl border border-border bg-card p-1.5">
775
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Отменить (Ctrl+Z)" disabled={!canUndo} onClick={() => restore(-1)}>
776
+ <Undo2 className="h-4 w-4" />
777
+ </Button>
778
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Повторить (Ctrl+Shift+Z)" disabled={!canRedo} onClick={() => restore(1)}>
779
+ <Redo2 className="h-4 w-4" />
780
+ </Button>
781
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Дублировать выбранный элемент" onClick={duplicate}>
782
+ <Copy className="h-4 w-4" />
783
+ </Button>
784
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Сгруппировать выделенные элементы" onClick={groupSelected}>
785
+ <Group className="h-4 w-4" />
786
+ </Button>
787
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Разгруппировать элементы" onClick={ungroupSelected}>
788
+ <Ungroup className="h-4 w-4" />
789
+ </Button>
790
+ <Button
791
+ variant="outline"
792
+ size="icon"
793
+ className="h-9 w-9"
794
+ title="Отразить по горизонтали"
795
+ onClick={() => withActive((a) => a.set("flipX", !a.flipX))}
796
+ >
797
+ <FlipHorizontal className="h-4 w-4" />
798
+ </Button>
799
+ <Button
800
+ variant="outline"
801
+ size="icon"
802
+ className="h-9 w-9"
803
+ title="Отразить по вертикали"
804
+ onClick={() => withActive((a) => a.set("flipY", !a.flipY))}
805
+ >
806
+ <FlipVertical className="h-4 w-4" />
807
+ </Button>
808
+ <Button
809
+ variant="outline"
810
+ size="icon"
811
+ className="h-9 w-9"
812
+ title="На передний план"
813
+ onClick={() => withActive((a, c) => c.bringObjectToFront(a))}
814
+ >
815
+ <ArrowUpToLine className="h-4 w-4" />
816
+ </Button>
817
+ <Button
818
+ variant="outline"
819
+ size="icon"
820
+ className="h-9 w-9"
821
+ title="На задний план"
822
+ onClick={() =>
823
+ withActive((a, c) => {
824
+ c.sendObjectToBack(a);
825
+ c.getObjects()
826
+ .filter((o) => (o as TattingObject).isGrid)
827
+ .forEach((g) => c.sendObjectToBack(g));
828
+ })
829
+ }
830
+ >
831
+ <ArrowDownToLine className="h-4 w-4" />
832
+ </Button>
833
+
834
+ <span className="mx-1 hidden h-6 w-px bg-border sm:block" />
835
+
836
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Приблизить" onClick={() => applyZoom(zoom * 1.2)}>
837
+ <ZoomIn className="h-4 w-4" />
838
+ </Button>
839
+ <Button variant="outline" size="icon" className="h-9 w-9" title="Отдалить" onClick={() => applyZoom(zoom / 1.2)}>
840
+ <ZoomOut className="h-4 w-4" />
841
+ </Button>
842
+ <Button variant="outline" size="sm" className="h-9 gap-1.5" title="Вписать схему в экран" onClick={fitToScreen}>
843
+ <Maximize className="h-4 w-4" />
844
+ <span className="tabular-nums">{Math.round(zoom * 100)}%</span>
845
+ </Button>
846
+ <Button
847
+ variant={showGrid ? "default" : "outline"}
848
+ size="icon"
849
+ className="h-9 w-9"
850
+ title="Показать или скрыть сетку"
851
+ onClick={() => setShowGrid((v) => !v)}
852
+ >
853
+ <Grid3x3 className="h-4 w-4" />
854
+ </Button>
855
+ <Button
856
+ variant={showRulers ? "default" : "outline"}
857
+ size="icon"
858
+ className="h-9 w-9"
859
+ title="Показать или скрыть линейки"
860
+ onClick={() => setShowRulers((v) => !v)}
861
+ >
862
+ <Ruler className="h-4 w-4" />
863
+ </Button>
864
+ </div>
865
+
866
+ <div className="tatting-frame relative mt-2 min-h-0 flex-1 rounded-xl border border-border bg-card p-1.5 shadow-sm">
867
+ <div className={`tatting-box ${showRulers ? "with-rulers" : ""}`}>
868
+ {showRulers && <RulerBar axis="x" zoom={zoom} pan={pan.x} />}
869
+ <div className="flex min-h-0 min-w-0 gap-1">
870
+ {showRulers && <RulerBar axis="y" zoom={zoom} pan={pan.y} />}
871
+ <div
872
+ ref={wrapRef}
873
+ className={`tatting-wrap relative min-w-0 flex-1 overflow-hidden rounded-lg ${
874
+ panning ? "cursor-grabbing" : ""
875
+ }`}
876
+ >
877
+ <canvas ref={elRef} className="touch-none" />
878
+ {booting && (
879
+ <div className="absolute inset-0 animate-pulse backdrop-blur-md">
880
+ <div className="h-full w-full bg-gradient-to-br from-secondary/70 via-card/60 to-secondary/70" />
881
+ </div>
882
+ )}
883
+ </div>
884
+ </div>
885
+ </div>
886
+
887
+ {/* мягкий индикатор автосохранения в углу */}
888
+ <div
889
+ aria-live="polite"
890
+ className={`pointer-events-none absolute bottom-3 right-3 flex items-center gap-1.5 rounded-full border border-border bg-card/90 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm transition-all duration-300 ${
891
+ saved ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"
892
+ }`}
893
+ >
894
+ <Cloud className="h-3.5 w-3.5 animate-pulse text-accent" /> сохранено
895
+ </div>
896
+ </div>
897
+
898
+
899
+
900
+ </main>
901
+
902
+ {/* Свойства */}
903
+ <aside className="w-full shrink-0 space-y-3 overflow-y-auto rounded-xl border border-border bg-card p-3 lg:w-60 xl:w-72">
904
+ <div>
905
+ <h2 className="font-display text-base">Сетка и симметрия</h2>
906
+ <div className="mt-1 text-xs text-muted-foreground">Шаг привязки</div>
907
+ <div className="mt-1 flex gap-1">
908
+ {SNAP_STEPS.map((s) => (
909
+ <button
910
+ key={s}
911
+ onClick={() => setSnap(s)}
912
+ title={`Привязка к сетке ${s} px`}
913
+ className={`h-8 flex-1 rounded-lg border text-xs font-medium ${
914
+ snap === s ? "border-accent bg-secondary" : "border-border text-muted-foreground"
915
+ }`}
916
+ >
917
+ {s} px
918
+ </button>
919
+ ))}
920
+ </div>
921
+ <div className="mt-2 text-xs text-muted-foreground">Круговая симметрия (осей)</div>
922
+ <div className="mt-1 flex gap-1">
923
+ {SYMMETRY_AXES.map((s) => (
924
+ <button
925
+ key={s}
926
+ onClick={() => setSymmetry(s)}
927
+ title={s === 1 ? "Без симметрии" : `Радиальная симметрия: ${s} осей`}
928
+ className={`h-8 flex-1 rounded-lg border text-xs font-medium ${
929
+ symmetry === s ? "border-accent bg-secondary" : "border-border text-muted-foreground"
930
+ }`}
931
+ >
932
+ {s === 1 ? "нет" : s}
933
+ </button>
934
+ ))}
935
+ </div>
936
+ </div>
937
+
938
+ <div className="border-t border-border pt-3">
939
+ <h2 className="font-display text-base">Нити и цвет</h2>
940
+ {(["Металлик", "Пастель", "Градиент"] as const).map((g) => (
941
+ <div key={g} className="mt-2">
942
+ <div className="text-[10px] uppercase tracking-wide text-muted-foreground">{g}</div>
943
+ <div className="mt-1 flex flex-wrap gap-1.5">
944
+ {THREADS.filter((t) => t.group === g).map((t) => (
945
+ <button
946
+ key={t.name}
947
+ title={`Нить: ${t.name}`}
948
+ aria-label={t.name}
949
+ onClick={() => {
950
+ setThread(t);
951
+ withActive((a) => applyThread(a, t));
952
+ setSelected((s) => (s ? { ...s, thread: t.name } : s));
953
+ }}
954
+ style={{ background: t.to ? `linear-gradient(135deg, ${t.value}, ${t.to})` : t.value }}
955
+ className={`h-8 w-8 rounded-full border-2 transition-transform active:scale-90 ${
956
+ thread.name === t.name ? "scale-110 border-accent" : "border-border"
957
+ }`}
958
+ />
959
+ ))}
960
+ </div>
961
+ </div>
962
+ ))}
963
+ <div className="mt-3">
964
+ <div className="mb-1.5 flex items-center justify-between text-xs">
965
+ <span className="flex items-center gap-1.5">
966
+ <Scissors className="h-3.5 w-3.5" /> Толщина нити
967
+ </span>
968
+ <span className="tabular-nums text-muted-foreground">{width.toFixed(1)}</span>
969
+ </div>
970
+ <Slider
971
+ value={[width]}
972
+ min={1}
973
+ max={10}
974
+ step={0.5}
975
+ onValueChange={([v]) => {
976
+ const nv = v ?? 3;
977
+ setWidth(nv);
978
+ withActive((a) => applyWidth(a, nv));
979
+ }}
980
+ />
981
+ </div>
982
+ </div>
983
+
984
+ <div className="border-t border-border pt-3">
985
+ <h2 className="font-display text-base">Выбранный элемент</h2>
986
+ {selected ? (
987
+ <dl className="mt-1.5 space-y-1 text-xs">
988
+ <div className="flex justify-between gap-2">
989
+ <dt className="text-muted-foreground">Название</dt>
990
+ <dd className="text-right font-medium">{selected.name}</dd>
991
+ </div>
992
+ <div className="flex justify-between gap-2">
993
+ <dt className="text-muted-foreground">Узлов</dt>
994
+ <dd className="font-medium tabular-nums">{selected.knots}</dd>
995
+ </div>
996
+ <div className="flex justify-between gap-2">
997
+ <dt className="text-muted-foreground">Нить</dt>
998
+ <dd className="font-medium">{selected.thread}</dd>
999
+ </div>
1000
+ </dl>
1001
+ ) : (
1002
+ <p className="mt-1.5 text-xs text-muted-foreground">
1003
+ Выберите элемент на холсте, чтобы изменить цвет, толщину и положение.
1004
+ </p>
1005
+ )}
1006
+ <Button
1007
+ variant="destructive"
1008
+ size="sm"
1009
+ className="mt-2 h-9 w-full gap-2"
1010
+ title="Удалить выбранный элемент"
1011
+ disabled={!selected}
1012
+ onClick={deleteSelected}
1013
+ >
1014
+ <Trash2 className="h-4 w-4" /> Удалить элемент
1015
+ </Button>
1016
+ </div>
1017
+ </aside>
1018
+ </div>
1019
+
1020
+ {/* Нижняя строка состояния */}
1021
+ <footer className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-3 py-1.5">
1022
+ <p className="text-xs tabular-nums">
1023
+ Узлов: <strong>{stats.knots}</strong> · расход <strong>≈ {meters.toFixed(2)} м</strong>{" "}
1024
+ <span className="text-muted-foreground">(запас 15%)</span>
1025
+ </p>
1026
+
1027
+ <div className="flex items-center gap-2">
1028
+ <Button
1029
+ variant="destructive"
1030
+ size="sm"
1031
+ className="h-10 gap-2"
1032
+ title="Удалить выбранный элемент"
1033
+ disabled={!selected}
1034
+ onClick={deleteSelected}
1035
+ >
1036
+ <Trash2 className="h-4 w-4" /> Удалить
1037
+ </Button>
1038
+ <Button
1039
+ variant="outline"
1040
+ size="sm"
1041
+ className="h-10 gap-2 border-destructive/40 text-destructive"
1042
+ title="Очистить холст полностью"
1043
+ onClick={clearCanvas}
1044
+ >
1045
+ <Eraser className="h-4 w-4" /> Очистить холст
1046
+ </Button>
1047
+ </div>
1048
+ </footer>
1049
+
1050
+ <ProjectsDialog
1051
+ open={projectsOpen}
1052
+ onOpenChange={setProjectsOpen}
1053
+ onOpenProject={(p) => {
1054
+ loadData(p.data as object);
1055
+ setProjectsOpen(false);
1056
+ }}
1057
+ onImportFile={() => fileRef.current?.click()}
1058
+ onSaveCurrent={saveToGallery}
1059
+ ownerId={user.id}
1060
+ />
1061
+
1062
+ <AdminPanel open={adminOpen} onOpenChange={setAdminOpen} />
1063
+
1064
+ <ProfileDialog
1065
+ open={profileOpen}
1066
+ onOpenChange={setProfileOpen}
1067
+ user={user}
1068
+ onUserChange={setUser}
1069
+ onOpenProject={(p) => {
1070
+ loadData(p.data as object);
1071
+ setProfileOpen(false);
1072
+ }}
1073
+ />
1074
+
1075
+ <FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} user={user} />
1076
+
1077
+ <Dialog open={helpOpen} onOpenChange={setHelpOpen}>
1078
+ <DialogContent className="max-w-lg">
1079
+ <DialogHeader>
1080
+ <DialogTitle className="font-display text-2xl">Как пользоваться студией</DialogTitle>
1081
+ <DialogDescription>Три коротких шага для создания схемы фриволите.</DialogDescription>
1082
+ </DialogHeader>
1083
+ <ol className="space-y-4 text-sm">
1084
+ <li className="flex gap-3">
1085
+ <MousePointer2 className="mt-0.5 h-6 w-6 shrink-0 text-primary" />
1086
+ <span>
1087
+ <strong className="block">1. Добавьте и перетащите</strong>
1088
+ Нажмите на элемент в «Библиотеке» — он появится на холсте. Включите круговую симметрию, чтобы сразу
1089
+ получить мандалу или салфетку.
1090
+ </span>
1091
+ </li>
1092
+ <li className="flex gap-3">
1093
+ <RotateCw className="mt-0.5 h-6 w-6 shrink-0 text-primary" />
1094
+ <span>
1095
+ <strong className="block">2. Настройте</strong>
1096
+ Меняйте цвет и толщину нити, группируйте элементы, отражайте и меняйте порядок слоёв. Ctrl+Z отменяет
1097
+ действие.
1098
+ </span>
1099
+ </li>
1100
+ <li className="flex gap-3">
1101
+ <Download className="mt-0.5 h-6 w-6 shrink-0 text-primary" />
1102
+ <span>
1103
+ <strong className="block">3. Сохраните</strong>
1104
+ «Мои проекты» хранит схемы в браузере, PNG и PDF A4 — для печати, «Скачать .json» — файл проекта.
1105
+ </span>
1106
+ </li>
1107
+ </ol>
1108
+ </DialogContent>
1109
+ </Dialog>
1110
+ </div>
1111
+ );
1112
+ }
1113
+
1114
+ function RulerBar({ axis, zoom, pan }: { axis: "x" | "y"; zoom: number; pan: number }) {
1115
+ const total = axis === "x" ? W : H;
1116
+ const step = zoom >= 2.5 ? 25 : zoom >= 1.4 ? 50 : zoom >= 0.7 ? 100 : 200;
1117
+ const from = Math.floor(-pan / zoom / step) * step;
1118
+ const to = from + Math.ceil(total / zoom / step) * step + step;
1119
+ const ticks: number[] = [];
1120
+ for (let t = from; t <= to; t += step) ticks.push(t);
1121
+ const pos = (t: number) => ((t * zoom + pan) / total) * 100;
1122
+
1123
+ if (axis === "x")
1124
+ return (
1125
+ <div className="relative mb-1 ml-7 h-5 shrink-0 overflow-hidden rounded-md bg-secondary text-[10px] text-muted-foreground">
1126
+ {ticks.map((t) => (
1127
+ <span
1128
+ key={t}
1129
+ className="absolute top-0 flex h-full items-center border-l border-border pl-1"
1130
+ style={{ left: `${pos(t)}%` }}
1131
+ >
1132
+ {t}
1133
+ </span>
1134
+ ))}
1135
+ </div>
1136
+ );
1137
+
1138
+ return (
1139
+ <div className="relative w-6 shrink-0 overflow-hidden rounded-md bg-secondary text-[10px] text-muted-foreground">
1140
+ {ticks.map((t) => (
1141
+ <span
1142
+ key={t}
1143
+ className="absolute left-0 flex w-full justify-center border-t border-border pt-0.5"
1144
+ style={{ top: `${pos(t)}%` }}
1145
+ >
1146
+ {t}
1147
+ </span>
1148
+ ))}
1149
+ </div>
1150
+ );
1151
+ }
1152
+
1153
+
1154
+ function ElementIcon({ def, color }: { def: ElDef; color: string }) {
1155
+ const r = 30;
1156
+ const picotR = 5;
1157
+ const dots = Array.from({ length: def.picots }, (_, i) => {
1158
+ const a = (i / Math.max(def.picots, 1)) * Math.PI * 2 - Math.PI / 2;
1159
+ return { cx: Math.cos(a) * (r + 4), cy: Math.sin(a) * (r + 4) };
1160
+ });
1161
+ return (
1162
+ <svg viewBox="-45 -45 90 90" className="h-8 w-8 shrink-0" aria-hidden>
1163
+ <g fill="none" stroke={color} strokeWidth={3}>
1164
+ {def.kind === "ring" && (
1165
+ <>
1166
+ <circle r={r} />
1167
+ {dots.map((d, i) => (
1168
+ <circle key={i} cx={d.cx} cy={d.cy} r={picotR} />
1169
+ ))}
1170
+ </>
1171
+ )}
1172
+ {(def.kind === "arc" || def.kind === "arc-curved") && (
1173
+ <>
1174
+ <path d="M -34 12 A 36 36 0 0 1 34 12" />
1175
+ {Array.from({ length: def.picots }, (_, i) => {
1176
+ const t = (i + 1) / (def.picots + 1);
1177
+ const a = Math.PI - t * Math.PI;
1178
+ return <circle key={i} cx={Math.cos(a) * 40} cy={-Math.sin(a) * 40 + 12} r={4} />;
1179
+ })}
1180
+ </>
1181
+ )}
1182
+ {def.kind === "picot" && (
1183
+ <>
1184
+ <path d="M -26 8 Q 0 -34 26 8" />
1185
+ <circle cy={-14} r={7} />
1186
+ </>
1187
+ )}
1188
+ {def.kind === "bead" && <circle r={16} fill={color} />}
1189
+ {def.kind === "crystal" && <polygon points="0,-28 20,0 0,28 -20,0" fill={color} />}
1190
+ {def.kind === "text" && (
1191
+ <text x="0" y="10" textAnchor="middle" fontSize="34" fill={color} stroke="none">
1192
+ А1
1193
+ </text>
1194
+ )}
1195
+ </g>
1196
+ </svg>
1197
+ );
1198
+ }
components/ui/accordion.tsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as AccordionPrimitive from "@radix-ui/react-accordion";
3
+ import { ChevronDown } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Accordion = AccordionPrimitive.Root;
8
+
9
+ const AccordionItem = React.forwardRef<
10
+ React.ElementRef<typeof AccordionPrimitive.Item>,
11
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
12
+ >(({ className, ...props }, ref) => (
13
+ <AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
14
+ ));
15
+ AccordionItem.displayName = "AccordionItem";
16
+
17
+ const AccordionTrigger = React.forwardRef<
18
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
19
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
20
+ >(({ className, children, ...props }, ref) => (
21
+ <AccordionPrimitive.Header className="flex">
22
+ <AccordionPrimitive.Trigger
23
+ ref={ref}
24
+ className={cn(
25
+ "flex flex-1 items-center justify-between py-4 text-sm font-medium cursor-pointer transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
26
+ className,
27
+ )}
28
+ {...props}
29
+ >
30
+ {children}
31
+ <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
32
+ </AccordionPrimitive.Trigger>
33
+ </AccordionPrimitive.Header>
34
+ ));
35
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
36
+
37
+ const AccordionContent = React.forwardRef<
38
+ React.ElementRef<typeof AccordionPrimitive.Content>,
39
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
40
+ >(({ className, children, ...props }, ref) => (
41
+ <AccordionPrimitive.Content
42
+ ref={ref}
43
+ className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
44
+ {...props}
45
+ >
46
+ <div className={cn("pb-4 pt-0", className)}>{children}</div>
47
+ </AccordionPrimitive.Content>
48
+ ));
49
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName;
50
+
51
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
3
+
4
+ import { cn } from "@/lib/utils";
5
+ import { buttonVariants } from "@/components/ui/button";
6
+
7
+ const AlertDialog = AlertDialogPrimitive.Root;
8
+
9
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
10
+
11
+ const AlertDialogPortal = AlertDialogPrimitive.Portal;
12
+
13
+ const AlertDialogOverlay = React.forwardRef<
14
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
15
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
16
+ >(({ className, ...props }, ref) => (
17
+ <AlertDialogPrimitive.Overlay
18
+ className={cn(
19
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
20
+ className,
21
+ )}
22
+ {...props}
23
+ ref={ref}
24
+ />
25
+ ));
26
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
27
+
28
+ const AlertDialogContent = React.forwardRef<
29
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
30
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
31
+ >(({ className, ...props }, ref) => (
32
+ <AlertDialogPortal>
33
+ <AlertDialogOverlay />
34
+ <AlertDialogPrimitive.Content
35
+ ref={ref}
36
+ className={cn(
37
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
38
+ className,
39
+ )}
40
+ {...props}
41
+ />
42
+ </AlertDialogPortal>
43
+ ));
44
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
45
+
46
+ const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
47
+ <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
48
+ );
49
+ AlertDialogHeader.displayName = "AlertDialogHeader";
50
+
51
+ const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
52
+ <div
53
+ className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
54
+ {...props}
55
+ />
56
+ );
57
+ AlertDialogFooter.displayName = "AlertDialogFooter";
58
+
59
+ const AlertDialogTitle = React.forwardRef<
60
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
61
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
62
+ >(({ className, ...props }, ref) => (
63
+ <AlertDialogPrimitive.Title
64
+ ref={ref}
65
+ className={cn("text-lg font-semibold", className)}
66
+ {...props}
67
+ />
68
+ ));
69
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
70
+
71
+ const AlertDialogDescription = React.forwardRef<
72
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
73
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
74
+ >(({ className, ...props }, ref) => (
75
+ <AlertDialogPrimitive.Description
76
+ ref={ref}
77
+ className={cn("text-sm text-muted-foreground", className)}
78
+ {...props}
79
+ />
80
+ ));
81
+ AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
82
+
83
+ const AlertDialogAction = React.forwardRef<
84
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
85
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
86
+ >(({ className, ...props }, ref) => (
87
+ <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
88
+ ));
89
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
90
+
91
+ const AlertDialogCancel = React.forwardRef<
92
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
93
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
94
+ >(({ className, ...props }, ref) => (
95
+ <AlertDialogPrimitive.Cancel
96
+ ref={ref}
97
+ className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
98
+ {...props}
99
+ />
100
+ ));
101
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
102
+
103
+ export {
104
+ AlertDialog,
105
+ AlertDialogPortal,
106
+ AlertDialogOverlay,
107
+ AlertDialogTrigger,
108
+ AlertDialogContent,
109
+ AlertDialogHeader,
110
+ AlertDialogFooter,
111
+ AlertDialogTitle,
112
+ AlertDialogDescription,
113
+ AlertDialogAction,
114
+ AlertDialogCancel,
115
+ };
components/ui/alert.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const alertVariants = cva(
7
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-background text-foreground",
12
+ destructive:
13
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ },
20
+ );
21
+
22
+ const Alert = React.forwardRef<
23
+ HTMLDivElement,
24
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
25
+ >(({ className, variant, ...props }, ref) => (
26
+ <div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
27
+ ));
28
+ Alert.displayName = "Alert";
29
+
30
+ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
31
+ ({ className, ...props }, ref) => (
32
+ <h5
33
+ ref={ref}
34
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
35
+ {...props}
36
+ />
37
+ ),
38
+ );
39
+ AlertTitle.displayName = "AlertTitle";
40
+
41
+ const AlertDescription = React.forwardRef<
42
+ HTMLParagraphElement,
43
+ React.HTMLAttributes<HTMLParagraphElement>
44
+ >(({ className, ...props }, ref) => (
45
+ <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
46
+ ));
47
+ AlertDescription.displayName = "AlertDescription";
48
+
49
+ export { Alert, AlertTitle, AlertDescription };
components/ui/aspect-ratio.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
2
+
3
+ const AspectRatio = AspectRatioPrimitive.Root;
4
+
5
+ export { AspectRatio };
components/ui/avatar.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as AvatarPrimitive from "@radix-ui/react-avatar";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const Avatar = React.forwardRef<
9
+ React.ElementRef<typeof AvatarPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
11
+ >(({ className, ...props }, ref) => (
12
+ <AvatarPrimitive.Root
13
+ ref={ref}
14
+ className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
15
+ {...props}
16
+ />
17
+ ));
18
+ Avatar.displayName = AvatarPrimitive.Root.displayName;
19
+
20
+ const AvatarImage = React.forwardRef<
21
+ React.ElementRef<typeof AvatarPrimitive.Image>,
22
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
23
+ >(({ className, ...props }, ref) => (
24
+ <AvatarPrimitive.Image
25
+ ref={ref}
26
+ className={cn("aspect-square h-full w-full", className)}
27
+ {...props}
28
+ />
29
+ ));
30
+ AvatarImage.displayName = AvatarPrimitive.Image.displayName;
31
+
32
+ const AvatarFallback = React.forwardRef<
33
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
34
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
35
+ >(({ className, ...props }, ref) => (
36
+ <AvatarPrimitive.Fallback
37
+ ref={ref}
38
+ className={cn(
39
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ ));
45
+ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
46
+
47
+ export { Avatar, AvatarImage, AvatarFallback };
components/ui/badge.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
12
+ secondary:
13
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
+ destructive:
15
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
16
+ outline: "text-foreground",
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: "default",
21
+ },
22
+ },
23
+ );
24
+
25
+ export interface BadgeProps
26
+ extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
27
+
28
+ function Badge({ className, variant, ...props }: BadgeProps) {
29
+ return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
30
+ }
31
+
32
+ export { Badge, badgeVariants };
components/ui/breadcrumb.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { ChevronRight, MoreHorizontal } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Breadcrumb = React.forwardRef<
8
+ HTMLElement,
9
+ React.ComponentPropsWithoutRef<"nav"> & {
10
+ separator?: React.ReactNode;
11
+ }
12
+ >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
13
+ Breadcrumb.displayName = "Breadcrumb";
14
+
15
+ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
16
+ ({ className, ...props }, ref) => (
17
+ <ol
18
+ ref={ref}
19
+ className={cn(
20
+ "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ ),
26
+ );
27
+ BreadcrumbList.displayName = "BreadcrumbList";
28
+
29
+ const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
30
+ ({ className, ...props }, ref) => (
31
+ <li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
32
+ ),
33
+ );
34
+ BreadcrumbItem.displayName = "BreadcrumbItem";
35
+
36
+ const BreadcrumbLink = React.forwardRef<
37
+ HTMLAnchorElement,
38
+ React.ComponentPropsWithoutRef<"a"> & {
39
+ asChild?: boolean;
40
+ }
41
+ >(({ asChild, className, ...props }, ref) => {
42
+ const Comp = asChild ? Slot : "a";
43
+
44
+ return (
45
+ <Comp
46
+ ref={ref}
47
+ className={cn("transition-colors hover:text-foreground", className)}
48
+ {...props}
49
+ />
50
+ );
51
+ });
52
+ BreadcrumbLink.displayName = "BreadcrumbLink";
53
+
54
+ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
55
+ ({ className, ...props }, ref) => (
56
+ <span
57
+ ref={ref}
58
+ role="link"
59
+ aria-disabled="true"
60
+ aria-current="page"
61
+ className={cn("font-normal text-foreground", className)}
62
+ {...props}
63
+ />
64
+ ),
65
+ );
66
+ BreadcrumbPage.displayName = "BreadcrumbPage";
67
+
68
+ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
69
+ <li
70
+ role="presentation"
71
+ aria-hidden="true"
72
+ className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
73
+ {...props}
74
+ >
75
+ {children ?? <ChevronRight />}
76
+ </li>
77
+ );
78
+ BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
79
+
80
+ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
81
+ <span
82
+ role="presentation"
83
+ aria-hidden="true"
84
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
85
+ {...props}
86
+ >
87
+ <MoreHorizontal className="h-4 w-4" />
88
+ <span className="sr-only">More</span>
89
+ </span>
90
+ );
91
+ BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
92
+
93
+ export {
94
+ Breadcrumb,
95
+ BreadcrumbList,
96
+ BreadcrumbItem,
97
+ BreadcrumbLink,
98
+ BreadcrumbPage,
99
+ BreadcrumbSeparator,
100
+ BreadcrumbEllipsis,
101
+ };
components/ui/button.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
13
+ destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
14
+ outline:
15
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
16
+ secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
17
+ ghost: "hover:bg-accent hover:text-accent-foreground",
18
+ link: "text-primary underline-offset-4 hover:underline",
19
+ },
20
+ size: {
21
+ default: "h-9 px-4 py-2",
22
+ sm: "h-8 rounded-md px-3 text-xs",
23
+ lg: "h-10 rounded-md px-8",
24
+ icon: "h-9 w-9",
25
+ },
26
+ },
27
+ defaultVariants: {
28
+ variant: "default",
29
+ size: "default",
30
+ },
31
+ },
32
+ );
33
+
34
+ export interface ButtonProps
35
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
36
+ asChild?: boolean;
37
+ }
38
+
39
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
40
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
41
+ const Comp = asChild ? Slot : "button";
42
+ return (
43
+ <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
44
+ );
45
+ },
46
+ );
47
+ Button.displayName = "Button";
48
+
49
+ export { Button, buttonVariants };
components/ui/calendar.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
5
+ import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
6
+
7
+ import { cn } from "@/lib/utils";
8
+ import { Button, buttonVariants } from "@/components/ui/button";
9
+
10
+ function Calendar({
11
+ className,
12
+ classNames,
13
+ showOutsideDays = true,
14
+ captionLayout = "label",
15
+ buttonVariant = "ghost",
16
+ formatters,
17
+ components,
18
+ ...props
19
+ }: React.ComponentProps<typeof DayPicker> & {
20
+ buttonVariant?: React.ComponentProps<typeof Button>["variant"];
21
+ }) {
22
+ const defaultClassNames = getDefaultClassNames();
23
+
24
+ return (
25
+ <DayPicker
26
+ showOutsideDays={showOutsideDays}
27
+ className={cn(
28
+ "bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
29
+ String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
30
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
31
+ className,
32
+ )}
33
+ captionLayout={captionLayout}
34
+ formatters={{
35
+ formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
36
+ ...formatters,
37
+ }}
38
+ classNames={{
39
+ root: cn("w-fit", defaultClassNames.root),
40
+ months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
41
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
42
+ nav: cn(
43
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
44
+ defaultClassNames.nav,
45
+ ),
46
+ button_previous: cn(
47
+ buttonVariants({ variant: buttonVariant }),
48
+ "h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
49
+ defaultClassNames.button_previous,
50
+ ),
51
+ button_next: cn(
52
+ buttonVariants({ variant: buttonVariant }),
53
+ "h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
54
+ defaultClassNames.button_next,
55
+ ),
56
+ month_caption: cn(
57
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
58
+ defaultClassNames.month_caption,
59
+ ),
60
+ dropdowns: cn(
61
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
62
+ defaultClassNames.dropdowns,
63
+ ),
64
+ dropdown_root: cn(
65
+ "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
66
+ defaultClassNames.dropdown_root,
67
+ ),
68
+ dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown),
69
+ caption_label: cn(
70
+ "select-none font-medium",
71
+ captionLayout === "label"
72
+ ? "text-sm"
73
+ : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
74
+ defaultClassNames.caption_label,
75
+ ),
76
+ table: "w-full border-collapse",
77
+ weekdays: cn("flex", defaultClassNames.weekdays),
78
+ weekday: cn(
79
+ "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
80
+ defaultClassNames.weekday,
81
+ ),
82
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
83
+ week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
84
+ week_number: cn(
85
+ "text-muted-foreground select-none text-[0.8rem]",
86
+ defaultClassNames.week_number,
87
+ ),
88
+ day: cn(
89
+ "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
90
+ defaultClassNames.day,
91
+ ),
92
+ range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
93
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
94
+ range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
95
+ today: cn(
96
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
97
+ defaultClassNames.today,
98
+ ),
99
+ outside: cn(
100
+ "text-muted-foreground aria-selected:text-muted-foreground",
101
+ defaultClassNames.outside,
102
+ ),
103
+ disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
104
+ hidden: cn("invisible", defaultClassNames.hidden),
105
+ ...classNames,
106
+ }}
107
+ components={{
108
+ Root: ({ className, rootRef, ...props }) => {
109
+ return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
110
+ },
111
+ Chevron: ({ className, orientation, ...props }) => {
112
+ if (orientation === "left") {
113
+ return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
114
+ }
115
+
116
+ if (orientation === "right") {
117
+ return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
118
+ }
119
+
120
+ return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
121
+ },
122
+ DayButton: CalendarDayButton,
123
+ WeekNumber: ({ children, ...props }) => {
124
+ return (
125
+ <td {...props}>
126
+ <div className="flex size-(--cell-size) items-center justify-center text-center">
127
+ {children}
128
+ </div>
129
+ </td>
130
+ );
131
+ },
132
+ ...components,
133
+ }}
134
+ {...props}
135
+ />
136
+ );
137
+ }
138
+
139
+ function CalendarDayButton({
140
+ className,
141
+ day,
142
+ modifiers,
143
+ ...props
144
+ }: React.ComponentProps<typeof DayButton>) {
145
+ const defaultClassNames = getDefaultClassNames();
146
+
147
+ const ref = React.useRef<HTMLButtonElement>(null);
148
+ React.useEffect(() => {
149
+ if (modifiers["focused"]) ref.current?.focus();
150
+ }, [modifiers]);
151
+
152
+ return (
153
+ <Button
154
+ ref={ref}
155
+ variant="ghost"
156
+ size="icon"
157
+ data-day={day.date.toLocaleDateString()}
158
+ data-selected-single={
159
+ modifiers["selected"] &&
160
+ !modifiers["range_start"] &&
161
+ !modifiers["range_end"] &&
162
+ !modifiers["range_middle"]
163
+ }
164
+ data-range-start={modifiers["range_start"]}
165
+ data-range-end={modifiers["range_end"]}
166
+ data-range-middle={modifiers["range_middle"]}
167
+ className={cn(
168
+ "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
169
+ defaultClassNames.day,
170
+ className,
171
+ )}
172
+ {...props}
173
+ />
174
+ );
175
+ }
176
+
177
+ export { Calendar, CalendarDayButton };
components/ui/card.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
6
+ ({ className, ...props }, ref) => (
7
+ <div
8
+ ref={ref}
9
+ className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
10
+ {...props}
11
+ />
12
+ ),
13
+ );
14
+ Card.displayName = "Card";
15
+
16
+ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
17
+ ({ className, ...props }, ref) => (
18
+ <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
19
+ ),
20
+ );
21
+ CardHeader.displayName = "CardHeader";
22
+
23
+ const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
24
+ ({ className, ...props }, ref) => (
25
+ <div
26
+ ref={ref}
27
+ className={cn("font-semibold leading-none tracking-tight", className)}
28
+ {...props}
29
+ />
30
+ ),
31
+ );
32
+ CardTitle.displayName = "CardTitle";
33
+
34
+ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
35
+ ({ className, ...props }, ref) => (
36
+ <div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
37
+ ),
38
+ );
39
+ CardDescription.displayName = "CardDescription";
40
+
41
+ const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
42
+ ({ className, ...props }, ref) => (
43
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
44
+ ),
45
+ );
46
+ CardContent.displayName = "CardContent";
47
+
48
+ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
49
+ ({ className, ...props }, ref) => (
50
+ <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
51
+ ),
52
+ );
53
+ CardFooter.displayName = "CardFooter";
54
+
55
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
components/ui/carousel.tsx ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
3
+ import { ArrowLeft, ArrowRight } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ type CarouselApi = UseEmblaCarouselType[1];
9
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
10
+ type CarouselOptions = UseCarouselParameters[0];
11
+ type CarouselPlugin = UseCarouselParameters[1];
12
+
13
+ type CarouselProps = {
14
+ opts?: CarouselOptions;
15
+ plugins?: CarouselPlugin;
16
+ orientation?: "horizontal" | "vertical";
17
+ setApi?: (api: CarouselApi) => void;
18
+ };
19
+
20
+ type CarouselContextProps = {
21
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0];
22
+ api: ReturnType<typeof useEmblaCarousel>[1];
23
+ scrollPrev: () => void;
24
+ scrollNext: () => void;
25
+ canScrollPrev: boolean;
26
+ canScrollNext: boolean;
27
+ } & CarouselProps;
28
+
29
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null);
30
+
31
+ function useCarousel() {
32
+ const context = React.useContext(CarouselContext);
33
+
34
+ if (!context) {
35
+ throw new Error("useCarousel must be used within a <Carousel />");
36
+ }
37
+
38
+ return context;
39
+ }
40
+
41
+ const Carousel = React.forwardRef<
42
+ HTMLDivElement,
43
+ React.HTMLAttributes<HTMLDivElement> & CarouselProps
44
+ >(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
45
+ const [carouselRef, api] = useEmblaCarousel(
46
+ {
47
+ ...opts,
48
+ axis: orientation === "horizontal" ? "x" : "y",
49
+ },
50
+ plugins,
51
+ );
52
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
53
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
54
+
55
+ const onSelect = React.useCallback((api: CarouselApi) => {
56
+ if (!api) {
57
+ return;
58
+ }
59
+
60
+ setCanScrollPrev(api.canScrollPrev());
61
+ setCanScrollNext(api.canScrollNext());
62
+ }, []);
63
+
64
+ const scrollPrev = React.useCallback(() => {
65
+ api?.scrollPrev();
66
+ }, [api]);
67
+
68
+ const scrollNext = React.useCallback(() => {
69
+ api?.scrollNext();
70
+ }, [api]);
71
+
72
+ const handleKeyDown = React.useCallback(
73
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
74
+ if (event.key === "ArrowLeft") {
75
+ event.preventDefault();
76
+ scrollPrev();
77
+ } else if (event.key === "ArrowRight") {
78
+ event.preventDefault();
79
+ scrollNext();
80
+ }
81
+ },
82
+ [scrollPrev, scrollNext],
83
+ );
84
+
85
+ React.useEffect(() => {
86
+ if (!api || !setApi) {
87
+ return;
88
+ }
89
+
90
+ setApi(api);
91
+ }, [api, setApi]);
92
+
93
+ React.useEffect(() => {
94
+ if (!api) {
95
+ return;
96
+ }
97
+
98
+ onSelect(api);
99
+ api.on("reInit", onSelect);
100
+ api.on("select", onSelect);
101
+
102
+ return () => {
103
+ api?.off("select", onSelect);
104
+ };
105
+ }, [api, onSelect]);
106
+
107
+ return (
108
+ <CarouselContext.Provider
109
+ value={{
110
+ carouselRef,
111
+ api: api,
112
+ opts,
113
+ orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
114
+ scrollPrev,
115
+ scrollNext,
116
+ canScrollPrev,
117
+ canScrollNext,
118
+ }}
119
+ >
120
+ <div
121
+ ref={ref}
122
+ onKeyDownCapture={handleKeyDown}
123
+ className={cn("relative", className)}
124
+ role="region"
125
+ aria-roledescription="carousel"
126
+ {...props}
127
+ >
128
+ {children}
129
+ </div>
130
+ </CarouselContext.Provider>
131
+ );
132
+ });
133
+ Carousel.displayName = "Carousel";
134
+
135
+ const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
136
+ ({ className, ...props }, ref) => {
137
+ const { carouselRef, orientation } = useCarousel();
138
+
139
+ return (
140
+ <div ref={carouselRef} className="overflow-hidden">
141
+ <div
142
+ ref={ref}
143
+ className={cn(
144
+ "flex",
145
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
146
+ className,
147
+ )}
148
+ {...props}
149
+ />
150
+ </div>
151
+ );
152
+ },
153
+ );
154
+ CarouselContent.displayName = "CarouselContent";
155
+
156
+ const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
157
+ ({ className, ...props }, ref) => {
158
+ const { orientation } = useCarousel();
159
+
160
+ return (
161
+ <div
162
+ ref={ref}
163
+ role="group"
164
+ aria-roledescription="slide"
165
+ className={cn(
166
+ "min-w-0 shrink-0 grow-0 basis-full",
167
+ orientation === "horizontal" ? "pl-4" : "pt-4",
168
+ className,
169
+ )}
170
+ {...props}
171
+ />
172
+ );
173
+ },
174
+ );
175
+ CarouselItem.displayName = "CarouselItem";
176
+
177
+ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
178
+ ({ className, variant = "outline", size = "icon", ...props }, ref) => {
179
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
180
+
181
+ return (
182
+ <Button
183
+ ref={ref}
184
+ variant={variant}
185
+ size={size}
186
+ className={cn(
187
+ "absolute h-8 w-8 rounded-full",
188
+ orientation === "horizontal"
189
+ ? "-left-12 top-1/2 -translate-y-1/2"
190
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
191
+ className,
192
+ )}
193
+ disabled={!canScrollPrev}
194
+ onClick={scrollPrev}
195
+ {...props}
196
+ >
197
+ <ArrowLeft className="h-4 w-4" />
198
+ <span className="sr-only">Previous slide</span>
199
+ </Button>
200
+ );
201
+ },
202
+ );
203
+ CarouselPrevious.displayName = "CarouselPrevious";
204
+
205
+ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
206
+ ({ className, variant = "outline", size = "icon", ...props }, ref) => {
207
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
208
+
209
+ return (
210
+ <Button
211
+ ref={ref}
212
+ variant={variant}
213
+ size={size}
214
+ className={cn(
215
+ "absolute h-8 w-8 rounded-full",
216
+ orientation === "horizontal"
217
+ ? "-right-12 top-1/2 -translate-y-1/2"
218
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
219
+ className,
220
+ )}
221
+ disabled={!canScrollNext}
222
+ onClick={scrollNext}
223
+ {...props}
224
+ >
225
+ <ArrowRight className="h-4 w-4" />
226
+ <span className="sr-only">Next slide</span>
227
+ </Button>
228
+ );
229
+ },
230
+ );
231
+ CarouselNext.displayName = "CarouselNext";
232
+
233
+ export {
234
+ type CarouselApi,
235
+ Carousel,
236
+ CarouselContent,
237
+ CarouselItem,
238
+ CarouselPrevious,
239
+ CarouselNext,
240
+ };
components/ui/chart.tsx ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as RechartsPrimitive from "recharts";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ // Format: { THEME_NAME: CSS_SELECTOR }
7
+ const THEMES = { light: "", dark: ".dark" } as const;
8
+
9
+ export type ChartConfig = {
10
+ [k in string]: {
11
+ label?: React.ReactNode;
12
+ icon?: React.ComponentType;
13
+ } & (
14
+ | { color?: string; theme?: never }
15
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
16
+ );
17
+ };
18
+
19
+ type ChartContextProps = {
20
+ config: ChartConfig;
21
+ };
22
+
23
+ const ChartContext = React.createContext<ChartContextProps | null>(null);
24
+
25
+ function useChart() {
26
+ const context = React.useContext(ChartContext);
27
+
28
+ if (!context) {
29
+ throw new Error("useChart must be used within a <ChartContainer />");
30
+ }
31
+
32
+ return context;
33
+ }
34
+
35
+ const ChartContainer = React.forwardRef<
36
+ HTMLDivElement,
37
+ React.ComponentProps<"div"> & {
38
+ config: ChartConfig;
39
+ children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
40
+ }
41
+ >(({ id, className, children, config, ...props }, ref) => {
42
+ const uniqueId = React.useId();
43
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
44
+
45
+ return (
46
+ <ChartContext.Provider value={{ config }}>
47
+ <div
48
+ data-chart={chartId}
49
+ ref={ref}
50
+ className={cn(
51
+ "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
52
+ className,
53
+ )}
54
+ {...props}
55
+ >
56
+ <ChartStyle id={chartId} config={config} />
57
+ <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
58
+ </div>
59
+ </ChartContext.Provider>
60
+ );
61
+ });
62
+ ChartContainer.displayName = "Chart";
63
+
64
+ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
65
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
66
+
67
+ if (!colorConfig.length) {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <style
73
+ dangerouslySetInnerHTML={{
74
+ __html: Object.entries(THEMES)
75
+ .map(
76
+ ([theme, prefix]) => `
77
+ ${prefix} [data-chart=${id}] {
78
+ ${colorConfig
79
+ .map(([key, itemConfig]) => {
80
+ const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
81
+ return color ? ` --color-${key}: ${color};` : null;
82
+ })
83
+ .join("\n")}
84
+ }
85
+ `,
86
+ )
87
+ .join("\n"),
88
+ }}
89
+ />
90
+ );
91
+ };
92
+
93
+ const ChartTooltip = RechartsPrimitive.Tooltip;
94
+
95
+ const ChartTooltipContent = React.forwardRef<
96
+ HTMLDivElement,
97
+ React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
98
+ React.ComponentProps<"div"> & {
99
+ hideLabel?: boolean;
100
+ hideIndicator?: boolean;
101
+ indicator?: "line" | "dot" | "dashed";
102
+ nameKey?: string;
103
+ labelKey?: string;
104
+ }
105
+ >(
106
+ (
107
+ {
108
+ active,
109
+ payload,
110
+ className,
111
+ indicator = "dot",
112
+ hideLabel = false,
113
+ hideIndicator = false,
114
+ label,
115
+ labelFormatter,
116
+ labelClassName,
117
+ formatter,
118
+ color,
119
+ nameKey,
120
+ labelKey,
121
+ },
122
+ ref,
123
+ ) => {
124
+ const { config } = useChart();
125
+
126
+ const tooltipLabel = React.useMemo(() => {
127
+ if (hideLabel || !payload?.length) {
128
+ return null;
129
+ }
130
+
131
+ const [item] = payload;
132
+ const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
133
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
134
+ const value =
135
+ !labelKey && typeof label === "string"
136
+ ? config[label as keyof typeof config]?.label || label
137
+ : itemConfig?.label;
138
+
139
+ if (labelFormatter) {
140
+ return (
141
+ <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
142
+ );
143
+ }
144
+
145
+ if (!value) {
146
+ return null;
147
+ }
148
+
149
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>;
150
+ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
151
+
152
+ if (!active || !payload?.length) {
153
+ return null;
154
+ }
155
+
156
+ const nestLabel = payload.length === 1 && indicator !== "dot";
157
+
158
+ return (
159
+ <div
160
+ ref={ref}
161
+ className={cn(
162
+ "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
163
+ className,
164
+ )}
165
+ >
166
+ {!nestLabel ? tooltipLabel : null}
167
+ <div className="grid gap-1.5">
168
+ {payload
169
+ .filter((item) => item.type !== "none")
170
+ .map((item, index) => {
171
+ const key = `${nameKey || item.name || item.dataKey || "value"}`;
172
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
173
+ const indicatorColor = color || item.payload.fill || item.color;
174
+
175
+ return (
176
+ <div
177
+ key={item.dataKey}
178
+ className={cn(
179
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
180
+ indicator === "dot" && "items-center",
181
+ )}
182
+ >
183
+ {formatter && item?.value !== undefined && item.name ? (
184
+ formatter(item.value, item.name, item, index, item.payload)
185
+ ) : (
186
+ <>
187
+ {itemConfig?.icon ? (
188
+ <itemConfig.icon />
189
+ ) : (
190
+ !hideIndicator && (
191
+ <div
192
+ className={cn(
193
+ "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
194
+ {
195
+ "h-2.5 w-2.5": indicator === "dot",
196
+ "w-1": indicator === "line",
197
+ "w-0 border-[1.5px] border-dashed bg-transparent":
198
+ indicator === "dashed",
199
+ "my-0.5": nestLabel && indicator === "dashed",
200
+ },
201
+ )}
202
+ style={
203
+ {
204
+ "--color-bg": indicatorColor,
205
+ "--color-border": indicatorColor,
206
+ } as React.CSSProperties
207
+ }
208
+ />
209
+ )
210
+ )}
211
+ <div
212
+ className={cn(
213
+ "flex flex-1 justify-between leading-none",
214
+ nestLabel ? "items-end" : "items-center",
215
+ )}
216
+ >
217
+ <div className="grid gap-1.5">
218
+ {nestLabel ? tooltipLabel : null}
219
+ <span className="text-muted-foreground">
220
+ {itemConfig?.label || item.name}
221
+ </span>
222
+ </div>
223
+ {item.value && (
224
+ <span className="font-mono font-medium tabular-nums text-foreground">
225
+ {item.value.toLocaleString()}
226
+ </span>
227
+ )}
228
+ </div>
229
+ </>
230
+ )}
231
+ </div>
232
+ );
233
+ })}
234
+ </div>
235
+ </div>
236
+ );
237
+ },
238
+ );
239
+ ChartTooltipContent.displayName = "ChartTooltip";
240
+
241
+ const ChartLegend = RechartsPrimitive.Legend;
242
+
243
+ const ChartLegendContent = React.forwardRef<
244
+ HTMLDivElement,
245
+ React.ComponentProps<"div"> &
246
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
247
+ hideIcon?: boolean;
248
+ nameKey?: string;
249
+ }
250
+ >(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
251
+ const { config } = useChart();
252
+
253
+ if (!payload?.length) {
254
+ return null;
255
+ }
256
+
257
+ return (
258
+ <div
259
+ ref={ref}
260
+ className={cn(
261
+ "flex items-center justify-center gap-4",
262
+ verticalAlign === "top" ? "pb-3" : "pt-3",
263
+ className,
264
+ )}
265
+ >
266
+ {payload
267
+ .filter((item) => item.type !== "none")
268
+ .map((item) => {
269
+ const key = `${nameKey || item.dataKey || "value"}`;
270
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
271
+
272
+ return (
273
+ <div
274
+ key={item.value}
275
+ className={cn(
276
+ "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
277
+ )}
278
+ >
279
+ {itemConfig?.icon && !hideIcon ? (
280
+ <itemConfig.icon />
281
+ ) : (
282
+ <div
283
+ className="h-2 w-2 shrink-0 rounded-[2px]"
284
+ style={{
285
+ backgroundColor: item.color,
286
+ }}
287
+ />
288
+ )}
289
+ {itemConfig?.label}
290
+ </div>
291
+ );
292
+ })}
293
+ </div>
294
+ );
295
+ });
296
+ ChartLegendContent.displayName = "ChartLegend";
297
+
298
+ // Helper to extract item config from a payload.
299
+ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
300
+ if (typeof payload !== "object" || payload === null) {
301
+ return undefined;
302
+ }
303
+
304
+ const payloadPayload =
305
+ "payload" in payload && typeof payload.payload === "object" && payload.payload !== null
306
+ ? payload.payload
307
+ : undefined;
308
+
309
+ let configLabelKey: string = key;
310
+
311
+ if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
312
+ configLabelKey = payload[key as keyof typeof payload] as string;
313
+ } else if (
314
+ payloadPayload &&
315
+ key in payloadPayload &&
316
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
317
+ ) {
318
+ configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
319
+ }
320
+
321
+ return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
322
+ }
323
+
324
+ export {
325
+ ChartContainer,
326
+ ChartTooltip,
327
+ ChartTooltipContent,
328
+ ChartLegend,
329
+ ChartLegendContent,
330
+ ChartStyle,
331
+ };
components/ui/checkbox.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
3
+ import { Check } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const Checkbox = React.forwardRef<
8
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
9
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
10
+ >(({ className, ...props }, ref) => (
11
+ <CheckboxPrimitive.Root
12
+ ref={ref}
13
+ className={cn(
14
+ "grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
15
+ className,
16
+ )}
17
+ {...props}
18
+ >
19
+ <CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
20
+ <Check className="h-4 w-4" />
21
+ </CheckboxPrimitive.Indicator>
22
+ </CheckboxPrimitive.Root>
23
+ ));
24
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName;
25
+
26
+ export { Checkbox };
components/ui/collapsible.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
4
+
5
+ const Collapsible = CollapsiblePrimitive.Root;
6
+
7
+ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
8
+
9
+ const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
10
+
11
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent };
components/ui/command.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { type DialogProps } from "@radix-ui/react-dialog";
5
+ import { Command as CommandPrimitive } from "cmdk";
6
+ import { Search } from "lucide-react";
7
+
8
+ import { cn } from "@/lib/utils";
9
+ import { Dialog, DialogContent } from "@/components/ui/dialog";
10
+
11
+ const Command = React.forwardRef<
12
+ React.ElementRef<typeof CommandPrimitive>,
13
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
14
+ >(({ className, ...props }, ref) => (
15
+ <CommandPrimitive
16
+ ref={ref}
17
+ className={cn(
18
+ "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
19
+ className,
20
+ )}
21
+ {...props}
22
+ />
23
+ ));
24
+ Command.displayName = CommandPrimitive.displayName;
25
+
26
+ const CommandDialog = ({ children, ...props }: DialogProps) => {
27
+ return (
28
+ <Dialog {...props}>
29
+ <DialogContent className="overflow-hidden p-0">
30
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
31
+ {children}
32
+ </Command>
33
+ </DialogContent>
34
+ </Dialog>
35
+ );
36
+ };
37
+
38
+ const CommandInput = React.forwardRef<
39
+ React.ElementRef<typeof CommandPrimitive.Input>,
40
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
41
+ >(({ className, ...props }, ref) => (
42
+ <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
43
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
44
+ <CommandPrimitive.Input
45
+ ref={ref}
46
+ className={cn(
47
+ "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
48
+ className,
49
+ )}
50
+ {...props}
51
+ />
52
+ </div>
53
+ ));
54
+
55
+ CommandInput.displayName = CommandPrimitive.Input.displayName;
56
+
57
+ const CommandList = React.forwardRef<
58
+ React.ElementRef<typeof CommandPrimitive.List>,
59
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
60
+ >(({ className, ...props }, ref) => (
61
+ <CommandPrimitive.List
62
+ ref={ref}
63
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
64
+ {...props}
65
+ />
66
+ ));
67
+
68
+ CommandList.displayName = CommandPrimitive.List.displayName;
69
+
70
+ const CommandEmpty = React.forwardRef<
71
+ React.ElementRef<typeof CommandPrimitive.Empty>,
72
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
73
+ >((props, ref) => (
74
+ <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
75
+ ));
76
+
77
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
78
+
79
+ const CommandGroup = React.forwardRef<
80
+ React.ElementRef<typeof CommandPrimitive.Group>,
81
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
82
+ >(({ className, ...props }, ref) => (
83
+ <CommandPrimitive.Group
84
+ ref={ref}
85
+ className={cn(
86
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ ));
92
+
93
+ CommandGroup.displayName = CommandPrimitive.Group.displayName;
94
+
95
+ const CommandSeparator = React.forwardRef<
96
+ React.ElementRef<typeof CommandPrimitive.Separator>,
97
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
98
+ >(({ className, ...props }, ref) => (
99
+ <CommandPrimitive.Separator
100
+ ref={ref}
101
+ className={cn("-mx-1 h-px bg-border", className)}
102
+ {...props}
103
+ />
104
+ ));
105
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
106
+
107
+ const CommandItem = React.forwardRef<
108
+ React.ElementRef<typeof CommandPrimitive.Item>,
109
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
110
+ >(({ className, ...props }, ref) => (
111
+ <CommandPrimitive.Item
112
+ ref={ref}
113
+ className={cn(
114
+ "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
115
+ className,
116
+ )}
117
+ {...props}
118
+ />
119
+ ));
120
+
121
+ CommandItem.displayName = CommandPrimitive.Item.displayName;
122
+
123
+ const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
124
+ return (
125
+ <span
126
+ className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
127
+ {...props}
128
+ />
129
+ );
130
+ };
131
+ CommandShortcut.displayName = "CommandShortcut";
132
+
133
+ export {
134
+ Command,
135
+ CommandDialog,
136
+ CommandInput,
137
+ CommandList,
138
+ CommandEmpty,
139
+ CommandGroup,
140
+ CommandItem,
141
+ CommandShortcut,
142
+ CommandSeparator,
143
+ };
components/ui/context-menu.tsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
3
+ import { Check, ChevronRight, Circle } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const ContextMenu = ContextMenuPrimitive.Root;
8
+
9
+ const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
10
+
11
+ const ContextMenuGroup = ContextMenuPrimitive.Group;
12
+
13
+ const ContextMenuPortal = ContextMenuPrimitive.Portal;
14
+
15
+ const ContextMenuSub = ContextMenuPrimitive.Sub;
16
+
17
+ const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
18
+
19
+ const ContextMenuSubTrigger = React.forwardRef<
20
+ React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
21
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
22
+ inset?: boolean;
23
+ }
24
+ >(({ className, inset, children, ...props }, ref) => (
25
+ <ContextMenuPrimitive.SubTrigger
26
+ ref={ref}
27
+ className={cn(
28
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
29
+ inset && "pl-8",
30
+ className,
31
+ )}
32
+ {...props}
33
+ >
34
+ {children}
35
+ <ChevronRight className="ml-auto h-4 w-4" />
36
+ </ContextMenuPrimitive.SubTrigger>
37
+ ));
38
+ ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
39
+
40
+ const ContextMenuSubContent = React.forwardRef<
41
+ React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
42
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
43
+ >(({ className, ...props }, ref) => (
44
+ <ContextMenuPrimitive.SubContent
45
+ ref={ref}
46
+ className={cn(
47
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
48
+ className,
49
+ )}
50
+ {...props}
51
+ />
52
+ ));
53
+ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
54
+
55
+ const ContextMenuContent = React.forwardRef<
56
+ React.ElementRef<typeof ContextMenuPrimitive.Content>,
57
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
58
+ >(({ className, ...props }, ref) => (
59
+ <ContextMenuPrimitive.Portal>
60
+ <ContextMenuPrimitive.Content
61
+ ref={ref}
62
+ className={cn(
63
+ "z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
64
+ className,
65
+ )}
66
+ {...props}
67
+ />
68
+ </ContextMenuPrimitive.Portal>
69
+ ));
70
+ ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
71
+
72
+ const ContextMenuItem = React.forwardRef<
73
+ React.ElementRef<typeof ContextMenuPrimitive.Item>,
74
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
75
+ inset?: boolean;
76
+ }
77
+ >(({ className, inset, ...props }, ref) => (
78
+ <ContextMenuPrimitive.Item
79
+ ref={ref}
80
+ className={cn(
81
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
82
+ inset && "pl-8",
83
+ className,
84
+ )}
85
+ {...props}
86
+ />
87
+ ));
88
+ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
89
+
90
+ const ContextMenuCheckboxItem = React.forwardRef<
91
+ React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
92
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
93
+ >(({ className, children, ...props }, ref) => (
94
+ <ContextMenuPrimitive.CheckboxItem
95
+ ref={ref}
96
+ className={cn(
97
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
98
+ className,
99
+ )}
100
+ {...props}
101
+ >
102
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
103
+ <ContextMenuPrimitive.ItemIndicator>
104
+ <Check className="h-4 w-4" />
105
+ </ContextMenuPrimitive.ItemIndicator>
106
+ </span>
107
+ {children}
108
+ </ContextMenuPrimitive.CheckboxItem>
109
+ ));
110
+ ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
111
+
112
+ const ContextMenuRadioItem = React.forwardRef<
113
+ React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
114
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
115
+ >(({ className, children, ...props }, ref) => (
116
+ <ContextMenuPrimitive.RadioItem
117
+ ref={ref}
118
+ className={cn(
119
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
120
+ className,
121
+ )}
122
+ {...props}
123
+ >
124
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
125
+ <ContextMenuPrimitive.ItemIndicator>
126
+ <Circle className="h-4 w-4 fill-current" />
127
+ </ContextMenuPrimitive.ItemIndicator>
128
+ </span>
129
+ {children}
130
+ </ContextMenuPrimitive.RadioItem>
131
+ ));
132
+ ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
133
+
134
+ const ContextMenuLabel = React.forwardRef<
135
+ React.ElementRef<typeof ContextMenuPrimitive.Label>,
136
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
137
+ inset?: boolean;
138
+ }
139
+ >(({ className, inset, ...props }, ref) => (
140
+ <ContextMenuPrimitive.Label
141
+ ref={ref}
142
+ className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
143
+ {...props}
144
+ />
145
+ ));
146
+ ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
147
+
148
+ const ContextMenuSeparator = React.forwardRef<
149
+ React.ElementRef<typeof ContextMenuPrimitive.Separator>,
150
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
151
+ >(({ className, ...props }, ref) => (
152
+ <ContextMenuPrimitive.Separator
153
+ ref={ref}
154
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
155
+ {...props}
156
+ />
157
+ ));
158
+ ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
159
+
160
+ const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
161
+ return (
162
+ <span
163
+ className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
164
+ {...props}
165
+ />
166
+ );
167
+ };
168
+ ContextMenuShortcut.displayName = "ContextMenuShortcut";
169
+
170
+ export {
171
+ ContextMenu,
172
+ ContextMenuTrigger,
173
+ ContextMenuContent,
174
+ ContextMenuItem,
175
+ ContextMenuCheckboxItem,
176
+ ContextMenuRadioItem,
177
+ ContextMenuLabel,
178
+ ContextMenuSeparator,
179
+ ContextMenuShortcut,
180
+ ContextMenuGroup,
181
+ ContextMenuPortal,
182
+ ContextMenuSub,
183
+ ContextMenuSubContent,
184
+ ContextMenuSubTrigger,
185
+ ContextMenuRadioGroup,
186
+ };
components/ui/dialog.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { X } from "lucide-react";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const Dialog = DialogPrimitive.Root;
10
+
11
+ const DialogTrigger = DialogPrimitive.Trigger;
12
+
13
+ const DialogPortal = DialogPrimitive.Portal;
14
+
15
+ const DialogClose = DialogPrimitive.Close;
16
+
17
+ const DialogOverlay = React.forwardRef<
18
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
19
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
+ >(({ className, ...props }, ref) => (
21
+ <DialogPrimitive.Overlay
22
+ ref={ref}
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ ));
30
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31
+
32
+ const DialogContent = React.forwardRef<
33
+ React.ElementRef<typeof DialogPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35
+ >(({ className, children, ...props }, ref) => (
36
+ <DialogPortal>
37
+ <DialogOverlay />
38
+ <DialogPrimitive.Content
39
+ ref={ref}
40
+ className={cn(
41
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
42
+ className,
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48
+ <X className="h-4 w-4" />
49
+ <span className="sr-only">Close</span>
50
+ </DialogPrimitive.Close>
51
+ </DialogPrimitive.Content>
52
+ </DialogPortal>
53
+ ));
54
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
55
+
56
+ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
57
+ <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
58
+ );
59
+ DialogHeader.displayName = "DialogHeader";
60
+
61
+ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
62
+ <div
63
+ className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
64
+ {...props}
65
+ />
66
+ );
67
+ DialogFooter.displayName = "DialogFooter";
68
+
69
+ const DialogTitle = React.forwardRef<
70
+ React.ElementRef<typeof DialogPrimitive.Title>,
71
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
72
+ >(({ className, ...props }, ref) => (
73
+ <DialogPrimitive.Title
74
+ ref={ref}
75
+ className={cn("text-lg font-semibold leading-none tracking-tight", className)}
76
+ {...props}
77
+ />
78
+ ));
79
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
80
+
81
+ const DialogDescription = React.forwardRef<
82
+ React.ElementRef<typeof DialogPrimitive.Description>,
83
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
84
+ >(({ className, ...props }, ref) => (
85
+ <DialogPrimitive.Description
86
+ ref={ref}
87
+ className={cn("text-sm text-muted-foreground", className)}
88
+ {...props}
89
+ />
90
+ ));
91
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
92
+
93
+ export {
94
+ Dialog,
95
+ DialogPortal,
96
+ DialogOverlay,
97
+ DialogTrigger,
98
+ DialogClose,
99
+ DialogContent,
100
+ DialogHeader,
101
+ DialogFooter,
102
+ DialogTitle,
103
+ DialogDescription,
104
+ };
components/ui/drawer.tsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Drawer as DrawerPrimitive } from "vaul";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const Drawer = ({
7
+ shouldScaleBackground = true,
8
+ ...props
9
+ }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
10
+ <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
11
+ );
12
+ Drawer.displayName = "Drawer";
13
+
14
+ const DrawerTrigger = DrawerPrimitive.Trigger;
15
+
16
+ const DrawerPortal = DrawerPrimitive.Portal;
17
+
18
+ const DrawerClose = DrawerPrimitive.Close;
19
+
20
+ const DrawerOverlay = React.forwardRef<
21
+ React.ElementRef<typeof DrawerPrimitive.Overlay>,
22
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
23
+ >(({ className, ...props }, ref) => (
24
+ <DrawerPrimitive.Overlay
25
+ ref={ref}
26
+ className={cn("fixed inset-0 z-50 bg-black/80", className)}
27
+ {...props}
28
+ />
29
+ ));
30
+ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
31
+
32
+ const DrawerContent = React.forwardRef<
33
+ React.ElementRef<typeof DrawerPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
35
+ >(({ className, children, ...props }, ref) => (
36
+ <DrawerPortal>
37
+ <DrawerOverlay />
38
+ <DrawerPrimitive.Content
39
+ ref={ref}
40
+ className={cn(
41
+ "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
42
+ className,
43
+ )}
44
+ {...props}
45
+ >
46
+ <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
47
+ {children}
48
+ </DrawerPrimitive.Content>
49
+ </DrawerPortal>
50
+ ));
51
+ DrawerContent.displayName = "DrawerContent";
52
+
53
+ const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
54
+ <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
55
+ );
56
+ DrawerHeader.displayName = "DrawerHeader";
57
+
58
+ const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
59
+ <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
60
+ );
61
+ DrawerFooter.displayName = "DrawerFooter";
62
+
63
+ const DrawerTitle = React.forwardRef<
64
+ React.ElementRef<typeof DrawerPrimitive.Title>,
65
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
66
+ >(({ className, ...props }, ref) => (
67
+ <DrawerPrimitive.Title
68
+ ref={ref}
69
+ className={cn("text-lg font-semibold leading-none tracking-tight", className)}
70
+ {...props}
71
+ />
72
+ ));
73
+ DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
74
+
75
+ const DrawerDescription = React.forwardRef<
76
+ React.ElementRef<typeof DrawerPrimitive.Description>,
77
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
78
+ >(({ className, ...props }, ref) => (
79
+ <DrawerPrimitive.Description
80
+ ref={ref}
81
+ className={cn("text-sm text-muted-foreground", className)}
82
+ {...props}
83
+ />
84
+ ));
85
+ DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
86
+
87
+ export {
88
+ Drawer,
89
+ DrawerPortal,
90
+ DrawerOverlay,
91
+ DrawerTrigger,
92
+ DrawerClose,
93
+ DrawerContent,
94
+ DrawerHeader,
95
+ DrawerFooter,
96
+ DrawerTitle,
97
+ DrawerDescription,
98
+ };
components/ui/dropdown-menu.tsx ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5
+ import { Check, ChevronRight, Circle } from "lucide-react";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const DropdownMenu = DropdownMenuPrimitive.Root;
10
+
11
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12
+
13
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14
+
15
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16
+
17
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18
+
19
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20
+
21
+ const DropdownMenuSubTrigger = React.forwardRef<
22
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
23
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
24
+ inset?: boolean;
25
+ }
26
+ >(({ className, inset, children, ...props }, ref) => (
27
+ <DropdownMenuPrimitive.SubTrigger
28
+ ref={ref}
29
+ className={cn(
30
+ "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
31
+ inset && "pl-8",
32
+ className,
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ <ChevronRight className="ml-auto" />
38
+ </DropdownMenuPrimitive.SubTrigger>
39
+ ));
40
+ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
41
+
42
+ const DropdownMenuSubContent = React.forwardRef<
43
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
44
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
45
+ >(({ className, ...props }, ref) => (
46
+ <DropdownMenuPrimitive.SubContent
47
+ ref={ref}
48
+ className={cn(
49
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ ));
55
+ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
56
+
57
+ const DropdownMenuContent = React.forwardRef<
58
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
59
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
60
+ >(({ className, sideOffset = 4, ...props }, ref) => (
61
+ <DropdownMenuPrimitive.Portal>
62
+ <DropdownMenuPrimitive.Content
63
+ ref={ref}
64
+ sideOffset={sideOffset}
65
+ className={cn(
66
+ "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
67
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
68
+ className,
69
+ )}
70
+ {...props}
71
+ />
72
+ </DropdownMenuPrimitive.Portal>
73
+ ));
74
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
75
+
76
+ const DropdownMenuItem = React.forwardRef<
77
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
78
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
79
+ inset?: boolean;
80
+ }
81
+ >(({ className, inset, ...props }, ref) => (
82
+ <DropdownMenuPrimitive.Item
83
+ ref={ref}
84
+ className={cn(
85
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
86
+ inset && "pl-8",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ ));
92
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
93
+
94
+ const DropdownMenuCheckboxItem = React.forwardRef<
95
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
96
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
97
+ >(({ className, children, ...props }, ref) => (
98
+ <DropdownMenuPrimitive.CheckboxItem
99
+ ref={ref}
100
+ className={cn(
101
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
102
+ className,
103
+ )}
104
+ {...props}
105
+ >
106
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
107
+ <DropdownMenuPrimitive.ItemIndicator>
108
+ <Check className="h-4 w-4" />
109
+ </DropdownMenuPrimitive.ItemIndicator>
110
+ </span>
111
+ {children}
112
+ </DropdownMenuPrimitive.CheckboxItem>
113
+ ));
114
+ DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
115
+
116
+ const DropdownMenuRadioItem = React.forwardRef<
117
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
118
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
119
+ >(({ className, children, ...props }, ref) => (
120
+ <DropdownMenuPrimitive.RadioItem
121
+ ref={ref}
122
+ className={cn(
123
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
124
+ className,
125
+ )}
126
+ {...props}
127
+ >
128
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
129
+ <DropdownMenuPrimitive.ItemIndicator>
130
+ <Circle className="h-2 w-2 fill-current" />
131
+ </DropdownMenuPrimitive.ItemIndicator>
132
+ </span>
133
+ {children}
134
+ </DropdownMenuPrimitive.RadioItem>
135
+ ));
136
+ DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
137
+
138
+ const DropdownMenuLabel = React.forwardRef<
139
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
140
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
141
+ inset?: boolean;
142
+ }
143
+ >(({ className, inset, ...props }, ref) => (
144
+ <DropdownMenuPrimitive.Label
145
+ ref={ref}
146
+ className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
147
+ {...props}
148
+ />
149
+ ));
150
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
151
+
152
+ const DropdownMenuSeparator = React.forwardRef<
153
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
154
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
155
+ >(({ className, ...props }, ref) => (
156
+ <DropdownMenuPrimitive.Separator
157
+ ref={ref}
158
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
159
+ {...props}
160
+ />
161
+ ));
162
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
163
+
164
+ const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
165
+ return (
166
+ <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
167
+ );
168
+ };
169
+ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
170
+
171
+ export {
172
+ DropdownMenu,
173
+ DropdownMenuTrigger,
174
+ DropdownMenuContent,
175
+ DropdownMenuItem,
176
+ DropdownMenuCheckboxItem,
177
+ DropdownMenuRadioItem,
178
+ DropdownMenuLabel,
179
+ DropdownMenuSeparator,
180
+ DropdownMenuShortcut,
181
+ DropdownMenuGroup,
182
+ DropdownMenuPortal,
183
+ DropdownMenuSub,
184
+ DropdownMenuSubContent,
185
+ DropdownMenuSubTrigger,
186
+ DropdownMenuRadioGroup,
187
+ };
components/ui/form.tsx ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as LabelPrimitive from "@radix-ui/react-label";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import {
5
+ Controller,
6
+ FormProvider,
7
+ useFormContext,
8
+ type ControllerProps,
9
+ type FieldPath,
10
+ type FieldValues,
11
+ } from "react-hook-form";
12
+
13
+ import { cn } from "@/lib/utils";
14
+ import { Label } from "@/components/ui/label";
15
+
16
+ const Form = FormProvider;
17
+
18
+ type FormFieldContextValue<
19
+ TFieldValues extends FieldValues = FieldValues,
20
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
21
+ > = {
22
+ name: TName;
23
+ };
24
+
25
+ const FormFieldContext = React.createContext<FormFieldContextValue | null>(null);
26
+
27
+ const FormField = <
28
+ TFieldValues extends FieldValues = FieldValues,
29
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
30
+ >({
31
+ ...props
32
+ }: ControllerProps<TFieldValues, TName>) => {
33
+ return (
34
+ <FormFieldContext.Provider value={{ name: props.name }}>
35
+ <Controller {...props} />
36
+ </FormFieldContext.Provider>
37
+ );
38
+ };
39
+
40
+ const useFormField = () => {
41
+ const fieldContext = React.useContext(FormFieldContext);
42
+ const itemContext = React.useContext(FormItemContext);
43
+ const { getFieldState, formState } = useFormContext();
44
+
45
+ if (!fieldContext) {
46
+ throw new Error("useFormField should be used within <FormField>");
47
+ }
48
+
49
+ if (!itemContext) {
50
+ throw new Error("useFormField should be used within <FormItem>");
51
+ }
52
+
53
+ const fieldState = getFieldState(fieldContext.name, formState);
54
+
55
+ const { id } = itemContext;
56
+
57
+ return {
58
+ id,
59
+ name: fieldContext.name,
60
+ formItemId: `${id}-form-item`,
61
+ formDescriptionId: `${id}-form-item-description`,
62
+ formMessageId: `${id}-form-item-message`,
63
+ ...fieldState,
64
+ };
65
+ };
66
+
67
+ type FormItemContextValue = {
68
+ id: string;
69
+ };
70
+
71
+ const FormItemContext = React.createContext<FormItemContextValue | null>(null);
72
+
73
+ const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
74
+ ({ className, ...props }, ref) => {
75
+ const id = React.useId();
76
+
77
+ return (
78
+ <FormItemContext.Provider value={{ id }}>
79
+ <div ref={ref} className={cn("space-y-2", className)} {...props} />
80
+ </FormItemContext.Provider>
81
+ );
82
+ },
83
+ );
84
+ FormItem.displayName = "FormItem";
85
+
86
+ const FormLabel = React.forwardRef<
87
+ React.ElementRef<typeof LabelPrimitive.Root>,
88
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
89
+ >(({ className, ...props }, ref) => {
90
+ const { error, formItemId } = useFormField();
91
+
92
+ return (
93
+ <Label
94
+ ref={ref}
95
+ className={cn(error && "text-destructive", className)}
96
+ htmlFor={formItemId}
97
+ {...props}
98
+ />
99
+ );
100
+ });
101
+ FormLabel.displayName = "FormLabel";
102
+
103
+ const FormControl = React.forwardRef<
104
+ React.ElementRef<typeof Slot>,
105
+ React.ComponentPropsWithoutRef<typeof Slot>
106
+ >(({ ...props }, ref) => {
107
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
108
+
109
+ return (
110
+ <Slot
111
+ ref={ref}
112
+ id={formItemId}
113
+ aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
114
+ aria-invalid={!!error}
115
+ {...props}
116
+ />
117
+ );
118
+ });
119
+ FormControl.displayName = "FormControl";
120
+
121
+ const FormDescription = React.forwardRef<
122
+ HTMLParagraphElement,
123
+ React.HTMLAttributes<HTMLParagraphElement>
124
+ >(({ className, ...props }, ref) => {
125
+ const { formDescriptionId } = useFormField();
126
+
127
+ return (
128
+ <p
129
+ ref={ref}
130
+ id={formDescriptionId}
131
+ className={cn("text-[0.8rem] text-muted-foreground", className)}
132
+ {...props}
133
+ />
134
+ );
135
+ });
136
+ FormDescription.displayName = "FormDescription";
137
+
138
+ const FormMessage = React.forwardRef<
139
+ HTMLParagraphElement,
140
+ React.HTMLAttributes<HTMLParagraphElement>
141
+ >(({ className, children, ...props }, ref) => {
142
+ const { error, formMessageId } = useFormField();
143
+ const body = error ? String(error?.message ?? "") : children;
144
+
145
+ if (!body) {
146
+ return null;
147
+ }
148
+
149
+ return (
150
+ <p
151
+ ref={ref}
152
+ id={formMessageId}
153
+ className={cn("text-[0.8rem] font-medium text-destructive", className)}
154
+ {...props}
155
+ >
156
+ {body}
157
+ </p>
158
+ );
159
+ });
160
+ FormMessage.displayName = "FormMessage";
161
+
162
+ export {
163
+ useFormField,
164
+ Form,
165
+ FormItem,
166
+ FormLabel,
167
+ FormControl,
168
+ FormDescription,
169
+ FormMessage,
170
+ FormField,
171
+ };
components/ui/hover-card.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const HoverCard = HoverCardPrimitive.Root;
7
+
8
+ const HoverCardTrigger = HoverCardPrimitive.Trigger;
9
+
10
+ const HoverCardContent = React.forwardRef<
11
+ React.ElementRef<typeof HoverCardPrimitive.Content>,
12
+ React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
13
+ >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14
+ <HoverCardPrimitive.Content
15
+ ref={ref}
16
+ align={align}
17
+ sideOffset={sideOffset}
18
+ className={cn(
19
+ "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
20
+ className,
21
+ )}
22
+ {...props}
23
+ />
24
+ ));
25
+ HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
26
+
27
+ export { HoverCard, HoverCardTrigger, HoverCardContent };
components/ui/input-otp.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { OTPInput, OTPInputContext } from "input-otp";
3
+ import { Minus } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const InputOTP = React.forwardRef<
8
+ React.ElementRef<typeof OTPInput>,
9
+ React.ComponentPropsWithoutRef<typeof OTPInput>
10
+ >(({ className, containerClassName, ...props }, ref) => (
11
+ <OTPInput
12
+ ref={ref}
13
+ containerClassName={cn(
14
+ "flex items-center gap-2 has-[:disabled]:opacity-50",
15
+ containerClassName,
16
+ )}
17
+ className={cn("disabled:cursor-not-allowed", className)}
18
+ {...props}
19
+ />
20
+ ));
21
+ InputOTP.displayName = "InputOTP";
22
+
23
+ const InputOTPGroup = React.forwardRef<
24
+ React.ElementRef<"div">,
25
+ React.ComponentPropsWithoutRef<"div">
26
+ >(({ className, ...props }, ref) => (
27
+ <div ref={ref} className={cn("flex items-center", className)} {...props} />
28
+ ));
29
+ InputOTPGroup.displayName = "InputOTPGroup";
30
+
31
+ const InputOTPSlot = React.forwardRef<
32
+ React.ElementRef<"div">,
33
+ React.ComponentPropsWithoutRef<"div"> & { index: number }
34
+ >(({ index, className, ...props }, ref) => {
35
+ const inputOTPContext = React.useContext(OTPInputContext);
36
+ const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] ?? {
37
+ char: null,
38
+ hasFakeCaret: false,
39
+ isActive: false,
40
+ };
41
+
42
+ return (
43
+ <div
44
+ ref={ref}
45
+ className={cn(
46
+ "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
47
+ isActive && "z-10 ring-1 ring-ring",
48
+ className,
49
+ )}
50
+ {...props}
51
+ >
52
+ {char}
53
+ {hasFakeCaret && (
54
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
55
+ <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
56
+ </div>
57
+ )}
58
+ </div>
59
+ );
60
+ });
61
+ InputOTPSlot.displayName = "InputOTPSlot";
62
+
63
+ const InputOTPSeparator = React.forwardRef<
64
+ React.ElementRef<"div">,
65
+ React.ComponentPropsWithoutRef<"div">
66
+ >(({ ...props }, ref) => (
67
+ <div ref={ref} role="separator" {...props}>
68
+ <Minus />
69
+ </div>
70
+ ));
71
+ InputOTPSeparator.displayName = "InputOTPSeparator";
72
+
73
+ export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
components/ui/input.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
6
+ ({ className, type, ...props }, ref) => {
7
+ return (
8
+ <input
9
+ type={type}
10
+ className={cn(
11
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
+ className,
13
+ )}
14
+ ref={ref}
15
+ {...props}
16
+ />
17
+ );
18
+ },
19
+ );
20
+ Input.displayName = "Input";
21
+
22
+ export { Input };
components/ui/label.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as LabelPrimitive from "@radix-ui/react-label";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const labelVariants = cva(
10
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
11
+ );
12
+
13
+ const Label = React.forwardRef<
14
+ React.ElementRef<typeof LabelPrimitive.Root>,
15
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
16
+ >(({ className, ...props }, ref) => (
17
+ <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
18
+ ));
19
+ Label.displayName = LabelPrimitive.Root.displayName;
20
+
21
+ export { Label };
components/ui/menubar.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as MenubarPrimitive from "@radix-ui/react-menubar";
3
+ import { Check, ChevronRight, Circle } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
8
+ return <MenubarPrimitive.Menu {...props} />;
9
+ }
10
+
11
+ function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
12
+ return <MenubarPrimitive.Group {...props} />;
13
+ }
14
+
15
+ function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
16
+ return <MenubarPrimitive.Portal {...props} />;
17
+ }
18
+
19
+ function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
20
+ return <MenubarPrimitive.RadioGroup {...props} />;
21
+ }
22
+
23
+ function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
24
+ return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
25
+ }
26
+
27
+ const Menubar = React.forwardRef<
28
+ React.ElementRef<typeof MenubarPrimitive.Root>,
29
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
30
+ >(({ className, ...props }, ref) => (
31
+ <MenubarPrimitive.Root
32
+ ref={ref}
33
+ className={cn(
34
+ "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
35
+ className,
36
+ )}
37
+ {...props}
38
+ />
39
+ ));
40
+ Menubar.displayName = MenubarPrimitive.Root.displayName;
41
+
42
+ const MenubarTrigger = React.forwardRef<
43
+ React.ElementRef<typeof MenubarPrimitive.Trigger>,
44
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
45
+ >(({ className, ...props }, ref) => (
46
+ <MenubarPrimitive.Trigger
47
+ ref={ref}
48
+ className={cn(
49
+ "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ ));
55
+ MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
56
+
57
+ const MenubarSubTrigger = React.forwardRef<
58
+ React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
59
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
60
+ inset?: boolean;
61
+ }
62
+ >(({ className, inset, children, ...props }, ref) => (
63
+ <MenubarPrimitive.SubTrigger
64
+ ref={ref}
65
+ className={cn(
66
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
67
+ inset && "pl-8",
68
+ className,
69
+ )}
70
+ {...props}
71
+ >
72
+ {children}
73
+ <ChevronRight className="ml-auto h-4 w-4" />
74
+ </MenubarPrimitive.SubTrigger>
75
+ ));
76
+ MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
77
+
78
+ const MenubarSubContent = React.forwardRef<
79
+ React.ElementRef<typeof MenubarPrimitive.SubContent>,
80
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
81
+ >(({ className, ...props }, ref) => (
82
+ <MenubarPrimitive.SubContent
83
+ ref={ref}
84
+ className={cn(
85
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin)",
86
+ className,
87
+ )}
88
+ {...props}
89
+ />
90
+ ));
91
+ MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
92
+
93
+ const MenubarContent = React.forwardRef<
94
+ React.ElementRef<typeof MenubarPrimitive.Content>,
95
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
96
+ >(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
97
+ <MenubarPrimitive.Portal>
98
+ <MenubarPrimitive.Content
99
+ ref={ref}
100
+ align={align}
101
+ alignOffset={alignOffset}
102
+ sideOffset={sideOffset}
103
+ className={cn(
104
+ "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin)",
105
+ className,
106
+ )}
107
+ {...props}
108
+ />
109
+ </MenubarPrimitive.Portal>
110
+ ));
111
+ MenubarContent.displayName = MenubarPrimitive.Content.displayName;
112
+
113
+ const MenubarItem = React.forwardRef<
114
+ React.ElementRef<typeof MenubarPrimitive.Item>,
115
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
116
+ inset?: boolean;
117
+ }
118
+ >(({ className, inset, ...props }, ref) => (
119
+ <MenubarPrimitive.Item
120
+ ref={ref}
121
+ className={cn(
122
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
123
+ inset && "pl-8",
124
+ className,
125
+ )}
126
+ {...props}
127
+ />
128
+ ));
129
+ MenubarItem.displayName = MenubarPrimitive.Item.displayName;
130
+
131
+ const MenubarCheckboxItem = React.forwardRef<
132
+ React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
133
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
134
+ >(({ className, children, ...props }, ref) => (
135
+ <MenubarPrimitive.CheckboxItem
136
+ ref={ref}
137
+ className={cn(
138
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
139
+ className,
140
+ )}
141
+ {...props}
142
+ >
143
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
144
+ <MenubarPrimitive.ItemIndicator>
145
+ <Check className="h-4 w-4" />
146
+ </MenubarPrimitive.ItemIndicator>
147
+ </span>
148
+ {children}
149
+ </MenubarPrimitive.CheckboxItem>
150
+ ));
151
+ MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
152
+
153
+ const MenubarRadioItem = React.forwardRef<
154
+ React.ElementRef<typeof MenubarPrimitive.RadioItem>,
155
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
156
+ >(({ className, children, ...props }, ref) => (
157
+ <MenubarPrimitive.RadioItem
158
+ ref={ref}
159
+ className={cn(
160
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
161
+ className,
162
+ )}
163
+ {...props}
164
+ >
165
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
166
+ <MenubarPrimitive.ItemIndicator>
167
+ <Circle className="h-4 w-4 fill-current" />
168
+ </MenubarPrimitive.ItemIndicator>
169
+ </span>
170
+ {children}
171
+ </MenubarPrimitive.RadioItem>
172
+ ));
173
+ MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
174
+
175
+ const MenubarLabel = React.forwardRef<
176
+ React.ElementRef<typeof MenubarPrimitive.Label>,
177
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
178
+ inset?: boolean;
179
+ }
180
+ >(({ className, inset, ...props }, ref) => (
181
+ <MenubarPrimitive.Label
182
+ ref={ref}
183
+ className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
184
+ {...props}
185
+ />
186
+ ));
187
+ MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
188
+
189
+ const MenubarSeparator = React.forwardRef<
190
+ React.ElementRef<typeof MenubarPrimitive.Separator>,
191
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
192
+ >(({ className, ...props }, ref) => (
193
+ <MenubarPrimitive.Separator
194
+ ref={ref}
195
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
196
+ {...props}
197
+ />
198
+ ));
199
+ MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
200
+
201
+ const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
202
+ return (
203
+ <span
204
+ className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
205
+ {...props}
206
+ />
207
+ );
208
+ };
209
+ MenubarShortcut.displayname = "MenubarShortcut";
210
+
211
+ export {
212
+ Menubar,
213
+ MenubarMenu,
214
+ MenubarTrigger,
215
+ MenubarContent,
216
+ MenubarItem,
217
+ MenubarSeparator,
218
+ MenubarLabel,
219
+ MenubarCheckboxItem,
220
+ MenubarRadioGroup,
221
+ MenubarRadioItem,
222
+ MenubarPortal,
223
+ MenubarSubContent,
224
+ MenubarSubTrigger,
225
+ MenubarGroup,
226
+ MenubarSub,
227
+ MenubarShortcut,
228
+ };
components/ui/navigation-menu.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
3
+ import { cva } from "class-variance-authority";
4
+ import { ChevronDown } from "lucide-react";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const NavigationMenu = React.forwardRef<
9
+ React.ElementRef<typeof NavigationMenuPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
11
+ >(({ className, children, ...props }, ref) => (
12
+ <NavigationMenuPrimitive.Root
13
+ ref={ref}
14
+ className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
15
+ {...props}
16
+ >
17
+ {children}
18
+ <NavigationMenuViewport />
19
+ </NavigationMenuPrimitive.Root>
20
+ ));
21
+ NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
22
+
23
+ const NavigationMenuList = React.forwardRef<
24
+ React.ElementRef<typeof NavigationMenuPrimitive.List>,
25
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
26
+ >(({ className, ...props }, ref) => (
27
+ <NavigationMenuPrimitive.List
28
+ ref={ref}
29
+ className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
30
+ {...props}
31
+ />
32
+ ));
33
+ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
34
+
35
+ const NavigationMenuItem = NavigationMenuPrimitive.Item;
36
+
37
+ const navigationMenuTriggerStyle = cva(
38
+ "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent",
39
+ );
40
+
41
+ const NavigationMenuTrigger = React.forwardRef<
42
+ React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
43
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
44
+ >(({ className, children, ...props }, ref) => (
45
+ <NavigationMenuPrimitive.Trigger
46
+ ref={ref}
47
+ className={cn(navigationMenuTriggerStyle(), "group", className)}
48
+ {...props}
49
+ >
50
+ {children}{" "}
51
+ <ChevronDown
52
+ className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
53
+ aria-hidden="true"
54
+ />
55
+ </NavigationMenuPrimitive.Trigger>
56
+ ));
57
+ NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
58
+
59
+ const NavigationMenuContent = React.forwardRef<
60
+ React.ElementRef<typeof NavigationMenuPrimitive.Content>,
61
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
62
+ >(({ className, ...props }, ref) => (
63
+ <NavigationMenuPrimitive.Content
64
+ ref={ref}
65
+ className={cn(
66
+ "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
67
+ className,
68
+ )}
69
+ {...props}
70
+ />
71
+ ));
72
+ NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
73
+
74
+ const NavigationMenuLink = NavigationMenuPrimitive.Link;
75
+
76
+ const NavigationMenuViewport = React.forwardRef<
77
+ React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
78
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
79
+ >(({ className, ...props }, ref) => (
80
+ <div className={cn("absolute left-0 top-full flex justify-center")}>
81
+ <NavigationMenuPrimitive.Viewport
82
+ className={cn(
83
+ "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
84
+ className,
85
+ )}
86
+ ref={ref}
87
+ {...props}
88
+ />
89
+ </div>
90
+ ));
91
+ NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
92
+
93
+ const NavigationMenuIndicator = React.forwardRef<
94
+ React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
95
+ React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
96
+ >(({ className, ...props }, ref) => (
97
+ <NavigationMenuPrimitive.Indicator
98
+ ref={ref}
99
+ className={cn(
100
+ "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
101
+ className,
102
+ )}
103
+ {...props}
104
+ >
105
+ <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
106
+ </NavigationMenuPrimitive.Indicator>
107
+ ));
108
+ NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
109
+
110
+ export {
111
+ navigationMenuTriggerStyle,
112
+ NavigationMenu,
113
+ NavigationMenuList,
114
+ NavigationMenuItem,
115
+ NavigationMenuContent,
116
+ NavigationMenuTrigger,
117
+ NavigationMenuLink,
118
+ NavigationMenuIndicator,
119
+ NavigationMenuViewport,
120
+ };
components/ui/pagination.tsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
3
+
4
+ import { cn } from "@/lib/utils";
5
+ import { ButtonProps, buttonVariants } from "@/components/ui/button";
6
+
7
+ const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
8
+ <nav
9
+ role="navigation"
10
+ aria-label="pagination"
11
+ className={cn("mx-auto flex w-full justify-center", className)}
12
+ {...props}
13
+ />
14
+ );
15
+ Pagination.displayName = "Pagination";
16
+
17
+ const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
18
+ ({ className, ...props }, ref) => (
19
+ <ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
20
+ ),
21
+ );
22
+ PaginationContent.displayName = "PaginationContent";
23
+
24
+ const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
25
+ ({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
26
+ );
27
+ PaginationItem.displayName = "PaginationItem";
28
+
29
+ type PaginationLinkProps = {
30
+ isActive?: boolean;
31
+ } & Pick<ButtonProps, "size"> &
32
+ React.ComponentProps<"a">;
33
+
34
+ const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
35
+ <a
36
+ aria-current={isActive ? "page" : undefined}
37
+ className={cn(
38
+ buttonVariants({
39
+ variant: isActive ? "outline" : "ghost",
40
+ size,
41
+ }),
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ );
47
+ PaginationLink.displayName = "PaginationLink";
48
+
49
+ const PaginationPrevious = ({
50
+ className,
51
+ ...props
52
+ }: React.ComponentProps<typeof PaginationLink>) => (
53
+ <PaginationLink
54
+ aria-label="Go to previous page"
55
+ size="default"
56
+ className={cn("gap-1 pl-2.5", className)}
57
+ {...props}
58
+ >
59
+ <ChevronLeft className="h-4 w-4" />
60
+ <span>Previous</span>
61
+ </PaginationLink>
62
+ );
63
+ PaginationPrevious.displayName = "PaginationPrevious";
64
+
65
+ const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
66
+ <PaginationLink
67
+ aria-label="Go to next page"
68
+ size="default"
69
+ className={cn("gap-1 pr-2.5", className)}
70
+ {...props}
71
+ >
72
+ <span>Next</span>
73
+ <ChevronRight className="h-4 w-4" />
74
+ </PaginationLink>
75
+ );
76
+ PaginationNext.displayName = "PaginationNext";
77
+
78
+ const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
79
+ <span
80
+ aria-hidden
81
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
82
+ {...props}
83
+ >
84
+ <MoreHorizontal className="h-4 w-4" />
85
+ <span className="sr-only">More pages</span>
86
+ </span>
87
+ );
88
+ PaginationEllipsis.displayName = "PaginationEllipsis";
89
+
90
+ export {
91
+ Pagination,
92
+ PaginationContent,
93
+ PaginationLink,
94
+ PaginationItem,
95
+ PaginationPrevious,
96
+ PaginationNext,
97
+ PaginationEllipsis,
98
+ };
components/ui/popover.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const Popover = PopoverPrimitive.Root;
7
+
8
+ const PopoverTrigger = PopoverPrimitive.Trigger;
9
+
10
+ const PopoverAnchor = PopoverPrimitive.Anchor;
11
+
12
+ const PopoverContent = React.forwardRef<
13
+ React.ElementRef<typeof PopoverPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15
+ >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16
+ <PopoverPrimitive.Portal>
17
+ <PopoverPrimitive.Content
18
+ ref={ref}
19
+ align={align}
20
+ sideOffset={sideOffset}
21
+ className={cn(
22
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
23
+ className,
24
+ )}
25
+ {...props}
26
+ />
27
+ </PopoverPrimitive.Portal>
28
+ ));
29
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30
+
31
+ export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
components/ui/progress.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as ProgressPrimitive from "@radix-ui/react-progress";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const Progress = React.forwardRef<
9
+ React.ElementRef<typeof ProgressPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
11
+ >(({ className, value, ...props }, ref) => (
12
+ <ProgressPrimitive.Root
13
+ ref={ref}
14
+ className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
15
+ {...props}
16
+ >
17
+ <ProgressPrimitive.Indicator
18
+ className="h-full w-full flex-1 bg-primary transition-all"
19
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
20
+ />
21
+ </ProgressPrimitive.Root>
22
+ ));
23
+ Progress.displayName = ProgressPrimitive.Root.displayName;
24
+
25
+ export { Progress };
components/ui/radio-group.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
3
+ import { Circle } from "lucide-react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const RadioGroup = React.forwardRef<
8
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
9
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
10
+ >(({ className, ...props }, ref) => {
11
+ return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
12
+ });
13
+ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
14
+
15
+ const RadioGroupItem = React.forwardRef<
16
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
17
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
18
+ >(({ className, ...props }, ref) => {
19
+ return (
20
+ <RadioGroupPrimitive.Item
21
+ ref={ref}
22
+ className={cn(
23
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
24
+ className,
25
+ )}
26
+ {...props}
27
+ >
28
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
29
+ <Circle className="h-3.5 w-3.5 fill-primary" />
30
+ </RadioGroupPrimitive.Indicator>
31
+ </RadioGroupPrimitive.Item>
32
+ );
33
+ });
34
+ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
35
+
36
+ export { RadioGroup, RadioGroupItem };
components/ui/resizable.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GripVertical } from "lucide-react";
2
+ import { Group, Panel, Separator } from "react-resizable-panels";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof Group>) => (
7
+ <Group
8
+ className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
9
+ {...props}
10
+ />
11
+ );
12
+
13
+ const ResizablePanel = Panel;
14
+
15
+ const ResizableHandle = ({
16
+ withHandle,
17
+ className,
18
+ ...props
19
+ }: React.ComponentProps<typeof Separator> & {
20
+ withHandle?: boolean;
21
+ }) => (
22
+ <Separator
23
+ className={cn(
24
+ "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
25
+ className,
26
+ )}
27
+ {...props}
28
+ >
29
+ {withHandle && (
30
+ <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
31
+ <GripVertical className="h-2.5 w-2.5" />
32
+ </div>
33
+ )}
34
+ </Separator>
35
+ );
36
+
37
+ export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
components/ui/scroll-area.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const ScrollArea = React.forwardRef<
7
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
9
+ >(({ className, children, ...props }, ref) => (
10
+ <ScrollAreaPrimitive.Root
11
+ ref={ref}
12
+ className={cn("relative overflow-hidden", className)}
13
+ {...props}
14
+ >
15
+ <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
16
+ {children}
17
+ </ScrollAreaPrimitive.Viewport>
18
+ <ScrollBar />
19
+ <ScrollAreaPrimitive.Corner />
20
+ </ScrollAreaPrimitive.Root>
21
+ ));
22
+ ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23
+
24
+ const ScrollBar = React.forwardRef<
25
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
26
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
27
+ >(({ className, orientation = "vertical", ...props }, ref) => (
28
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
29
+ ref={ref}
30
+ orientation={orientation}
31
+ className={cn(
32
+ "flex touch-none select-none transition-colors",
33
+ orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
34
+ orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
35
+ className,
36
+ )}
37
+ {...props}
38
+ >
39
+ <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
40
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
41
+ ));
42
+ ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
43
+
44
+ export { ScrollArea, ScrollBar };
components/ui/select.tsx ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SelectPrimitive from "@radix-ui/react-select";
5
+ import { Check, ChevronDown, ChevronUp } from "lucide-react";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const Select = SelectPrimitive.Root;
10
+
11
+ const SelectGroup = SelectPrimitive.Group;
12
+
13
+ const SelectValue = SelectPrimitive.Value;
14
+
15
+ const SelectTrigger = React.forwardRef<
16
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
17
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
18
+ >(({ className, children, ...props }, ref) => (
19
+ <SelectPrimitive.Trigger
20
+ ref={ref}
21
+ className={cn(
22
+ "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background cursor-pointer data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
23
+ className,
24
+ )}
25
+ {...props}
26
+ >
27
+ {children}
28
+ <SelectPrimitive.Icon asChild>
29
+ <ChevronDown className="h-4 w-4 opacity-50" />
30
+ </SelectPrimitive.Icon>
31
+ </SelectPrimitive.Trigger>
32
+ ));
33
+ SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34
+
35
+ const SelectScrollUpButton = React.forwardRef<
36
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
37
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
38
+ >(({ className, ...props }, ref) => (
39
+ <SelectPrimitive.ScrollUpButton
40
+ ref={ref}
41
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
42
+ {...props}
43
+ >
44
+ <ChevronUp className="h-4 w-4" />
45
+ </SelectPrimitive.ScrollUpButton>
46
+ ));
47
+ SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
48
+
49
+ const SelectScrollDownButton = React.forwardRef<
50
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
51
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
52
+ >(({ className, ...props }, ref) => (
53
+ <SelectPrimitive.ScrollDownButton
54
+ ref={ref}
55
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
56
+ {...props}
57
+ >
58
+ <ChevronDown className="h-4 w-4" />
59
+ </SelectPrimitive.ScrollDownButton>
60
+ ));
61
+ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
62
+
63
+ const SelectContent = React.forwardRef<
64
+ React.ElementRef<typeof SelectPrimitive.Content>,
65
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
66
+ >(({ className, children, position = "popper", ...props }, ref) => (
67
+ <SelectPrimitive.Portal>
68
+ <SelectPrimitive.Content
69
+ ref={ref}
70
+ className={cn(
71
+ "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-select-content-transform-origin)",
72
+ position === "popper" &&
73
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
74
+ className,
75
+ )}
76
+ position={position}
77
+ {...props}
78
+ >
79
+ <SelectScrollUpButton />
80
+ <SelectPrimitive.Viewport
81
+ className={cn(
82
+ "p-1",
83
+ position === "popper" &&
84
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
85
+ )}
86
+ >
87
+ {children}
88
+ </SelectPrimitive.Viewport>
89
+ <SelectScrollDownButton />
90
+ </SelectPrimitive.Content>
91
+ </SelectPrimitive.Portal>
92
+ ));
93
+ SelectContent.displayName = SelectPrimitive.Content.displayName;
94
+
95
+ const SelectLabel = React.forwardRef<
96
+ React.ElementRef<typeof SelectPrimitive.Label>,
97
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
98
+ >(({ className, ...props }, ref) => (
99
+ <SelectPrimitive.Label
100
+ ref={ref}
101
+ className={cn("px-2 py-1.5 text-sm font-semibold", className)}
102
+ {...props}
103
+ />
104
+ ));
105
+ SelectLabel.displayName = SelectPrimitive.Label.displayName;
106
+
107
+ const SelectItem = React.forwardRef<
108
+ React.ElementRef<typeof SelectPrimitive.Item>,
109
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
110
+ >(({ className, children, ...props }, ref) => (
111
+ <SelectPrimitive.Item
112
+ ref={ref}
113
+ className={cn(
114
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
115
+ className,
116
+ )}
117
+ {...props}
118
+ >
119
+ <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
120
+ <SelectPrimitive.ItemIndicator>
121
+ <Check className="h-4 w-4" />
122
+ </SelectPrimitive.ItemIndicator>
123
+ </span>
124
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
125
+ </SelectPrimitive.Item>
126
+ ));
127
+ SelectItem.displayName = SelectPrimitive.Item.displayName;
128
+
129
+ const SelectSeparator = React.forwardRef<
130
+ React.ElementRef<typeof SelectPrimitive.Separator>,
131
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
132
+ >(({ className, ...props }, ref) => (
133
+ <SelectPrimitive.Separator
134
+ ref={ref}
135
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
136
+ {...props}
137
+ />
138
+ ));
139
+ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
140
+
141
+ export {
142
+ Select,
143
+ SelectGroup,
144
+ SelectValue,
145
+ SelectTrigger,
146
+ SelectContent,
147
+ SelectLabel,
148
+ SelectItem,
149
+ SelectSeparator,
150
+ SelectScrollUpButton,
151
+ SelectScrollDownButton,
152
+ };
components/ui/separator.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as SeparatorPrimitive from "@radix-ui/react-separator";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const Separator = React.forwardRef<
7
+ React.ElementRef<typeof SeparatorPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
9
+ >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
10
+ <SeparatorPrimitive.Root
11
+ ref={ref}
12
+ decorative={decorative}
13
+ orientation={orientation}
14
+ className={cn(
15
+ "shrink-0 bg-border",
16
+ orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ ));
22
+ Separator.displayName = SeparatorPrimitive.Root.displayName;
23
+
24
+ export { Separator };
components/ui/sheet.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SheetPrimitive from "@radix-ui/react-dialog";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { X } from "lucide-react";
7
+
8
+ import { cn } from "@/lib/utils";
9
+
10
+ const Sheet = SheetPrimitive.Root;
11
+
12
+ const SheetTrigger = SheetPrimitive.Trigger;
13
+
14
+ const SheetClose = SheetPrimitive.Close;
15
+
16
+ const SheetPortal = SheetPrimitive.Portal;
17
+
18
+ const SheetOverlay = React.forwardRef<
19
+ React.ElementRef<typeof SheetPrimitive.Overlay>,
20
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
21
+ >(({ className, ...props }, ref) => (
22
+ <SheetPrimitive.Overlay
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ className,
26
+ )}
27
+ {...props}
28
+ ref={ref}
29
+ />
30
+ ));
31
+ SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
32
+
33
+ const sheetVariants = cva(
34
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
35
+ {
36
+ variants: {
37
+ side: {
38
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39
+ bottom:
40
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42
+ right:
43
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44
+ },
45
+ },
46
+ defaultVariants: {
47
+ side: "right",
48
+ },
49
+ },
50
+ );
51
+
52
+ interface SheetContentProps
53
+ extends
54
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
55
+ VariantProps<typeof sheetVariants> {}
56
+
57
+ const SheetContent = React.forwardRef<
58
+ React.ElementRef<typeof SheetPrimitive.Content>,
59
+ SheetContentProps
60
+ >(({ side = "right", className, children, ...props }, ref) => (
61
+ <SheetPortal>
62
+ <SheetOverlay />
63
+ <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
64
+ <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
65
+ <X className="h-4 w-4" />
66
+ <span className="sr-only">Close</span>
67
+ </SheetPrimitive.Close>
68
+ {children}
69
+ </SheetPrimitive.Content>
70
+ </SheetPortal>
71
+ ));
72
+ SheetContent.displayName = SheetPrimitive.Content.displayName;
73
+
74
+ const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
75
+ <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
76
+ );
77
+ SheetHeader.displayName = "SheetHeader";
78
+
79
+ const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
80
+ <div
81
+ className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
82
+ {...props}
83
+ />
84
+ );
85
+ SheetFooter.displayName = "SheetFooter";
86
+
87
+ const SheetTitle = React.forwardRef<
88
+ React.ElementRef<typeof SheetPrimitive.Title>,
89
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
90
+ >(({ className, ...props }, ref) => (
91
+ <SheetPrimitive.Title
92
+ ref={ref}
93
+ className={cn("text-lg font-semibold text-foreground", className)}
94
+ {...props}
95
+ />
96
+ ));
97
+ SheetTitle.displayName = SheetPrimitive.Title.displayName;
98
+
99
+ const SheetDescription = React.forwardRef<
100
+ React.ElementRef<typeof SheetPrimitive.Description>,
101
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
102
+ >(({ className, ...props }, ref) => (
103
+ <SheetPrimitive.Description
104
+ ref={ref}
105
+ className={cn("text-sm text-muted-foreground", className)}
106
+ {...props}
107
+ />
108
+ ));
109
+ SheetDescription.displayName = SheetPrimitive.Description.displayName;
110
+
111
+ export {
112
+ Sheet,
113
+ SheetPortal,
114
+ SheetOverlay,
115
+ SheetTrigger,
116
+ SheetClose,
117
+ SheetContent,
118
+ SheetHeader,
119
+ SheetFooter,
120
+ SheetTitle,
121
+ SheetDescription,
122
+ };
components/ui/sidebar.tsx ADDED
@@ -0,0 +1,744 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { PanelLeft } from "lucide-react";
5
+
6
+ import { useIsMobile } from "@/hooks/use-mobile";
7
+ import { cn } from "@/lib/utils";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Separator } from "@/components/ui/separator";
11
+ import {
12
+ Sheet,
13
+ SheetContent,
14
+ SheetDescription,
15
+ SheetHeader,
16
+ SheetTitle,
17
+ } from "@/components/ui/sheet";
18
+ import { Skeleton } from "@/components/ui/skeleton";
19
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
20
+
21
+ const SIDEBAR_COOKIE_NAME = "sidebar_state";
22
+ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
23
+ const SIDEBAR_WIDTH = "16rem";
24
+ const SIDEBAR_WIDTH_MOBILE = "18rem";
25
+ const SIDEBAR_WIDTH_ICON = "3rem";
26
+ const SIDEBAR_KEYBOARD_SHORTCUT = "b";
27
+
28
+ type SidebarContextProps = {
29
+ state: "expanded" | "collapsed";
30
+ open: boolean;
31
+ setOpen: (open: boolean) => void;
32
+ openMobile: boolean;
33
+ setOpenMobile: (open: boolean) => void;
34
+ isMobile: boolean;
35
+ toggleSidebar: () => void;
36
+ };
37
+
38
+ const SidebarContext = React.createContext<SidebarContextProps | null>(null);
39
+
40
+ function useSidebar() {
41
+ const context = React.useContext(SidebarContext);
42
+ if (!context) {
43
+ throw new Error("useSidebar must be used within a SidebarProvider.");
44
+ }
45
+
46
+ return context;
47
+ }
48
+
49
+ const SidebarProvider = React.forwardRef<
50
+ HTMLDivElement,
51
+ React.ComponentProps<"div"> & {
52
+ defaultOpen?: boolean;
53
+ open?: boolean;
54
+ onOpenChange?: (open: boolean) => void;
55
+ }
56
+ >(
57
+ (
58
+ {
59
+ defaultOpen = true,
60
+ open: openProp,
61
+ onOpenChange: setOpenProp,
62
+ className,
63
+ style,
64
+ children,
65
+ ...props
66
+ },
67
+ ref,
68
+ ) => {
69
+ const isMobile = useIsMobile();
70
+ const [openMobile, setOpenMobile] = React.useState(false);
71
+
72
+ // This is the internal state of the sidebar.
73
+ // We use openProp and setOpenProp for control from outside the component.
74
+ const [_open, _setOpen] = React.useState(defaultOpen);
75
+ const open = openProp ?? _open;
76
+ const setOpen = React.useCallback(
77
+ (value: boolean | ((value: boolean) => boolean)) => {
78
+ const openState = typeof value === "function" ? value(open) : value;
79
+ if (setOpenProp) {
80
+ setOpenProp(openState);
81
+ } else {
82
+ _setOpen(openState);
83
+ }
84
+
85
+ // This sets the cookie to keep the sidebar state.
86
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
87
+ },
88
+ [setOpenProp, open],
89
+ );
90
+
91
+ // Helper to toggle the sidebar.
92
+ const toggleSidebar = React.useCallback(() => {
93
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
94
+ }, [isMobile, setOpen, setOpenMobile]);
95
+
96
+ // Adds a keyboard shortcut to toggle the sidebar.
97
+ React.useEffect(() => {
98
+ const handleKeyDown = (event: KeyboardEvent) => {
99
+ if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
100
+ event.preventDefault();
101
+ toggleSidebar();
102
+ }
103
+ };
104
+
105
+ window.addEventListener("keydown", handleKeyDown);
106
+ return () => window.removeEventListener("keydown", handleKeyDown);
107
+ }, [toggleSidebar]);
108
+
109
+ // We add a state so that we can do data-state="expanded" or "collapsed".
110
+ // This makes it easier to style the sidebar with Tailwind classes.
111
+ const state = open ? "expanded" : "collapsed";
112
+
113
+ const contextValue = React.useMemo<SidebarContextProps>(
114
+ () => ({
115
+ state,
116
+ open,
117
+ setOpen,
118
+ isMobile,
119
+ openMobile,
120
+ setOpenMobile,
121
+ toggleSidebar,
122
+ }),
123
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
124
+ );
125
+
126
+ return (
127
+ <SidebarContext.Provider value={contextValue}>
128
+ <TooltipProvider delayDuration={0}>
129
+ <div
130
+ style={
131
+ {
132
+ "--sidebar-width": SIDEBAR_WIDTH,
133
+ "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
134
+ ...style,
135
+ } as React.CSSProperties
136
+ }
137
+ className={cn(
138
+ "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
139
+ className,
140
+ )}
141
+ ref={ref}
142
+ {...props}
143
+ >
144
+ {children}
145
+ </div>
146
+ </TooltipProvider>
147
+ </SidebarContext.Provider>
148
+ );
149
+ },
150
+ );
151
+ SidebarProvider.displayName = "SidebarProvider";
152
+
153
+ const Sidebar = React.forwardRef<
154
+ HTMLDivElement,
155
+ React.ComponentProps<"div"> & {
156
+ side?: "left" | "right";
157
+ variant?: "sidebar" | "floating" | "inset";
158
+ collapsible?: "offcanvas" | "icon" | "none";
159
+ }
160
+ >(
161
+ (
162
+ {
163
+ side = "left",
164
+ variant = "sidebar",
165
+ collapsible = "offcanvas",
166
+ className,
167
+ children,
168
+ ...props
169
+ },
170
+ ref,
171
+ ) => {
172
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
173
+
174
+ if (collapsible === "none") {
175
+ return (
176
+ <div
177
+ className={cn(
178
+ "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
179
+ className,
180
+ )}
181
+ ref={ref}
182
+ {...props}
183
+ >
184
+ {children}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ if (isMobile) {
190
+ return (
191
+ <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
192
+ <SheetContent
193
+ data-sidebar="sidebar"
194
+ data-mobile="true"
195
+ className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
196
+ style={
197
+ {
198
+ "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
199
+ } as React.CSSProperties
200
+ }
201
+ side={side}
202
+ >
203
+ <SheetHeader className="sr-only">
204
+ <SheetTitle>Sidebar</SheetTitle>
205
+ <SheetDescription>Displays the mobile sidebar.</SheetDescription>
206
+ </SheetHeader>
207
+ <div className="flex h-full w-full flex-col">{children}</div>
208
+ </SheetContent>
209
+ </Sheet>
210
+ );
211
+ }
212
+
213
+ return (
214
+ <div
215
+ ref={ref}
216
+ className="group peer hidden text-sidebar-foreground md:block"
217
+ data-state={state}
218
+ data-collapsible={state === "collapsed" ? collapsible : ""}
219
+ data-variant={variant}
220
+ data-side={side}
221
+ >
222
+ {/* This is what handles the sidebar gap on desktop */}
223
+ <div
224
+ className={cn(
225
+ "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
226
+ "group-data-[collapsible=offcanvas]:w-0",
227
+ "group-data-[side=right]:rotate-180",
228
+ variant === "floating" || variant === "inset"
229
+ ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
230
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
231
+ )}
232
+ />
233
+ <div
234
+ className={cn(
235
+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
236
+ side === "left"
237
+ ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
238
+ : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
239
+ // Adjust the padding for floating and inset variants.
240
+ variant === "floating" || variant === "inset"
241
+ ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
242
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
243
+ className,
244
+ )}
245
+ {...props}
246
+ >
247
+ <div
248
+ data-sidebar="sidebar"
249
+ className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
250
+ >
251
+ {children}
252
+ </div>
253
+ </div>
254
+ </div>
255
+ );
256
+ },
257
+ );
258
+ Sidebar.displayName = "Sidebar";
259
+
260
+ const SidebarTrigger = React.forwardRef<
261
+ React.ElementRef<typeof Button>,
262
+ React.ComponentProps<typeof Button>
263
+ >(({ className, onClick, ...props }, ref) => {
264
+ const { toggleSidebar } = useSidebar();
265
+
266
+ return (
267
+ <Button
268
+ ref={ref}
269
+ data-sidebar="trigger"
270
+ variant="ghost"
271
+ size="icon"
272
+ className={cn("h-7 w-7", className)}
273
+ onClick={(event) => {
274
+ onClick?.(event);
275
+ toggleSidebar();
276
+ }}
277
+ {...props}
278
+ >
279
+ <PanelLeft />
280
+ <span className="sr-only">Toggle Sidebar</span>
281
+ </Button>
282
+ );
283
+ });
284
+ SidebarTrigger.displayName = "SidebarTrigger";
285
+
286
+ const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
287
+ ({ className, ...props }, ref) => {
288
+ const { toggleSidebar } = useSidebar();
289
+
290
+ return (
291
+ <button
292
+ ref={ref}
293
+ data-sidebar="rail"
294
+ aria-label="Toggle Sidebar"
295
+ tabIndex={-1}
296
+ onClick={toggleSidebar}
297
+ title="Toggle Sidebar"
298
+ className={cn(
299
+ "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
300
+ "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
301
+ "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
302
+ "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
303
+ "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
304
+ "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
305
+ className,
306
+ )}
307
+ {...props}
308
+ />
309
+ );
310
+ },
311
+ );
312
+ SidebarRail.displayName = "SidebarRail";
313
+
314
+ const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(
315
+ ({ className, ...props }, ref) => {
316
+ return (
317
+ <main
318
+ ref={ref}
319
+ className={cn(
320
+ "relative flex w-full flex-1 flex-col bg-background",
321
+ "md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
322
+ className,
323
+ )}
324
+ {...props}
325
+ />
326
+ );
327
+ },
328
+ );
329
+ SidebarInset.displayName = "SidebarInset";
330
+
331
+ const SidebarInput = React.forwardRef<
332
+ React.ElementRef<typeof Input>,
333
+ React.ComponentProps<typeof Input>
334
+ >(({ className, ...props }, ref) => {
335
+ return (
336
+ <Input
337
+ ref={ref}
338
+ data-sidebar="input"
339
+ className={cn(
340
+ "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
341
+ className,
342
+ )}
343
+ {...props}
344
+ />
345
+ );
346
+ });
347
+ SidebarInput.displayName = "SidebarInput";
348
+
349
+ const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
350
+ ({ className, ...props }, ref) => {
351
+ return (
352
+ <div
353
+ ref={ref}
354
+ data-sidebar="header"
355
+ className={cn("flex flex-col gap-2 p-2", className)}
356
+ {...props}
357
+ />
358
+ );
359
+ },
360
+ );
361
+ SidebarHeader.displayName = "SidebarHeader";
362
+
363
+ const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
364
+ ({ className, ...props }, ref) => {
365
+ return (
366
+ <div
367
+ ref={ref}
368
+ data-sidebar="footer"
369
+ className={cn("flex flex-col gap-2 p-2", className)}
370
+ {...props}
371
+ />
372
+ );
373
+ },
374
+ );
375
+ SidebarFooter.displayName = "SidebarFooter";
376
+
377
+ const SidebarSeparator = React.forwardRef<
378
+ React.ElementRef<typeof Separator>,
379
+ React.ComponentProps<typeof Separator>
380
+ >(({ className, ...props }, ref) => {
381
+ return (
382
+ <Separator
383
+ ref={ref}
384
+ data-sidebar="separator"
385
+ className={cn("mx-2 w-auto bg-sidebar-border", className)}
386
+ {...props}
387
+ />
388
+ );
389
+ });
390
+ SidebarSeparator.displayName = "SidebarSeparator";
391
+
392
+ const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
393
+ ({ className, ...props }, ref) => {
394
+ return (
395
+ <div
396
+ ref={ref}
397
+ data-sidebar="content"
398
+ className={cn(
399
+ "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
400
+ className,
401
+ )}
402
+ {...props}
403
+ />
404
+ );
405
+ },
406
+ );
407
+ SidebarContent.displayName = "SidebarContent";
408
+
409
+ const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
410
+ ({ className, ...props }, ref) => {
411
+ return (
412
+ <div
413
+ ref={ref}
414
+ data-sidebar="group"
415
+ className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
416
+ {...props}
417
+ />
418
+ );
419
+ },
420
+ );
421
+ SidebarGroup.displayName = "SidebarGroup";
422
+
423
+ const SidebarGroupLabel = React.forwardRef<
424
+ HTMLDivElement,
425
+ React.ComponentProps<"div"> & { asChild?: boolean }
426
+ >(({ className, asChild = false, ...props }, ref) => {
427
+ const Comp = asChild ? Slot : "div";
428
+
429
+ return (
430
+ <Comp
431
+ ref={ref}
432
+ data-sidebar="group-label"
433
+ className={cn(
434
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
435
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
436
+ className,
437
+ )}
438
+ {...props}
439
+ />
440
+ );
441
+ });
442
+ SidebarGroupLabel.displayName = "SidebarGroupLabel";
443
+
444
+ const SidebarGroupAction = React.forwardRef<
445
+ HTMLButtonElement,
446
+ React.ComponentProps<"button"> & { asChild?: boolean }
447
+ >(({ className, asChild = false, ...props }, ref) => {
448
+ const Comp = asChild ? Slot : "button";
449
+
450
+ return (
451
+ <Comp
452
+ ref={ref}
453
+ data-sidebar="group-action"
454
+ className={cn(
455
+ "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
456
+ // Increases the hit area of the button on mobile.
457
+ "after:absolute after:-inset-2 after:md:hidden",
458
+ "group-data-[collapsible=icon]:hidden",
459
+ className,
460
+ )}
461
+ {...props}
462
+ />
463
+ );
464
+ });
465
+ SidebarGroupAction.displayName = "SidebarGroupAction";
466
+
467
+ const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
468
+ ({ className, ...props }, ref) => (
469
+ <div
470
+ ref={ref}
471
+ data-sidebar="group-content"
472
+ className={cn("w-full text-sm", className)}
473
+ {...props}
474
+ />
475
+ ),
476
+ );
477
+ SidebarGroupContent.displayName = "SidebarGroupContent";
478
+
479
+ const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
480
+ ({ className, ...props }, ref) => (
481
+ <ul
482
+ ref={ref}
483
+ data-sidebar="menu"
484
+ className={cn("flex w-full min-w-0 flex-col gap-1", className)}
485
+ {...props}
486
+ />
487
+ ),
488
+ );
489
+ SidebarMenu.displayName = "SidebarMenu";
490
+
491
+ const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
492
+ ({ className, ...props }, ref) => (
493
+ <li
494
+ ref={ref}
495
+ data-sidebar="menu-item"
496
+ className={cn("group/menu-item relative", className)}
497
+ {...props}
498
+ />
499
+ ),
500
+ );
501
+ SidebarMenuItem.displayName = "SidebarMenuItem";
502
+
503
+ const sidebarMenuButtonVariants = cva(
504
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring cursor-pointer transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
505
+ {
506
+ variants: {
507
+ variant: {
508
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
509
+ outline:
510
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
511
+ },
512
+ size: {
513
+ default: "h-8 text-sm",
514
+ sm: "h-7 text-xs",
515
+ lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
516
+ },
517
+ },
518
+ defaultVariants: {
519
+ variant: "default",
520
+ size: "default",
521
+ },
522
+ },
523
+ );
524
+
525
+ const SidebarMenuButton = React.forwardRef<
526
+ HTMLButtonElement,
527
+ React.ComponentProps<"button"> & {
528
+ asChild?: boolean;
529
+ isActive?: boolean;
530
+ tooltip?: string | React.ComponentProps<typeof TooltipContent>;
531
+ } & VariantProps<typeof sidebarMenuButtonVariants>
532
+ >(
533
+ (
534
+ {
535
+ asChild = false,
536
+ isActive = false,
537
+ variant = "default",
538
+ size = "default",
539
+ tooltip,
540
+ className,
541
+ ...props
542
+ },
543
+ ref,
544
+ ) => {
545
+ const Comp = asChild ? Slot : "button";
546
+ const { isMobile, state } = useSidebar();
547
+
548
+ const button = (
549
+ <Comp
550
+ ref={ref}
551
+ data-sidebar="menu-button"
552
+ data-size={size}
553
+ data-active={isActive}
554
+ className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
555
+ {...props}
556
+ />
557
+ );
558
+
559
+ if (!tooltip) {
560
+ return button;
561
+ }
562
+
563
+ if (typeof tooltip === "string") {
564
+ tooltip = {
565
+ children: tooltip,
566
+ };
567
+ }
568
+
569
+ return (
570
+ <Tooltip>
571
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
572
+ <TooltipContent
573
+ side="right"
574
+ align="center"
575
+ hidden={state !== "collapsed" || isMobile}
576
+ {...tooltip}
577
+ />
578
+ </Tooltip>
579
+ );
580
+ },
581
+ );
582
+ SidebarMenuButton.displayName = "SidebarMenuButton";
583
+
584
+ const SidebarMenuAction = React.forwardRef<
585
+ HTMLButtonElement,
586
+ React.ComponentProps<"button"> & {
587
+ asChild?: boolean;
588
+ showOnHover?: boolean;
589
+ }
590
+ >(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
591
+ const Comp = asChild ? Slot : "button";
592
+
593
+ return (
594
+ <Comp
595
+ ref={ref}
596
+ data-sidebar="menu-action"
597
+ className={cn(
598
+ "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
599
+ // Increases the hit area of the button on mobile.
600
+ "after:absolute after:-inset-2 after:md:hidden",
601
+ "peer-data-[size=sm]/menu-button:top-1",
602
+ "peer-data-[size=default]/menu-button:top-1.5",
603
+ "peer-data-[size=lg]/menu-button:top-2.5",
604
+ "group-data-[collapsible=icon]:hidden",
605
+ showOnHover &&
606
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
607
+ className,
608
+ )}
609
+ {...props}
610
+ />
611
+ );
612
+ });
613
+ SidebarMenuAction.displayName = "SidebarMenuAction";
614
+
615
+ const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
616
+ ({ className, ...props }, ref) => (
617
+ <div
618
+ ref={ref}
619
+ data-sidebar="menu-badge"
620
+ className={cn(
621
+ "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
622
+ "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
623
+ "peer-data-[size=sm]/menu-button:top-1",
624
+ "peer-data-[size=default]/menu-button:top-1.5",
625
+ "peer-data-[size=lg]/menu-button:top-2.5",
626
+ "group-data-[collapsible=icon]:hidden",
627
+ className,
628
+ )}
629
+ {...props}
630
+ />
631
+ ),
632
+ );
633
+ SidebarMenuBadge.displayName = "SidebarMenuBadge";
634
+
635
+ const SidebarMenuSkeleton = React.forwardRef<
636
+ HTMLDivElement,
637
+ React.ComponentProps<"div"> & {
638
+ showIcon?: boolean;
639
+ }
640
+ >(({ className, showIcon = false, ...props }, ref) => {
641
+ // Random width between 50 to 90%.
642
+ const width = React.useMemo(() => {
643
+ return `${Math.floor(Math.random() * 40) + 50}%`;
644
+ }, []);
645
+
646
+ return (
647
+ <div
648
+ ref={ref}
649
+ data-sidebar="menu-skeleton"
650
+ className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
651
+ {...props}
652
+ >
653
+ {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
654
+ <Skeleton
655
+ className="h-4 max-w-(--skeleton-width) flex-1"
656
+ data-sidebar="menu-skeleton-text"
657
+ style={
658
+ {
659
+ "--skeleton-width": width,
660
+ } as React.CSSProperties
661
+ }
662
+ />
663
+ </div>
664
+ );
665
+ });
666
+ SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
667
+
668
+ const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
669
+ ({ className, ...props }, ref) => (
670
+ <ul
671
+ ref={ref}
672
+ data-sidebar="menu-sub"
673
+ className={cn(
674
+ "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
675
+ "group-data-[collapsible=icon]:hidden",
676
+ className,
677
+ )}
678
+ {...props}
679
+ />
680
+ ),
681
+ );
682
+ SidebarMenuSub.displayName = "SidebarMenuSub";
683
+
684
+ const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
685
+ ({ ...props }, ref) => <li ref={ref} {...props} />,
686
+ );
687
+ SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
688
+
689
+ const SidebarMenuSubButton = React.forwardRef<
690
+ HTMLAnchorElement,
691
+ React.ComponentProps<"a"> & {
692
+ asChild?: boolean;
693
+ size?: "sm" | "md";
694
+ isActive?: boolean;
695
+ }
696
+ >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
697
+ const Comp = asChild ? Slot : "a";
698
+
699
+ return (
700
+ <Comp
701
+ ref={ref}
702
+ data-sidebar="menu-sub-button"
703
+ data-size={size}
704
+ data-active={isActive}
705
+ className={cn(
706
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
707
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
708
+ size === "sm" && "text-xs",
709
+ size === "md" && "text-sm",
710
+ "group-data-[collapsible=icon]:hidden",
711
+ className,
712
+ )}
713
+ {...props}
714
+ />
715
+ );
716
+ });
717
+ SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
718
+
719
+ export {
720
+ Sidebar,
721
+ SidebarContent,
722
+ SidebarFooter,
723
+ SidebarGroup,
724
+ SidebarGroupAction,
725
+ SidebarGroupContent,
726
+ SidebarGroupLabel,
727
+ SidebarHeader,
728
+ SidebarInput,
729
+ SidebarInset,
730
+ SidebarMenu,
731
+ SidebarMenuAction,
732
+ SidebarMenuBadge,
733
+ SidebarMenuButton,
734
+ SidebarMenuItem,
735
+ SidebarMenuSkeleton,
736
+ SidebarMenuSub,
737
+ SidebarMenuSubButton,
738
+ SidebarMenuSubItem,
739
+ SidebarProvider,
740
+ SidebarRail,
741
+ SidebarSeparator,
742
+ SidebarTrigger,
743
+ useSidebar,
744
+ };
components/ui/skeleton.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils";
2
+
3
+ function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
4
+ return <div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />;
5
+ }
6
+
7
+ export { Skeleton };
components/ui/slider.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as SliderPrimitive from "@radix-ui/react-slider";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const Slider = React.forwardRef<
7
+ React.ElementRef<typeof SliderPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <SliderPrimitive.Root
11
+ ref={ref}
12
+ className={cn("relative flex w-full touch-none select-none items-center", className)}
13
+ {...props}
14
+ >
15
+ <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
16
+ <SliderPrimitive.Range className="absolute h-full bg-primary" />
17
+ </SliderPrimitive.Track>
18
+ <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
19
+ </SliderPrimitive.Root>
20
+ ));
21
+ Slider.displayName = SliderPrimitive.Root.displayName;
22
+
23
+ export { Slider };
components/ui/sonner.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Toaster as Sonner } from "sonner";
2
+
3
+ type ToasterProps = React.ComponentProps<typeof Sonner>;
4
+
5
+ const Toaster = ({ ...props }: ToasterProps) => {
6
+ return (
7
+ <Sonner
8
+ className="toaster group"
9
+ toastOptions={{
10
+ classNames: {
11
+ toast:
12
+ "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
13
+ description: "group-[.toast]:text-muted-foreground",
14
+ actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
15
+ cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
16
+ },
17
+ }}
18
+ {...props}
19
+ />
20
+ );
21
+ };
22
+
23
+ export { Toaster };
components/ui/switch.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as SwitchPrimitives from "@radix-ui/react-switch";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const Switch = React.forwardRef<
7
+ React.ElementRef<typeof SwitchPrimitives.Root>,
8
+ React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <SwitchPrimitives.Root
11
+ className={cn(
12
+ "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
13
+ className,
14
+ )}
15
+ {...props}
16
+ ref={ref}
17
+ >
18
+ <SwitchPrimitives.Thumb
19
+ className={cn(
20
+ "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
21
+ )}
22
+ />
23
+ </SwitchPrimitives.Root>
24
+ ));
25
+ Switch.displayName = SwitchPrimitives.Root.displayName;
26
+
27
+ export { Switch };
components/ui/table.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
6
+ ({ className, ...props }, ref) => (
7
+ <div className="relative w-full overflow-auto">
8
+ <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
9
+ </div>
10
+ ),
11
+ );
12
+ Table.displayName = "Table";
13
+
14
+ const TableHeader = React.forwardRef<
15
+ HTMLTableSectionElement,
16
+ React.HTMLAttributes<HTMLTableSectionElement>
17
+ >(({ className, ...props }, ref) => (
18
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
19
+ ));
20
+ TableHeader.displayName = "TableHeader";
21
+
22
+ const TableBody = React.forwardRef<
23
+ HTMLTableSectionElement,
24
+ React.HTMLAttributes<HTMLTableSectionElement>
25
+ >(({ className, ...props }, ref) => (
26
+ <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
27
+ ));
28
+ TableBody.displayName = "TableBody";
29
+
30
+ const TableFooter = React.forwardRef<
31
+ HTMLTableSectionElement,
32
+ React.HTMLAttributes<HTMLTableSectionElement>
33
+ >(({ className, ...props }, ref) => (
34
+ <tfoot
35
+ ref={ref}
36
+ className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
37
+ {...props}
38
+ />
39
+ ));
40
+ TableFooter.displayName = "TableFooter";
41
+
42
+ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
43
+ ({ className, ...props }, ref) => (
44
+ <tr
45
+ ref={ref}
46
+ className={cn(
47
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
48
+ className,
49
+ )}
50
+ {...props}
51
+ />
52
+ ),
53
+ );
54
+ TableRow.displayName = "TableRow";
55
+
56
+ const TableHead = React.forwardRef<
57
+ HTMLTableCellElement,
58
+ React.ThHTMLAttributes<HTMLTableCellElement>
59
+ >(({ className, ...props }, ref) => (
60
+ <th
61
+ ref={ref}
62
+ className={cn(
63
+ "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
64
+ className,
65
+ )}
66
+ {...props}
67
+ />
68
+ ));
69
+ TableHead.displayName = "TableHead";
70
+
71
+ const TableCell = React.forwardRef<
72
+ HTMLTableCellElement,
73
+ React.TdHTMLAttributes<HTMLTableCellElement>
74
+ >(({ className, ...props }, ref) => (
75
+ <td
76
+ ref={ref}
77
+ className={cn(
78
+ "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
79
+ className,
80
+ )}
81
+ {...props}
82
+ />
83
+ ));
84
+ TableCell.displayName = "TableCell";
85
+
86
+ const TableCaption = React.forwardRef<
87
+ HTMLTableCaptionElement,
88
+ React.HTMLAttributes<HTMLTableCaptionElement>
89
+ >(({ className, ...props }, ref) => (
90
+ <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
91
+ ));
92
+ TableCaption.displayName = "TableCaption";
93
+
94
+ export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
components/ui/tabs.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import * as TabsPrimitive from "@radix-ui/react-tabs";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const Tabs = TabsPrimitive.Root;
7
+
8
+ const TabsList = React.forwardRef<
9
+ React.ElementRef<typeof TabsPrimitive.List>,
10
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
11
+ >(({ className, ...props }, ref) => (
12
+ <TabsPrimitive.List
13
+ ref={ref}
14
+ className={cn(
15
+ "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
16
+ className,
17
+ )}
18
+ {...props}
19
+ />
20
+ ));
21
+ TabsList.displayName = TabsPrimitive.List.displayName;
22
+
23
+ const TabsTrigger = React.forwardRef<
24
+ React.ElementRef<typeof TabsPrimitive.Trigger>,
25
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
26
+ >(({ className, ...props }, ref) => (
27
+ <TabsPrimitive.Trigger
28
+ ref={ref}
29
+ className={cn(
30
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background cursor-pointer transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
31
+ className,
32
+ )}
33
+ {...props}
34
+ />
35
+ ));
36
+ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
37
+
38
+ const TabsContent = React.forwardRef<
39
+ React.ElementRef<typeof TabsPrimitive.Content>,
40
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
41
+ >(({ className, ...props }, ref) => (
42
+ <TabsPrimitive.Content
43
+ ref={ref}
44
+ className={cn(
45
+ "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ ));
51
+ TabsContent.displayName = TabsPrimitive.Content.displayName;
52
+
53
+ export { Tabs, TabsList, TabsTrigger, TabsContent };
components/ui/textarea.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
6
+ ({ className, ...props }, ref) => {
7
+ return (
8
+ <textarea
9
+ className={cn(
10
+ "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ className,
12
+ )}
13
+ ref={ref}
14
+ {...props}
15
+ />
16
+ );
17
+ },
18
+ );
19
+ Textarea.displayName = "Textarea";
20
+
21
+ export { Textarea };