authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-14 10:16:31-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-14 23:35:48-08:00
logd3c93752e5c89b2a77b15630a629e70295878582
tree8baabdb3705c77f73fd8f6477a8997d7cd96eae7
parent4b4abcdd7d197647b8b21b18d2344e4e12ae4c34

Adopt Base UI components and improve editor open behavior

- migrate web UI to shared Base UI primitives (alerts, buttons, combobox, toast, etc.) - refactor chat and branch toolbars to use new components and cleaner interactions - add cross-platform file-manager fallback in `openInEditor` server handling - update model contracts/tests and web dependencies to support new UI flow

48 files changed, 4540 insertions(+), 1202 deletions(-)

apps/server/src/wsServer.ts+24-3
......@@ -324,11 +324,32 @@ export function createServer(options: ServerOptions) {
324324 if (!params?.cwd) throw new Error("cwd is required");
325325 const editorDef = EDITORS.find((e) => e.id === params.editor);
326326 if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`);
327 if (!editorDef.command) {
328 // Can't open file manager programmatically from server, just succeed silently
327
328 let command: string;
329 let args: string[];
330
331 if (editorDef.command) {
332 command = editorDef.command;
333 args = [params.cwd];
334 } else if (editorDef.id === "file-manager") {
335 // Use platform-specific file manager command
336 switch (process.platform) {
337 case "darwin":
338 command = "open";
339 break;
340 case "win32":
341 command = "explorer";
342 break;
343 default:
344 command = "xdg-open";
345 break;
346 }
347 args = [params.cwd];
348 } else {
329349 return undefined;
330350 }
331 const child = spawn(editorDef.command, [params.cwd], {
351
352 const child = spawn(command, args, {
332353 detached: true,
333354 stdio: "ignore",
334355 });
apps/web/components.json created+27
......@@ -0,0 +1,27 @@
1{
2 "$schema": "https://ui.shadcn.com/schema.json",
3 "style": "base-mira",
4 "rsc": false,
5 "tsx": true,
6 "tailwind": {
7 "config": "",
8 "css": "src/index.css",
9 "baseColor": "zinc",
10 "cssVariables": true,
11 "prefix": ""
12 },
13 "iconLibrary": "lucide",
14 "rtl": false,
15 "menuColor": "default",
16 "menuAccent": "bold",
17 "aliases": {
18 "components": "~/components",
19 "utils": "~/lib/utils",
20 "ui": "~/components/ui",
21 "lib": "~/lib",
22 "hooks": "~/hooks"
23 },
24 "registries": {
25 "@coss": "https://coss.com/ui/r/{name}.json"
26 }
27}
apps/web/package.json+6-3
......@@ -11,17 +11,20 @@
1111 "test": "vitest run --passWithNoTests"
1212 },
1313 "dependencies": {
14 "@xterm/addon-fit": "^0.11.0",
15 "@xterm/xterm": "^6.0.0",
14 "@base-ui/react": "^1.2.0",
1615 "@t3tools/contracts": "workspace:*",
1716 "@tanstack/react-query": "^5.90.0",
17 "@xterm/addon-fit": "^0.11.0",
18 "@xterm/xterm": "^6.0.0",
19 "class-variance-authority": "^0.7.1",
1820 "highlight.js": "^11.11.1",
19 "lucide-react": "^0.563.0",
21 "lucide-react": "^0.564.0",
2022 "react": "^19.0.0",
2123 "react-dom": "^19.0.0",
2224 "react-markdown": "^10.1.0",
2325 "rehype-highlight": "^7.0.2",
2426 "remark-gfm": "^4.0.1",
27 "tailwind-merge": "^3.4.0",
2528 "zod": "^3.24.1"
2629 },
2730 "devDependencies": {
apps/web/src/App.tsx+7-2
......@@ -10,6 +10,7 @@ import { StoreProvider, useStore } from "./store";
1010import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
1111import { onServerWelcome } from "./wsNativeApi";
1212import { useNativeApi } from "./hooks/useNativeApi";
13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
1314
1415function EventRouter() {
1516 const api = useNativeApi();
......@@ -181,7 +182,7 @@ function Layout() {
181182 }
182183
183184 return (
184 <div className="flex h-screen overflow-hidden bg-background text-foreground">
185 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
185186 <EventRouter />
186187 <AutoProjectBootstrap />
187188 <DesktopProjectBootstrap />
......@@ -198,7 +199,11 @@ export default function App() {
198199 return (
199200 <QueryClientProvider client={queryClient}>
200201 <StoreProvider>
201 <Layout />
202 <ToastProvider>
203 <AnchoredToastProvider>
204 <Layout />
205 </AnchoredToastProvider>
206 </ToastProvider>
202207 </StoreProvider>
203208 </QueryClientProvider>
204209 );
apps/web/src/components/BranchToolbar.tsx+117-109
......@@ -1,9 +1,21 @@
11import type { GitBranch } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useRef, useState } from "react";
3import { useEffect, useState } from "react";
44
5import { useStore } from "../store";
65import { useNativeApi } from "../hooks/useNativeApi";
6import { useStore } from "../store";
7import { Button } from "./ui/button";
8import {
9 Combobox,
10 ComboboxEmpty,
11 ComboboxInput,
12 ComboboxItem,
13 ComboboxList,
14 ComboboxPopup,
15 ComboboxTrigger,
16} from "./ui/combobox";
17import { ChevronDownIcon, PlusIcon } from "lucide-react";
18import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group";
719
820interface BranchToolbarProps {
921 envMode: "local" | "worktree";
......@@ -19,7 +31,6 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
1931 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
2032 const [isCreatingBranch, setIsCreatingBranch] = useState(false);
2133 const [newBranchName, setNewBranchName] = useState("");
22 const branchMenuRef = useRef<HTMLDivElement>(null);
2334
2435 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
2536 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
......@@ -37,6 +48,8 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
3748 });
3849
3950 const branches = branchesQuery.data?.branches ?? [];
51 const branchNames = branches.map((branch) => branch.name);
52 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
4053 // Default to true while loading — showing "Initialize git" during a fetch is wrong,
4154 // and worktrees are inherently git repos.
4255 const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading;
......@@ -46,8 +59,10 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
4659 const checkoutMutation = useMutation({
4760 mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),
4861 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
49 onError: (error) =>
50 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."),
62 onError: (error) => {
63 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
64 setIsBranchMenuOpen(true);
65 },
5166 });
5267
5368 const createBranchMutation = useMutation({
......@@ -88,17 +103,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
88103 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]);
89104
90105 useEffect(() => {
91 if (!isBranchMenuOpen) return;
92 const handleClickOutside = (event: MouseEvent) => {
93 if (!branchMenuRef.current) return;
94 if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) {
95 setIsBranchMenuOpen(false);
96 }
97 };
98 window.addEventListener("mousedown", handleClickOutside);
99 return () => {
100 window.removeEventListener("mousedown", handleClickOutside);
101 };
106 if (isBranchMenuOpen) return;
107 setIsCreatingBranch(false);
108 setNewBranchName("");
102109 }, [isBranchMenuOpen]);
103110
104111 // ── Helpers ───────────────────────────────────────────────────────────
......@@ -171,21 +178,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
171178 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">
172179 <div className="flex items-center gap-2">
173180 {envLocked || activeWorktreePath ? (
174 <span className="inline-flex items-center gap-1.5 px-1 text-[12px] text-muted-foreground/55">
181 <span className="border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
175182 {activeWorktreePath ? "Worktree" : "Local"}
176183 </span>
177184 ) : (
178 <button
185 <Button
179186 type="button"
180 className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] transition-colors duration-150 ${
181 envMode === "worktree"
182 ? "bg-accent text-foreground/80"
183 : "text-muted-foreground/60 hover:bg-accent/50 hover:text-muted-foreground/80"
184 }`}
187 variant="ghost"
188 className="text-muted-foreground/70 hover:text-foreground/80"
189 size="xs"
185190 onClick={() => onEnvModeChange(envMode === "local" ? "worktree" : "local")}
186191 >
187192 {envMode === "worktree" ? "New worktree" : "Local"}
188 </button>
193 </Button>
189194 )}
190195 </div>
191196
......@@ -199,59 +204,56 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
199204 {initMutation.isPending ? "Initializing\u2026" : "Initialize git"}
200205 </button>
201206 ) : (
202 <div className="relative" ref={branchMenuRef}>
203 <button
204 type="button"
205 className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] text-muted-foreground/60 transition-colors duration-150 hover:bg-accent/50 hover:text-muted-foreground/80"
206 onClick={() => setIsBranchMenuOpen((open) => !open)}
207 <Combobox
208 items={branchNames}
209 autoHighlight
210 onOpenChange={(open) => setIsBranchMenuOpen(open)}
211 open={isBranchMenuOpen}
212 value={activeThread.branch}
213 >
214 <ComboboxTrigger
215 render={<Button variant="ghost" size="xs" />}
216 className="text-muted-foreground/70 hover:text-foreground/80"
207217 disabled={branchesQuery.isLoading}
208218 >
209 <span className="max-w-[240px] truncate font-mono">
219 <span className="max-w-[240px] truncate">
210220 {activeThread.branch
211221 ? envMode === "worktree" && !activeWorktreePath
212222 ? `From ${activeThread.branch}`
213223 : activeThread.branch
214224 : "Select branch"}
215225 </span>
216 <svg
217 width="10"
218 height="10"
219 viewBox="0 0 10 10"
220 fill="none"
221 className="opacity-40"
222 aria-hidden="true"
223 >
224 <path
225 d="M2.5 4L5 6.5L7.5 4"
226 stroke="currentColor"
227 strokeWidth="1.2"
228 strokeLinecap="round"
229 strokeLinejoin="round"
226 <ChevronDownIcon />
227 </ComboboxTrigger>
228 <ComboboxPopup align="end" side="top" className="w-64">
229 <div className="border-b">
230 <ComboboxInput
231 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"
232 placeholder="Search branches..."
233 showClear
234 showTrigger={false}
235 size="sm"
230236 />
231 </svg>
232 </button>
233 {isBranchMenuOpen && (
234 <div className="absolute bottom-full right-0 z-20 mb-2 w-[320px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">
235 <p className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55">
236 Branch
237 </p>
238 <div className="max-h-64 overflow-y-auto">
239 {branches.map((branch) => {
240 const isSelected = branch.name === activeThread.branch;
241 const hasSecondaryWorktree =
242 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
243 return (
244 <button
245 key={branch.name}
246 type="button"
247 className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-1.5 text-left font-mono text-xs transition-colors duration-150 ${
248 isSelected
249 ? "bg-accent text-foreground"
250 : "text-foreground/90 hover:bg-accent/50"
251 }`}
252 onClick={() => selectBranch(branch)}
253 >
254 <span className="truncate">{branch.name}</span>
237 </div>
238 <ComboboxEmpty>No branches found.</ComboboxEmpty>
239
240 <ComboboxList className="max-h-56">
241 {(branchName) => {
242 const branch = branchByName.get(branchName);
243 if (!branch) return null;
244
245 const hasSecondaryWorktree =
246 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
247 return (
248 <ComboboxItem
249 hideIndicator
250 key={branchName}
251 value={branchName}
252 className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined}
253 onClick={() => selectBranch(branch)}
254 >
255 <div className="flex w-full items-center justify-between gap-2">
256 <span className="truncate">{branchName}</span>
255257 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
256258 <span className="shrink-0 text-[10px] text-muted-foreground/45">
257259 {branch.current
......@@ -261,58 +263,64 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
261263 : "default"}
262264 </span>
263265 )}
264 </button>
265 );
266 })}
267 </div>
268 {envMode === "local" && (
269 <>
270 <div className="mx-1 my-1 h-px bg-border" />
271 {isCreatingBranch ? (
272 <form
273 className="flex items-center gap-1 px-1"
274 onSubmit={(event) => {
275 event.preventDefault();
276 createBranch();
277 }}
278 >
279 <input
280 type="text"
281 className="flex-1 rounded-lg border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/30 focus:border-ring focus:outline-none"
266 </div>
267 </ComboboxItem>
268 );
269 }}
270 </ComboboxList>
271 {envMode === "local" && (
272 <>
273 {isCreatingBranch ? (
274 <form
275 onSubmit={(event) => {
276 event.preventDefault();
277 createBranch();
278 }}
279 >
280 <InputGroup className="rounded-t-none before:rounded-t-none">
281 <InputGroupInput
282 className="[&_input]:font-sans"
283 size="sm"
282284 placeholder="branch-name"
285 type="text"
283286 value={newBranchName}
284287 onChange={(event) => setNewBranchName(event.target.value)}
285288 onKeyDown={(event) => {
286289 if (event.key === "Escape") {
290 event.stopPropagation();
287291 setIsCreatingBranch(false);
288292 setNewBranchName("");
289293 }
290294 }}
291 // biome-ignore lint/a11y/noAutofocus: branch name input should focus when shown
292295 autoFocus
293296 />
294 <button
295 type="submit"
296 disabled={!newBranchName.trim() || createBranchMutation.isPending}
297 className="rounded-lg px-2 py-1.5 text-xs text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground/80 disabled:opacity-30"
298 >
299 Create
300 </button>
301 </form>
302 ) : (
303 <button
304 type="button"
305 className="w-full rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80"
306 onClick={() => setIsCreatingBranch(true)}
307 >
308 + New branch
309 </button>
310 )}
311 </>
312 )}
313 </div>
314 )}
315 </div>
297 <InputGroupAddon align="inline-end">
298 <Button
299 size="xs"
300 variant="secondary"
301 type="submit"
302 disabled={!newBranchName.trim() || createBranchMutation.isPending}
303 >
304 Create
305 </Button>
306 </InputGroupAddon>
307 </InputGroup>
308 </form>
309 ) : (
310 <Button
311 size="sm"
312 className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"
313 variant="outline"
314 onClick={() => setIsCreatingBranch(true)}
315 >
316 <PlusIcon />
317 New branch
318 </Button>
319 )}
320 </>
321 )}
322 </ComboboxPopup>
323 </Combobox>
316324 )}
317325 </div>
318326 );
apps/web/src/components/ChatView.tsx+274-353
......@@ -4,6 +4,7 @@ import {
44 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
55 type ProviderSendTurnAttachmentInput,
66 type ProviderApprovalDecision,
7 ModelSlug,
78} from "@t3tools/contracts";
89import {
910 type ClipboardEvent,
......@@ -27,6 +28,7 @@ import {
2728 DEFAULT_REASONING,
2829 MODEL_OPTIONS,
2930 REASONING_OPTIONS,
31 ReasoningEffort,
3032 resolveModelSlug,
3133} from "../model-logic";
3234import {
......@@ -46,22 +48,30 @@ import { isTerminalToggleShortcut } from "../terminal-shortcuts";
4648import ChatMarkdown from "./ChatMarkdown";
4749import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
4850import { useNativeApi } from "../hooks/useNativeApi";
51import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
52import {
53 ChevronDownIcon,
54 CircleAlertIcon,
55 FolderClosedIcon,
56 InfoIcon,
57 LockIcon,
58 LockOpenIcon,
59 XIcon,
60} from "lucide-react";
61import { Button } from "./ui/button";
62import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
63import { Separator } from "./ui/separator";
64import { Group, GroupSeparator } from "./ui/group";
65import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
66import { CursorIcon, Icon } from "./Icons";
67import { cn } from "~/lib/utils";
68import { Badge } from "./ui/badge";
4969
5070function formatMessageMeta(createdAt: string, duration: string | null): string {
5171 if (!duration) return formatTimestamp(createdAt);
5272 return `${formatTimestamp(createdAt)} • ${duration}`;
5373}
5474
55const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")
56 ? "Finder"
57 : navigator.platform.includes("Win")
58 ? "Explorer"
59 : "Files";
60
61function editorLabel(editor: (typeof EDITORS)[number]): string {
62 return editor.command ? editor.label : FILE_MANAGER_LABEL;
63}
64
6575const LAST_EDITOR_KEY = "t3code:last-editor";
6676const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
6777const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
......@@ -119,13 +129,7 @@ export default function ChatView() {
119129 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
120130 const [isSending, setIsSending] = useState(false);
121131 const [isConnecting, setIsConnecting] = useState(false);
122 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
123 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
124 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
125 const stored = localStorage.getItem(LAST_EDITOR_KEY);
126 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
127 });
128 const [selectedEffort, setSelectedEffort] = useState<string>(DEFAULT_REASONING);
132 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
129133 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
130134 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
131135 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
......@@ -137,8 +141,6 @@ export default function ChatView() {
137141 const textareaRef = useRef<HTMLTextAreaElement>(null);
138142 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
139143 const dragDepthRef = useRef(0);
140 const modelMenuRef = useRef<HTMLDivElement>(null);
141 const editorMenuRef = useRef<HTMLDivElement>(null);
142144 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
143145
144146 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
......@@ -150,7 +152,6 @@ export default function ChatView() {
150152 const phase = derivePhase(activeThread?.session ?? null);
151153 const isWorking = phase === "running" || isSending || isConnecting;
152154 const nowIso = new Date(nowTick).toISOString();
153 const modelOptions = MODEL_OPTIONS;
154155 const workLogEntries = useMemo(
155156 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
156157 [activeThread?.events],
......@@ -304,7 +305,9 @@ export default function ChatView() {
304305 (terminalId: string) => {
305306 if (!activeThreadId || !api) return;
306307 const fallbackExitWrite = () =>
307 api.terminal.write({ threadId: activeThreadId, terminalId, data: "exit\n" }).catch(() => undefined);
308 api.terminal
309 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
310 .catch(() => undefined);
308311 if ("close" in api.terminal && typeof api.terminal.close === "function") {
309312 void api.terminal
310313 .close({ threadId: activeThreadId, terminalId })
......@@ -395,22 +398,21 @@ export default function ChatView() {
395398 };
396399 }, [revokePreviewUrls]);
397400
401 /**
402 * Close expanded image on Escape key
403 */
398404 useEffect(() => {
399405 if (!expandedImage) {
400406 return;
401407 }
402408
403409 const onKeyDown = (event: globalThis.KeyboardEvent) => {
404 if (event.key !== "Escape") {
405 return;
406 }
410 if (event.key !== "Escape") return;
407411 setExpandedImage(null);
408412 };
409413
410414 window.addEventListener("keydown", onKeyDown);
411 return () => {
412 window.removeEventListener("keydown", onKeyDown);
413 };
415 return () => window.removeEventListener("keydown", onKeyDown);
414416 }, [expandedImage]);
415417
416418 const activeWorktreePath = activeThread?.worktreePath;
......@@ -438,53 +440,6 @@ export default function ChatView() {
438440 };
439441 }, [phase]);
440442
441 useEffect(() => {
442 if (!isModelMenuOpen) return;
443
444 const handleClickOutside = (event: MouseEvent) => {
445 if (!modelMenuRef.current) return;
446 if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) {
447 setIsModelMenuOpen(false);
448 }
449 };
450
451 window.addEventListener("mousedown", handleClickOutside);
452 return () => {
453 window.removeEventListener("mousedown", handleClickOutside);
454 };
455 }, [isModelMenuOpen]);
456
457 useEffect(() => {
458 if (!isEditorMenuOpen) return;
459
460 const handleClickOutside = (event: MouseEvent) => {
461 if (!editorMenuRef.current) return;
462 if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) {
463 setIsEditorMenuOpen(false);
464 }
465 };
466
467 window.addEventListener("mousedown", handleClickOutside);
468 return () => {
469 window.removeEventListener("mousedown", handleClickOutside);
470 };
471 }, [isEditorMenuOpen]);
472
473 // Cmd+O / Ctrl+O to open in last-used editor
474 useEffect(() => {
475 const handler = (e: globalThis.KeyboardEvent) => {
476 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
477 if (api && activeProject) {
478 e.preventDefault();
479 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
480 void api.shell.openInEditor(cwd, lastEditor);
481 }
482 }
483 };
484 window.addEventListener("keydown", handler);
485 return () => window.removeEventListener("keydown", handler);
486 }, [api, activeProject, activeThread, lastEditor]);
487
488443 useEffect(() => {
489444 if (!activeThreadId) return;
490445 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
......@@ -516,15 +471,6 @@ export default function ChatView() {
516471 return () => window.removeEventListener("keydown", handler);
517472 }, [activeThreadId, toggleTerminalVisibility]);
518473
519 const openInEditor = (editorId: EditorId) => {
520 if (!api || !activeProject) return;
521 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
522 void api.shell.openInEditor(cwd, editorId);
523 setLastEditor(editorId);
524 localStorage.setItem(LAST_EDITOR_KEY, editorId);
525 setIsEditorMenuOpen(false);
526 };
527
528474 const setThreadError = (threadId: string | null, error: string | null) => {
529475 if (!threadId) return;
530476 dispatch({
......@@ -579,13 +525,6 @@ export default function ChatView() {
579525 });
580526 };
581527
582 const openImageDialog = (src: string | undefined, name: string) => {
583 if (!src) {
584 return;
585 }
586 setExpandedImage({ src, name });
587 };
588
589528 const onComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
590529 const files = Array.from(event.clipboardData.files);
591530 if (files.length === 0) {
......@@ -782,13 +721,15 @@ export default function ChatView() {
782721 setIsSending(true);
783722 try {
784723 const turnAttachments = await Promise.all(
785 composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnAttachmentInput> => ({
786 type: "image",
787 name: image.name,
788 mimeType: image.mimeType,
789 sizeBytes: image.sizeBytes,
790 dataUrl: await readFileAsDataUrl(image.file),
791 })),
724 composerImagesSnapshot.map(
725 async (image): Promise<ProviderSendTurnAttachmentInput> => ({
726 type: "image",
727 name: image.name,
728 mimeType: image.mimeType,
729 sizeBytes: image.sizeBytes,
730 dataUrl: await readFileAsDataUrl(image.file),
731 }),
732 ),
792733 );
793734 const shouldBootstrap =
794735 previousMessages.length > 0 &&
......@@ -800,7 +741,7 @@ export default function ChatView() {
800741 latestPromptForBootstrap,
801742 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
802743 ).text
803 : (trimmed || undefined);
744 : trimmed || undefined;
804745 await api.providers.sendTurn({
805746 sessionId: sessionInfo.sessionId,
806747 ...(input ? { input } : {}),
......@@ -849,14 +790,13 @@ export default function ChatView() {
849790 }
850791 };
851792
852 const onModelSelect = (model: string) => {
793 const onModelSelect = (model: ModelSlug) => {
853794 if (!activeThread) return;
854795 dispatch({
855796 type: "SET_THREAD_MODEL",
856797 threadId: activeThread.id,
857798 model: resolveModelSlug(model),
858799 });
859 setIsModelMenuOpen(false);
860800 };
861801
862802 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
......@@ -892,132 +832,97 @@ export default function ChatView() {
892832 >
893833 <div className="flex items-center gap-3">
894834 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
895 {activeProject && (
896 <span className="rounded-full bg-accent px-2 py-0.5 text-[10px] text-muted-foreground/50">
897 {activeProject.name}
898 </span>
899 )}
835 {activeProject && <Badge variant="outline">{activeProject.name}</Badge>}
900836 </div>
901837 <div className="flex items-center gap-3">
902838 {/* Open in editor */}
903 {activeProject && (
904 <div className="relative" ref={editorMenuRef}>
905 <button
906 type="button"
907 className="rounded-md px-2 py-1 text-[10px] text-muted-foreground/40 transition-colors duration-150 hover:text-muted-foreground/60"
908 onClick={() => setIsEditorMenuOpen((v) => !v)}
909 >
910 Open in&hellip;
911 </button>
912 {isEditorMenuOpen && (
913 <div className="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-md border border-border bg-popover py-1 shadow-xl">
914 {EDITORS.map((editor) => (
915 <button
916 key={editor.id}
917 type="button"
918 className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-foreground hover:bg-accent"
919 onClick={() => openInEditor(editor.id)}
920 >
921 {editorLabel(editor)}
922 {editor.id === lastEditor && (
923 <kbd className="ml-auto text-[9px] text-muted-foreground/40">
924 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
925 </kbd>
926 )}
927 </button>
928 ))}
929 </div>
930 )}
931 </div>
932 )}
839 {activeProject && <OpenInPicker />}
933840 {/* Git actions */}
934841 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
935842
936843 {/* Diff toggle */}
937 <button
938 type="button"
939 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
940 state.diffOpen
941 ? "bg-accent text-foreground"
942 : "text-muted-foreground/40 hover:text-muted-foreground/60"
943 }`}
844 <Button
845 size="xs"
846 variant="ghost"
847 className={cn(
848 "text-muted-foreground/70 hover:text-foreground/80",
849 state.diffOpen && "bg-accent text-accent-foreground",
850 )}
944851 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
945852 >
946853 Diff
947 </button>
854 </Button>
948855 </div>
949856 </header>
950857
951858 {/* Error banner */}
952859 {activeThread.error && (
953 <div className="mx-4 mt-3 rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-xs text-destructive">
954 {activeThread.error}
860 <div className="pt-3 mx-auto max-w-3xl">
861 <Alert variant="error">
862 <CircleAlertIcon />
863 <AlertDescription className="line-clamp-3" title={activeThread.error}>
864 {activeThread.error}
865 </AlertDescription>
866 </Alert>
955867 </div>
956868 )}
957869
958870 {pendingApprovals.length > 0 && (
959 <div className="px-5 pt-3">
960 <div className="mx-auto max-w-3xl space-y-2">
961 {pendingApprovals.map((approval) => {
962 const isResponding = respondingRequestIds.includes(approval.requestId);
963 return (
964 <div
965 key={approval.requestId}
966 className="rounded-lg border border-amber-300/40 bg-amber-50 px-3 py-2 dark:border-amber-300/20 dark:bg-amber-500/[0.07]"
871 <div className="pt-3 mx-auto max-w-3xl space-y-2">
872 {pendingApprovals.map((approval) => {
873 const isResponding = respondingRequestIds.includes(approval.requestId);
874
875 return (
876 <Alert variant="warning" key={approval.requestId}>
877 <InfoIcon />
878 <AlertTitle className="text-xs">
879 {approval.requestKind === "command"
880 ? "Command approval requested"
881 : "File-change approval requested"}
882 </AlertTitle>
883 <AlertDescription
884 className="truncate block font-mono text-[11px]"
885 title={approval.detail}
967886 >
968 <p className="text-xs font-medium text-amber-900 dark:text-amber-100">
969 {approval.requestKind === "command"
970 ? "Command approval requested"
971 : "File-change approval requested"}
972 </p>
973 {approval.detail && (
974 <p
975 className="mt-1 truncate font-mono text-[11px] text-amber-900/80 dark:text-amber-100/75"
976 title={approval.detail}
977 >
978 {approval.detail}
979 </p>
980 )}
981 <div className="mt-2 flex flex-wrap gap-1.5">
982 <button
983 type="button"
984 className="rounded-md border border-border bg-accent px-2 py-1 text-[11px] text-foreground transition-colors duration-150 hover:bg-accent/80 disabled:cursor-not-allowed disabled:opacity-50"
985 disabled={isResponding}
986 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
987 >
988 Approve once
989 </button>
990 <button
991 type="button"
992 className="rounded-md border border-sky-300/70 bg-sky-100 px-2 py-1 text-[11px] text-sky-800 transition-colors duration-150 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-sky-300/30 dark:bg-sky-500/15 dark:text-sky-100 dark:hover:bg-sky-500/22"
993 disabled={isResponding}
994 onClick={() =>
995 void onRespondToApproval(approval.requestId, "acceptForSession")
996 }
997 >
998 Always allow this session
999 </button>
1000 <button
1001 type="button"
1002 className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
1003 disabled={isResponding}
1004 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
1005 >
1006 Decline
1007 </button>
1008 <button
1009 type="button"
1010 className="rounded-md border border-rose-300/70 bg-rose-100 px-2 py-1 text-[11px] text-rose-800 transition-colors duration-150 hover:bg-rose-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-rose-300/30 dark:bg-rose-500/12 dark:text-rose-100 dark:hover:bg-rose-500/20"
1011 disabled={isResponding}
1012 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
1013 >
1014 Cancel turn
1015 </button>
1016 </div>
1017 </div>
1018 );
1019 })}
1020 </div>
887 {approval.detail}
888 </AlertDescription>
889 <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto">
890 <Button
891 size="xs"
892 variant="default"
893 disabled={isResponding}
894 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
895 >
896 Approve once
897 </Button>
898 <Button
899 size="xs"
900 variant="outline"
901 disabled={isResponding}
902 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
903 >
904 Always allow this session
905 </Button>
906 <Button
907 size="xs"
908 variant="destructive-outline"
909 disabled={isResponding}
910 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
911 >
912 Decline
913 </Button>
914 <Button
915 size="xs"
916 variant="ghost"
917 disabled={isResponding}
918 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
919 >
920 Cancel turn
921 </Button>
922 </AlertAction>
923 </Alert>
924 );
925 })}
1021926 </div>
1022927 )}
1023928
......@@ -1140,7 +1045,9 @@ export default function ChatView() {
11401045 src={image.previewUrl}
11411046 alt={image.name}
11421047 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1143 onClick={() => openImageDialog(image.previewUrl, image.name)}
1048 onClick={() =>
1049 setExpandedImage({ src: image.previewUrl!, name: image.name })
1050 }
11441051 />
11451052 ) : (
11461053 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
......@@ -1152,7 +1059,7 @@ export default function ChatView() {
11521059 </div>
11531060 )}
11541061 {timelineEntry.message.text && (
1155 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
1062 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
11561063 {timelineEntry.message.text}
11571064 </pre>
11581065 )}
......@@ -1241,21 +1148,24 @@ export default function ChatView() {
12411148 src={image.previewUrl}
12421149 alt={image.name}
12431150 className="h-full w-full cursor-zoom-in object-cover"
1244 onClick={() => openImageDialog(image.previewUrl, image.name)}
1151 onClick={() =>
1152 setExpandedImage({ src: image.previewUrl, name: image.name })
1153 }
12451154 />
12461155 ) : (
12471156 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
12481157 {image.name}
12491158 </div>
12501159 )}
1251 <button
1252 type="button"
1253 className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-background/80 text-[11px] leading-none text-foreground transition-colors duration-150 hover:bg-background"
1160 <Button
1161 variant="ghost"
1162 size="icon-xs"
1163 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
12541164 onClick={() => removeComposerImage(image.id)}
12551165 aria-label={`Remove ${image.name}`}
12561166 >
1257 ×
1258 </button>
1167 <XIcon />
1168 </Button>
12591169 </div>
12601170 ))}
12611171 </div>
......@@ -1281,110 +1191,23 @@ export default function ChatView() {
12811191 <div className="flex items-center justify-between px-3 pb-3">
12821192 <div className="flex items-center gap-1">
12831193 {/* Model picker */}
1284 <div className="relative" ref={modelMenuRef}>
1285 <button
1286 type="button"
1287 className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80"
1288 onClick={() => setIsModelMenuOpen((open) => !open)}
1289 >
1290 <span className="max-w-[180px] truncate">{selectedModel}</span>
1291 <svg
1292 width="10"
1293 height="10"
1294 viewBox="0 0 10 10"
1295 fill="none"
1296 className="opacity-50"
1297 aria-hidden="true"
1298 >
1299 <path
1300 d="M2.5 4L5 6.5L7.5 4"
1301 stroke="currentColor"
1302 strokeWidth="1.2"
1303 strokeLinecap="round"
1304 strokeLinejoin="round"
1305 />
1306 </svg>
1307 </button>
1308 {isModelMenuOpen && (
1309 <div className="absolute bottom-full left-0 z-20 mb-2 w-[320px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">
1310 <p className="px-2 py-1 text-[11px] text-muted-foreground/70">Select model</p>
1311 <div className="max-h-72 overflow-y-auto">
1312 {modelOptions.map((model) => {
1313 const isSelected = model === selectedModel;
1314 return (
1315 <button
1316 key={model}
1317 type="button"
1318 className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left font-mono text-sm transition-colors duration-150 ${
1319 isSelected
1320 ? "bg-accent text-foreground"
1321 : "text-foreground/90 hover:bg-accent"
1322 }`}
1323 onClick={() => onModelSelect(model)}
1324 >
1325 <span className="truncate">{model}</span>
1326 <span
1327 className={`pt-0.5 text-sm ${
1328 isSelected ? "text-foreground" : "text-transparent"
1329 }`}
1330 >
1331 ✓
1332 </span>
1333 </button>
1334 );
1335 })}
1336 </div>
1337 </div>
1338 )}
1339 </div>
1194 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
13401195
13411196 {/* Divider */}
1342 <div className="mx-0.5 h-4 w-px bg-border" />
1197 <Separator orientation="vertical" className="mx-0.5 h-4" />
13431198
13441199 {/* Reasoning effort */}
1345 <label
1346 className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80"
1347 htmlFor="reasoning-effort"
1348 >
1349 <span>{selectedEffort.charAt(0).toUpperCase() + selectedEffort.slice(1)}</span>
1350 <select
1351 id="reasoning-effort"
1352 className="absolute opacity-0 w-0 h-0"
1353 value={selectedEffort}
1354 onChange={(event) => setSelectedEffort(event.target.value)}
1355 >
1356 {REASONING_OPTIONS.map((effort) => (
1357 <option key={effort} value={effort} className="bg-popover">
1358 {effort}
1359 {effort === DEFAULT_REASONING ? " (default)" : ""}
1360 </option>
1361 ))}
1362 </select>
1363 <svg
1364 width="10"
1365 height="10"
1366 viewBox="0 0 10 10"
1367 fill="none"
1368 className="opacity-50"
1369 aria-hidden="true"
1370 >
1371 <path
1372 d="M2.5 4L5 6.5L7.5 4"
1373 stroke="currentColor"
1374 strokeWidth="1.2"
1375 strokeLinecap="round"
1376 strokeLinejoin="round"
1377 />
1378 </svg>
1379 </label>
1200 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={setSelectedEffort} />
13801201
13811202 {/* Divider */}
1382 <div className="mx-0.5 h-4 w-px bg-border" />
1203 <Separator orientation="vertical" className="mx-0.5 h-4" />
13831204
13841205 {/* Runtime mode toggle */}
1385 <button
1206 <Button
1207 variant="ghost"
1208 className="text-muted-foreground/70 hover:text-foreground/80"
1209 size="sm"
13861210 type="button"
1387 className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80"
13881211 disabled={isSwitchingRuntimeMode}
13891212 onClick={() =>
13901213 void handleRuntimeModeChange(
......@@ -1397,45 +1220,9 @@ export default function ChatView() {
13971220 : "Approval required — click for full access"
13981221 }
13991222 >
1400 {state.runtimeMode === "full-access" ? (
1401 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1402 <rect
1403 x="2"
1404 y="5.5"
1405 width="10"
1406 height="7"
1407 rx="1.5"
1408 stroke="currentColor"
1409 strokeWidth="1.3"
1410 />
1411 <path
1412 d="M9.5 5.5V4a2.5 2.5 0 0 0-5 0"
1413 stroke="currentColor"
1414 strokeWidth="1.3"
1415 strokeLinecap="round"
1416 />
1417 </svg>
1418 ) : (
1419 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1420 <rect
1421 x="2"
1422 y="5.5"
1423 width="10"
1424 height="7"
1425 rx="1.5"
1426 stroke="currentColor"
1427 strokeWidth="1.3"
1428 />
1429 <path
1430 d="M4.5 5.5V4a2.5 2.5 0 0 1 5 0v1.5"
1431 stroke="currentColor"
1432 strokeWidth="1.3"
1433 strokeLinecap="round"
1434 />
1435 </svg>
1436 )}
1437 <span>{state.runtimeMode === "full-access" ? "Full access" : "Supervised"}</span>
1438 </button>
1223 {state.runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
1224 {state.runtimeMode === "full-access" ? "Full access" : "Supervised"}
1225 </Button>
14391226 </div>
14401227
14411228 {/* Right side: send / stop button */}
......@@ -1461,7 +1248,9 @@ export default function ChatView() {
14611248 <button
14621249 type="submit"
14631250 className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100"
1464 disabled={isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)}
1251 disabled={
1252 isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
1253 }
14651254 aria-label={
14661255 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
14671256 }
......@@ -1550,14 +1339,16 @@ export default function ChatView() {
15501339 className="relative isolate max-h-[92vh] max-w-[92vw]"
15511340 onClick={(event) => event.stopPropagation()}
15521341 >
1553 <button
1342 <Button
15541343 type="button"
1555 className="absolute right-2 top-2 z-30 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-black/60 text-xl leading-none text-white transition-colors duration-150 hover:bg-black/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [-webkit-app-region:no-drag]"
1344 size="icon-xs"
1345 variant="ghost"
1346 className="absolute right-2 top-2"
15561347 onClick={() => setExpandedImage(null)}
15571348 aria-label="Close image preview"
15581349 >
1559 <span className="pointer-events-none">×</span>
1560 </button>
1350 <XIcon />
1351 </Button>
15611352 <img
15621353 src={expandedImage.src}
15631354 alt={expandedImage.name}
......@@ -1573,3 +1364,133 @@ export default function ChatView() {
15731364 </div>
15741365 );
15751366}
1367
1368function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) {
1369 return (
1370 <Select
1371 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}
1372 value={props.model}
1373 onValueChange={(value) => (value ? props.onModelChange(value) : undefined)}
1374 >
1375 <SelectTrigger size="sm" variant="ghost">
1376 <SelectValue />
1377 </SelectTrigger>
1378 <SelectPopup alignItemWithTrigger={false}>
1379 {MODEL_OPTIONS.map(({ slug, name }) => (
1380 <SelectItem key={slug} value={slug}>
1381 {name}
1382 </SelectItem>
1383 ))}
1384 </SelectPopup>
1385 </Select>
1386 );
1387}
1388
1389function ReasoningEffortPicker(props: {
1390 effort: ReasoningEffort;
1391 onEffortChange: (effort: ReasoningEffort) => void;
1392}) {
1393 return (
1394 <Select
1395 value={props.effort}
1396 onValueChange={(value) => (value ? props.onEffortChange(value) : undefined)}
1397 >
1398 <SelectTrigger variant="ghost" size="sm">
1399 <SelectValue />
1400 </SelectTrigger>
1401 <SelectPopup alignItemWithTrigger={false}>
1402 {REASONING_OPTIONS.map((effort) => (
1403 <SelectItem key={effort} value={effort}>
1404 {effort}
1405 {effort === DEFAULT_REASONING ? " (default)" : ""}
1406 </SelectItem>
1407 ))}
1408 </SelectPopup>
1409 </Select>
1410 );
1411}
1412
1413function OpenInPicker() {
1414 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
1415 const stored = localStorage.getItem(LAST_EDITOR_KEY);
1416 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
1417 });
1418
1419 const options = [
1420 {
1421 label: "Cursor",
1422 Icon: CursorIcon,
1423 value: "cursor",
1424 },
1425 {
1426 label: navigator.platform.includes("Mac")
1427 ? "Finder"
1428 : navigator.platform.includes("Win")
1429 ? "Explorer"
1430 : "Files",
1431 Icon: FolderClosedIcon,
1432 value: "file-manager",
1433 },
1434 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
1435 const primaryOption = options.find(({ value }) => value === lastEditor);
1436
1437 const api = useNativeApi();
1438 const { state } = useStore();
1439 const activeProject = state.projects.find((p) => p.id === state.activeThreadId);
1440 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
1441
1442 const openInEditor = useCallback(
1443 (editorId: EditorId | null) => {
1444 if (!api || !activeProject) return;
1445 const editor = editorId ?? lastEditor;
1446 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1447 void api.shell.openInEditor(cwd, editor);
1448 localStorage.setItem(LAST_EDITOR_KEY, editor);
1449 setLastEditor(editor);
1450 },
1451 [api, activeProject, activeThread, lastEditor, setLastEditor],
1452 );
1453
1454 // Cmd+O / Ctrl+O to open in last-used editor
1455 useEffect(() => {
1456 const handler = (e: globalThis.KeyboardEvent) => {
1457 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
1458 if (api && activeProject) {
1459 e.preventDefault();
1460 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1461 void api.shell.openInEditor(cwd, lastEditor);
1462 }
1463 }
1464 };
1465 window.addEventListener("keydown", handler);
1466 return () => window.removeEventListener("keydown", handler);
1467 }, [api, activeProject, activeThread, lastEditor]);
1468
1469 return (
1470 <Group aria-label="Subscription actions">
1471 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>
1472 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3" />}
1473 Open
1474 </Button>
1475 <GroupSeparator />
1476 <Menu>
1477 <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>
1478 <ChevronDownIcon aria-hidden="true" className="size-4" />
1479 </MenuTrigger>
1480 <MenuPopup align="end">
1481 {options.map(({ label, Icon, value }) => (
1482 <MenuItem key={value} onClick={() => openInEditor(value)}>
1483 <Icon aria-hidden="true" className="text-muted-foreground" />
1484 {label}
1485 {value === lastEditor && (
1486 <MenuShortcut>
1487 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
1488 </MenuShortcut>
1489 )}
1490 </MenuItem>
1491 ))}
1492 </MenuPopup>
1493 </Menu>
1494 </Group>
1495 );
1496}
apps/web/src/components/DiffPanel.tsx+7-4
......@@ -1,5 +1,7 @@
1import { XIcon } from "lucide-react";
12import { isElectron } from "../env";
23import { useStore } from "../store";
4import { Button } from "./ui/button";
35
46export default function DiffPanel() {
57 const { dispatch } = useStore();
......@@ -11,13 +13,14 @@ export default function DiffPanel() {
1113 className={`flex items-center justify-between border-b border-border px-4 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
1214 >
1315 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
14 <button
16 <Button
1517 type="button"
16 className="text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground"
18 size="icon-xs"
19 variant="ghost"
1720 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
1821 >
19 <span className="text-sm">&times;</span>
20 </button>
22 <XIcon />
23 </Button>
2124 </div>
2225
2326 {/* Placeholder content */}
apps/web/src/components/GitActionsControl.tsx+136-670
......@@ -5,15 +5,13 @@ import {
55 type NativeApi,
66} from "@t3tools/contracts";
77import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9import {
10 CheckIcon,
11 CloudUploadIcon,
12 GitCommitIcon,
13 Loader2Icon,
14 XIcon,
15} from "lucide-react";
8import { useCallback, useEffect, useMemo, useState } from "react";
9import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon } from "lucide-react";
1610import { GitHubIcon } from "./Icons";
11import { Button } from "~/components/ui/button";
12import { Group, GroupSeparator } from "~/components/ui/group";
13import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
14import { toastManager } from "~/components/ui/toast";
1715
1816interface GitActionsControlProps {
1917 api: NativeApi | undefined;
......@@ -27,112 +25,38 @@ interface GitActionMenuItem {
2725 label: string;
2826 disabled: boolean;
2927 icon: GitActionIconName;
30 kind: "open_modal" | "run_action" | "open_pr";
28 kind: "run_action" | "open_pr";
3129 action?: GitStackedAction;
3230}
3331
34interface GitModalActionOption {
35 action: GitStackedAction;
36 label: string;
37 disabled: boolean;
38 icon: GitActionIconName;
39 detail?: string;
40}
41
42type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed";
43
44interface GitProgressStep {
45 id: "generate" | "commit" | "push" | "pr";
46 label: string;
47 status: GitProgressStepStatus;
48 detail?: string;
49}
50
51function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) {
52 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
53
54 if (props.icon === "commit") {
55 return <GitCommitIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
56 }
57
58 if (props.icon === "push") {
59 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
60 }
61
62 return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
63}
64
65function runningStepMessage(stepId: GitProgressStep["id"]): string {
66 if (stepId === "generate") return "Generating commit message...";
67 if (stepId === "commit") return "Committing changes...";
68 if (stepId === "push") return "Pushing branch...";
69 return "Preparing pull request...";
32function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
33 if (icon === "commit") return <GitCommitIcon />;
34 if (icon === "push") return <CloudUploadIcon />;
35 return <GitHubIcon />;
7036}
7137
72function gitActionModalTitle(): string {
73 return "Commit your changes";
74}
38function describeGitResult(result: GitRunStackedActionResult): string {
39 const parts: string[] = [];
7540
76function initialGitProgressSteps(
77 action: GitStackedAction,
78 commitMessage: string,
79): GitProgressStep[] {
80 const hasCustomMessage = commitMessage.trim().length > 0;
81 const steps: GitProgressStep[] = [];
82
83 if (!hasCustomMessage) {
84 steps.push({
85 id: "generate",
86 label: "Generate commit message",
87 status: "pending",
88 });
41 if (result.commit.status === "created") {
42 parts.push(result.commit.subject ?? "Committed changes");
8943 }
9044
91 steps.push({
92 id: "commit",
93 label: "Commit changes",
94 status: "pending",
95 });
96
97 if (action !== "commit") {
98 steps.push({
99 id: "push",
100 label: "Push branch",
101 status: "pending",
102 });
45 if (result.push.status === "pushed") {
46 parts.push(
47 result.push.upstreamBranch
48 ? `Pushed to ${result.push.upstreamBranch}`
49 : "Pushed",
50 );
10351 }
10452
105 if (action === "commit_push_pr") {
106 steps.push({
107 id: "pr",
108 label: "Create or open PR",
109 status: "pending",
110 });
53 if (result.pr.status === "created") {
54 parts.push(result.pr.number ? `Created PR #${result.pr.number}` : "Created PR");
55 } else if (result.pr.status === "opened_existing") {
56 parts.push(result.pr.number ? `PR #${result.pr.number}` : "Opened existing PR");
11157 }
11258
113 return steps;
114}
115
116function updateProgressStep(
117 steps: GitProgressStep[],
118 id: GitProgressStep["id"],
119 status: GitProgressStepStatus,
120 detail?: string,
121): GitProgressStep[] {
122 return steps.map((step) => {
123 if (step.id !== id) return step;
124 return {
125 ...step,
126 status,
127 ...(detail ? { detail } : {}),
128 };
129 });
130}
131
132function runActionLabel(action: GitStackedAction): string {
133 if (action === "commit") return "Commit changes";
134 if (action === "commit_push") return "Commit and push";
135 return "Commit and create PR";
59 return parts.join(" · ") || "No changes needed.";
13660}
13761
13862function buildGitActionMenuItems(
......@@ -162,7 +86,8 @@ function buildGitActionMenuItems(
16286 label: "Commit",
16387 disabled: !canCommit,
16488 icon: "commit",
165 kind: "open_modal",
89 kind: "run_action",
90 action: "commit",
16691 },
16792 {
16893 id: "push",
......@@ -191,41 +116,6 @@ function buildGitActionMenuItems(
191116 ];
192117}
193118
194function buildGitModalActionOptions(
195 gitStatus: GitStatusResult | null,
196 isDisabled: boolean,
197): GitModalActionOption[] {
198 const hasBranch = gitStatus?.branch !== null;
199 const hasOpenPr = gitStatus?.openPr !== null;
200
201 return [
202 {
203 action: "commit",
204 label: "Commit",
205 disabled: isDisabled,
206 icon: "commit",
207 },
208 {
209 action: "commit_push",
210 label: "Commit and push",
211 disabled: isDisabled || !hasBranch,
212 icon: "push",
213 ...(!hasBranch ? { detail: "Requires an attached branch." } : {}),
214 },
215 {
216 action: "commit_push_pr",
217 label: "Commit and create PR",
218 disabled: isDisabled || !hasBranch || hasOpenPr,
219 icon: "pr",
220 ...(!hasBranch
221 ? { detail: "Requires an attached branch." }
222 : hasOpenPr
223 ? { detail: "A PR is already open for this branch." }
224 : {}),
225 },
226 ];
227}
228
229119function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
230120 return ["git", "status", gitCwd];
231121}
......@@ -245,17 +135,7 @@ function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null
245135
246136export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
247137 const queryClient = useQueryClient();
248 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
249 const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false);
250 const [gitActionError, setGitActionError] = useState<string | null>(null);
251 const [isGitModalOpen, setIsGitModalOpen] = useState(false);
252 const [gitModalSelectedAction, setGitModalSelectedAction] = useState<GitStackedAction>("commit");
253 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
254 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
255 const [gitModalError, setGitModalError] = useState<string | null>(null);
256 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
257 const [isGitProgressToastVisible, setIsGitProgressToastVisible] = useState(false);
258 const gitMenuRef = useRef<HTMLDivElement>(null);
138 const [selectedGitActionId, setSelectedGitActionId] = useState<GitActionMenuItem["id"]>("commit");
259139
260140 const { data: gitStatus = null, error: gitStatusError } = useQuery(
261141 gitStatusQueryOptions(api, gitCwd),
......@@ -268,581 +148,167 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
268148 }
269149 return api.git.runStackedAction({ cwd: gitCwd, action });
270150 },
271 onMutate: () => {
272 setIsGitMenuOpen(false);
273 setGitActionError(null);
274 setGitModalError(null);
275 },
276151 onSuccess: async () => {
277152 await queryClient.invalidateQueries({ queryKey: ["git"] });
278153 },
279 onError: (error) => {
280 setGitActionError(error instanceof Error ? error.message : "Git action failed.");
281 },
282154 });
283155
284 const isGitActionRunning = isGitModalActionRunning || runImmediateGitActionMutation.isPending;
156 const isGitActionRunning = runImmediateGitActionMutation.isPending;
285157
286158 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
287159 const gitActionMenuItems = useMemo(
288160 () => buildGitActionMenuItems(gitStatus, gitBaseDisabled),
289161 [gitBaseDisabled, gitStatus],
290162 );
291 const gitModalActionOptions = useMemo(
292 () => buildGitModalActionOptions(gitStatus, !gitStatus || isGitActionRunning),
293 [gitStatus, isGitActionRunning],
294 );
295 const selectedGitModalActionOption = useMemo(
296 () => gitModalActionOptions.find((option) => option.action === gitModalSelectedAction) ?? null,
297 [gitModalActionOptions, gitModalSelectedAction],
163 const selectedGitActionItem = useMemo(
164 () => gitActionMenuItems.find((item) => item.id === selectedGitActionId) ?? null,
165 [gitActionMenuItems, selectedGitActionId],
298166 );
299 const visibleGitActionError = gitActionError ?? gitStatusError?.message;
300 const gitProgressToastOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null;
301 const showGitProgressToast =
302 isGitProgressToastVisible &&
303 (isGitModalActionRunning ||
304 gitModalProgress.length > 0 ||
305 gitModalError !== null ||
306 gitModalResult !== null);
307 const activeGitProgressStep = useMemo(
308 () => gitModalProgress.find((step) => step.status === "running") ?? null,
309 [gitModalProgress],
310 );
311 const gitProgressToastStatus = isGitModalActionRunning
312 ? "running"
313 : gitModalError
314 ? "failed"
315 : "done";
316 const gitProgressToastMessage = isGitModalActionRunning
317 ? runningStepMessage(activeGitProgressStep?.id ?? "commit")
318 : gitModalError
319 ? "Git action failed."
320 : "Done.";
321167
322168 const refreshGitStatus = useCallback(async () => {
323169 if (!api || !gitCwd) return;
324170 await queryClient.invalidateQueries({ queryKey: ["git"] });
325 setGitActionError(null);
326171 }, [api, gitCwd, queryClient]);
327172
328 const openGitActionModal = useCallback(() => {
329 setIsGitMenuOpen(false);
330 setIsGitModalOpen(true);
331 setGitModalSelectedAction("commit");
332 setGitModalCommitMessage("");
333 setIsGitProgressToastVisible(false);
334 setGitModalProgress([]);
335 setGitModalError(null);
336 setGitModalResult(null);
337 setGitActionError(null);
338 }, []);
339
340 const closeGitActionModal = useCallback(() => {
341 if (isGitActionRunning) return;
342 setIsGitModalOpen(false);
343 setGitModalSelectedAction("commit");
344 setGitModalCommitMessage("");
345 }, [isGitActionRunning]);
346
347173 const runGitActionImmediately = useCallback(
348174 (action: GitStackedAction) => {
349 runImmediateGitActionMutation.mutate(action);
175 const promise = runImmediateGitActionMutation.mutateAsync(action);
176 toastManager.promise(promise, {
177 loading: {
178 title: action === "commit_push" ? "Pushing..." : "Creating PR...",
179 },
180 success: (result) => {
181 const prUrl = result.pr.url;
182 return {
183 title: "Done",
184 description: describeGitResult(result),
185 ...(prUrl
186 ? {
187 timeout: 10_000,
188 actionProps: {
189 children: "Open PR",
190 onClick: () => void api?.shell.openExternal(prUrl),
191 },
192 }
193 : {}),
194 };
195 },
196 error: (err) => ({
197 title: "Action failed",
198 description: err instanceof Error ? err.message : "An error occurred.",
199 }),
200 });
201 void promise.catch(() => undefined);
350202 },
351 [runImmediateGitActionMutation],
203 [api, runImmediateGitActionMutation],
352204 );
353205
354206 const openExistingPr = useCallback(() => {
355 setIsGitMenuOpen(false);
356 setGitActionError(null);
357207 if (!api) {
358 setGitActionError("Link opening is unavailable.");
208 toastManager.add({ type: "error", title: "Link opening is unavailable." });
359209 return;
360210 }
361211
362212 const prUrl = gitStatus?.openPr?.url ?? null;
363213 if (!prUrl) {
364 setGitActionError("No open PR found for the current branch.");
214 toastManager.add({ type: "error", title: "No open PR found." });
365215 return;
366216 }
367217
368218 void api.shell.openExternal(prUrl).catch((error) => {
369 setGitActionError(error instanceof Error ? error.message : "Unable to open PR link.");
219 toastManager.add({
220 type: "error",
221 title: "Unable to open PR link",
222 description: error instanceof Error ? error.message : undefined,
223 });
370224 });
371225 }, [api, gitStatus?.openPr?.url]);
372226
373 const openPrFromToast = useCallback(() => {
374 if (!api) {
375 setGitModalError("Link opening is unavailable.");
227 const runSelectedAction = useCallback(() => {
228 if (!selectedGitActionItem || selectedGitActionItem.disabled) return;
229 if (selectedGitActionItem.kind === "open_pr") {
230 openExistingPr();
376231 return;
377232 }
378
379 const prUrl = gitProgressToastOpenPrUrl;
380 if (!prUrl) return;
381
382 void api.shell.openExternal(prUrl).catch((error) => {
383 setGitModalError(error instanceof Error ? error.message : "Unable to open PR link.");
384 });
385 }, [api, gitProgressToastOpenPrUrl]);
386
387 const dismissGitProgressToast = useCallback(() => {
388 if (isGitModalActionRunning) return;
389 setIsGitProgressToastVisible(false);
390 setGitModalProgress([]);
391 setGitModalError(null);
392 setGitModalResult(null);
393 }, [isGitModalActionRunning]);
394
395 const runGitAction = useCallback(async () => {
396 if (!api || !gitCwd) return;
397 if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return;
398 const actionCwd = gitCwd;
399 const action = selectedGitModalActionOption.action;
400 const commitMessage = gitModalCommitMessage.trim();
401 const includeGeneratedCommitMessage = commitMessage.length === 0;
402
403 setIsGitMenuOpen(false);
404 setIsGitModalOpen(false);
405 setIsGitProgressToastVisible(true);
406 setIsGitModalActionRunning(true);
407 setGitModalError(null);
408 setGitActionError(null);
409 setGitModalResult(null);
410 setGitModalProgress(initialGitProgressSteps(action, commitMessage));
411
412 let commit: GitRunStackedActionResult["commit"] = {
413 status: "skipped_no_changes",
414 };
415 let push: GitRunStackedActionResult["push"] = {
416 status: "skipped_not_requested",
417 };
418 let pr: GitRunStackedActionResult["pr"] = {
419 status: "skipped_not_requested",
420 };
421
422 const updateStep = (
423 id: GitProgressStep["id"],
424 status: GitProgressStepStatus,
425 detail?: string,
426 ) => {
427 setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail));
428 };
429
430 try {
431 if (includeGeneratedCommitMessage) {
432 updateStep("generate", "running");
433 } else {
434 updateStep("commit", "running");
435 }
436
437 const commitRun = await api.git.runStackedAction({
438 cwd: actionCwd,
439 action: "commit",
440 ...(commitMessage.length > 0 ? { commitMessage } : {}),
441 });
442 commit = commitRun.commit;
443
444 if (includeGeneratedCommitMessage) {
445 if (commitRun.commit.status === "created") {
446 if (commitRun.commit.subject) {
447 setGitModalCommitMessage(commitRun.commit.subject);
448 }
449 updateStep("generate", "completed");
450 } else {
451 updateStep("generate", "skipped", "No local changes to commit.");
452 }
453 }
454
455 if (commitRun.commit.status === "created") {
456 updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes.");
457 } else {
458 updateStep("commit", "skipped", "No local changes to commit.");
459 }
460
461 if (action !== "commit") {
462 updateStep("push", "running");
463 const pushRun = await api.git.runStackedAction({
464 cwd: actionCwd,
465 action: "commit_push",
466 });
467 push = pushRun.push;
468 if (pushRun.push.status === "pushed") {
469 updateStep(
470 "push",
471 "completed",
472 pushRun.push.upstreamBranch
473 ? `Pushed to ${pushRun.push.upstreamBranch}.`
474 : "Pushed latest commits.",
475 );
476 } else {
477 updateStep("push", "skipped", "Branch already up to date.");
478 }
479 }
480
481 if (action === "commit_push_pr") {
482 updateStep("pr", "running");
483 const prRun = await api.git.runStackedAction({
484 cwd: actionCwd,
485 action: "commit_push_pr",
486 });
487 pr = prRun.pr;
488 if (prRun.pr.status === "opened_existing") {
489 updateStep(
490 "pr",
491 "completed",
492 prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.",
493 );
494 } else if (prRun.pr.status === "created") {
495 updateStep(
496 "pr",
497 "completed",
498 prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.",
499 );
500 } else {
501 updateStep("pr", "skipped", "PR step was not requested.");
502 }
503 }
504
505 setGitModalResult({ action, commit, push, pr });
506 } catch (error) {
507 const message = error instanceof Error ? error.message : "Git action failed.";
508 setGitModalError(message);
509 setGitActionError(message);
510 setGitModalProgress((steps) => {
511 const active = steps.find((step) => step.status === "running");
512 if (!active) return steps;
513 return updateProgressStep(steps, active.id, "failed", message);
514 });
515 } finally {
516 setIsGitModalActionRunning(false);
517 await refreshGitStatus().catch(() => undefined);
233 if (selectedGitActionItem.action) {
234 runGitActionImmediately(selectedGitActionItem.action);
518235 }
519 }, [
520 api,
521 gitCwd,
522 gitModalCommitMessage,
523 refreshGitStatus,
524 selectedGitModalActionOption,
525 ]);
236 }, [openExistingPr, runGitActionImmediately, selectedGitActionItem]);
526237
527238 useEffect(() => {
528 setGitActionError(null);
529 setGitModalError(null);
530 setIsGitModalOpen(false);
531 setIsGitProgressToastVisible(false);
532 setGitModalSelectedAction("commit");
533 setGitModalCommitMessage("");
534 setGitModalProgress([]);
535 setGitModalResult(null);
239 setSelectedGitActionId("commit");
536240 }, [gitCwd]);
537241
538242 useEffect(() => {
539 if (!isGitModalOpen) return;
540 if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return;
541
542 const fallback = gitModalActionOptions.find((option) => !option.disabled);
243 if (selectedGitActionItem) return;
244 const fallback = gitActionMenuItems.find((item) => !item.disabled) ?? gitActionMenuItems[0];
543245 if (fallback) {
544 setGitModalSelectedAction(fallback.action);
246 setSelectedGitActionId(fallback.id);
545247 }
546 }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]);
547
548 useEffect(() => {
549 if (!isGitMenuOpen) return;
550
551 const handleClickOutside = (event: MouseEvent) => {
552 if (!gitMenuRef.current) return;
553 if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) {
554 setIsGitMenuOpen(false);
555 }
556 };
557
558 window.addEventListener("mousedown", handleClickOutside);
559 return () => {
560 window.removeEventListener("mousedown", handleClickOutside);
561 };
562 }, [isGitMenuOpen]);
563
564 useEffect(() => {
565 if (!isGitModalOpen) return;
566
567 const handleEscape = (event: globalThis.KeyboardEvent) => {
568 if (event.key !== "Escape") return;
569 if (isGitActionRunning) return;
570 closeGitActionModal();
571 };
572
573 window.addEventListener("keydown", handleEscape);
574 return () => {
575 window.removeEventListener("keydown", handleEscape);
576 };
577 }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]);
248 }, [gitActionMenuItems, selectedGitActionItem]);
578249
579250 if (!gitCwd) return null;
580251
581252 return (
582 <>
583 <div className="relative" ref={gitMenuRef}>
584 <button
585 type="button"
586 className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"
587 onClick={() => {
588 if (!isGitMenuOpen) {
589 void refreshGitStatus().catch(() => undefined);
590 }
591 setIsGitMenuOpen((v) => !v);
592 }}
253 <Group aria-label="Git actions">
254 <Button
255 variant="ghost"
256 size="xs"
257 className="text-muted-foreground/70 hover:text-foreground/80"
258 disabled={!selectedGitActionItem || selectedGitActionItem.disabled || isGitActionRunning}
259 onClick={runSelectedAction}
260 >
261 {isGitActionRunning ? "Running..." : selectedGitActionItem?.label ?? "Git actions"}
262 </Button>
263 <GroupSeparator />
264 <Menu
265 onOpenChange={(open) => {
266 if (open) void refreshGitStatus();
267 }}
268 >
269 <MenuTrigger
270 render={<Button aria-label="Git action options" size="icon-xs" variant="ghost" />}
593271 disabled={!gitStatus || isGitActionRunning}
594272 >
595 {isGitActionRunning ? "Running..." : "Git actions"}
596 <span aria-hidden="true">▾</span>
597 </button>
598 {isGitMenuOpen && (
599 <div className="absolute right-0 top-full z-50 mt-1 w-[280px] rounded-3xl border border-border bg-popover p-3 shadow-xl">
600 <p className="px-3 pb-2 text-[13px] text-muted-foreground/75">Git actions</p>
601 {gitActionMenuItems.map((item) => {
602 return (
603 <button
604 key={`${item.id}-${item.label}`}
605 type="button"
606 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"
607 disabled={item.disabled}
608 onClick={() => {
609 if (item.kind === "open_modal") {
610 openGitActionModal();
611 return;
612 }
613
614 if (item.kind === "open_pr") {
615 openExistingPr();
616 return;
617 }
618
619 if (item.action) {
620 void runGitActionImmediately(item.action);
621 }
622 }}
623 >
624 <GitActionIcon icon={item.icon} disabled={item.disabled} />
625 <span>{item.label}</span>
626 </button>
627 );
628 })}
629 {gitStatus?.branch === null && (
630 <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
631 Detached HEAD: push and PR actions are unavailable.
632 </p>
633 )}
634 {gitStatus &&
635 gitStatus.branch !== null &&
636 !gitStatus.hasWorkingTreeChanges &&
637 gitStatus.aheadCount === 0 &&
638 gitStatus.behindCount > 0 && (
639 <p className="px-3 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
640 Branch is behind upstream. Pull/rebase before opening a PR.
641 </p>
642 )}
643 {visibleGitActionError && (
644 <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300">
645 {visibleGitActionError}
273 <ChevronDownIcon aria-hidden="true" className="size-4" />
274 </MenuTrigger>
275 <MenuPopup align="end" sideOffset={4}>
276 {gitActionMenuItems.map((item) => (
277 <MenuItem
278 key={`${item.id}-${item.label}`}
279 disabled={item.disabled}
280 onClick={() => {
281 setSelectedGitActionId(item.id);
282 if (item.kind === "open_pr") {
283 openExistingPr();
284 } else if (item.action) {
285 runGitActionImmediately(item.action);
286 }
287 }}
288 >
289 <GitActionItemIcon icon={item.icon} />
290 {item.label}
291 </MenuItem>
292 ))}
293 {gitStatus?.branch === null && (
294 <p className="px-2 py-1.5 text-xs text-warning">
295 Detached HEAD: push and PR are unavailable.
296 </p>
297 )}
298 {gitStatus &&
299 gitStatus.branch !== null &&
300 !gitStatus.hasWorkingTreeChanges &&
301 gitStatus.aheadCount === 0 &&
302 gitStatus.behindCount > 0 && (
303 <p className="px-2 py-1.5 text-xs text-warning">
304 Behind upstream. Pull/rebase first.
646305 </p>
647306 )}
648 </div>
649 )}
650 </div>
651
652 {isGitModalOpen && (
653 <div
654 className="fixed inset-0 z-80 flex items-center justify-center bg-black/35 px-4 py-6"
655 onMouseDown={() => {
656 closeGitActionModal();
657 }}
658 >
659 <div
660 className="w-full max-w-[640px] rounded-3xl border border-border bg-popover p-6 shadow-2xl"
661 role="dialog"
662 aria-modal="true"
663 aria-label="Git action confirmation"
664 onMouseDown={(event) => {
665 event.stopPropagation();
666 }}
667 >
668 <div className="flex items-start justify-between gap-4">
669 <div className="flex items-center gap-3">
670 <div className="rounded-2xl bg-accent p-3">
671 <GitActionIcon
672 icon={selectedGitModalActionOption?.icon ?? "commit"}
673 disabled={false}
674 />
675 </div>
676 <div>
677 <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground/70">
678 Git actions
679 </p>
680 <h3 className="text-3xl font-semibold tracking-tight text-foreground">
681 {gitActionModalTitle()}
682 </h3>
683 </div>
684 </div>
685 <button
686 type="button"
687 className="rounded-md p-1 text-muted-foreground/60 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
688 onClick={closeGitActionModal}
689 disabled={isGitActionRunning}
690 aria-label="Close git action dialog"
691 >
692 <XIcon className="h-5 w-5" />
693 </button>
694 </div>
695 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">
696 <div className="flex items-center justify-between gap-4 text-sm">
697 <span className="text-muted-foreground/70">Branch</span>
698 <span className="font-mono text-foreground">
699 {gitStatus?.branch ?? "(detached HEAD)"}
700 </span>
701 </div>
702 <div className="flex items-center justify-between gap-4 text-sm">
703 <span className="text-muted-foreground/70">Changes</span>
704 <span className="text-foreground">
705 {gitStatus?.hasWorkingTreeChanges
706 ? "Working tree has changes"
707 : "No local changes"}
708 </span>
709 </div>
710 </div>
711 <div className="mt-6">
712 <label
713 htmlFor="git-commit-message"
714 className="mb-2 block text-sm font-medium text-foreground"
715 >
716 Commit message
717 </label>
718 <textarea
719 id="git-commit-message"
720 rows={3}
721 className="w-full resize-none rounded-2xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none disabled:cursor-not-allowed disabled:opacity-70"
722 placeholder="Leave blank to autogenerate a commit message"
723 value={gitModalCommitMessage}
724 onChange={(event) => setGitModalCommitMessage(event.target.value)}
725 disabled={isGitActionRunning}
726 />
727 </div>
728 <div className="mt-6">
729 <p className="text-sm font-medium text-foreground">Next steps</p>
730 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
731 {gitModalActionOptions.map((option, index) => {
732 const borderClass =
733 index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
734 const selected = option.action === gitModalSelectedAction && !option.disabled;
735 const disabled = option.disabled || isGitActionRunning;
736
737 return (
738 <button
739 key={option.action}
740 type="button"
741 className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${
742 selected
743 ? "bg-accent/55"
744 : disabled
745 ? "bg-card/25"
746 : "bg-card/45 hover:bg-accent/35"
747 } disabled:cursor-not-allowed`}
748 disabled={disabled}
749 onClick={() => {
750 setGitModalSelectedAction(option.action);
751 }}
752 >
753 <span className="mt-0.5">
754 <GitActionIcon icon={option.icon} disabled={option.disabled} />
755 </span>
756 <div className="min-w-0 flex-1">
757 <p
758 className={`text-sm ${disabled ? "text-muted-foreground/60" : "text-foreground"}`}
759 >
760 {option.label}
761 </p>
762 {option.detail && (
763 <p
764 className={`mt-0.5 text-xs ${disabled ? "text-muted-foreground/55" : "text-muted-foreground/70"}`}
765 >
766 {option.detail}
767 </p>
768 )}
769 </div>
770 {selected && !disabled ? (
771 <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
772 ) : null}
773 </button>
774 );
775 })}
776 </div>
777 </div>
778 {(gitModalError ?? visibleGitActionError) && (
779 <div className="mt-4 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
780 {gitModalError ?? visibleGitActionError}
781 </div>
782 )}
783 <div className="mt-6 flex justify-end gap-2">
784 <button
785 type="button"
786 className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
787 onClick={closeGitActionModal}
788 disabled={isGitActionRunning}
789 >
790 Cancel
791 </button>
792 <button
793 type="button"
794 className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
795 onClick={() => {
796 void runGitAction();
797 }}
798 disabled={isGitActionRunning || !selectedGitModalActionOption}
799 >
800 {runActionLabel(gitModalSelectedAction)}
801 </button>
802 </div>
803 </div>
804 </div>
805 )}
806
807 {showGitProgressToast && (
808 <div
809 className="fixed bottom-4 right-4 z-[90]"
810 role="status"
811 aria-live="polite"
812 >
813 <div className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-xl border border-border bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
814 <span className="shrink-0">
815 {gitProgressToastStatus === "running" ? (
816 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
817 ) : gitProgressToastStatus === "failed" ? (
818 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
819 ) : (
820 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
821 )}
822 </span>
823 <span className="min-w-0 truncate text-sm text-foreground">{gitProgressToastMessage}</span>
824 {!isGitModalActionRunning && gitModalResult && gitProgressToastOpenPrUrl && (
825 <button
826 type="button"
827 className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
828 onClick={openPrFromToast}
829 disabled={isGitModalActionRunning}
830 >
831 Open PR
832 </button>
833 )}
834 <button
835 type="button"
836 className="rounded p-1 text-muted-foreground/60 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
837 onClick={dismissGitProgressToast}
838 disabled={isGitModalActionRunning}
839 aria-label="Dismiss git action progress toast"
840 >
841 <XIcon className="h-4 w-4" />
842 </button>
843 </div>
844 </div>
845 )}
846 </>
307 {gitStatusError && (
308 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
309 )}
310 </MenuPopup>
311 </Menu>
312 </Group>
847313 );
848314}
apps/web/src/components/Icons.tsx+6
......@@ -13,3 +13,9 @@ export const GitHubIcon: Icon = (props) => (
1313 />
1414 </svg>
1515);
16
17export const CursorIcon: Icon = (props) => (
18 <svg {...props} viewBox="0 0 466.73 532.09" fill="currentColor">
19 <path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
20 </svg>
21);
apps/web/src/components/ui/alert-dialog.tsx created+169
......@@ -0,0 +1,169 @@
1"use client";
2
3import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
4
5import { cn } from "~/lib/utils";
6
7const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle;
8
9const AlertDialog = AlertDialogPrimitive.Root;
10
11const AlertDialogPortal = AlertDialogPrimitive.Portal;
12
13function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
14 return (
15 <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
16 );
17}
18
19function AlertDialogBackdrop({
20 className,
21 ...props
22}: AlertDialogPrimitive.Backdrop.Props) {
23 return (
24 <AlertDialogPrimitive.Backdrop
25 className={cn(
26 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0",
27 className,
28 )}
29 data-slot="alert-dialog-backdrop"
30 {...props}
31 />
32 );
33}
34
35function AlertDialogViewport({
36 className,
37 ...props
38}: AlertDialogPrimitive.Viewport.Props) {
39 return (
40 <AlertDialogPrimitive.Viewport
41 className={cn(
42 "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center p-4",
43 className,
44 )}
45 data-slot="alert-dialog-viewport"
46 {...props}
47 />
48 );
49}
50
51function AlertDialogPopup({
52 className,
53 bottomStickOnMobile = true,
54 ...props
55}: AlertDialogPrimitive.Popup.Props & {
56 bottomStickOnMobile?: boolean;
57}) {
58 return (
59 <AlertDialogPortal>
60 <AlertDialogBackdrop />
61 <AlertDialogViewport
62 className={cn(
63 bottomStickOnMobile &&
64 "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12",
65 )}
66 >
67 <AlertDialogPrimitive.Popup
68 className={cn(
69 "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative row-start-2 flex max-h-full min-h-0 w-full min-w-0 max-w-lg scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border bg-popover not-dark:bg-clip-padding text-popover-foreground opacity-[calc(1-0.1*var(--nested-dialogs))] shadow-lg/5 transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
70 bottomStickOnMobile &&
71 "max-sm:max-w-none max-sm:rounded-none max-sm:border-x-0 max-sm:border-t max-sm:border-b-0 max-sm:opacity-[calc(1-min(var(--nested-dialogs),1))] max-sm:data-ending-style:translate-y-4 max-sm:data-starting-style:translate-y-4 max-sm:before:hidden max-sm:before:rounded-none",
72 className,
73 )}
74 data-slot="alert-dialog-popup"
75 {...props}
76 />
77 </AlertDialogViewport>
78 </AlertDialogPortal>
79 );
80}
81
82function AlertDialogHeader({
83 className,
84 ...props
85}: React.ComponentProps<"div">) {
86 return (
87 <div
88 className={cn(
89 "flex flex-col gap-2 p-6 text-center max-sm:pb-4 sm:text-left",
90 className,
91 )}
92 data-slot="alert-dialog-header"
93 {...props}
94 />
95 );
96}
97
98function AlertDialogFooter({
99 className,
100 variant = "default",
101 ...props
102}: React.ComponentProps<"div"> & {
103 variant?: "default" | "bare";
104}) {
105 return (
106 <div
107 className={cn(
108 "flex flex-col-reverse gap-2 px-6 sm:flex-row sm:justify-end sm:rounded-b-[calc(var(--radius-2xl)-1px)]",
109 variant === "default" && "border-t bg-muted/72 py-4",
110 variant === "bare" && "pb-6",
111 className,
112 )}
113 data-slot="alert-dialog-footer"
114 {...props}
115 />
116 );
117}
118
119function AlertDialogTitle({
120 className,
121 ...props
122}: AlertDialogPrimitive.Title.Props) {
123 return (
124 <AlertDialogPrimitive.Title
125 className={cn(
126 "font-heading font-semibold text-xl leading-none",
127 className,
128 )}
129 data-slot="alert-dialog-title"
130 {...props}
131 />
132 );
133}
134
135function AlertDialogDescription({
136 className,
137 ...props
138}: AlertDialogPrimitive.Description.Props) {
139 return (
140 <AlertDialogPrimitive.Description
141 className={cn("text-muted-foreground text-sm", className)}
142 data-slot="alert-dialog-description"
143 {...props}
144 />
145 );
146}
147
148function AlertDialogClose(props: AlertDialogPrimitive.Close.Props) {
149 return (
150 <AlertDialogPrimitive.Close data-slot="alert-dialog-close" {...props} />
151 );
152}
153
154export {
155 AlertDialogCreateHandle,
156 AlertDialog,
157 AlertDialogPortal,
158 AlertDialogBackdrop,
159 AlertDialogBackdrop as AlertDialogOverlay,
160 AlertDialogTrigger,
161 AlertDialogPopup,
162 AlertDialogPopup as AlertDialogContent,
163 AlertDialogHeader,
164 AlertDialogFooter,
165 AlertDialogTitle,
166 AlertDialogDescription,
167 AlertDialogClose,
168 AlertDialogViewport,
169};
apps/web/src/components/ui/alert.tsx created+80
......@@ -0,0 +1,80 @@
1import { cva, type VariantProps } from "class-variance-authority";
2import type * as React from "react";
3
4import { cn } from "~/lib/utils";
5
6const alertVariants = cva(
7 "relative grid w-full items-start gap-x-2 gap-y-0.5 rounded-xl border px-3.5 py-3 text-card-foreground text-sm has-[>svg]:has-data-[slot=alert-action]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-data-[slot=alert-action]:grid-cols-[1fr_auto] has-[>svg]:gap-x-2 [&>svg]:h-lh [&>svg]:w-4",
8 {
9 defaultVariants: {
10 variant: "default",
11 },
12 variants: {
13 variant: {
14 default:
15 "bg-transparent dark:bg-input/32 [&>svg]:text-muted-foreground",
16 error:
17 "border-destructive/32 bg-destructive/4 [&>svg]:text-destructive",
18 info: "border-info/32 bg-info/4 [&>svg]:text-info",
19 success: "border-success/32 bg-success/4 [&>svg]:text-success",
20 warning: "border-warning/32 bg-warning/4 [&>svg]:text-warning",
21 },
22 },
23 },
24);
25
26function Alert({
27 className,
28 variant,
29 ...props
30}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
31 return (
32 <div
33 className={cn(alertVariants({ variant }), className)}
34 data-slot="alert"
35 role="alert"
36 {...props}
37 />
38 );
39}
40
41function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
42 return (
43 <div
44 className={cn("font-medium [svg~&]:col-start-2", className)}
45 data-slot="alert-title"
46 {...props}
47 />
48 );
49}
50
51function AlertDescription({
52 className,
53 ...props
54}: React.ComponentProps<"div">) {
55 return (
56 <div
57 className={cn(
58 "flex flex-col gap-2.5 text-muted-foreground [svg~&]:col-start-2",
59 className,
60 )}
61 data-slot="alert-description"
62 {...props}
63 />
64 );
65}
66
67function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
68 return (
69 <div
70 className={cn(
71 "flex gap-1 max-sm:col-start-2 max-sm:mt-2 sm:row-start-1 sm:row-end-3 sm:self-center sm:[[data-slot=alert-description]~&]:col-start-2 sm:[[data-slot=alert-title]~&]:col-start-2 sm:[svg~&]:col-start-2 sm:[svg~[data-slot=alert-description]~&]:col-start-3 sm:[svg~[data-slot=alert-title]~&]:col-start-3",
72 className,
73 )}
74 data-slot="alert-action"
75 {...props}
76 />
77 );
78}
79
80export { Alert, AlertTitle, AlertDescription, AlertAction };
apps/web/src/components/ui/autocomplete.tsx created+318
......@@ -0,0 +1,318 @@
1"use client";
2
3import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";
4import { ChevronsUpDownIcon, XIcon } from "lucide-react";
5
6import { cn } from "~/lib/utils";
7import { Input } from "~/components/ui/input";
8import { ScrollArea } from "~/components/ui/scroll-area";
9
10const Autocomplete = AutocompletePrimitive.Root;
11
12function AutocompleteInput({
13 className,
14 showTrigger = false,
15 showClear = false,
16 startAddon,
17 size,
18 ...props
19}: Omit<AutocompletePrimitive.Input.Props, "size"> & {
20 showTrigger?: boolean;
21 showClear?: boolean;
22 startAddon?: React.ReactNode;
23 size?: "sm" | "default" | "lg" | number;
24 ref?: React.Ref<HTMLInputElement>;
25}) {
26 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
27
28 return (
29 <div className="relative not-has-[>*.w-full]:w-fit w-full text-foreground has-disabled:opacity-64">
30 {startAddon && (
31 <div
32 aria-hidden="true"
33 className="[&_svg]:-mx-0.5 pointer-events-none absolute inset-y-0 start-px z-10 flex items-center ps-[calc(--spacing(3)-1px)] opacity-80 has-[+[data-size=sm]]:ps-[calc(--spacing(2.5)-1px)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4"
34 data-slot="autocomplete-start-addon"
35 >
36 {startAddon}
37 </div>
38 )}
39 <AutocompletePrimitive.Input
40 className={cn(
41 startAddon &&
42 "data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=autocomplete-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(8)-1px)]",
43 sizeValue === "sm"
44 ? "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-6.5"
45 : "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-7",
46 className,
47 )}
48 data-slot="autocomplete-input"
49 render={<Input nativeInput size={sizeValue} />}
50 {...props}
51 />
52 {showTrigger && (
53 <AutocompleteTrigger
54 className={cn(
55 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
56 sizeValue === "sm" ? "end-0" : "end-0.5",
57 )}
58 >
59 <AutocompletePrimitive.Icon data-slot="autocomplete-icon">
60 <ChevronsUpDownIcon />
61 </AutocompletePrimitive.Icon>
62 </AutocompleteTrigger>
63 )}
64 {showClear && (
65 <AutocompleteClear
66 className={cn(
67 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
68 sizeValue === "sm" ? "end-0" : "end-0.5",
69 )}
70 >
71 <XIcon />
72 </AutocompleteClear>
73 )}
74 </div>
75 );
76}
77
78function AutocompletePopup({
79 className,
80 children,
81 side = "bottom",
82 sideOffset = 4,
83 alignOffset,
84 align = "start",
85 anchor,
86 ...props
87}: AutocompletePrimitive.Popup.Props & {
88 align?: AutocompletePrimitive.Positioner.Props["align"];
89 sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"];
90 alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"];
91 side?: AutocompletePrimitive.Positioner.Props["side"];
92 anchor?: AutocompletePrimitive.Positioner.Props["anchor"];
93}) {
94 return (
95 <AutocompletePrimitive.Portal>
96 <AutocompletePrimitive.Positioner
97 align={align}
98 alignOffset={alignOffset}
99 anchor={anchor}
100 className="z-50 select-none"
101 data-slot="autocomplete-positioner"
102 side={side}
103 sideOffset={sideOffset}
104 >
105 <span
106 className={cn(
107 "relative flex max-h-full min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
108 className,
109 )}
110 >
111 <AutocompletePrimitive.Popup
112 className="flex max-h-[min(var(--available-height),23rem)] flex-1 flex-col text-foreground"
113 data-slot="autocomplete-popup"
114 {...props}
115 >
116 {children}
117 </AutocompletePrimitive.Popup>
118 </span>
119 </AutocompletePrimitive.Positioner>
120 </AutocompletePrimitive.Portal>
121 );
122}
123
124function AutocompleteItem({
125 className,
126 children,
127 ...props
128}: AutocompletePrimitive.Item.Props) {
129 return (
130 <AutocompletePrimitive.Item
131 className={cn(
132 "flex min-h-8 cursor-default select-none items-center rounded-sm px-2 py-1 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm",
133 className,
134 )}
135 data-slot="autocomplete-item"
136 {...props}
137 >
138 {children}
139 </AutocompletePrimitive.Item>
140 );
141}
142
143function AutocompleteSeparator({
144 className,
145 ...props
146}: AutocompletePrimitive.Separator.Props) {
147 return (
148 <AutocompletePrimitive.Separator
149 className={cn("mx-2 my-1 h-px bg-border last:hidden", className)}
150 data-slot="autocomplete-separator"
151 {...props}
152 />
153 );
154}
155
156function AutocompleteGroup({
157 className,
158 ...props
159}: AutocompletePrimitive.Group.Props) {
160 return (
161 <AutocompletePrimitive.Group
162 className={cn("[[role=group]+&]:mt-1.5", className)}
163 data-slot="autocomplete-group"
164 {...props}
165 />
166 );
167}
168
169function AutocompleteGroupLabel({
170 className,
171 ...props
172}: AutocompletePrimitive.GroupLabel.Props) {
173 return (
174 <AutocompletePrimitive.GroupLabel
175 className={cn(
176 "px-2 py-1.5 font-medium text-muted-foreground text-xs",
177 className,
178 )}
179 data-slot="autocomplete-group-label"
180 {...props}
181 />
182 );
183}
184
185function AutocompleteEmpty({
186 className,
187 ...props
188}: AutocompletePrimitive.Empty.Props) {
189 return (
190 <AutocompletePrimitive.Empty
191 className={cn(
192 "not-empty:p-2 text-center text-base text-muted-foreground sm:text-sm",
193 className,
194 )}
195 data-slot="autocomplete-empty"
196 {...props}
197 />
198 );
199}
200
201function AutocompleteRow({
202 className,
203 ...props
204}: AutocompletePrimitive.Row.Props) {
205 return (
206 <AutocompletePrimitive.Row
207 className={className}
208 data-slot="autocomplete-row"
209 {...props}
210 />
211 );
212}
213
214function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
215 return (
216 <AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
217 );
218}
219
220function AutocompleteList({
221 className,
222 ...props
223}: AutocompletePrimitive.List.Props) {
224 return (
225 <ScrollArea scrollbarGutter scrollFade>
226 <AutocompletePrimitive.List
227 className={cn(
228 "not-empty:scroll-py-1 not-empty:p-1 in-data-has-overflow-y:pe-3",
229 className,
230 )}
231 data-slot="autocomplete-list"
232 {...props}
233 />
234 </ScrollArea>
235 );
236}
237
238function AutocompleteClear({
239 className,
240 ...props
241}: AutocompletePrimitive.Clear.Props) {
242 return (
243 <AutocompletePrimitive.Clear
244 className={cn(
245 "-translate-y-1/2 absolute end-0.5 top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-[color,background-color,box-shadow,opacity] pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
246 className,
247 )}
248 data-slot="autocomplete-clear"
249 {...props}
250 >
251 <XIcon />
252 </AutocompletePrimitive.Clear>
253 );
254}
255
256function AutocompleteStatus({
257 className,
258 ...props
259}: AutocompletePrimitive.Status.Props) {
260 return (
261 <AutocompletePrimitive.Status
262 className={cn(
263 "px-3 py-2 font-medium text-muted-foreground text-xs empty:m-0 empty:p-0",
264 className,
265 )}
266 data-slot="autocomplete-status"
267 {...props}
268 />
269 );
270}
271
272function AutocompleteCollection({
273 ...props
274}: AutocompletePrimitive.Collection.Props) {
275 return (
276 <AutocompletePrimitive.Collection
277 data-slot="autocomplete-collection"
278 {...props}
279 />
280 );
281}
282
283function AutocompleteTrigger({
284 className,
285 children,
286 ...props
287}: AutocompletePrimitive.Trigger.Props) {
288 return (
289 <AutocompletePrimitive.Trigger
290 className={className}
291 data-slot="autocomplete-trigger"
292 {...props}
293 >
294 {children}
295 </AutocompletePrimitive.Trigger>
296 );
297}
298
299const useAutocompleteFilter = AutocompletePrimitive.useFilter;
300
301export {
302 Autocomplete,
303 AutocompleteInput,
304 AutocompleteTrigger,
305 AutocompletePopup,
306 AutocompleteItem,
307 AutocompleteSeparator,
308 AutocompleteGroup,
309 AutocompleteGroupLabel,
310 AutocompleteEmpty,
311 AutocompleteValue,
312 AutocompleteList,
313 AutocompleteClear,
314 AutocompleteStatus,
315 AutocompleteRow,
316 AutocompleteCollection,
317 useAutocompleteFilter,
318};
apps/web/src/components/ui/badge.tsx created+60
......@@ -0,0 +1,60 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5import { cva, type VariantProps } from "class-variance-authority";
6
7import { cn } from "~/lib/utils";
8
9const badgeVariants = cva(
10 "relative inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-sm border border-transparent font-medium outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-3.5 sm:[&_svg:not([class*='size-'])]:size-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [button&,a&]:cursor-pointer [button&,a&]:pointer-coarse:after:absolute [button&,a&]:pointer-coarse:after:size-full [button&,a&]:pointer-coarse:after:min-h-11 [button&,a&]:pointer-coarse:after:min-w-11",
11 {
12 defaultVariants: {
13 size: "default",
14 variant: "default",
15 },
16 variants: {
17 size: {
18 default:
19 "h-5.5 min-w-5.5 px-[calc(--spacing(1)-1px)] text-sm sm:h-4.5 sm:min-w-4.5 sm:text-xs",
20 lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm",
21 sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs sm:h-4 sm:min-w-4 sm:text-[.625rem]",
22 },
23 variant: {
24 default:
25 "bg-primary text-primary-foreground [button&,a&]:hover:bg-primary/90",
26 destructive:
27 "bg-destructive text-white [button&,a&]:hover:bg-destructive/90",
28 error:
29 "bg-destructive/8 text-destructive-foreground dark:bg-destructive/16",
30 info: "bg-info/8 text-info-foreground dark:bg-info/16",
31 outline:
32 "border-input bg-background text-foreground dark:bg-input/32 [button&,a&]:hover:bg-accent/50 dark:[button&,a&]:hover:bg-input/48",
33 secondary:
34 "bg-secondary text-secondary-foreground [button&,a&]:hover:bg-secondary/90",
35 success: "bg-success/8 text-success-foreground dark:bg-success/16",
36 warning: "bg-warning/8 text-warning-foreground dark:bg-warning/16",
37 },
38 },
39 },
40);
41
42interface BadgeProps extends useRender.ComponentProps<"span"> {
43 variant?: VariantProps<typeof badgeVariants>["variant"];
44 size?: VariantProps<typeof badgeVariants>["size"];
45}
46
47function Badge({ className, variant, size, render, ...props }: BadgeProps) {
48 const defaultProps = {
49 className: cn(badgeVariants({ className, size, variant })),
50 "data-slot": "badge",
51 };
52
53 return useRender({
54 defaultTagName: "span",
55 props: mergeProps<"span">(defaultProps, props),
56 render,
57 });
58}
59
60export { Badge, badgeVariants };
apps/web/src/components/ui/button.tsx created+74
......@@ -0,0 +1,74 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5import { cva, type VariantProps } from "class-variance-authority";
6import type * as React from "react";
7
8import { cn } from "~/lib/utils";
9
10const buttonVariants = cva(
11 "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
12 {
13 defaultVariants: {
14 size: "default",
15 variant: "default",
16 },
17 variants: {
18 size: {
19 default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8",
20 icon: "size-9 sm:size-8",
21 "icon-lg": "size-10 sm:size-9",
22 "icon-sm": "size-8 sm:size-7",
23 "icon-xl":
24 "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
25 "icon-xs":
26 "size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5",
27 lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",
28 sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",
29 xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
30 xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",
31 },
32 variant: {
33 default:
34 "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-primary/90",
35 destructive:
36 "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-destructive/90",
37 "destructive-outline":
38 "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4",
39 ghost:
40 "border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent",
41 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
42 outline:
43 "border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",
44 secondary:
45 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
46 },
47 },
48 },
49);
50
51interface ButtonProps extends useRender.ComponentProps<"button"> {
52 variant?: VariantProps<typeof buttonVariants>["variant"];
53 size?: VariantProps<typeof buttonVariants>["size"];
54}
55
56function Button({ className, variant, size, render, ...props }: ButtonProps) {
57 const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>["type"] = render
58 ? undefined
59 : "button";
60
61 const defaultProps = {
62 className: cn(buttonVariants({ className, size, variant })),
63 "data-slot": "button",
64 type: typeValue,
65 };
66
67 return useRender({
68 defaultTagName: "button",
69 props: mergeProps<"button">(defaultProps, props),
70 render,
71 });
72}
73
74export { Button, buttonVariants };
apps/web/src/components/ui/card.tsx created+244
......@@ -0,0 +1,244 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5
6import { cn } from "~/lib/utils";
7
8function Card({
9 className,
10 render,
11 ...props
12}: useRender.ComponentProps<"div">) {
13 const defaultProps = {
14 className: cn(
15 "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
16 className,
17 ),
18 "data-slot": "card",
19 };
20
21 return useRender({
22 defaultTagName: "div",
23 props: mergeProps<"div">(defaultProps, props),
24 render,
25 });
26}
27
28function CardFrame({
29 className,
30 render,
31 ...props
32}: useRender.ComponentProps<"div">) {
33 const defaultProps = {
34 className: cn(
35 "[--clip-top:-1rem] [--clip-bottom:-1rem] *:data-[slot=card]:first:[--clip-top:1px] *:data-[slot=card]:last:[--clip-bottom:1px] flex flex-col relative rounded-2xl border bg-card before:bg-muted/72 not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=card]:-m-px *:not-last:data-[slot=card]:rounded-b-xl *:not-last:data-[slot=card]:before:rounded-b-[calc(var(--radius-xl)-1px)] *:not-first:data-[slot=card]:rounded-t-xl *:not-first:data-[slot=card]:before:rounded-t-[calc(var(--radius-xl)-1px)] *:data-[slot=card]:[clip-path:inset(var(--clip-top)_1px_var(--clip-bottom)_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:shadow-none *:data-[slot=card]:before:hidden *:data-[slot=card]:bg-clip-padding",
36 className,
37 ),
38 "data-slot": "card-frame",
39 };
40
41 return useRender({
42 defaultTagName: "div",
43 props: mergeProps<"div">(defaultProps, props),
44 render,
45 });
46}
47
48function CardFrameHeader({
49 className,
50 render,
51 ...props
52}: useRender.ComponentProps<"div">) {
53 const defaultProps = {
54 className: cn("relative flex flex-col px-6 py-4", className),
55 "data-slot": "card-frame-header",
56 };
57
58 return useRender({
59 defaultTagName: "div",
60 props: mergeProps<"div">(defaultProps, props),
61 render,
62 });
63}
64
65function CardFrameTitle({
66 className,
67 render,
68 ...props
69}: useRender.ComponentProps<"div">) {
70 const defaultProps = {
71 className: cn("font-semibold text-sm", className),
72 "data-slot": "card-frame-title",
73 };
74
75 return useRender({
76 defaultTagName: "div",
77 props: mergeProps<"div">(defaultProps, props),
78 render,
79 });
80}
81
82function CardFrameDescription({
83 className,
84 render,
85 ...props
86}: useRender.ComponentProps<"div">) {
87 const defaultProps = {
88 className: cn("text-muted-foreground text-sm", className),
89 "data-slot": "card-frame-description",
90 };
91
92 return useRender({
93 defaultTagName: "div",
94 props: mergeProps<"div">(defaultProps, props),
95 render,
96 });
97}
98
99function CardFrameFooter({
100 className,
101 render,
102 ...props
103}: useRender.ComponentProps<"div">) {
104 const defaultProps = {
105 className: cn("px-6 py-4", className),
106 "data-slot": "card-frame-footer",
107 };
108
109 return useRender({
110 defaultTagName: "div",
111 props: mergeProps<"div">(defaultProps, props),
112 render,
113 });
114}
115
116function CardHeader({
117 className,
118 render,
119 ...props
120}: useRender.ComponentProps<"div">) {
121 const defaultProps = {
122 className: cn(
123 "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
124 className,
125 ),
126 "data-slot": "card-header",
127 };
128
129 return useRender({
130 defaultTagName: "div",
131 props: mergeProps<"div">(defaultProps, props),
132 render,
133 });
134}
135
136function CardTitle({
137 className,
138 render,
139 ...props
140}: useRender.ComponentProps<"div">) {
141 const defaultProps = {
142 className: cn("font-semibold text-lg leading-none", className),
143 "data-slot": "card-title",
144 };
145
146 return useRender({
147 defaultTagName: "div",
148 props: mergeProps<"div">(defaultProps, props),
149 render,
150 });
151}
152
153function CardDescription({
154 className,
155 render,
156 ...props
157}: useRender.ComponentProps<"div">) {
158 const defaultProps = {
159 className: cn("text-muted-foreground text-sm", className),
160 "data-slot": "card-description",
161 };
162
163 return useRender({
164 defaultTagName: "div",
165 props: mergeProps<"div">(defaultProps, props),
166 render,
167 });
168}
169
170function CardAction({
171 className,
172 render,
173 ...props
174}: useRender.ComponentProps<"div">) {
175 const defaultProps = {
176 className: cn(
177 "col-start-2 row-span-2 row-start-1 self-start justify-self-end inline-flex",
178 className,
179 ),
180 "data-slot": "card-action",
181 };
182
183 return useRender({
184 defaultTagName: "div",
185 props: mergeProps<"div">(defaultProps, props),
186 render,
187 });
188}
189
190function CardPanel({
191 className,
192 render,
193 ...props
194}: useRender.ComponentProps<"div">) {
195 const defaultProps = {
196 className: cn(
197 "flex-1 p-6 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0",
198 className,
199 ),
200 "data-slot": "card-panel",
201 };
202
203 return useRender({
204 defaultTagName: "div",
205 props: mergeProps<"div">(defaultProps, props),
206 render,
207 });
208}
209
210function CardFooter({
211 className,
212 render,
213 ...props
214}: useRender.ComponentProps<"div">) {
215 const defaultProps = {
216 className: cn(
217 "flex items-center p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pt-4",
218 className,
219 ),
220 "data-slot": "card-footer",
221 };
222
223 return useRender({
224 defaultTagName: "div",
225 props: mergeProps<"div">(defaultProps, props),
226 render,
227 });
228}
229
230export {
231 Card,
232 CardFrame,
233 CardFrameHeader,
234 CardFrameTitle,
235 CardFrameDescription,
236 CardFrameFooter,
237 CardAction,
238 CardDescription,
239 CardFooter,
240 CardHeader,
241 CardPanel,
242 CardPanel as CardContent,
243 CardTitle,
244};
apps/web/src/components/ui/checkbox.tsx created+60
......@@ -0,0 +1,60 @@
1"use client";
2
3import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
4
5import { cn } from "~/lib/utils";
6
7function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
8 return (
9 <CheckboxPrimitive.Root
10 className={cn(
11 "relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-[.25rem] border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[3px] not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
12 className,
13 )}
14 data-slot="checkbox"
15 {...props}
16 >
17 <CheckboxPrimitive.Indicator
18 className="-inset-px absolute flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground"
19 data-slot="checkbox-indicator"
20 render={(props, state) => (
21 <span {...props}>
22 {state.indeterminate ? (
23 <svg
24 className="size-3.5 sm:size-3"
25 fill="none"
26 height="24"
27 stroke="currentColor"
28 strokeLinecap="round"
29 strokeLinejoin="round"
30 strokeWidth="3"
31 viewBox="0 0 24 24"
32 width="24"
33 xmlns="http://www.w3.org/2000/svg"
34 >
35 <path d="M5.252 12h13.496" />
36 </svg>
37 ) : (
38 <svg
39 className="size-3.5 sm:size-3"
40 fill="none"
41 height="24"
42 stroke="currentColor"
43 strokeLinecap="round"
44 strokeLinejoin="round"
45 strokeWidth="3"
46 viewBox="0 0 24 24"
47 width="24"
48 xmlns="http://www.w3.org/2000/svg"
49 >
50 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
51 </svg>
52 )}
53 </span>
54 )}
55 />
56 </CheckboxPrimitive.Root>
57 );
58}
59
60export { Checkbox };
apps/web/src/components/ui/combobox.tsx created+372
......@@ -0,0 +1,372 @@
1"use client";
2
3import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
4import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react";
5import * as React from "react";
6
7import { cn } from "~/lib/utils";
8import { Input } from "~/components/ui/input";
9import { ScrollArea } from "~/components/ui/scroll-area";
10
11const ComboboxContext = React.createContext<{
12 chipsRef: React.RefObject<Element | null> | null;
13 multiple: boolean;
14}>({
15 chipsRef: null,
16 multiple: false,
17});
18
19function Combobox<Value, Multiple extends boolean | undefined = false>(
20 props: ComboboxPrimitive.Root.Props<Value, Multiple>,
21) {
22 const chipsRef = React.useRef<Element | null>(null);
23 return (
24 <ComboboxContext.Provider value={{ chipsRef, multiple: !!props.multiple }}>
25 <ComboboxPrimitive.Root {...props} />
26 </ComboboxContext.Provider>
27 );
28}
29
30function ComboboxChipsInput({
31 className,
32 size,
33 ...props
34}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
35 size?: "sm" | "default" | "lg" | number;
36 ref?: React.Ref<HTMLInputElement>;
37}) {
38 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
39
40 return (
41 <ComboboxPrimitive.Input
42 className={cn(
43 "min-w-12 flex-1 text-base outline-none sm:text-sm [[data-slot=combobox-chip]+&]:ps-0.5",
44 sizeValue === "sm" ? "ps-1.5" : "ps-2",
45 className,
46 )}
47 data-size={typeof sizeValue === "string" ? sizeValue : undefined}
48 data-slot="combobox-chips-input"
49 size={typeof sizeValue === "number" ? sizeValue : undefined}
50 {...props}
51 />
52 );
53}
54
55function ComboboxInput({
56 className,
57 showTrigger = true,
58 showClear = false,
59 startAddon,
60 size,
61 ...props
62}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
63 showTrigger?: boolean;
64 showClear?: boolean;
65 startAddon?: React.ReactNode;
66 size?: "sm" | "default" | "lg" | number;
67 ref?: React.Ref<HTMLInputElement>;
68}) {
69 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
70
71 return (
72 <div className="relative not-has-[>*.w-full]:w-fit w-full text-foreground has-disabled:opacity-64">
73 {startAddon && (
74 <div
75 aria-hidden="true"
76 className="[&_svg]:-mx-0.5 pointer-events-none absolute inset-y-0 start-px z-10 flex items-center ps-[calc(--spacing(3)-1px)] opacity-80 has-[+[data-size=sm]]:ps-[calc(--spacing(2.5)-1px)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4"
77 data-slot="combobox-start-addon"
78 >
79 {startAddon}
80 </div>
81 )}
82 <ComboboxPrimitive.Input
83 className={cn(
84 startAddon &&
85 "data-[size=sm]:*:data-[slot=combobox-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=combobox-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=combobox-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=combobox-input]:ps-[calc(--spacing(8)-1px)]",
86 sizeValue === "sm"
87 ? "has-[+[data-slot=combobox-trigger],+[data-slot=combobox-clear]]:*:data-[slot=combobox-input]:pe-6.5"
88 : "has-[+[data-slot=combobox-trigger],+[data-slot=combobox-clear]]:*:data-[slot=combobox-input]:pe-7",
89 className,
90 )}
91 data-slot="combobox-input"
92 render={<Input className="has-disabled:opacity-100" nativeInput size={sizeValue} />}
93 {...props}
94 />
95 {showTrigger && (
96 <ComboboxTrigger
97 className={cn(
98 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-opacity pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=combobox-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
99 sizeValue === "sm" ? "end-0" : "end-0.5",
100 )}
101 >
102 <ComboboxPrimitive.Icon data-slot="combobox-icon">
103 <ChevronsUpDownIcon />
104 </ComboboxPrimitive.Icon>
105 </ComboboxTrigger>
106 )}
107 {showClear && (
108 <ComboboxClear
109 className={cn(
110 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-opacity pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=combobox-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
111 sizeValue === "sm" ? "end-0" : "end-0.5",
112 )}
113 >
114 <XIcon />
115 </ComboboxClear>
116 )}
117 </div>
118 );
119}
120
121function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
122 return (
123 <ComboboxPrimitive.Trigger className={className} data-slot="combobox-trigger" {...props}>
124 {children}
125 </ComboboxPrimitive.Trigger>
126 );
127}
128
129function ComboboxPopup({
130 className,
131 children,
132 side = "bottom",
133 sideOffset = 4,
134 alignOffset,
135 align = "start",
136 anchor: anchorProp,
137 ...props
138}: ComboboxPrimitive.Popup.Props & {
139 align?: ComboboxPrimitive.Positioner.Props["align"];
140 sideOffset?: ComboboxPrimitive.Positioner.Props["sideOffset"];
141 alignOffset?: ComboboxPrimitive.Positioner.Props["alignOffset"];
142 side?: ComboboxPrimitive.Positioner.Props["side"];
143 anchor?: ComboboxPrimitive.Positioner.Props["anchor"];
144}) {
145 const { chipsRef } = React.useContext(ComboboxContext);
146 const anchor = anchorProp ?? chipsRef;
147
148 return (
149 <ComboboxPrimitive.Portal>
150 <ComboboxPrimitive.Positioner
151 align={align}
152 alignOffset={alignOffset}
153 anchor={anchor}
154 className="z-50 select-none"
155 data-slot="combobox-positioner"
156 side={side}
157 sideOffset={sideOffset}
158 >
159 <span
160 className={cn(
161 "relative flex max-h-full min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
162 className,
163 )}
164 >
165 <ComboboxPrimitive.Popup
166 className="flex max-h-[min(var(--available-height),23rem)] flex-1 flex-col text-foreground"
167 data-slot="combobox-popup"
168 {...props}
169 >
170 {children}
171 </ComboboxPrimitive.Popup>
172 </span>
173 </ComboboxPrimitive.Positioner>
174 </ComboboxPrimitive.Portal>
175 );
176}
177
178function ComboboxItem({
179 className,
180 children,
181 hideIndicator = false,
182 ...props
183}: ComboboxPrimitive.Item.Props & {
184 hideIndicator?: boolean;
185}) {
186 return (
187 <ComboboxPrimitive.Item
188 className={cn(
189 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default grid-cols-[1rem_1fr] items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
190 className,
191 )}
192 data-slot="combobox-item"
193 {...props}
194 >
195 <ComboboxPrimitive.ItemIndicator className={cn("col-start-1", hideIndicator && "hidden")}>
196 <CheckIcon />
197 </ComboboxPrimitive.ItemIndicator>
198 <div className={hideIndicator ? "col-start-1 col-span-full" : "col-start-2"}>{children}</div>
199 </ComboboxPrimitive.Item>
200 );
201}
202
203function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
204 return (
205 <ComboboxPrimitive.Separator
206 className={cn("mx-2 my-1 h-px bg-border last:hidden", className)}
207 data-slot="combobox-separator"
208 {...props}
209 />
210 );
211}
212
213function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
214 return (
215 <ComboboxPrimitive.Group
216 className={cn("[[role=group]+&]:mt-1.5", className)}
217 data-slot="combobox-group"
218 {...props}
219 />
220 );
221}
222
223function ComboboxGroupLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
224 return (
225 <ComboboxPrimitive.GroupLabel
226 className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs", className)}
227 data-slot="combobox-group-label"
228 {...props}
229 />
230 );
231}
232
233function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
234 return (
235 <ComboboxPrimitive.Empty
236 className={cn(
237 "not-empty:p-2 text-center text-base text-muted-foreground sm:text-sm",
238 className,
239 )}
240 data-slot="combobox-empty"
241 {...props}
242 />
243 );
244}
245
246function ComboboxRow({ className, ...props }: ComboboxPrimitive.Row.Props) {
247 return <ComboboxPrimitive.Row className={className} data-slot="combobox-row" {...props} />;
248}
249
250function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
251 return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
252}
253
254function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
255 return (
256 <ScrollArea scrollbarGutter scrollFade>
257 <ComboboxPrimitive.List
258 className={cn(
259 "not-empty:scroll-py-1 not-empty:px-1 not-empty:py-1 in-data-has-overflow-y:pe-3",
260 className,
261 )}
262 data-slot="combobox-list"
263 {...props}
264 />
265 </ScrollArea>
266 );
267}
268
269function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
270 return <ComboboxPrimitive.Clear className={className} data-slot="combobox-clear" {...props} />;
271}
272
273function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props) {
274 return (
275 <ComboboxPrimitive.Status
276 className={cn(
277 "px-3 py-2 font-medium text-muted-foreground text-xs empty:m-0 empty:p-0",
278 className,
279 )}
280 data-slot="combobox-status"
281 {...props}
282 />
283 );
284}
285
286function ComboboxCollection(props: ComboboxPrimitive.Collection.Props) {
287 return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
288}
289
290function ComboboxChips({
291 className,
292 children,
293 startAddon,
294 ...props
295}: ComboboxPrimitive.Chips.Props & {
296 startAddon?: React.ReactNode;
297}) {
298 const { chipsRef } = React.useContext(ComboboxContext);
299
300 return (
301 <ComboboxPrimitive.Chips
302 className={cn(
303 "relative inline-flex min-h-9 w-full flex-wrap gap-1 rounded-lg border border-input bg-background not-dark:bg-clip-padding p-[calc(--spacing(1)-1px)] text-base shadow-xs/5 outline-none ring-ring/24 transition-shadow *:min-h-7 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-focus-within:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-within:border-ring focus-within:ring-[3px] has-disabled:pointer-events-none has-data-[size=lg]:min-h-10 has-data-[size=sm]:min-h-8 has-aria-invalid:border-destructive/36 has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-within,[aria-invalid]]:shadow-none focus-within:has-aria-invalid:border-destructive/64 focus-within:has-aria-invalid:ring-destructive/16 has-data-[size=lg]:*:min-h-8 has-data-[size=sm]:*:min-h-6 sm:min-h-8 sm:text-sm sm:has-data-[size=lg]:min-h-9 sm:has-data-[size=sm]:min-h-7 sm:*:min-h-6 sm:has-data-[size=lg]:*:min-h-7 sm:has-data-[size=sm]:*:min-h-5 dark:not-has-disabled:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-focus-within:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
304 className,
305 )}
306 data-slot="combobox-chips"
307 ref={chipsRef as React.Ref<HTMLDivElement> | null}
308 {...props}
309 >
310 {startAddon && (
311 <div
312 aria-hidden="true"
313 className="[&_svg]:-ms-0.5 [&_svg]:-me-1.5 flex shrink-0 items-center ps-2 opacity-80 has-[~[data-size=sm]]:has-[+[data-slot=combobox-chip]]:pe-1.5 has-[~[data-size=sm]]:ps-1.5 has-[+[data-slot=combobox-chip]]:pe-2 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none"
314 data-slot="combobox-start-addon"
315 >
316 {startAddon}
317 </div>
318 )}
319 {children}
320 </ComboboxPrimitive.Chips>
321 );
322}
323
324function ComboboxChip({ children, ...props }: ComboboxPrimitive.Chip.Props) {
325 return (
326 <ComboboxPrimitive.Chip
327 className="flex items-center rounded-[calc(var(--radius-md)-1px)] bg-accent ps-2 font-medium text-accent-foreground text-sm outline-none sm:text-xs/(--text-xs--line-height) [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5"
328 data-slot="combobox-chip"
329 {...props}
330 >
331 {children}
332 <ComboboxChipRemove />
333 </ComboboxPrimitive.Chip>
334 );
335}
336
337function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props) {
338 return (
339 <ComboboxPrimitive.ChipRemove
340 aria-label="Remove"
341 className="h-full shrink-0 cursor-pointer px-1.5 opacity-80 hover:opacity-100 [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5"
342 data-slot="combobox-chip-remove"
343 {...props}
344 >
345 <XIcon />
346 </ComboboxPrimitive.ChipRemove>
347 );
348}
349
350const useComboboxFilter = ComboboxPrimitive.useFilter;
351
352export {
353 Combobox,
354 ComboboxChipsInput,
355 ComboboxInput,
356 ComboboxTrigger,
357 ComboboxPopup,
358 ComboboxItem,
359 ComboboxSeparator,
360 ComboboxGroup,
361 ComboboxGroupLabel,
362 ComboboxEmpty,
363 ComboboxValue,
364 ComboboxList,
365 ComboboxClear,
366 ComboboxStatus,
367 ComboboxRow,
368 ComboboxCollection,
369 ComboboxChips,
370 ComboboxChip,
371 useComboboxFilter,
372};
apps/web/src/components/ui/command.tsx created+264
......@@ -0,0 +1,264 @@
1"use client";
2
3import { Dialog as CommandDialogPrimitive } from "@base-ui/react/dialog";
4import { SearchIcon } from "lucide-react";
5import type * as React from "react";
6import { cn } from "~/lib/utils";
7import {
8 Autocomplete,
9 AutocompleteCollection,
10 AutocompleteEmpty,
11 AutocompleteGroup,
12 AutocompleteGroupLabel,
13 AutocompleteInput,
14 AutocompleteItem,
15 AutocompleteList,
16 AutocompleteSeparator,
17} from "~/components/ui/autocomplete";
18
19const CommandDialog = CommandDialogPrimitive.Root;
20
21const CommandDialogPortal = CommandDialogPrimitive.Portal;
22
23const CommandCreateHandle = CommandDialogPrimitive.createHandle;
24
25function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) {
26 return (
27 <CommandDialogPrimitive.Trigger
28 data-slot="command-dialog-trigger"
29 {...props}
30 />
31 );
32}
33
34function CommandDialogBackdrop({
35 className,
36 ...props
37}: CommandDialogPrimitive.Backdrop.Props) {
38 return (
39 <CommandDialogPrimitive.Backdrop
40 className={cn(
41 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
42 className,
43 )}
44 data-slot="command-dialog-backdrop"
45 {...props}
46 />
47 );
48}
49
50function CommandDialogViewport({
51 className,
52 ...props
53}: CommandDialogPrimitive.Viewport.Props) {
54 return (
55 <CommandDialogPrimitive.Viewport
56 className={cn(
57 "fixed inset-0 z-50 flex flex-col items-center px-4 py-[max(--spacing(4),4vh)] sm:py-[10vh]",
58 className,
59 )}
60 data-slot="command-dialog-viewport"
61 {...props}
62 />
63 );
64}
65
66function CommandDialogPopup({
67 className,
68 children,
69 ...props
70}: CommandDialogPrimitive.Popup.Props) {
71 return (
72 <CommandDialogPortal>
73 <CommandDialogBackdrop />
74 <CommandDialogViewport>
75 <CommandDialogPrimitive.Popup
76 className={cn(
77 "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative row-start-2 flex max-h-105 min-h-0 w-full min-w-0 max-w-xl scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border bg-popover not-dark:bg-clip-padding text-popover-foreground opacity-[calc(1-0.1*var(--nested-dialogs))] shadow-lg/5 outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:bg-muted/72 before:shadow-[0_1px_--theme(--color-black/4%)] data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=scroll-area-viewport]:data-has-overflow-y:pe-1 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
78 className,
79 )}
80 data-slot="command-dialog-popup"
81 {...props}
82 >
83 {children}
84 </CommandDialogPrimitive.Popup>
85 </CommandDialogViewport>
86 </CommandDialogPortal>
87 );
88}
89
90function Command({
91 autoHighlight = "always",
92 keepHighlight = true,
93 ...props
94}: React.ComponentProps<typeof Autocomplete>) {
95 return (
96 <Autocomplete
97 autoHighlight={autoHighlight}
98 inline
99 keepHighlight={keepHighlight}
100 open
101 {...props}
102 />
103 );
104}
105
106function CommandInput({
107 className,
108 placeholder = undefined,
109 ...props
110}: React.ComponentProps<typeof AutocompleteInput>) {
111 return (
112 <div className="px-2.5 py-1.5">
113 <AutocompleteInput
114 autoFocus
115 className={cn(
116 "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0",
117 className,
118 )}
119 placeholder={placeholder}
120 size="lg"
121 startAddon={<SearchIcon />}
122 {...props}
123 />
124 </div>
125 );
126}
127
128function CommandList({
129 className,
130 ...props
131}: React.ComponentProps<typeof AutocompleteList>) {
132 return (
133 <AutocompleteList
134 className={cn("not-empty:scroll-py-2 not-empty:p-2", className)}
135 data-slot="command-list"
136 {...props}
137 />
138 );
139}
140
141function CommandEmpty({
142 className,
143 ...props
144}: React.ComponentProps<typeof AutocompleteEmpty>) {
145 return (
146 <AutocompleteEmpty
147 className={cn("not-empty:py-6", className)}
148 data-slot="command-empty"
149 {...props}
150 />
151 );
152}
153
154function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
155 return (
156 <div
157 className="-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2"
158 {...props}
159 />
160 );
161}
162
163function CommandGroup({
164 className,
165 ...props
166}: React.ComponentProps<typeof AutocompleteGroup>) {
167 return (
168 <AutocompleteGroup
169 className={className}
170 data-slot="command-group"
171 {...props}
172 />
173 );
174}
175
176function CommandGroupLabel({
177 className,
178 ...props
179}: React.ComponentProps<typeof AutocompleteGroupLabel>) {
180 return (
181 <AutocompleteGroupLabel
182 className={className}
183 data-slot="command-group-label"
184 {...props}
185 />
186 );
187}
188
189function CommandCollection({
190 ...props
191}: React.ComponentProps<typeof AutocompleteCollection>) {
192 return <AutocompleteCollection data-slot="command-collection" {...props} />;
193}
194
195function CommandItem({
196 className,
197 ...props
198}: React.ComponentProps<typeof AutocompleteItem>) {
199 return (
200 <AutocompleteItem
201 className={cn("py-1.5", className)}
202 data-slot="command-item"
203 {...props}
204 />
205 );
206}
207
208function CommandSeparator({
209 className,
210 ...props
211}: React.ComponentProps<typeof AutocompleteSeparator>) {
212 return (
213 <AutocompleteSeparator
214 className={cn("my-2", className)}
215 data-slot="command-separator"
216 {...props}
217 />
218 );
219}
220
221function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
222 return (
223 <kbd
224 className={cn(
225 "ms-auto font-medium font-sans text-muted-foreground/72 text-xs tracking-widest",
226 className,
227 )}
228 data-slot="command-shortcut"
229 {...props}
230 />
231 );
232}
233
234function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {
235 return (
236 <div
237 className={cn(
238 "flex items-center justify-between gap-2 rounded-b-[calc(var(--radius-2xl)-1px)] border-t px-5 py-3 text-muted-foreground text-xs",
239 className,
240 )}
241 data-slot="command-footer"
242 {...props}
243 />
244 );
245}
246
247export {
248 CommandCreateHandle,
249 Command,
250 CommandCollection,
251 CommandDialog,
252 CommandDialogPopup,
253 CommandDialogTrigger,
254 CommandEmpty,
255 CommandFooter,
256 CommandGroup,
257 CommandGroupLabel,
258 CommandInput,
259 CommandItem,
260 CommandList,
261 CommandPanel,
262 CommandSeparator,
263 CommandShortcut,
264};
apps/web/src/components/ui/dialog.tsx created+196
......@@ -0,0 +1,196 @@
1"use client";
2
3import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
4import { XIcon } from "lucide-react";
5import { cn } from "~/lib/utils";
6import { Button } from "~/components/ui/button";
7import { ScrollArea } from "~/components/ui/scroll-area";
8
9const DialogCreateHandle = DialogPrimitive.createHandle;
10
11const Dialog = DialogPrimitive.Root;
12
13const DialogPortal = DialogPrimitive.Portal;
14
15function DialogTrigger(props: DialogPrimitive.Trigger.Props) {
16 return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
17}
18
19function DialogClose(props: DialogPrimitive.Close.Props) {
20 return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
21}
22
23function DialogBackdrop({
24 className,
25 ...props
26}: DialogPrimitive.Backdrop.Props) {
27 return (
28 <DialogPrimitive.Backdrop
29 className={cn(
30 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
31 className,
32 )}
33 data-slot="dialog-backdrop"
34 {...props}
35 />
36 );
37}
38
39function DialogViewport({
40 className,
41 ...props
42}: DialogPrimitive.Viewport.Props) {
43 return (
44 <DialogPrimitive.Viewport
45 className={cn(
46 "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center p-4",
47 className,
48 )}
49 data-slot="dialog-viewport"
50 {...props}
51 />
52 );
53}
54
55function DialogPopup({
56 className,
57 children,
58 showCloseButton = true,
59 bottomStickOnMobile = true,
60 ...props
61}: DialogPrimitive.Popup.Props & {
62 showCloseButton?: boolean;
63 bottomStickOnMobile?: boolean;
64}) {
65 return (
66 <DialogPortal>
67 <DialogBackdrop />
68 <DialogViewport
69 className={cn(
70 bottomStickOnMobile &&
71 "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12",
72 )}
73 >
74 <DialogPrimitive.Popup
75 className={cn(
76 "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative row-start-2 flex max-h-full min-h-0 w-full min-w-0 max-w-lg scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border bg-popover not-dark:bg-clip-padding text-popover-foreground opacity-[calc(1-0.1*var(--nested-dialogs))] shadow-lg/5 transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
77 bottomStickOnMobile &&
78 "max-sm:max-w-none max-sm:rounded-none max-sm:border-x-0 max-sm:border-t max-sm:border-b-0 max-sm:opacity-[calc(1-min(var(--nested-dialogs),1))] max-sm:data-ending-style:translate-y-4 max-sm:data-starting-style:translate-y-4 max-sm:before:hidden max-sm:before:rounded-none",
79 className,
80 )}
81 data-slot="dialog-popup"
82 {...props}
83 >
84 {children}
85 {showCloseButton && (
86 <DialogPrimitive.Close
87 aria-label="Close"
88 className="absolute end-2 top-2"
89 render={<Button size="icon" variant="ghost" />}
90 >
91 <XIcon />
92 </DialogPrimitive.Close>
93 )}
94 </DialogPrimitive.Popup>
95 </DialogViewport>
96 </DialogPortal>
97 );
98}
99
100function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
101 return (
102 <div
103 className={cn(
104 "flex flex-col gap-2 p-6 in-[[data-slot=dialog-popup]:has([data-slot=dialog-panel])]:pb-3 max-sm:pb-4",
105 className,
106 )}
107 data-slot="dialog-header"
108 {...props}
109 />
110 );
111}
112
113function DialogFooter({
114 className,
115 variant = "default",
116 ...props
117}: React.ComponentProps<"div"> & {
118 variant?: "default" | "bare";
119}) {
120 return (
121 <div
122 className={cn(
123 "flex flex-col-reverse gap-2 px-6 sm:flex-row sm:justify-end sm:rounded-b-[calc(var(--radius-2xl)-1px)]",
124 variant === "default" && "border-t bg-muted/72 py-4",
125 variant === "bare" &&
126 "in-[[data-slot=dialog-popup]:has([data-slot=dialog-panel])]:pt-3 pt-4 pb-6",
127 className,
128 )}
129 data-slot="dialog-footer"
130 {...props}
131 />
132 );
133}
134
135function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
136 return (
137 <DialogPrimitive.Title
138 className={cn(
139 "font-heading font-semibold text-xl leading-none",
140 className,
141 )}
142 data-slot="dialog-title"
143 {...props}
144 />
145 );
146}
147
148function DialogDescription({
149 className,
150 ...props
151}: DialogPrimitive.Description.Props) {
152 return (
153 <DialogPrimitive.Description
154 className={cn("text-muted-foreground text-sm", className)}
155 data-slot="dialog-description"
156 {...props}
157 />
158 );
159}
160
161function DialogPanel({
162 className,
163 scrollFade = true,
164 ...props
165}: React.ComponentProps<"div"> & { scrollFade?: boolean }) {
166 return (
167 <ScrollArea scrollFade={scrollFade}>
168 <div
169 className={cn(
170 "p-6 in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1 in-[[data-slot=dialog-popup]:has([data-slot=dialog-footer]:not(.border-t))]:pb-1",
171 className,
172 )}
173 data-slot="dialog-panel"
174 {...props}
175 />
176 </ScrollArea>
177 );
178}
179
180export {
181 DialogCreateHandle,
182 Dialog,
183 DialogTrigger,
184 DialogPortal,
185 DialogClose,
186 DialogBackdrop,
187 DialogBackdrop as DialogOverlay,
188 DialogPopup,
189 DialogPopup as DialogContent,
190 DialogHeader,
191 DialogFooter,
192 DialogTitle,
193 DialogDescription,
194 DialogPanel,
195 DialogViewport,
196};
apps/web/src/components/ui/empty.tsx created+127
......@@ -0,0 +1,127 @@
1import { cva, type VariantProps } from "class-variance-authority";
2
3import { cn } from "~/lib/utils";
4
5function Empty({ className, ...props }: React.ComponentProps<"div">) {
6 return (
7 <div
8 className={cn(
9 "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance p-6 text-center md:p-12",
10 className,
11 )}
12 data-slot="empty"
13 {...props}
14 />
15 );
16}
17
18function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
19 return (
20 <div
21 className={cn(
22 "flex max-w-sm flex-col items-center text-center",
23 className,
24 )}
25 data-slot="empty-header"
26 {...props}
27 />
28 );
29}
30
31const emptyMediaVariants = cva(
32 "flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
33 {
34 defaultVariants: {
35 variant: "default",
36 },
37 variants: {
38 variant: {
39 default: "bg-transparent",
40 icon: "relative flex size-9 shrink-0 items-center justify-center rounded-md border bg-card not-dark:bg-clip-padding text-foreground shadow-sm/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5",
41 },
42 },
43 },
44);
45
46function EmptyMedia({
47 className,
48 variant = "default",
49 ...props
50}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
51 return (
52 <div
53 className={cn("relative mb-6", className)}
54 data-slot="empty-media"
55 data-variant={variant}
56 {...props}
57 >
58 {variant === "icon" && (
59 <>
60 <div
61 aria-hidden="true"
62 className={cn(
63 emptyMediaVariants({ className, variant }),
64 "-translate-x-0.5 -rotate-10 pointer-events-none absolute bottom-px origin-bottom-left scale-84 shadow-none",
65 )}
66 />
67 <div
68 aria-hidden="true"
69 className={cn(
70 emptyMediaVariants({ className, variant }),
71 "pointer-events-none absolute bottom-px origin-bottom-right translate-x-0.5 rotate-10 scale-84 shadow-none",
72 )}
73 />
74 </>
75 )}
76 <div
77 className={cn(emptyMediaVariants({ className, variant }))}
78 {...props}
79 />
80 </div>
81 );
82}
83
84function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
85 return (
86 <div
87 className={cn("font-heading font-semibold text-xl", className)}
88 data-slot="empty-title"
89 {...props}
90 />
91 );
92}
93
94function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
95 return (
96 <div
97 className={cn(
98 "text-muted-foreground text-sm [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4 [[data-slot=empty-title]+&]:mt-1",
99 className,
100 )}
101 data-slot="empty-description"
102 {...props}
103 />
104 );
105}
106
107function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
108 return (
109 <div
110 className={cn(
111 "flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
112 className,
113 )}
114 data-slot="empty-content"
115 {...props}
116 />
117 );
118}
119
120export {
121 Empty,
122 EmptyHeader,
123 EmptyTitle,
124 EmptyDescription,
125 EmptyContent,
126 EmptyMedia,
127};
apps/web/src/components/ui/field.tsx created+74
......@@ -0,0 +1,74 @@
1"use client";
2
3import { Field as FieldPrimitive } from "@base-ui/react/field";
4
5import { cn } from "~/lib/utils";
6
7function Field({ className, ...props }: FieldPrimitive.Root.Props) {
8 return (
9 <FieldPrimitive.Root
10 className={cn("flex flex-col items-start gap-2", className)}
11 data-slot="field"
12 {...props}
13 />
14 );
15}
16
17function FieldLabel({ className, ...props }: FieldPrimitive.Label.Props) {
18 return (
19 <FieldPrimitive.Label
20 className={cn(
21 "inline-flex items-center gap-2 font-medium text-base/4.5 text-foreground sm:text-sm/4",
22 className,
23 )}
24 data-slot="field-label"
25 {...props}
26 />
27 );
28}
29
30function FieldItem({ className, ...props }: FieldPrimitive.Item.Props) {
31 return (
32 <FieldPrimitive.Item
33 className={cn("flex", className)}
34 data-slot="field-item"
35 {...props}
36 />
37 );
38}
39
40function FieldDescription({
41 className,
42 ...props
43}: FieldPrimitive.Description.Props) {
44 return (
45 <FieldPrimitive.Description
46 className={cn("text-muted-foreground text-xs", className)}
47 data-slot="field-description"
48 {...props}
49 />
50 );
51}
52
53function FieldError({ className, ...props }: FieldPrimitive.Error.Props) {
54 return (
55 <FieldPrimitive.Error
56 className={cn("text-destructive-foreground text-xs", className)}
57 data-slot="field-error"
58 {...props}
59 />
60 );
61}
62
63const FieldControl = FieldPrimitive.Control;
64const FieldValidity = FieldPrimitive.Validity;
65
66export {
67 Field,
68 FieldLabel,
69 FieldDescription,
70 FieldError,
71 FieldControl,
72 FieldItem,
73 FieldValidity,
74};
apps/web/src/components/ui/fieldset.tsx created+29
......@@ -0,0 +1,29 @@
1"use client";
2
3import { Fieldset as FieldsetPrimitive } from "@base-ui/react/fieldset";
4
5import { cn } from "~/lib/utils";
6
7function Fieldset({ className, ...props }: FieldsetPrimitive.Root.Props) {
8 return (
9 <FieldsetPrimitive.Root
10 className={cn("flex w-full max-w-64 flex-col gap-6", className)}
11 data-slot="fieldset"
12 {...props}
13 />
14 );
15}
16function FieldsetLegend({
17 className,
18 ...props
19}: FieldsetPrimitive.Legend.Props) {
20 return (
21 <FieldsetPrimitive.Legend
22 className={cn("font-semibold text-foreground", className)}
23 data-slot="fieldset-legend"
24 {...props}
25 />
26 );
27}
28
29export { Fieldset, FieldsetLegend };
apps/web/src/components/ui/form.tsx created+17
......@@ -0,0 +1,17 @@
1"use client";
2
3import { Form as FormPrimitive } from "@base-ui/react/form";
4
5import { cn } from "~/lib/utils";
6
7function Form({ className, ...props }: FormPrimitive.Props) {
8 return (
9 <FormPrimitive
10 className={cn("flex w-full flex-col gap-4", className)}
11 data-slot="form"
12 {...props}
13 />
14 );
15}
16
17export { Form };
apps/web/src/components/ui/group.tsx created+93
......@@ -0,0 +1,93 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5import { cva, type VariantProps } from "class-variance-authority";
6import type * as React from "react";
7
8import { cn } from "~/lib/utils";
9import { Separator } from "./separator";
10
11const groupVariants = cva(
12 "flex w-fit *:focus-visible:z-1 has-[>[data-slot=group]]:gap-2 *:has-focus-visible:z-1 dark:*:[[data-slot=button]:hover~[data-slot=separator]:not([data-slot]:hover~[data-slot=separator]~[data-slot=separator]),[data-slot][data-pressed]~[data-slot=separator]:not([data-slot][data-pressed]~[data-slot=separator]~[data-slot=separator])]:before:bg-input/64 dark:*:[[data-slot=separator]:has(~[data-slot=button]:hover):not(:has(~[data-slot=separator]~[data-slot]:hover)),[data-slot=separator]:has(~[data-slot][data-pressed]):not(:has(~[data-slot=separator]~[data-slot][data-pressed]))]:before:bg-input/64",
13 {
14 defaultVariants: {
15 orientation: "horizontal",
16 },
17 variants: {
18 orientation: {
19 horizontal:
20 "*:[[data-slot]~[data-slot]:not([data-slot=separator])]:before:-start-[0.5px] *:data-slot:not-data-[slot=separator]:has-[~[data-slot]]:before:-end-[0.5px] *:pointer-coarse:after:min-w-auto *:data-slot:has-[~[data-slot]]:rounded-e-none *:data-slot:has-[~[data-slot]]:border-e-0 *:data-slot:has-[~[data-slot]]:before:rounded-e-none *:[[data-slot]~[data-slot]]:rounded-s-none *:[[data-slot]~[data-slot]]:border-s-0 *:[[data-slot]~[data-slot]]:before:rounded-s-none",
21 vertical:
22 "*:[[data-slot]~[data-slot]:not([data-slot=separator])]:before:-top-[0.5px] *:data-slot:not-data-[slot=separator]:has-[~[data-slot]]:before:-bottom-[0.5px] flex-col *:pointer-coarse:after:min-h-auto *:data-slot:has-[~[data-slot]]:rounded-b-none *:data-slot:has-[~[data-slot]]:border-b-0 *:data-slot:not-data-[slot=separator]:has-[~[data-slot]]:before:hidden *:data-slot:has-[~[data-slot]]:before:rounded-b-none dark:*:last:before:hidden dark:*:first:before:block *:[[data-slot]~[data-slot]]:rounded-t-none *:[[data-slot]~[data-slot]]:border-t-0 *:[[data-slot]~[data-slot]]:before:rounded-t-none",
23 },
24 },
25 },
26);
27
28function Group({
29 className,
30 orientation,
31 children,
32 ...props
33}: {
34 className?: string;
35 orientation?: VariantProps<typeof groupVariants>["orientation"];
36 children: React.ReactNode;
37} & React.ComponentProps<"div">) {
38 return (
39 <div
40 className={cn(groupVariants({ orientation }), className)}
41 data-orientation={orientation}
42 data-slot="group"
43 role="group"
44 {...props}
45 >
46 {children}
47 </div>
48 );
49}
50
51function GroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
52 const defaultProps = {
53 className: cn(
54 "relative inline-flex items-center whitespace-nowrap gap-2 rounded-lg border border-input bg-muted not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-muted-foreground text-base sm:text-sm shadow-xs/5 outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:bg-input/64 dark:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 [&_svg]:-mx-0.5",
55 className,
56 ),
57 "data-slot": "group-text",
58 };
59 return useRender({
60 defaultTagName: "div",
61 props: mergeProps(defaultProps, props),
62 render,
63 });
64}
65
66function GroupSeparator({
67 className,
68 orientation = "vertical",
69 ...props
70}: {
71 className?: string;
72} & React.ComponentProps<typeof Separator>) {
73 return (
74 <Separator
75 className={cn(
76 "[[data-slot=input-control]:focus-within+&,[data-slot=input-group]:focus-within+&,[data-slot=select-trigger]:focus-visible+*+&,[data-slot=number-field]:focus-within+input+&]:-translate-x-px pointer-events-none relative z-2 bg-input before:absolute before:inset-0 has-[+[data-slot=input-control]:focus-within,+[data-slot=input-group]:focus-within,+[data-slot=select-trigger]:focus-visible+*,+[data-slot=number-field]:focus-within]:translate-x-px has-[+[data-slot=input-control]:focus-within,+[data-slot=input-group]:focus-within,+[data-slot=select-trigger]:focus-visible+*,+[data-slot=number-field]:focus-within]:bg-ring dark:before:bg-input/32 [[data-slot=input-control]:focus-within+&,[data-slot=input-group]:focus-within+&,[data-slot=select-trigger]:focus-visible+*+&,[data-slot=number-field]:focus-within+&,[data-slot=number-field]:focus-within+input+&]:bg-ring",
77 className,
78 )}
79 orientation={orientation}
80 {...props}
81 />
82 );
83}
84
85export {
86 Group,
87 Group as ButtonGroup,
88 GroupText,
89 GroupText as ButtonGroupText,
90 GroupSeparator,
91 GroupSeparator as ButtonGroupSeparator,
92 groupVariants,
93};
apps/web/src/components/ui/input-group.tsx created+101
......@@ -0,0 +1,101 @@
1"use client";
2
3import { cva, type VariantProps } from "class-variance-authority";
4import type * as React from "react";
5
6import { cn } from "~/lib/utils";
7import { Input, type InputProps } from "~/components/ui/input";
8import { Textarea, type TextareaProps } from "~/components/ui/textarea";
9
10function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
11 return (
12 <div
13 className={cn(
14 "relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]",
15 className,
16 )}
17 data-slot="input-group"
18 role="group"
19 {...props}
20 />
21 );
22}
23
24const inputGroupAddonVariants = cva(
25 "[&_svg]:-mx-0.5 flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80",
26 {
27 defaultVariants: {
28 align: "inline-start",
29 },
30 variants: {
31 align: {
32 "block-end":
33 "order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
34 "block-start":
35 "order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
36 "inline-end":
37 "has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 order-last pe-[calc(--spacing(3)-1px)] has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
38 "inline-start":
39 "has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 order-first ps-[calc(--spacing(3)-1px)] has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
40 },
41 },
42 },
43);
44
45function InputGroupAddon({
46 className,
47 align = "inline-start",
48 ...props
49}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
50 return (
51 <div
52 className={cn(inputGroupAddonVariants({ align }), className)}
53 data-align={align}
54 data-slot="input-group-addon"
55 onMouseDown={(e) => {
56 const target = e.target as HTMLElement;
57 const isInteractive = target.closest(
58 "button, a, input, select, textarea, [role='button'], [role='combobox'], [role='listbox'], [data-slot='select-trigger']",
59 );
60 if (isInteractive) return;
61 e.preventDefault();
62 const parent = e.currentTarget.parentElement;
63 const input = parent?.querySelector<
64 HTMLInputElement | HTMLTextAreaElement
65 >("input, textarea");
66 if (input && !parent?.querySelector("input:focus, textarea:focus")) {
67 input.focus();
68 }
69 }}
70 {...props}
71 />
72 );
73}
74
75function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
76 return (
77 <span
78 className={cn(
79 "[&_svg]:-mx-0.5 line-clamp-1 flex items-center gap-2 text-muted-foreground leading-none in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
80 className,
81 )}
82 {...props}
83 />
84 );
85}
86
87function InputGroupInput({ className, ...props }: InputProps) {
88 return <Input className={className} unstyled {...props} />;
89}
90
91function InputGroupTextarea({ className, ...props }: TextareaProps) {
92 return <Textarea className={className} unstyled {...props} />;
93}
94
95export {
96 InputGroup,
97 InputGroupAddon,
98 InputGroupText,
99 InputGroupInput,
100 InputGroupTextarea,
101};
apps/web/src/components/ui/input.tsx created+66
......@@ -0,0 +1,66 @@
1"use client";
2
3import { Input as InputPrimitive } from "@base-ui/react/input";
4import type * as React from "react";
5
6import { cn } from "~/lib/utils";
7
8type InputProps = Omit<
9 InputPrimitive.Props & React.RefAttributes<HTMLInputElement>,
10 "size"
11> & {
12 size?: "sm" | "default" | "lg" | number;
13 unstyled?: boolean;
14 nativeInput?: boolean;
15};
16
17function Input({
18 className,
19 size = "default",
20 unstyled = false,
21 nativeInput = false,
22 ...props
23}: InputProps) {
24 const inputClassName = cn(
25 "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-muted-foreground/72 sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]",
26 size === "sm" &&
27 "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5",
28 size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5",
29 props.type === "search" &&
30 "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
31 props.type === "file" &&
32 "text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm",
33 );
34
35 return (
36 <span
37 className={
38 cn(
39 !unstyled &&
40 "relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
41 className,
42 ) || undefined
43 }
44 data-size={size}
45 data-slot="input-control"
46 >
47 {nativeInput ? (
48 <input
49 className={inputClassName}
50 data-slot="input"
51 size={typeof size === "number" ? size : undefined}
52 {...props}
53 />
54 ) : (
55 <InputPrimitive
56 className={inputClassName}
57 data-slot="input"
58 size={typeof size === "number" ? size : undefined}
59 {...props}
60 />
61 )}
62 </span>
63 );
64}
65
66export { Input, type InputProps };
apps/web/src/components/ui/kbd.tsx created+28
......@@ -0,0 +1,28 @@
1import type * as React from "react";
2
3import { cn } from "~/lib/utils";
4
5function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
6 return (
7 <kbd
8 className={cn(
9 "pointer-events-none inline-flex h-5 min-w-5 select-none items-center justify-center gap-1 rounded bg-muted px-1 font-medium font-sans text-muted-foreground text-xs [&_svg:not([class*='size-'])]:size-3",
10 className,
11 )}
12 data-slot="kbd"
13 {...props}
14 />
15 );
16}
17
18function KbdGroup({ className, ...props }: React.ComponentProps<"kbd">) {
19 return (
20 <kbd
21 className={cn("inline-flex items-center gap-1", className)}
22 data-slot="kbd-group"
23 {...props}
24 />
25 );
26}
27
28export { Kbd, KbdGroup };
apps/web/src/components/ui/label.tsx created+28
......@@ -0,0 +1,28 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5
6import { cn } from "~/lib/utils";
7
8function Label({
9 className,
10 render,
11 ...props
12}: useRender.ComponentProps<"label">) {
13 const defaultProps = {
14 className: cn(
15 "inline-flex items-center gap-2 text-base/4.5 sm:text-sm/4 font-medium text-foreground",
16 className,
17 ),
18 "data-slot": "label",
19 };
20
21 return useRender({
22 defaultTagName: "label",
23 props: mergeProps<"label">(defaultProps, props),
24 render,
25 });
26}
27
28export { Label };
apps/web/src/components/ui/menu.tsx created+326
......@@ -0,0 +1,326 @@
1"use client";
2
3import { Menu as MenuPrimitive } from "@base-ui/react/menu";
4import { ChevronRightIcon } from "lucide-react";
5import type * as React from "react";
6
7import { cn } from "~/lib/utils";
8
9const MenuCreateHandle = MenuPrimitive.createHandle;
10
11const Menu = MenuPrimitive.Root;
12
13const MenuPortal = MenuPrimitive.Portal;
14
15function MenuTrigger({
16 className,
17 children,
18 ...props
19}: MenuPrimitive.Trigger.Props) {
20 return (
21 <MenuPrimitive.Trigger
22 className={className}
23 data-slot="menu-trigger"
24 {...props}
25 >
26 {children}
27 </MenuPrimitive.Trigger>
28 );
29}
30
31function MenuPopup({
32 children,
33 className,
34 sideOffset = 4,
35 align = "center",
36 alignOffset,
37 side = "bottom",
38 anchor,
39 ...props
40}: MenuPrimitive.Popup.Props & {
41 align?: MenuPrimitive.Positioner.Props["align"];
42 sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
43 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
44 side?: MenuPrimitive.Positioner.Props["side"];
45 anchor?: MenuPrimitive.Positioner.Props["anchor"];
46}) {
47 return (
48 <MenuPrimitive.Portal>
49 <MenuPrimitive.Positioner
50 align={align}
51 alignOffset={alignOffset}
52 anchor={anchor}
53 className="z-50"
54 data-slot="menu-positioner"
55 side={side}
56 sideOffset={sideOffset}
57 >
58 <MenuPrimitive.Popup
59 className={cn(
60 "relative flex not-[class*='w-']:min-w-32 origin-(--transform-origin) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 outline-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] focus:outline-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
61 className,
62 )}
63 data-slot="menu-popup"
64 {...props}
65 >
66 <div className="max-h-(--available-height) w-full overflow-y-auto p-1">
67 {children}
68 </div>
69 </MenuPrimitive.Popup>
70 </MenuPrimitive.Positioner>
71 </MenuPrimitive.Portal>
72 );
73}
74
75function MenuGroup(props: MenuPrimitive.Group.Props) {
76 return <MenuPrimitive.Group data-slot="menu-group" {...props} />;
77}
78
79function MenuItem({
80 className,
81 inset,
82 variant = "default",
83 ...props
84}: MenuPrimitive.Item.Props & {
85 inset?: boolean;
86 variant?: "default" | "destructive";
87}) {
88 return (
89 <MenuPrimitive.Item
90 className={cn(
91 "[&>svg]:-mx-0.5 flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:shrink-0",
92 className,
93 )}
94 data-inset={inset}
95 data-slot="menu-item"
96 data-variant={variant}
97 {...props}
98 />
99 );
100}
101
102function MenuCheckboxItem({
103 className,
104 children,
105 checked,
106 variant = "default",
107 ...props
108}: MenuPrimitive.CheckboxItem.Props & {
109 variant?: "default" | "switch";
110}) {
111 return (
112 <MenuPrimitive.CheckboxItem
113 checked={checked}
114 className={cn(
115 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default items-center gap-2 rounded-sm py-1 ps-2 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
116 variant === "switch"
117 ? "grid-cols-[1fr_auto] gap-4 pe-1.5"
118 : "grid-cols-[1rem_1fr] pe-4",
119 className,
120 )}
121 data-slot="menu-checkbox-item"
122 {...props}
123 >
124 {variant === "switch" ? (
125 <>
126 <span className="col-start-1">{children}</span>
127 <MenuPrimitive.CheckboxItemIndicator
128 className="inset-shadow-[0_1px_--theme(--color-black/4%)] inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(4)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-64 sm:[--thumb-size:--spacing(3)]"
129 keepMounted
130 >
131 <span className="pointer-events-none block aspect-square h-full in-[[data-slot=menu-checkbox-item][data-checked]]:origin-[var(--thumb-size)_50%] origin-left in-[[data-slot=menu-checkbox-item][data-checked]]:translate-x-[calc(var(--thumb-size)-4px)] in-[[data-slot=menu-checkbox-item]:active]:not-data-disabled:scale-x-110 in-[[data-slot=menu-checkbox-item]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.10)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s]" />
132 </MenuPrimitive.CheckboxItemIndicator>
133 </>
134 ) : (
135 <>
136 <MenuPrimitive.CheckboxItemIndicator className="col-start-1">
137 <svg
138 fill="none"
139 height="24"
140 stroke="currentColor"
141 strokeLinecap="round"
142 strokeLinejoin="round"
143 strokeWidth="2"
144 viewBox="0 0 24 24"
145 width="24"
146 xmlns="http://www.w3.org/2000/svg"
147 >
148 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
149 </svg>
150 </MenuPrimitive.CheckboxItemIndicator>
151 <span className="col-start-2">{children}</span>
152 </>
153 )}
154 </MenuPrimitive.CheckboxItem>
155 );
156}
157
158function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
159 return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />;
160}
161
162function MenuRadioItem({
163 className,
164 children,
165 ...props
166}: MenuPrimitive.RadioItem.Props) {
167 return (
168 <MenuPrimitive.RadioItem
169 className={cn(
170 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default grid-cols-[1rem_1fr] items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
171 className,
172 )}
173 data-slot="menu-radio-item"
174 {...props}
175 >
176 <MenuPrimitive.RadioItemIndicator className="col-start-1">
177 <svg
178 fill="none"
179 height="24"
180 stroke="currentColor"
181 strokeLinecap="round"
182 strokeLinejoin="round"
183 strokeWidth="2"
184 viewBox="0 0 24 24"
185 width="24"
186 xmlns="http://www.w3.org/2000/svg"
187 >
188 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
189 </svg>
190 </MenuPrimitive.RadioItemIndicator>
191 <span className="col-start-2">{children}</span>
192 </MenuPrimitive.RadioItem>
193 );
194}
195
196function MenuGroupLabel({
197 className,
198 inset,
199 ...props
200}: MenuPrimitive.GroupLabel.Props & {
201 inset?: boolean;
202}) {
203 return (
204 <MenuPrimitive.GroupLabel
205 className={cn(
206 "px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:ps-9 sm:data-inset:ps-8",
207 className,
208 )}
209 data-inset={inset}
210 data-slot="menu-label"
211 {...props}
212 />
213 );
214}
215
216function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
217 return (
218 <MenuPrimitive.Separator
219 className={cn("mx-2 my-1 h-px bg-border", className)}
220 data-slot="menu-separator"
221 {...props}
222 />
223 );
224}
225
226function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
227 return (
228 <kbd
229 className={cn(
230 "ms-auto font-medium font-sans text-muted-foreground/72 text-xs tracking-widest",
231 className,
232 )}
233 data-slot="menu-shortcut"
234 {...props}
235 />
236 );
237}
238
239function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
240 return <MenuPrimitive.SubmenuRoot data-slot="menu-sub" {...props} />;
241}
242
243function MenuSubTrigger({
244 className,
245 inset,
246 children,
247 ...props
248}: MenuPrimitive.SubmenuTrigger.Props & {
249 inset?: boolean;
250}) {
251 return (
252 <MenuPrimitive.SubmenuTrigger
253 className={cn(
254 "flex min-h-8 items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
255 className,
256 )}
257 data-inset={inset}
258 data-slot="menu-sub-trigger"
259 {...props}
260 >
261 {children}
262 <ChevronRightIcon className="-me-0.5 ms-auto opacity-80" />
263 </MenuPrimitive.SubmenuTrigger>
264 );
265}
266
267function MenuSubPopup({
268 className,
269 sideOffset = 0,
270 alignOffset,
271 align = "start",
272 ...props
273}: MenuPrimitive.Popup.Props & {
274 align?: MenuPrimitive.Positioner.Props["align"];
275 sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
276 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
277}) {
278 const defaultAlignOffset = align !== "center" ? -5 : undefined;
279
280 return (
281 <MenuPopup
282 align={align}
283 alignOffset={alignOffset ?? defaultAlignOffset}
284 className={className}
285 data-slot="menu-sub-content"
286 side="inline-end"
287 sideOffset={sideOffset}
288 {...props}
289 />
290 );
291}
292
293export {
294 MenuCreateHandle,
295 MenuCreateHandle as DropdownMenuCreateHandle,
296 Menu,
297 Menu as DropdownMenu,
298 MenuPortal,
299 MenuPortal as DropdownMenuPortal,
300 MenuTrigger,
301 MenuTrigger as DropdownMenuTrigger,
302 MenuPopup,
303 MenuPopup as DropdownMenuContent,
304 MenuGroup,
305 MenuGroup as DropdownMenuGroup,
306 MenuItem,
307 MenuItem as DropdownMenuItem,
308 MenuCheckboxItem,
309 MenuCheckboxItem as DropdownMenuCheckboxItem,
310 MenuRadioGroup,
311 MenuRadioGroup as DropdownMenuRadioGroup,
312 MenuRadioItem,
313 MenuRadioItem as DropdownMenuRadioItem,
314 MenuGroupLabel,
315 MenuGroupLabel as DropdownMenuLabel,
316 MenuSeparator,
317 MenuSeparator as DropdownMenuSeparator,
318 MenuShortcut,
319 MenuShortcut as DropdownMenuShortcut,
320 MenuSub,
321 MenuSub as DropdownMenuSub,
322 MenuSubTrigger,
323 MenuSubTrigger as DropdownMenuSubTrigger,
324 MenuSubPopup,
325 MenuSubPopup as DropdownMenuSubContent,
326};
apps/web/src/components/ui/popover.tsx created+119
......@@ -0,0 +1,119 @@
1"use client";
2
3import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
4
5import { cn } from "~/lib/utils";
6
7const PopoverCreateHandle = PopoverPrimitive.createHandle;
8
9const Popover = PopoverPrimitive.Root;
10
11function PopoverTrigger({
12 className,
13 children,
14 ...props
15}: PopoverPrimitive.Trigger.Props) {
16 return (
17 <PopoverPrimitive.Trigger
18 className={className}
19 data-slot="popover-trigger"
20 {...props}
21 >
22 {children}
23 </PopoverPrimitive.Trigger>
24 );
25}
26
27function PopoverPopup({
28 children,
29 className,
30 side = "bottom",
31 align = "center",
32 sideOffset = 4,
33 alignOffset = 0,
34 tooltipStyle = false,
35 anchor,
36 ...props
37}: PopoverPrimitive.Popup.Props & {
38 side?: PopoverPrimitive.Positioner.Props["side"];
39 align?: PopoverPrimitive.Positioner.Props["align"];
40 sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];
41 alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"];
42 tooltipStyle?: boolean;
43 anchor?: PopoverPrimitive.Positioner.Props["anchor"];
44}) {
45 return (
46 <PopoverPrimitive.Portal>
47 <PopoverPrimitive.Positioner
48 align={align}
49 alignOffset={alignOffset}
50 anchor={anchor}
51 className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none"
52 data-slot="popover-positioner"
53 side={side}
54 sideOffset={sideOffset}
55 >
56 <PopoverPrimitive.Popup
57 className={cn(
58 "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 outline-none transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] has-data-[slot=calendar]:rounded-xl has-data-[slot=calendar]:before:rounded-[calc(var(--radius-xl)-1px)] data-starting-style:scale-98 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
59 tooltipStyle &&
60 "w-fit text-balance rounded-md text-xs shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]",
61 className,
62 )}
63 data-slot="popover-popup"
64 {...props}
65 >
66 <PopoverPrimitive.Viewport
67 className={cn(
68 "relative size-full max-h-(--available-height) overflow-clip px-(--viewport-inline-padding) py-4 [--viewport-inline-padding:--spacing(4)] has-data-[slot=calendar]:p-2 data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity",
69 tooltipStyle
70 ? "py-1 [--viewport-inline-padding:--spacing(2)]"
71 : "not-data-transitioning:overflow-y-auto",
72 )}
73 data-slot="popover-viewport"
74 >
75 {children}
76 </PopoverPrimitive.Viewport>
77 </PopoverPrimitive.Popup>
78 </PopoverPrimitive.Positioner>
79 </PopoverPrimitive.Portal>
80 );
81}
82
83function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
84 return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
85}
86
87function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
88 return (
89 <PopoverPrimitive.Title
90 className={cn("font-semibold text-lg leading-none", className)}
91 data-slot="popover-title"
92 {...props}
93 />
94 );
95}
96
97function PopoverDescription({
98 className,
99 ...props
100}: PopoverPrimitive.Description.Props) {
101 return (
102 <PopoverPrimitive.Description
103 className={cn("text-muted-foreground text-sm", className)}
104 data-slot="popover-description"
105 {...props}
106 />
107 );
108}
109
110export {
111 PopoverCreateHandle,
112 Popover,
113 PopoverTrigger,
114 PopoverPopup,
115 PopoverPopup as PopoverContent,
116 PopoverTitle,
117 PopoverDescription,
118 PopoverClose,
119};
apps/web/src/components/ui/radio-group.tsx created+36
......@@ -0,0 +1,36 @@
1"use client";
2
3import { Radio as RadioPrimitive } from "@base-ui/react/radio";
4import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
5
6import { cn } from "~/lib/utils";
7
8function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
9 return (
10 <RadioGroupPrimitive
11 className={cn("flex flex-col gap-3", className)}
12 data-slot="radio-group"
13 {...props}
14 />
15 );
16}
17
18function Radio({ className, ...props }: RadioPrimitive.Root.Props) {
19 return (
20 <RadioPrimitive.Root
21 className={cn(
22 "relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-full border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-full not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
23 className,
24 )}
25 data-slot="radio"
26 {...props}
27 >
28 <RadioPrimitive.Indicator
29 className="-inset-px absolute flex size-4.5 items-center justify-center rounded-full before:size-2 before:rounded-full before:bg-primary-foreground data-unchecked:hidden data-checked:bg-primary sm:size-4 sm:before:size-1.5"
30 data-slot="radio-indicator"
31 />
32 </RadioPrimitive.Root>
33 );
34}
35
36export { RadioGroup, Radio, Radio as RadioGroupItem };
apps/web/src/components/ui/scroll-area.tsx created+64
......@@ -0,0 +1,64 @@
1"use client";
2
3import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
4
5import { cn } from "~/lib/utils";
6
7function ScrollArea({
8 className,
9 children,
10 scrollFade = false,
11 scrollbarGutter = false,
12 ...props
13}: ScrollAreaPrimitive.Root.Props & {
14 scrollFade?: boolean;
15 scrollbarGutter?: boolean;
16}) {
17 return (
18 <ScrollAreaPrimitive.Root
19 className={cn("size-full min-h-0", className)}
20 {...props}
21 >
22 <ScrollAreaPrimitive.Viewport
23 className={cn(
24 "h-full overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
25 scrollFade &&
26 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
27 scrollbarGutter &&
28 "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
29 )}
30 data-slot="scroll-area-viewport"
31 >
32 {children}
33 </ScrollAreaPrimitive.Viewport>
34 <ScrollBar orientation="vertical" />
35 <ScrollBar orientation="horizontal" />
36 <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
37 </ScrollAreaPrimitive.Root>
38 );
39}
40
41function ScrollBar({
42 className,
43 orientation = "vertical",
44 ...props
45}: ScrollAreaPrimitive.Scrollbar.Props) {
46 return (
47 <ScrollAreaPrimitive.Scrollbar
48 className={cn(
49 "m-1 flex opacity-0 transition-opacity delay-300 data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:w-1.5 data-[orientation=horizontal]:flex-col data-hovering:opacity-100 data-scrolling:opacity-100 data-hovering:delay-0 data-scrolling:delay-0 data-hovering:duration-100 data-scrolling:duration-100",
50 className,
51 )}
52 data-slot="scroll-area-scrollbar"
53 orientation={orientation}
54 {...props}
55 >
56 <ScrollAreaPrimitive.Thumb
57 className="relative flex-1 rounded-full bg-foreground/20"
58 data-slot="scroll-area-thumb"
59 />
60 </ScrollAreaPrimitive.Scrollbar>
61 );
62}
63
64export { ScrollArea, ScrollBar };
apps/web/src/components/ui/select.tsx created+234
......@@ -0,0 +1,234 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { Select as SelectPrimitive } from "@base-ui/react/select";
5import { useRender } from "@base-ui/react/use-render";
6import { cva, type VariantProps } from "class-variance-authority";
7import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
8import type * as React from "react";
9
10import { cn } from "~/lib/utils";
11
12const Select = SelectPrimitive.Root;
13
14const selectTriggerVariants = cva(
15 "relative inline-flex select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4",
16 {
17 defaultVariants: {
18 size: "default",
19 variant: "default",
20 },
21 variants: {
22 variant: {
23 default:
24 "w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
25 ghost:
26 "border-transparent text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-ring data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground/80",
27 },
28 size: {
29 default: "min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8",
30 lg: "min-h-10 px-[calc(--spacing(3)-1px)] sm:min-h-9",
31 sm: "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:min-h-7",
32 },
33 },
34 },
35);
36
37const selectTriggerIconClassName = "-me-1 size-4.5 opacity-80 sm:size-4";
38
39interface SelectButtonProps extends useRender.ComponentProps<"button"> {
40 size?: VariantProps<typeof selectTriggerVariants>["size"];
41 variant?: VariantProps<typeof selectTriggerVariants>["variant"];
42}
43
44function SelectButton({ className, size, variant, render, children, ...props }: SelectButtonProps) {
45 const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>["type"] = render
46 ? undefined
47 : "button";
48
49 const defaultProps = {
50 children: (
51 <>
52 <span className="flex-1 truncate in-data-placeholder:text-muted-foreground/72">
53 {children}
54 </span>
55 {variant === "ghost" ? (
56 <ChevronDownIcon className="size-3 opacity-50" />
57 ) : (
58 <ChevronsUpDownIcon className={selectTriggerIconClassName} />
59 )}
60 </>
61 ),
62 className: cn(selectTriggerVariants({ size, variant }), "min-w-none", className),
63 "data-slot": "select-button",
64 type: typeValue,
65 };
66
67 return useRender({
68 defaultTagName: "button",
69 props: mergeProps<"button">(defaultProps, props),
70 render,
71 });
72}
73
74function SelectTrigger({
75 className,
76 size = "default",
77 variant = "default",
78 children,
79 ...props
80}: SelectPrimitive.Trigger.Props & VariantProps<typeof selectTriggerVariants>) {
81 return (
82 <SelectPrimitive.Trigger
83 className={cn(selectTriggerVariants({ size, variant }), className)}
84 data-slot="select-trigger"
85 {...props}
86 >
87 {children}
88 <SelectPrimitive.Icon data-slot="select-icon">
89 <ChevronDownIcon className="size-3 opacity-50" />
90 </SelectPrimitive.Icon>
91 </SelectPrimitive.Trigger>
92 );
93}
94
95function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
96 return (
97 <SelectPrimitive.Value
98 className={cn("flex-1 truncate data-placeholder:text-muted-foreground", className)}
99 data-slot="select-value"
100 {...props}
101 />
102 );
103}
104
105function SelectPopup({
106 className,
107 children,
108 side = "bottom",
109 sideOffset = 4,
110 align = "start",
111 alignOffset = 0,
112 alignItemWithTrigger = true,
113 anchor,
114 ...props
115}: SelectPrimitive.Popup.Props & {
116 side?: SelectPrimitive.Positioner.Props["side"];
117 sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"];
118 align?: SelectPrimitive.Positioner.Props["align"];
119 alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"];
120 alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"];
121 anchor?: SelectPrimitive.Positioner.Props["anchor"];
122}) {
123 return (
124 <SelectPrimitive.Portal>
125 <SelectPrimitive.Positioner
126 align={align}
127 alignItemWithTrigger={alignItemWithTrigger}
128 alignOffset={alignOffset}
129 anchor={anchor}
130 className="z-50 select-none"
131 data-slot="select-positioner"
132 side={side}
133 sideOffset={sideOffset}
134 >
135 <SelectPrimitive.Popup
136 className="origin-(--transform-origin) text-foreground"
137 data-slot="select-popup"
138 {...props}
139 >
140 <SelectPrimitive.ScrollUpArrow
141 className="top-0 z-50 flex h-6 w-full cursor-default items-center justify-center before:pointer-events-none before:absolute before:inset-x-px before:top-px before:h-[200%] before:rounded-t-[calc(var(--radius-lg)-1px)] before:bg-linear-to-b before:from-50% before:from-popover"
142 data-slot="select-scroll-up-arrow"
143 >
144 <ChevronUpIcon className="relative size-4.5 sm:size-4" />
145 </SelectPrimitive.ScrollUpArrow>
146 <div className="relative h-full min-w-(--anchor-width) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
147 <SelectPrimitive.List
148 className={cn("max-h-(--available-height) overflow-y-auto p-1", className)}
149 data-slot="select-list"
150 >
151 {children}
152 </SelectPrimitive.List>
153 </div>
154 <SelectPrimitive.ScrollDownArrow
155 className="bottom-0 z-50 flex h-6 w-full cursor-default items-center justify-center before:pointer-events-none before:absolute before:inset-x-px before:bottom-px before:h-[200%] before:rounded-b-[calc(var(--radius-lg)-1px)] before:bg-linear-to-t before:from-50% before:from-popover"
156 data-slot="select-scroll-down-arrow"
157 >
158 <ChevronDownIcon className="relative size-4.5 sm:size-4" />
159 </SelectPrimitive.ScrollDownArrow>
160 </SelectPrimitive.Popup>
161 </SelectPrimitive.Positioner>
162 </SelectPrimitive.Portal>
163 );
164}
165
166function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
167 return (
168 <SelectPrimitive.Item
169 className={cn(
170 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default grid-cols-[1rem_1fr] items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
171 className,
172 )}
173 data-slot="select-item"
174 {...props}
175 >
176 <SelectPrimitive.ItemIndicator className="col-start-1">
177 <svg
178 fill="none"
179 height="24"
180 stroke="currentColor"
181 strokeLinecap="round"
182 strokeLinejoin="round"
183 strokeWidth="2"
184 viewBox="0 0 24 24"
185 width="24"
186 xmlns="http://www.w3.org/1500/svg"
187 >
188 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
189 </svg>
190 </SelectPrimitive.ItemIndicator>
191 <SelectPrimitive.ItemText className="col-start-2 min-w-0">
192 {children}
193 </SelectPrimitive.ItemText>
194 </SelectPrimitive.Item>
195 );
196}
197
198function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
199 return (
200 <SelectPrimitive.Separator
201 className={cn("mx-2 my-1 h-px bg-border", className)}
202 data-slot="select-separator"
203 {...props}
204 />
205 );
206}
207
208function SelectGroup(props: SelectPrimitive.Group.Props) {
209 return <SelectPrimitive.Group data-slot="select-group" {...props} />;
210}
211
212function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) {
213 return (
214 <SelectPrimitive.GroupLabel
215 className="px-2 py-1.5 font-medium text-muted-foreground text-xs"
216 data-slot="select-group-label"
217 {...props}
218 />
219 );
220}
221
222export {
223 Select,
224 SelectTrigger,
225 SelectButton,
226 selectTriggerVariants,
227 SelectValue,
228 SelectPopup,
229 SelectPopup as SelectContent,
230 SelectItem,
231 SelectSeparator,
232 SelectGroup,
233 SelectGroupLabel,
234};
apps/web/src/components/ui/separator.tsx created+23
......@@ -0,0 +1,23 @@
1import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
2
3import { cn } from "~/lib/utils";
4
5function Separator({
6 className,
7 orientation = "horizontal",
8 ...props
9}: SeparatorPrimitive.Props) {
10 return (
11 <SeparatorPrimitive
12 className={cn(
13 "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:not-[[class^='h-']]:not-[[class*='_h-']]:self-stretch",
14 className,
15 )}
16 data-slot="separator"
17 orientation={orientation}
18 {...props}
19 />
20 );
21}
22
23export { Separator };
apps/web/src/components/ui/sheet.tsx created+203
......@@ -0,0 +1,203 @@
1"use client";
2
3import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
4import { XIcon } from "lucide-react";
5import { cn } from "~/lib/utils";
6import { Button } from "~/components/ui/button";
7import { ScrollArea } from "~/components/ui/scroll-area";
8
9const Sheet = SheetPrimitive.Root;
10
11const SheetPortal = SheetPrimitive.Portal;
12
13function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
14 return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
15}
16
17function SheetClose(props: SheetPrimitive.Close.Props) {
18 return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
19}
20
21function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) {
22 return (
23 <SheetPrimitive.Backdrop
24 className={cn(
25 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
26 className,
27 )}
28 data-slot="sheet-backdrop"
29 {...props}
30 />
31 );
32}
33
34function SheetViewport({
35 className,
36 side,
37 variant = "default",
38 ...props
39}: SheetPrimitive.Viewport.Props & {
40 side?: "right" | "left" | "top" | "bottom";
41 variant?: "default" | "inset";
42}) {
43 return (
44 <SheetPrimitive.Viewport
45 className={cn(
46 "fixed inset-0 z-50 grid",
47 side === "bottom" && "grid grid-rows-[1fr_auto] pt-12",
48 side === "top" && "grid grid-rows-[auto_1fr] pb-12",
49 side === "left" && "flex justify-start",
50 side === "right" && "flex justify-end",
51 variant === "inset" && "sm:p-4",
52 )}
53 data-slot="sheet-viewport"
54 {...props}
55 />
56 );
57}
58
59function SheetPopup({
60 className,
61 children,
62 showCloseButton = true,
63 side = "right",
64 variant = "default",
65 ...props
66}: SheetPrimitive.Popup.Props & {
67 showCloseButton?: boolean;
68 side?: "right" | "left" | "top" | "bottom";
69 variant?: "default" | "inset";
70}) {
71 return (
72 <SheetPortal>
73 <SheetBackdrop />
74 <SheetViewport side={side} variant={variant}>
75 <SheetPrimitive.Popup
76 className={cn(
77 "relative flex max-h-full min-h-0 w-full min-w-0 flex-col bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 transition-[opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:opacity-0 data-starting-style:opacity-0 max-sm:before:hidden dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
78 side === "bottom" &&
79 "row-start-2 border-t data-ending-style:translate-y-8 data-starting-style:translate-y-8",
80 side === "top" &&
81 "data-ending-style:-translate-y-8 data-starting-style:-translate-y-8 border-b",
82 side === "left" &&
83 "data-ending-style:-translate-x-8 data-starting-style:-translate-x-8 w-[calc(100%-(--spacing(12)))] max-w-md border-e",
84 side === "right" &&
85 "col-start-2 w-[calc(100%-(--spacing(12)))] max-w-md border-s data-ending-style:translate-x-8 data-starting-style:translate-x-8",
86 variant === "inset" &&
87 "before:hidden sm:rounded-2xl sm:border sm:before:rounded-[calc(var(--radius-2xl)-1px)] sm:**:data-[slot=sheet-footer]:rounded-b-[calc(var(--radius-2xl)-1px)]",
88 className,
89 )}
90 data-slot="sheet-popup"
91 {...props}
92 >
93 {children}
94 {showCloseButton && (
95 <SheetPrimitive.Close
96 aria-label="Close"
97 className="absolute end-2 top-2"
98 render={<Button size="icon" variant="ghost" />}
99 >
100 <XIcon />
101 </SheetPrimitive.Close>
102 )}
103 </SheetPrimitive.Popup>
104 </SheetViewport>
105 </SheetPortal>
106 );
107}
108
109function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
110 return (
111 <div
112 className={cn(
113 "flex flex-col gap-2 p-6 in-[[data-slot=sheet-popup]:has([data-slot=sheet-panel])]:pb-3 max-sm:pb-4",
114 className,
115 )}
116 data-slot="sheet-header"
117 {...props}
118 />
119 );
120}
121
122function SheetFooter({
123 className,
124 variant = "default",
125 ...props
126}: React.ComponentProps<"div"> & {
127 variant?: "default" | "bare";
128}) {
129 return (
130 <div
131 className={cn(
132 "flex flex-col-reverse gap-2 px-6 sm:flex-row sm:justify-end",
133 variant === "default" && "border-t bg-muted/72 py-4",
134 variant === "bare" &&
135 "in-[[data-slot=sheet-popup]:has([data-slot=sheet-panel])]:pt-3 pt-4 pb-6",
136 className,
137 )}
138 data-slot="sheet-footer"
139 {...props}
140 />
141 );
142}
143
144function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
145 return (
146 <SheetPrimitive.Title
147 className={cn(
148 "font-heading font-semibold text-xl leading-none",
149 className,
150 )}
151 data-slot="sheet-title"
152 {...props}
153 />
154 );
155}
156
157function SheetDescription({
158 className,
159 ...props
160}: SheetPrimitive.Description.Props) {
161 return (
162 <SheetPrimitive.Description
163 className={cn("text-muted-foreground text-sm", className)}
164 data-slot="sheet-description"
165 {...props}
166 />
167 );
168}
169
170function SheetPanel({
171 className,
172 scrollFade = true,
173 ...props
174}: React.ComponentProps<"div"> & { scrollFade?: boolean }) {
175 return (
176 <ScrollArea scrollFade={scrollFade}>
177 <div
178 className={cn(
179 "p-6 in-[[data-slot=sheet-popup]:has([data-slot=sheet-header])]:pt-1 in-[[data-slot=sheet-popup]:has([data-slot=sheet-footer]:not(.border-t))]:pb-1",
180 className,
181 )}
182 data-slot="sheet-panel"
183 {...props}
184 />
185 </ScrollArea>
186 );
187}
188
189export {
190 Sheet,
191 SheetTrigger,
192 SheetPortal,
193 SheetClose,
194 SheetBackdrop,
195 SheetBackdrop as SheetOverlay,
196 SheetPopup,
197 SheetPopup as SheetContent,
198 SheetHeader,
199 SheetFooter,
200 SheetTitle,
201 SheetDescription,
202 SheetPanel,
203};
apps/web/src/components/ui/skeleton.tsx created+16
......@@ -0,0 +1,16 @@
1import { cn } from "~/lib/utils";
2
3function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 return (
5 <div
6 className={cn(
7 "animate-skeleton rounded-sm [--skeleton-highlight:--alpha(var(--color-white)/64%)] [background:linear-gradient(120deg,transparent_40%,var(--skeleton-highlight),transparent_60%)_var(--color-muted)_0_0/200%_100%_fixed] dark:[--skeleton-highlight:--alpha(var(--color-white)/4%)]",
8 className,
9 )}
10 data-slot="skeleton"
11 {...props}
12 />
13 );
14}
15
16export { Skeleton };
apps/web/src/components/ui/spinner.tsx created+18
......@@ -0,0 +1,18 @@
1import { Loader2Icon } from "lucide-react";
2import { cn } from "~/lib/utils";
3
4function Spinner({
5 className,
6 ...props
7}: React.ComponentProps<typeof Loader2Icon>) {
8 return (
9 <Loader2Icon
10 aria-label="Loading"
11 className={cn("animate-spin", className)}
12 role="status"
13 {...props}
14 />
15 );
16}
17
18export { Spinner };
apps/web/src/components/ui/switch.tsx created+27
......@@ -0,0 +1,27 @@
1"use client";
2
3import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
4
5import { cn } from "~/lib/utils";
6
7function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
8 return (
9 <SwitchPrimitive.Root
10 className={cn(
11 "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(5)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-64 sm:[--thumb-size:--spacing(4)]",
12 className,
13 )}
14 data-slot="switch"
15 {...props}
16 >
17 <SwitchPrimitive.Thumb
18 className={cn(
19 "pointer-events-none block aspect-square h-full origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] data-checked:translate-x-[calc(var(--thumb-size)-4px)]",
20 )}
21 data-slot="switch-thumb"
22 />
23 </SwitchPrimitive.Root>
24 );
25}
26
27export { Switch };
apps/web/src/components/ui/textarea.tsx created+51
......@@ -0,0 +1,51 @@
1"use client";
2
3import { Field as FieldPrimitive } from "@base-ui/react/field";
4import { mergeProps } from "@base-ui/react/merge-props";
5import type * as React from "react";
6
7import { cn } from "~/lib/utils";
8
9type TextareaProps = React.ComponentProps<"textarea"> & {
10 size?: "sm" | "default" | "lg" | number;
11 unstyled?: boolean;
12};
13
14function Textarea({
15 className,
16 size = "default",
17 unstyled = false,
18 ...props
19}: TextareaProps) {
20 return (
21 <span
22 className={
23 cn(
24 !unstyled &&
25 "relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] not-has-disabled:has-not-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] sm:text-sm dark:bg-input/32 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:has-not-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
26 className,
27 ) || undefined
28 }
29 data-size={size}
30 data-slot="textarea-control"
31 >
32 <FieldPrimitive.Control
33 render={(defaultProps) => (
34 <textarea
35 className={cn(
36 "field-sizing-content min-h-17.5 w-full rounded-[inherit] px-[calc(--spacing(3)-1px)] py-[calc(--spacing(1.5)-1px)] outline-none max-sm:min-h-20.5",
37 size === "sm" &&
38 "min-h-16.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] max-sm:min-h-19.5",
39 size === "lg" &&
40 "min-h-18.5 py-[calc(--spacing(2)-1px)] max-sm:min-h-21.5",
41 )}
42 data-slot="textarea"
43 {...mergeProps(defaultProps, props)}
44 />
45 )}
46 />
47 </span>
48 );
49}
50
51export { Textarea, type TextareaProps };
apps/web/src/components/ui/toast.tsx created+269
......@@ -0,0 +1,269 @@
1"use client";
2
3import { Toast } from "@base-ui/react/toast";
4import {
5 CircleAlertIcon,
6 CircleCheckIcon,
7 InfoIcon,
8 LoaderCircleIcon,
9 TriangleAlertIcon,
10} from "lucide-react";
11
12import { cn } from "~/lib/utils";
13import { buttonVariants } from "~/components/ui/button";
14
15const toastManager = Toast.createToastManager();
16const anchoredToastManager = Toast.createToastManager();
17
18const TOAST_ICONS = {
19 error: CircleAlertIcon,
20 info: InfoIcon,
21 loading: LoaderCircleIcon,
22 success: CircleCheckIcon,
23 warning: TriangleAlertIcon,
24} as const;
25
26type ToastPosition =
27 | "top-left"
28 | "top-center"
29 | "top-right"
30 | "bottom-left"
31 | "bottom-center"
32 | "bottom-right";
33
34interface ToastProviderProps extends Toast.Provider.Props {
35 position?: ToastPosition;
36}
37
38function ToastProvider({
39 children,
40 position = "bottom-right",
41 ...props
42}: ToastProviderProps) {
43 return (
44 <Toast.Provider toastManager={toastManager} {...props}>
45 {children}
46 <Toasts position={position} />
47 </Toast.Provider>
48 );
49}
50
51function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {
52 const { toasts } = Toast.useToastManager();
53 const isTop = position.startsWith("top");
54
55 return (
56 <Toast.Portal data-slot="toast-portal">
57 <Toast.Viewport
58 className={cn(
59 "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
60 // Vertical positioning
61 "data-[position*=top]:top-(--toast-inset)",
62 "data-[position*=bottom]:bottom-(--toast-inset)",
63 // Horizontal positioning
64 "data-[position*=left]:left-(--toast-inset)",
65 "data-[position*=right]:right-(--toast-inset)",
66 "data-[position*=center]:-translate-x-1/2 data-[position*=center]:left-1/2",
67 )}
68 data-position={position}
69 data-slot="toast-viewport"
70 >
71 {toasts.map((toast) => {
72 const Icon = toast.type
73 ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS]
74 : null;
75
76 return (
77 <Toast.Root
78 className={cn(
79 "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
80 // Base positioning using data-position
81 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
82 "data-[position*=left]:right-auto data-[position*=left]:left-0",
83 "data-[position*=center]:right-0 data-[position*=center]:left-0",
84 "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
85 "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
86 // Gap fill for hover
87 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
88 "data-[position*=top]:after:top-full",
89 "data-[position*=bottom]:after:bottom-full",
90 // Define some variables
91 "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
92 // Define offset-y variable
93 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
94 "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
95 // Default state transform
96 "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
97 "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
98 // Limited state
99 "data-limited:opacity-0",
100 // Expanded state
101 "data-expanded:h-(--toast-height)",
102 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
103 // Starting and ending animations
104 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
105 "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
106 "data-ending-style:opacity-0",
107 // Ending animations (direction-aware)
108 "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
109 "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
110 "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
111 "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
112 "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
113 // Ending animations (expanded)
114 "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
115 "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
116 "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
117 "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
118 )}
119 data-position={position}
120 key={toast.id}
121 swipeDirection={
122 position.includes("center")
123 ? [isTop ? "up" : "down"]
124 : position.includes("left")
125 ? ["left", isTop ? "up" : "down"]
126 : ["right", isTop ? "up" : "down"]
127 }
128 toast={toast}
129 >
130 <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100">
131 <div className="flex gap-2">
132 {Icon && (
133 <div
134 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
135 data-slot="toast-icon"
136 >
137 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
138 </div>
139 )}
140
141 <div className="flex flex-col gap-0.5">
142 <Toast.Title
143 className="font-medium"
144 data-slot="toast-title"
145 />
146 <Toast.Description
147 className="text-muted-foreground"
148 data-slot="toast-description"
149 />
150 </div>
151 </div>
152 {toast.actionProps && (
153 <Toast.Action
154 className={buttonVariants({ size: "xs" })}
155 data-slot="toast-action"
156 >
157 {toast.actionProps.children}
158 </Toast.Action>
159 )}
160 </Toast.Content>
161 </Toast.Root>
162 );
163 })}
164 </Toast.Viewport>
165 </Toast.Portal>
166 );
167}
168
169function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
170 return (
171 <Toast.Provider toastManager={anchoredToastManager} {...props}>
172 {children}
173 <AnchoredToasts />
174 </Toast.Provider>
175 );
176}
177
178function AnchoredToasts() {
179 const { toasts } = Toast.useToastManager();
180
181 return (
182 <Toast.Portal data-slot="toast-portal-anchored">
183 <Toast.Viewport
184 className="outline-none"
185 data-slot="toast-viewport-anchored"
186 >
187 {toasts.map((toast) => {
188 const Icon = toast.type
189 ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS]
190 : null;
191 const tooltipStyle =
192 (toast.data as { tooltipStyle?: boolean })?.tooltipStyle ?? false;
193 const positionerProps = toast.positionerProps;
194
195 if (!positionerProps?.anchor) {
196 return null;
197 }
198
199 return (
200 <Toast.Positioner
201 className="z-50 max-w-[min(--spacing(64),var(--available-width))]"
202 data-slot="toast-positioner"
203 key={toast.id}
204 sideOffset={positionerProps.sideOffset ?? 4}
205 toast={toast}
206 >
207 <Toast.Root
208 className={cn(
209 "relative text-balance border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
210 tooltipStyle
211 ? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"
212 : "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",
213 )}
214 data-slot="toast-popup"
215 toast={toast}
216 >
217 {tooltipStyle ? (
218 <Toast.Content className="pointer-events-auto px-2 py-1">
219 <Toast.Title data-slot="toast-title" />
220 </Toast.Content>
221 ) : (
222 <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm">
223 <div className="flex gap-2">
224 {Icon && (
225 <div
226 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
227 data-slot="toast-icon"
228 >
229 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
230 </div>
231 )}
232
233 <div className="flex flex-col gap-0.5">
234 <Toast.Title
235 className="font-medium"
236 data-slot="toast-title"
237 />
238 <Toast.Description
239 className="text-muted-foreground"
240 data-slot="toast-description"
241 />
242 </div>
243 </div>
244 {toast.actionProps && (
245 <Toast.Action
246 className={buttonVariants({ size: "xs" })}
247 data-slot="toast-action"
248 >
249 {toast.actionProps.children}
250 </Toast.Action>
251 )}
252 </Toast.Content>
253 )}
254 </Toast.Root>
255 </Toast.Positioner>
256 );
257 })}
258 </Toast.Viewport>
259 </Toast.Portal>
260 );
261}
262
263export {
264 ToastProvider,
265 type ToastPosition,
266 toastManager,
267 AnchoredToastProvider,
268 anchoredToastManager,
269};
apps/web/src/index.css+2-41
......@@ -11,19 +11,6 @@
1111 --color-info-foreground: var(--info-foreground);
1212 --color-info: var(--info);
1313 --color-destructive-foreground: var(--destructive-foreground);
14 --color-sidebar-ring: var(--sidebar-ring);
15 --color-sidebar-border: var(--sidebar-border);
16 --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
17 --color-sidebar-accent: var(--sidebar-accent);
18 --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
19 --color-sidebar-primary: var(--sidebar-primary);
20 --color-sidebar-foreground: var(--sidebar-foreground);
21 --color-sidebar: var(--sidebar);
22 --color-chart-5: var(--chart-5);
23 --color-chart-4: var(--chart-4);
24 --color-chart-3: var(--chart-3);
25 --color-chart-2: var(--chart-2);
26 --color-chart-1: var(--chart-1);
2714 --color-ring: var(--ring);
2815 --color-input: var(--input);
2916 --color-border: var(--border);
......@@ -61,7 +48,7 @@
6148 @apply border-border outline-ring/50;
6249 }
6350 body {
64 @apply bg-background text-foreground;
51 @apply bg-background text-foreground relative;
6552 }
6653}
6754
......@@ -89,25 +76,12 @@
8976 --secondary-foreground: var(--color-neutral-800);
9077 --muted: --alpha(var(--color-black) / 4%);
9178 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
92 --accent: --alpha(var(--color-black) / 5%);
79 --accent: --alpha(var(--color-black) / 4%);
9380 --accent-foreground: var(--color-neutral-800);
9481 --destructive: var(--color-red-500);
9582 --border: --alpha(var(--color-black) / 8%);
9683 --input: --alpha(var(--color-black) / 10%);
9784 --ring: oklch(0.488 0.217 264);
98 --chart-1: oklch(0.646 0.222 41.116);
99 --chart-2: oklch(0.6 0.118 184.704);
100 --chart-3: oklch(0.398 0.07 227.392);
101 --chart-4: oklch(0.828 0.189 84.429);
102 --chart-5: oklch(0.769 0.188 70.08);
103 --sidebar: oklch(0.985 0 0);
104 --sidebar-foreground: oklch(0.141 0.005 285.823);
105 --sidebar-primary: oklch(0.21 0.006 285.885);
106 --sidebar-primary-foreground: oklch(0.985 0 0);
107 --sidebar-accent: oklch(0.967 0.001 286.375);
108 --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
109 --sidebar-border: oklch(0.92 0.004 286.32);
110 --sidebar-ring: oklch(0.705 0.015 286.067);
11185 --destructive-foreground: var(--color-red-700);
11286 --info: var(--color-blue-500);
11387 --info-foreground: var(--color-blue-700);
......@@ -136,19 +110,6 @@
136110 --border: --alpha(var(--color-white) / 6%);
137111 --input: --alpha(var(--color-white) / 8%);
138112 --ring: oklch(0.588 0.217 264);
139 --chart-1: oklch(0.488 0.243 264.376);
140 --chart-2: oklch(0.696 0.17 162.48);
141 --chart-3: oklch(0.769 0.188 70.08);
142 --chart-4: oklch(0.627 0.265 303.9);
143 --chart-5: oklch(0.645 0.246 16.439);
144 --sidebar: oklch(0.21 0.006 285.885);
145 --sidebar-foreground: oklch(0.985 0 0);
146 --sidebar-primary: oklch(0.488 0.243 264.376);
147 --sidebar-primary-foreground: oklch(0.985 0 0);
148 --sidebar-accent: oklch(0.274 0.006 286.033);
149 --sidebar-accent-foreground: oklch(0.985 0 0);
150 --sidebar-border: oklch(1 0 0 / 10%);
151 --sidebar-ring: oklch(0.552 0.016 285.938);
152113 --destructive-foreground: var(--color-red-400);
153114 --info: var(--color-blue-500);
154115 --info-foreground: var(--color-blue-400);
apps/web/src/lib/utils.ts created+6
......@@ -0,0 +1,6 @@
1import { type ClassValue, clsx } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
apps/web/src/model-logic.ts+2-1
......@@ -6,4 +6,5 @@ export {
66} from "@t3tools/contracts";
77
88export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
9export const DEFAULT_REASONING = "high";
9export type ReasoningEffort = (typeof REASONING_OPTIONS)[number];
10export const DEFAULT_REASONING: ReasoningEffort = "high";
apps/web/tsconfig.json+4-1
......@@ -4,7 +4,10 @@
44 "composite": true,
55 "jsx": "react-jsx",
66 "lib": ["ES2023", "DOM", "DOM.Iterable"],
7 "types": ["vite/client"]
7 "types": ["vite/client"],
8 "paths": {
9 "~/*": ["./src/*"]
10 }
811 },
912 "include": ["src", "vite.config.ts"]
1013}
apps/web/vite.config.ts+6
......@@ -17,6 +17,12 @@ export default defineConfig({
1717 // In dev mode, tell the web app where the WebSocket server lives
1818 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
1919 },
20 experimental: {
21 enableNativePlugin: true,
22 },
23 resolve: {
24 tsconfigPaths: true,
25 },
2026 server: {
2127 port,
2228 strictPort: true,
bun.lock+118-3
......@@ -51,17 +51,20 @@
5151 "name": "@t3tools/web",
5252 "version": "0.0.0",
5353 "dependencies": {
54 "@base-ui/react": "^1.2.0",
5455 "@t3tools/contracts": "workspace:*",
5556 "@tanstack/react-query": "^5.90.0",
5657 "@xterm/addon-fit": "^0.11.0",
5758 "@xterm/xterm": "^6.0.0",
59 "class-variance-authority": "^0.7.1",
5860 "highlight.js": "^11.11.1",
59 "lucide-react": "^0.563.0",
61 "lucide-react": "^0.564.0",
6062 "react": "^19.0.0",
6163 "react-dom": "^19.0.0",
6264 "react-markdown": "^10.1.0",
6365 "rehype-highlight": "^7.0.2",
6466 "remark-gfm": "^4.0.1",
67 "tailwind-merge": "^3.4.0",
6568 "zod": "^3.24.1",
6669 },
6770 "devDependencies": {
......@@ -120,12 +123,18 @@
120123
121124 "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
122125
126 "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
127
123128 "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
124129
125130 "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
126131
127132 "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
128133
134 "@base-ui/react": ["@base-ui/react@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.5", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw=="],
135
136 "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="],
137
129138 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
130139
131140 "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
......@@ -186,6 +195,14 @@
186195
187196 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
188197
198 "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
199
200 "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
201
202 "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
203
204 "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
205
189206 "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
190207
191208 "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],
......@@ -198,6 +215,16 @@
198215
199216 "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
200217
218 "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
219
220 "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="],
221
222 "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="],
223
224 "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="],
225
226 "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
227
201228 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
202229
203230 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
......@@ -208,8 +235,16 @@
208235
209236 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
210237
238 "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="],
239
211240 "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
212241
242 "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
243
244 "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
245
246 "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
247
213248 "@oxc-project/runtime": ["@oxc-project/runtime@0.112.0", "", {}, "sha512-4vYtWXMnXM6EaweCxbJ6bISAhkNHeN33SihvuX3wrpqaSJA4ZEoW35i9mSvE74+GDf1yTeVE+aEHA+WBpjDk/g=="],
214249
215250 "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="],
......@@ -436,6 +471,8 @@
436471
437472 "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
438473
474 "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
475
439476 "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
440477
441478 "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
......@@ -464,6 +501,8 @@
464501
465502 "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
466503
504 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
505
467506 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
468507
469508 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
......@@ -518,8 +557,16 @@
518557
519558 "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
520559
560 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
561
562 "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
563
564 "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
565
521566 "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
522567
568 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
569
523570 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
524571
525572 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
......@@ -530,6 +577,8 @@
530577
531578 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
532579
580 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
581
533582 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
534583
535584 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
......@@ -572,6 +621,8 @@
572621
573622 "electronmon": ["electronmon@2.0.4", "", { "dependencies": { "chalk": "^3.0.0", "import-from": "^3.0.0", "runtime-required": "^1.1.0", "watchboy": "^0.4.3" }, "bin": { "electronmon": "bin/cli.js" } }, "sha512-u6eDrvUbqa+wsnMrhG2vHmo5neL1owLg2e5i1avGWcOb4rHsUf9lSfbs0FvfPsBNpLxxlPO98nrMhAGV+zw/fQ=="],
574623
624 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
625
575626 "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
576627
577628 "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
......@@ -626,6 +677,8 @@
626677
627678 "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
628679
680 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
681
629682 "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
630683
631684 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
......@@ -644,6 +697,8 @@
644697
645698 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
646699
700 "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
701
647702 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
648703
649704 "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
......@@ -662,6 +717,8 @@
662717
663718 "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
664719
720 "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
721
665722 "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
666723
667724 "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="],
......@@ -686,10 +743,14 @@
686743
687744 "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
688745
746 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
747
689748 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
690749
691750 "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
692751
752 "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
753
693754 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
694755
695756 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
......@@ -750,7 +811,7 @@
750811
751812 "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
752813
753 "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
814 "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
754815
755816 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
756817
......@@ -858,6 +919,10 @@
858919
859920 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
860921
922 "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="],
923
924 "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
925
861926 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
862927
863928 "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
......@@ -878,6 +943,8 @@
878943
879944 "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
880945
946 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
947
881948 "oxfmt": ["oxfmt@0.28.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.28.0", "@oxfmt/darwin-x64": "0.28.0", "@oxfmt/linux-arm64-gnu": "0.28.0", "@oxfmt/linux-arm64-musl": "0.28.0", "@oxfmt/linux-x64-gnu": "0.28.0", "@oxfmt/linux-x64-musl": "0.28.0", "@oxfmt/win32-arm64": "0.28.0", "@oxfmt/win32-x64": "0.28.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw=="],
882949
883950 "oxlint": ["oxlint@1.46.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.46.0", "@oxlint/binding-android-arm64": "1.46.0", "@oxlint/binding-darwin-arm64": "1.46.0", "@oxlint/binding-darwin-x64": "1.46.0", "@oxlint/binding-freebsd-x64": "1.46.0", "@oxlint/binding-linux-arm-gnueabihf": "1.46.0", "@oxlint/binding-linux-arm-musleabihf": "1.46.0", "@oxlint/binding-linux-arm64-gnu": "1.46.0", "@oxlint/binding-linux-arm64-musl": "1.46.0", "@oxlint/binding-linux-ppc64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-musl": "1.46.0", "@oxlint/binding-linux-s390x-gnu": "1.46.0", "@oxlint/binding-linux-x64-gnu": "1.46.0", "@oxlint/binding-linux-x64-musl": "1.46.0", "@oxlint/binding-openharmony-arm64": "1.46.0", "@oxlint/binding-win32-arm64-msvc": "1.46.0", "@oxlint/binding-win32-ia32-msvc": "1.46.0", "@oxlint/binding-win32-x64-msvc": "1.46.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA=="],
......@@ -886,6 +953,8 @@
886953
887954 "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
888955
956 "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
957
889958 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
890959
891960 "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
......@@ -930,6 +999,10 @@
930999
9311000 "remove-trailing-separator": ["remove-trailing-separator@1.1.0", "", {}, "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw=="],
9321001
1002 "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
1003
1004 "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
1005
9331006 "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
9341007
9351008 "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
......@@ -938,6 +1011,8 @@
9381011
9391012 "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
9401013
1014 "rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="],
1015
9411016 "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
9421017
9431018 "rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="],
......@@ -962,6 +1037,8 @@
9621037
9631038 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
9641039
1040 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
1041
9651042 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
9661043
9671044 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
......@@ -970,10 +1047,18 @@
9701047
9711048 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
9721049
1050 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
1051
9731052 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
9741053
1054 "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
1055
1056 "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
1057
9751058 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
9761059
1060 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
1061
9771062 "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
9781063
9791064 "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
......@@ -984,6 +1069,12 @@
9841069
9851070 "t3": ["t3@workspace:apps/server"],
9861071
1072 "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
1073
1074 "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
1075
1076 "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
1077
9871078 "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
9881079
9891080 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
......@@ -998,8 +1089,14 @@
9981089
9991090 "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
10001091
1092 "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="],
1093
1094 "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="],
1095
10011096 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
10021097
1098 "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
1099
10031100 "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
10041101
10051102 "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
......@@ -1024,7 +1121,7 @@
10241121
10251122 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ=="],
10261123
1027 "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
1124 "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
10281125
10291126 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
10301127
......@@ -1052,8 +1149,12 @@
10521149
10531150 "unrun": ["unrun@0.2.27", "", { "dependencies": { "rolldown": "1.0.0-rc.3" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw=="],
10541151
1152 "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
1153
10551154 "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
10561155
1156 "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
1157
10571158 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
10581159
10591160 "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
......@@ -1068,16 +1169,26 @@
10681169
10691170 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
10701171
1172 "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
1173
10711174 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
10721175
10731176 "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
10741177
10751178 "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
10761179
1180 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
1181
10771182 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
10781183
1184 "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
1185
1186 "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
1187
10791188 "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
10801189
1190 "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
1191
10811192 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
10821193
10831194 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
......@@ -1130,6 +1241,8 @@
11301241
11311242 "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
11321243
1244 "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
1245
11331246 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
11341247
11351248 "electron/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="],
......@@ -1142,6 +1255,8 @@
11421255
11431256 "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="],
11441257
1258 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
1259
11451260 "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
11461261
11471262 "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
packages/contracts/src/model.test.ts+1-1
......@@ -29,7 +29,7 @@ describe("resolveModelSlug", () => {
2929
3030 it("resolves only supported model options", () => {
3131 for (const model of MODEL_OPTIONS) {
32 expect(resolveModelSlug(model)).toBe(model);
32 expect(resolveModelSlug(model.slug)).toBe(model.slug);
3333 }
3434 });
3535});
packages/contracts/src/model.ts+11-11
......@@ -1,20 +1,22 @@
11export const MODEL_OPTIONS = [
2 "gpt-5.3-codex",
3 "gpt-5.3-codex-spark",
4 "gpt-5.2-codex",
5 "gpt-5.2",
2 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
3 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
4 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
5 { slug: "gpt-5.2", name: "GPT-5.2" },
66] as const;
77
8export type ModelSlug = (typeof MODEL_OPTIONS)[number]["slug"];
9
810export const DEFAULT_MODEL = "gpt-5.3-codex";
911
10export const MODEL_SLUG_ALIASES: Record<string, string> = {
12export const MODEL_SLUG_ALIASES: Record<string, ModelSlug> = {
1113 "5.3": "gpt-5.3-codex",
1214 "gpt-5.3": "gpt-5.3-codex",
1315 "5.3-spark": "gpt-5.3-codex-spark",
1416 "gpt-5.3-spark": "gpt-5.3-codex-spark",
1517};
1618
17export function normalizeModelSlug(model: string | null | undefined): string | null {
19export function normalizeModelSlug(model: string | null | undefined): ModelSlug | null {
1820 if (typeof model !== "string") {
1921 return null;
2022 }
......@@ -24,16 +26,14 @@ export function normalizeModelSlug(model: string | null | undefined): string | n
2426 return null;
2527 }
2628
27 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
29 return MODEL_SLUG_ALIASES[trimmed] ?? (trimmed as ModelSlug);
2830}
2931
30export function resolveModelSlug(model: string | null | undefined): string {
32export function resolveModelSlug(model: string | null | undefined): ModelSlug {
3133 const normalized = normalizeModelSlug(model);
3234 if (!normalized) {
3335 return DEFAULT_MODEL;
3436 }
3537
36 return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number])
37 ? normalized
38 : DEFAULT_MODEL;
38 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;
3939}