Leon4gr45 commited on
Commit
391c43e
·
verified ·
1 Parent(s): eeb9404

Upload folder using huggingface_hub (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. components.json +21 -0
  3. components/ui/discord-icon.tsx +18 -0
  4. components/ui/dropdown-menu.tsx +257 -0
  5. components/ui/input.tsx +21 -0
  6. components/ui/label.tsx +26 -0
  7. components/ui/logo.tsx +30 -0
  8. components/ui/pagination.tsx +145 -0
  9. components/ui/panel.tsx +90 -0
  10. components/ui/popover.tsx +50 -0
  11. components/ui/resizable.tsx +45 -0
  12. components/ui/select.tsx +185 -0
  13. components/ui/sonner.tsx +26 -0
  14. components/ui/spinner.tsx +34 -0
  15. components/ui/switch.tsx +31 -0
  16. components/ui/tabs.tsx +55 -0
  17. components/ui/textarea.tsx +22 -0
  18. components/ui/thumbnail-area.tsx +156 -0
  19. components/ui/toggle-group.tsx +58 -0
  20. components/ui/tooltip.tsx +65 -0
  21. components/views/content-area.tsx +83 -0
  22. components/views/dashboard-view.tsx +849 -0
  23. components/views/deployments-view.tsx +790 -0
  24. components/views/docs-view.tsx +282 -0
  25. components/views/interviews-view.tsx +11 -0
  26. components/views/projects-view.tsx +24 -0
  27. components/views/settings-view.tsx +33 -0
  28. components/views/skills-view.tsx +7 -0
  29. components/views/templates-view.tsx +24 -0
  30. components/views/users-view.tsx +632 -0
  31. components/views/workspaces-view.tsx +678 -0
  32. components/workspace-switcher/index.tsx +130 -0
  33. components/workspace/deployment-selector.tsx +135 -0
  34. components/workspace/index.tsx +2257 -0
  35. components/workspace/skills-panel.tsx +237 -0
  36. desktop/.gitignore +3 -0
  37. desktop/README.md +59 -0
  38. desktop/assemble-app.sh +40 -0
  39. desktop/electron-builder.yml +64 -0
  40. desktop/electron/__tests__/migrate-legacy.test.ts +74 -0
  41. desktop/electron/icons/icon.icns +3 -0
  42. desktop/electron/icons/icon.png +0 -0
  43. desktop/electron/main.ts +548 -0
  44. desktop/electron/migrate-legacy.ts +27 -0
  45. desktop/package-lock.json +0 -0
  46. desktop/package.json +32 -0
  47. desktop/tsconfig.json +14 -0
  48. desktop/tsup.config.ts +14 -0
  49. docs/BACKEND_FEATURES.md +969 -0
  50. docs/CUSTOM_DOMAINS.md +54 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ desktop/electron/icons/icon.icns filter=lfs diff=lfs merge=lfs -text
components.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
components/ui/discord-icon.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ interface DiscordIconProps {
4
+ className?: string;
5
+ }
6
+
7
+ export function DiscordIcon({ className }: DiscordIconProps) {
8
+ return (
9
+ <svg
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ viewBox="0 0 64 48"
12
+ fill="currentColor"
13
+ className={className}
14
+ >
15
+ <path d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z" />
16
+ </svg>
17
+ );
18
+ }
components/ui/dropdown-menu.tsx ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ function DropdownMenu({
10
+ ...props
11
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
12
+ return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
13
+ }
14
+
15
+ function DropdownMenuPortal({
16
+ ...props
17
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
18
+ return (
19
+ <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
20
+ );
21
+ }
22
+
23
+ function DropdownMenuTrigger({
24
+ ...props
25
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
26
+ return (
27
+ <DropdownMenuPrimitive.Trigger
28
+ data-slot="dropdown-menu-trigger"
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+
34
+ function DropdownMenuContent({
35
+ className,
36
+ sideOffset = 4,
37
+ ...props
38
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
39
+ return (
40
+ <DropdownMenuPrimitive.Portal>
41
+ <DropdownMenuPrimitive.Content
42
+ data-slot="dropdown-menu-content"
43
+ sideOffset={sideOffset}
44
+ className={cn(
45
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[100] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ </DropdownMenuPrimitive.Portal>
51
+ );
52
+ }
53
+
54
+ function DropdownMenuGroup({
55
+ ...props
56
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
57
+ return (
58
+ <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
59
+ );
60
+ }
61
+
62
+ function DropdownMenuItem({
63
+ className,
64
+ inset,
65
+ variant = "default",
66
+ ...props
67
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
68
+ inset?: boolean;
69
+ variant?: "default" | "destructive";
70
+ }) {
71
+ return (
72
+ <DropdownMenuPrimitive.Item
73
+ data-slot="dropdown-menu-item"
74
+ data-inset={inset}
75
+ data-variant={variant}
76
+ className={cn(
77
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
78
+ className
79
+ )}
80
+ {...props}
81
+ />
82
+ );
83
+ }
84
+
85
+ function DropdownMenuCheckboxItem({
86
+ className,
87
+ children,
88
+ checked,
89
+ ...props
90
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
91
+ return (
92
+ <DropdownMenuPrimitive.CheckboxItem
93
+ data-slot="dropdown-menu-checkbox-item"
94
+ className={cn(
95
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
96
+ className
97
+ )}
98
+ checked={checked}
99
+ {...props}
100
+ >
101
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
102
+ <DropdownMenuPrimitive.ItemIndicator>
103
+ <CheckIcon className="size-4" />
104
+ </DropdownMenuPrimitive.ItemIndicator>
105
+ </span>
106
+ {children}
107
+ </DropdownMenuPrimitive.CheckboxItem>
108
+ );
109
+ }
110
+
111
+ function DropdownMenuRadioGroup({
112
+ ...props
113
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
114
+ return (
115
+ <DropdownMenuPrimitive.RadioGroup
116
+ data-slot="dropdown-menu-radio-group"
117
+ {...props}
118
+ />
119
+ );
120
+ }
121
+
122
+ function DropdownMenuRadioItem({
123
+ className,
124
+ children,
125
+ ...props
126
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
127
+ return (
128
+ <DropdownMenuPrimitive.RadioItem
129
+ data-slot="dropdown-menu-radio-item"
130
+ className={cn(
131
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
132
+ className
133
+ )}
134
+ {...props}
135
+ >
136
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
137
+ <DropdownMenuPrimitive.ItemIndicator>
138
+ <CircleIcon className="size-2 fill-current" />
139
+ </DropdownMenuPrimitive.ItemIndicator>
140
+ </span>
141
+ {children}
142
+ </DropdownMenuPrimitive.RadioItem>
143
+ );
144
+ }
145
+
146
+ function DropdownMenuLabel({
147
+ className,
148
+ inset,
149
+ ...props
150
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
151
+ inset?: boolean;
152
+ }) {
153
+ return (
154
+ <DropdownMenuPrimitive.Label
155
+ data-slot="dropdown-menu-label"
156
+ data-inset={inset}
157
+ className={cn(
158
+ "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
159
+ className
160
+ )}
161
+ {...props}
162
+ />
163
+ );
164
+ }
165
+
166
+ function DropdownMenuSeparator({
167
+ className,
168
+ ...props
169
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
170
+ return (
171
+ <DropdownMenuPrimitive.Separator
172
+ data-slot="dropdown-menu-separator"
173
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
174
+ {...props}
175
+ />
176
+ );
177
+ }
178
+
179
+ function DropdownMenuShortcut({
180
+ className,
181
+ ...props
182
+ }: React.ComponentProps<"span">) {
183
+ return (
184
+ <span
185
+ data-slot="dropdown-menu-shortcut"
186
+ className={cn(
187
+ "text-muted-foreground ml-auto text-xs tracking-widest",
188
+ className
189
+ )}
190
+ {...props}
191
+ />
192
+ );
193
+ }
194
+
195
+ function DropdownMenuSub({
196
+ ...props
197
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
198
+ return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
199
+ }
200
+
201
+ function DropdownMenuSubTrigger({
202
+ className,
203
+ inset,
204
+ children,
205
+ ...props
206
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
207
+ inset?: boolean;
208
+ }) {
209
+ return (
210
+ <DropdownMenuPrimitive.SubTrigger
211
+ data-slot="dropdown-menu-sub-trigger"
212
+ data-inset={inset}
213
+ className={cn(
214
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
215
+ className
216
+ )}
217
+ {...props}
218
+ >
219
+ {children}
220
+ <ChevronRightIcon className="ml-auto size-4" />
221
+ </DropdownMenuPrimitive.SubTrigger>
222
+ );
223
+ }
224
+
225
+ function DropdownMenuSubContent({
226
+ className,
227
+ ...props
228
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
229
+ return (
230
+ <DropdownMenuPrimitive.SubContent
231
+ data-slot="dropdown-menu-sub-content"
232
+ className={cn(
233
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
234
+ className
235
+ )}
236
+ {...props}
237
+ />
238
+ );
239
+ }
240
+
241
+ export {
242
+ DropdownMenu,
243
+ DropdownMenuPortal,
244
+ DropdownMenuTrigger,
245
+ DropdownMenuContent,
246
+ DropdownMenuGroup,
247
+ DropdownMenuLabel,
248
+ DropdownMenuItem,
249
+ DropdownMenuCheckboxItem,
250
+ DropdownMenuRadioGroup,
251
+ DropdownMenuRadioItem,
252
+ DropdownMenuSeparator,
253
+ DropdownMenuShortcut,
254
+ DropdownMenuSub,
255
+ DropdownMenuSubTrigger,
256
+ DropdownMenuSubContent,
257
+ };
components/ui/input.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
13
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Input }
components/ui/label.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import * as LabelPrimitive from '@radix-ui/react-label'
5
+ import { cva, type VariantProps } from 'class-variance-authority'
6
+
7
+ import { cn } from '@/lib/utils'
8
+
9
+ const labelVariants = cva(
10
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
11
+ )
12
+
13
+ const Label = React.forwardRef<
14
+ React.ElementRef<typeof LabelPrimitive.Root>,
15
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
16
+ VariantProps<typeof labelVariants>
17
+ >(({ className, ...props }, ref) => (
18
+ <LabelPrimitive.Root
19
+ ref={ref}
20
+ className={cn(labelVariants(), className)}
21
+ {...props}
22
+ />
23
+ ))
24
+ Label.displayName = LabelPrimitive.Root.displayName
25
+
26
+ export { Label }
components/ui/logo.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ interface LogoProps {
4
+ width?: number;
5
+ height?: number;
6
+ className?: string;
7
+ }
8
+
9
+ // Letter fill uses `currentColor` (driven by Tailwind `text-*`/`dark:text-*`);
10
+ // background uses `--logo-bg` var so theme inversion works without prop plumbing.
11
+ export function Logo({ width = 64, height = 64, className }: LogoProps) {
12
+ return (
13
+ <svg
14
+ width={width}
15
+ height={height}
16
+ viewBox="0 0 256 256"
17
+ preserveAspectRatio="xMidYMid meet"
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ className={`text-white dark:text-black [--logo-bg:#000000] dark:[--logo-bg:#ffffff] ${className ?? ''}`}
20
+ >
21
+ <rect x="0" y="0" width="256" height="256" rx="20" ry="20" fill="var(--logo-bg)" />
22
+ <g transform="translate(0,256) scale(0.0476,-0.0476)" fill="currentColor" stroke="none">
23
+ <path d="M725 4825 c-50 -18 -100 -71 -114 -122 -15 -54 -15 -1573 0 -1628 16 -55 44 -92 89 -115 38 -19 62 -20 855 -20 781 0 817 1 853 19 46 23 67 46 87 94 13 32 15 138 15 830 0 566 -3 804 -11 828 -16 45 -55 87 -104 110 -38 18 -82 19 -835 18 -659 0 -802 -2 -835 -14z m1351 -371 c15 -11 37 -33 48 -48 21 -27 21 -38 21 -520 0 -547 3 -523 -68 -566 -31 -19 -54 -20 -521 -20 -483 0 -489 0 -524 22 -20 12 -42 38 -53 62 -17 38 -19 74 -19 504 0 496 1 503 51 548 46 41 66 43 561 41 464 -2 477 -3 504 -23z" />
24
+ <path d="M3058 4830 c-44 -13 -87 -49 -108 -90 -19 -37 -20 -61 -20 -471 0 -428 0 -432 22 -471 13 -22 41 -51 64 -64 41 -24 41 -24 685 -24 645 0 645 0 689 -22 63 -33 80 -71 80 -183 0 -101 -15 -144 -63 -179 -28 -21 -41 -21 -695 -26 -666 -5 -667 -5 -702 -27 -109 -68 -106 -247 5 -310 40 -23 40 -23 858 -23 664 0 824 3 850 14 43 17 95 78 102 118 3 18 5 225 3 459 -3 426 -3 426 -31 462 -58 76 -15 71 -757 77 -620 5 -667 6 -692 23 -44 30 -58 74 -58 179 0 116 16 153 80 186 44 22 44 22 693 22 710 0 678 -3 731 60 80 96 41 240 -79 287 -35 14 -1612 17 -1657 3z" />
25
+ <path d="M702 2509 c-48 -24 -75 -57 -91 -114 -9 -29 -11 -253 -9 -840 3 -779 4 -801 23 -834 11 -19 37 -48 58 -65 39 -31 39 -31 380 -31 342 0 342 0 399 28 31 15 63 39 73 53 16 25 16 25 62 -16 77 -67 104 -71 470 -68 320 3 320 3 360 30 24 16 49 44 62 70 21 44 21 49 21 854 0 773 -1 811 -19 851 -35 76 -135 120 -215 93 -41 -13 -90 -51 -109 -84 -9 -16 -13 -187 -17 -688 -5 -654 -5 -667 -26 -694 -43 -58 -68 -69 -169 -72 -82 -3 -99 -1 -133 18 -22 12 -49 39 -61 60 -21 37 -21 45 -21 664 0 439 -3 641 -11 673 -32 123 -190 174 -285 91 -73 -64 -69 -20 -70 -743 0 -721 3 -687 -66 -737 -28 -20 -47 -23 -133 -26 -91 -3 -103 -2 -134 20 -19 13 -44 36 -55 51 -21 28 -21 38 -26 695 -4 481 -8 673 -17 687 -50 87 -152 118 -241 74z" />
26
+ <path d="M3047 2515 c-47 -16 -81 -46 -101 -90 -14 -28 -16 -95 -16 -463 0 -281 4 -440 11 -459 15 -40 48 -73 94 -94 38 -17 79 -19 685 -19 626 0 646 -1 678 -20 58 -35 72 -72 72 -185 0 -110 -14 -147 -67 -182 -25 -17 -73 -18 -698 -23 -672 -5 -672 -5 -708 -33 -20 -15 -44 -42 -53 -60 -21 -39 -21 -125 -1 -163 20 -38 65 -80 100 -93 19 -8 289 -11 833 -11 701 0 809 2 841 15 48 20 71 41 94 88 19 35 19 60 17 480 -3 444 -3 444 -30 479 -54 71 -23 68 -740 68 -612 0 -645 1 -685 20 -67 30 -83 66 -83 183 0 116 14 156 68 189 35 21 35 21 691 22 606 1 658 2 688 19 137 74 130 264 -12 328 -38 18 -85 19 -840 18 -652 0 -807 -2 -838 -14z" />
27
+ </g>
28
+ </svg>
29
+ );
30
+ }
components/ui/pagination.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
5
+ import { cn } from '@/lib/utils';
6
+
7
+ export interface PaginationProps {
8
+ page: number;
9
+ totalPages: number;
10
+ onPageChange: (page: number) => void;
11
+ /**
12
+ * Optional scroll container to reset to top on page change. Defaults to
13
+ * window scroll when omitted. Passing `null` disables scroll-to-top.
14
+ */
15
+ scrollTarget?: React.RefObject<HTMLElement | null> | null;
16
+ className?: string;
17
+ }
18
+
19
+ export interface PaginationRangeProps {
20
+ total: number;
21
+ rangeStart: number;
22
+ rangeEnd: number;
23
+ totalPages: number;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * Build a compact page list with ellipsis:
29
+ * 1 ... 4 5 6 ... 12
30
+ * Always includes first, last, current ±1.
31
+ */
32
+ function buildPageList(page: number, totalPages: number): Array<number | 'ellipsis'> {
33
+ if (totalPages <= 7) {
34
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
35
+ }
36
+
37
+ const set = new Set<number>([1, totalPages, page - 1, page, page + 1]);
38
+ const pages = Array.from(set)
39
+ .filter(p => p >= 1 && p <= totalPages)
40
+ .sort((a, b) => a - b);
41
+
42
+ const out: Array<number | 'ellipsis'> = [];
43
+ for (let i = 0; i < pages.length; i++) {
44
+ out.push(pages[i]);
45
+ if (i < pages.length - 1 && pages[i + 1] - pages[i] > 1) {
46
+ out.push('ellipsis');
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Small "N–M of T" range label. Renders nothing when the list fits on a
54
+ * single page — lets callers place it unconditionally without a guard.
55
+ */
56
+ export function PaginationRange({
57
+ total,
58
+ rangeStart,
59
+ rangeEnd,
60
+ totalPages,
61
+ className,
62
+ }: PaginationRangeProps) {
63
+ if (totalPages <= 1) return null;
64
+ return (
65
+ <p className={cn('text-[11px] text-muted-foreground', className)}>
66
+ {rangeStart}–{rangeEnd} of {total}
67
+ </p>
68
+ );
69
+ }
70
+
71
+ export function Pagination({
72
+ page,
73
+ totalPages,
74
+ onPageChange,
75
+ scrollTarget,
76
+ className,
77
+ }: PaginationProps) {
78
+ if (totalPages <= 1) return null;
79
+
80
+ const pageList = buildPageList(page, totalPages);
81
+ const canPrev = page > 1;
82
+ const canNext = page < totalPages;
83
+
84
+ const handleChange = (p: number) => {
85
+ if (p === page) return;
86
+ onPageChange(p);
87
+ // Default: scroll window. Explicit null disables. Ref → scroll that element.
88
+ if (scrollTarget === null) return;
89
+ const el = scrollTarget?.current;
90
+ try {
91
+ if (el) {
92
+ el.scrollTo({ top: 0, behavior: 'smooth' });
93
+ } else if (typeof window !== 'undefined') {
94
+ window.scrollTo({ top: 0, behavior: 'smooth' });
95
+ }
96
+ } catch {
97
+ // Older browsers: ignore.
98
+ }
99
+ };
100
+
101
+ const navBtn = "h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-40 disabled:pointer-events-none transition-colors";
102
+ const pageBtn = "h-6 min-w-6 px-1.5 inline-flex items-center justify-center rounded text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors";
103
+ const pageBtnActive = "h-6 min-w-6 px-1.5 inline-flex items-center justify-center rounded text-[11px] font-medium text-foreground bg-muted";
104
+
105
+ return (
106
+ <div className={cn('flex items-center justify-center gap-0.5 pt-4 pb-1', className)}>
107
+ <button
108
+ type="button"
109
+ className={navBtn}
110
+ onClick={() => handleChange(page - 1)}
111
+ disabled={!canPrev}
112
+ aria-label="Previous page"
113
+ >
114
+ <ChevronLeft className="h-3.5 w-3.5" />
115
+ </button>
116
+
117
+ {pageList.map((p, i) =>
118
+ p === 'ellipsis' ? (
119
+ <span key={`e-${i}`} className="px-1 text-[11px] text-muted-foreground/60">…</span>
120
+ ) : (
121
+ <button
122
+ key={p}
123
+ type="button"
124
+ className={p === page ? pageBtnActive : pageBtn}
125
+ onClick={() => handleChange(p)}
126
+ aria-label={`Page ${p}`}
127
+ aria-current={p === page ? 'page' : undefined}
128
+ >
129
+ {p}
130
+ </button>
131
+ )
132
+ )}
133
+
134
+ <button
135
+ type="button"
136
+ className={navBtn}
137
+ onClick={() => handleChange(page + 1)}
138
+ disabled={!canNext}
139
+ aria-label="Next page"
140
+ >
141
+ <ChevronRight className="h-3.5 w-3.5" />
142
+ </button>
143
+ </div>
144
+ );
145
+ }
components/ui/panel.tsx ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext } from 'react';
2
+ import { X, GripVertical } from 'lucide-react';
3
+ import type { LucideIcon } from 'lucide-react';
4
+ import { Button } from '@/components/ui/button';
5
+
6
+ // Context for panel drag-to-reorder
7
+ interface PanelDragContext {
8
+ onDragStart: (panelKey: string) => void;
9
+ draggingPanel: string | null;
10
+ }
11
+ const PanelDragCtx = createContext<PanelDragContext | null>(null);
12
+ export const PanelDragProvider = PanelDragCtx.Provider;
13
+
14
+ interface PanelContainerProps {
15
+ children: React.ReactNode;
16
+ className?: string;
17
+ dataTourId?: string;
18
+ }
19
+
20
+ export function PanelContainer({ children, className, dataTourId }: PanelContainerProps) {
21
+ return (
22
+ <div
23
+ className={`h-full flex flex-col bg-card border border-border md:rounded-lg overflow-hidden ${className || ''}`}
24
+ {...(dataTourId ? { 'data-tour-id': dataTourId } : {})}
25
+ >
26
+ {children}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ interface PanelHeaderProps {
32
+ icon: LucideIcon;
33
+ title: string;
34
+ color?: string;
35
+ onClose?: () => void;
36
+ /** Inline content after the title (e.g. counts, badges) */
37
+ children?: React.ReactNode;
38
+ /** Action buttons on the right side of the header (before the close button) */
39
+ actions?: React.ReactNode;
40
+ /** Panel key for drag-to-reorder (enables drag handle when PanelDragProvider is present) */
41
+ panelKey?: string;
42
+ }
43
+
44
+ export function PanelHeader({ icon: Icon, title, color, onClose, children, actions, panelKey }: PanelHeaderProps) {
45
+ const dragCtx = useContext(PanelDragCtx);
46
+ const isDragging = dragCtx && panelKey ? dragCtx.draggingPanel === panelKey : false;
47
+ const canDrag = dragCtx && panelKey;
48
+
49
+ const hasActions = Boolean(actions) || Boolean(children);
50
+
51
+ return (
52
+ <div className={`flex items-center justify-end md:justify-between px-3 py-1.5 md:py-2 border-b border-border bg-muted/30 shrink-0 ${isDragging ? 'opacity-50' : ''} ${!hasActions ? 'md:flex hidden' : ''}`}>
53
+ <div className="hidden md:flex items-center gap-2">
54
+ <Icon className="h-4 w-4" style={color ? { color } : undefined} />
55
+ <span className="font-semibold text-sm">{title}</span>
56
+ {children}
57
+ </div>
58
+ {children && <div className="flex md:hidden items-center gap-2">{children}</div>}
59
+ <div className="flex items-center gap-1.5 md:gap-1">
60
+ {actions}
61
+ <div className="hidden md:flex items-center gap-0.5 ml-1 pl-1 border-l border-border/50">
62
+ {canDrag && (
63
+ <Button
64
+ variant="ghost"
65
+ size="icon"
66
+ className="h-5 w-5 cursor-grab active:cursor-grabbing"
67
+ title="Drag to reorder"
68
+ onMouseDown={() => {
69
+ dragCtx.onDragStart(panelKey);
70
+ }}
71
+ >
72
+ <GripVertical className="h-3 w-3" />
73
+ </Button>
74
+ )}
75
+ {onClose && (
76
+ <Button
77
+ variant="ghost"
78
+ size="icon"
79
+ onClick={onClose}
80
+ className="h-5 w-5"
81
+ title={`Close ${title.toLowerCase()} panel`}
82
+ >
83
+ <X className="h-3 w-3" />
84
+ </Button>
85
+ )}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
components/ui/popover.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ function Popover({
9
+ ...props
10
+ }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
11
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />;
12
+ }
13
+
14
+ function PopoverTrigger({
15
+ ...props
16
+ }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
17
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
18
+ }
19
+
20
+ function PopoverContent({
21
+ className,
22
+ align = "center",
23
+ sideOffset = 4,
24
+ collisionPadding = 16,
25
+ ...props
26
+ }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
27
+ return (
28
+ <PopoverPrimitive.Portal>
29
+ <PopoverPrimitive.Content
30
+ data-slot="popover-content"
31
+ align={align}
32
+ sideOffset={sideOffset}
33
+ collisionPadding={collisionPadding}
34
+ className={cn(
35
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
36
+ className
37
+ )}
38
+ {...props}
39
+ />
40
+ </PopoverPrimitive.Portal>
41
+ );
42
+ }
43
+
44
+ function PopoverAnchor({
45
+ ...props
46
+ }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
47
+ return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
48
+ }
49
+
50
+ export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
components/ui/resizable.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { GripVertical } from 'lucide-react'
4
+ import * as ResizablePrimitive from 'react-resizable-panels'
5
+
6
+ import { cn } from '@/lib/utils'
7
+
8
+ const ResizablePanelGroup = ({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
12
+ <ResizablePrimitive.PanelGroup
13
+ className={cn(
14
+ 'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
15
+ className
16
+ )}
17
+ {...props}
18
+ />
19
+ )
20
+
21
+ const ResizablePanel = ResizablePrimitive.Panel
22
+
23
+ const ResizableHandle = ({
24
+ withHandle,
25
+ className,
26
+ ...props
27
+ }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
28
+ withHandle?: boolean
29
+ }) => (
30
+ <ResizablePrimitive.PanelResizeHandle
31
+ className={cn(
32
+ 'group relative flex w-2 mx-1 items-center justify-center bg-transparent hover:bg-border/50 transition-colors after:absolute after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-2 data-[panel-group-direction=vertical]:my-1 data-[panel-group-direction=vertical]:mx-0 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-4 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 cursor-col-resize data-[panel-group-direction=vertical]:cursor-row-resize',
33
+ className
34
+ )}
35
+ {...props}
36
+ >
37
+ {withHandle && (
38
+ <div className="z-10 flex h-12 w-1 items-center justify-center rounded-full bg-border/30 hover:bg-border/50 transition-colors opacity-0 group-hover:opacity-100">
39
+ <GripVertical className="h-3 w-3 text-muted-foreground/50" />
40
+ </div>
41
+ )}
42
+ </ResizablePrimitive.PanelResizeHandle>
43
+ )
44
+
45
+ export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
components/ui/select.tsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SelectPrimitive from "@radix-ui/react-select"
5
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Select({
10
+ ...props
11
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12
+ return <SelectPrimitive.Root data-slot="select" {...props} />
13
+ }
14
+
15
+ function SelectGroup({
16
+ ...props
17
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
19
+ }
20
+
21
+ function SelectValue({
22
+ ...props
23
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
25
+ }
26
+
27
+ function SelectTrigger({
28
+ className,
29
+ size = "default",
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
33
+ size?: "sm" | "default"
34
+ }) {
35
+ return (
36
+ <SelectPrimitive.Trigger
37
+ data-slot="select-trigger"
38
+ data-size={size}
39
+ className={cn(
40
+ "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
41
+ className
42
+ )}
43
+ {...props}
44
+ >
45
+ {children}
46
+ <SelectPrimitive.Icon asChild>
47
+ <ChevronDownIcon className="size-4 opacity-50" />
48
+ </SelectPrimitive.Icon>
49
+ </SelectPrimitive.Trigger>
50
+ )
51
+ }
52
+
53
+ function SelectContent({
54
+ className,
55
+ children,
56
+ position = "popper",
57
+ ...props
58
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
59
+ return (
60
+ <SelectPrimitive.Portal>
61
+ <SelectPrimitive.Content
62
+ data-slot="select-content"
63
+ className={cn(
64
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
65
+ position === "popper" &&
66
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
67
+ className
68
+ )}
69
+ position={position}
70
+ {...props}
71
+ >
72
+ <SelectScrollUpButton />
73
+ <SelectPrimitive.Viewport
74
+ className={cn(
75
+ "p-1",
76
+ position === "popper" &&
77
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
78
+ )}
79
+ >
80
+ {children}
81
+ </SelectPrimitive.Viewport>
82
+ <SelectScrollDownButton />
83
+ </SelectPrimitive.Content>
84
+ </SelectPrimitive.Portal>
85
+ )
86
+ }
87
+
88
+ function SelectLabel({
89
+ className,
90
+ ...props
91
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
92
+ return (
93
+ <SelectPrimitive.Label
94
+ data-slot="select-label"
95
+ className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
96
+ {...props}
97
+ />
98
+ )
99
+ }
100
+
101
+ function SelectItem({
102
+ className,
103
+ children,
104
+ ...props
105
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
106
+ return (
107
+ <SelectPrimitive.Item
108
+ data-slot="select-item"
109
+ className={cn(
110
+ "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
111
+ className
112
+ )}
113
+ {...props}
114
+ >
115
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
116
+ <SelectPrimitive.ItemIndicator>
117
+ <CheckIcon className="size-4" />
118
+ </SelectPrimitive.ItemIndicator>
119
+ </span>
120
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
121
+ </SelectPrimitive.Item>
122
+ )
123
+ }
124
+
125
+ function SelectSeparator({
126
+ className,
127
+ ...props
128
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
129
+ return (
130
+ <SelectPrimitive.Separator
131
+ data-slot="select-separator"
132
+ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
133
+ {...props}
134
+ />
135
+ )
136
+ }
137
+
138
+ function SelectScrollUpButton({
139
+ className,
140
+ ...props
141
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
142
+ return (
143
+ <SelectPrimitive.ScrollUpButton
144
+ data-slot="select-scroll-up-button"
145
+ className={cn(
146
+ "flex cursor-default items-center justify-center py-1",
147
+ className
148
+ )}
149
+ {...props}
150
+ >
151
+ <ChevronUpIcon className="size-4" />
152
+ </SelectPrimitive.ScrollUpButton>
153
+ )
154
+ }
155
+
156
+ function SelectScrollDownButton({
157
+ className,
158
+ ...props
159
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
160
+ return (
161
+ <SelectPrimitive.ScrollDownButton
162
+ data-slot="select-scroll-down-button"
163
+ className={cn(
164
+ "flex cursor-default items-center justify-center py-1",
165
+ className
166
+ )}
167
+ {...props}
168
+ >
169
+ <ChevronDownIcon className="size-4" />
170
+ </SelectPrimitive.ScrollDownButton>
171
+ )
172
+ }
173
+
174
+ export {
175
+ Select,
176
+ SelectContent,
177
+ SelectGroup,
178
+ SelectItem,
179
+ SelectLabel,
180
+ SelectScrollDownButton,
181
+ SelectScrollUpButton,
182
+ SelectSeparator,
183
+ SelectTrigger,
184
+ SelectValue,
185
+ }
components/ui/sonner.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useTheme } from "next-themes";
4
+ import { Toaster as Sonner, ToasterProps } from "sonner";
5
+
6
+ const Toaster = ({ ...props }: ToasterProps) => {
7
+ const { theme } = useTheme();
8
+
9
+ return (
10
+ <Sonner
11
+ theme={theme as ToasterProps["theme"]}
12
+ className="toaster group"
13
+ closeButton
14
+ style={
15
+ {
16
+ "--normal-bg": "var(--popover)",
17
+ "--normal-text": "var(--popover-foreground)",
18
+ "--normal-border": "var(--border)",
19
+ } as React.CSSProperties
20
+ }
21
+ {...props}
22
+ />
23
+ );
24
+ };
25
+
26
+ export { Toaster };
components/ui/spinner.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from '@/lib/utils';
2
+
3
+ interface SpinnerProps {
4
+ className?: string;
5
+ size?: number;
6
+ color?: string;
7
+ }
8
+
9
+ export function Spinner({ className, size = 48, color }: SpinnerProps) {
10
+ const r = 18; // radius
11
+ const circumference = 2 * Math.PI * r;
12
+ const arcLength = circumference * 0.7; // 70% arc
13
+
14
+ return (
15
+ <svg
16
+ className={cn('animate-spin', className)}
17
+ width={size}
18
+ height={size}
19
+ viewBox="0 0 44 44"
20
+ fill="none"
21
+ xmlns="http://www.w3.org/2000/svg"
22
+ >
23
+ <circle
24
+ cx="22"
25
+ cy="22"
26
+ r={r}
27
+ stroke={color || 'currentColor'}
28
+ strokeWidth="3"
29
+ strokeLinecap="round"
30
+ strokeDasharray={`${arcLength} ${circumference - arcLength}`}
31
+ />
32
+ </svg>
33
+ );
34
+ }
components/ui/switch.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SwitchPrimitive from "@radix-ui/react-switch"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Switch({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
12
+ return (
13
+ <SwitchPrimitive.Root
14
+ data-slot="switch"
15
+ className={cn(
16
+ "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
17
+ className
18
+ )}
19
+ {...props}
20
+ >
21
+ <SwitchPrimitive.Thumb
22
+ data-slot="switch-thumb"
23
+ className={cn(
24
+ "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
25
+ )}
26
+ />
27
+ </SwitchPrimitive.Root>
28
+ )
29
+ }
30
+
31
+ export { Switch }
components/ui/tabs.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import * as TabsPrimitive from '@radix-ui/react-tabs';
5
+
6
+ import { cn } from '@/lib/utils';
7
+
8
+ const Tabs = TabsPrimitive.Root;
9
+
10
+ const TabsList = React.forwardRef<
11
+ React.ElementRef<typeof TabsPrimitive.List>,
12
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
13
+ >(({ className, ...props }, ref) => (
14
+ <TabsPrimitive.List
15
+ ref={ref}
16
+ className={cn(
17
+ 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
18
+ className
19
+ )}
20
+ {...props}
21
+ />
22
+ ));
23
+ TabsList.displayName = TabsPrimitive.List.displayName;
24
+
25
+ const TabsTrigger = React.forwardRef<
26
+ React.ElementRef<typeof TabsPrimitive.Trigger>,
27
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
28
+ >(({ className, ...props }, ref) => (
29
+ <TabsPrimitive.Trigger
30
+ ref={ref}
31
+ className={cn(
32
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
33
+ className
34
+ )}
35
+ {...props}
36
+ />
37
+ ));
38
+ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39
+
40
+ const TabsContent = React.forwardRef<
41
+ React.ElementRef<typeof TabsPrimitive.Content>,
42
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
43
+ >(({ className, ...props }, ref) => (
44
+ <TabsPrimitive.Content
45
+ ref={ref}
46
+ className={cn(
47
+ 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
48
+ className
49
+ )}
50
+ {...props}
51
+ />
52
+ ));
53
+ TabsContent.displayName = TabsPrimitive.Content.displayName;
54
+
55
+ export { Tabs, TabsList, TabsTrigger, TabsContent };
components/ui/textarea.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Textarea = React.forwardRef<
6
+ HTMLTextAreaElement,
7
+ React.ComponentProps<"textarea">
8
+ >(({ className, ...props }, ref) => {
9
+ return (
10
+ <textarea
11
+ className={cn(
12
+ "flex min-h-[84px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
13
+ className
14
+ )}
15
+ ref={ref}
16
+ {...props}
17
+ />
18
+ )
19
+ })
20
+ Textarea.displayName = "Textarea"
21
+
22
+ export { Textarea }
components/ui/thumbnail-area.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useRef, useState } from 'react';
4
+ import { Camera, ImageUp, X, Loader2 } from 'lucide-react';
5
+ import { compressImage } from '@/lib/utils/image-compress';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ interface ThumbnailAreaProps {
9
+ image: string | undefined;
10
+ onCapture?: () => Promise<string | null>; // undefined = capture unavailable
11
+ onImageChange: (image: string | undefined) => void;
12
+ size?: 'sm' | 'md';
13
+ className?: string;
14
+ }
15
+
16
+ export function ThumbnailArea({
17
+ image,
18
+ onCapture,
19
+ onImageChange,
20
+ size = 'md',
21
+ className,
22
+ }: ThumbnailAreaProps) {
23
+ const fileInputRef = useRef<HTMLInputElement>(null);
24
+ const [isCapturing, setIsCapturing] = useState(false);
25
+
26
+ const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
27
+ const file = e.target.files?.[0];
28
+ if (!file) return;
29
+ try {
30
+ const compressed = await compressImage(file);
31
+ onImageChange(compressed);
32
+ } catch {
33
+ // silently fail — user can retry
34
+ }
35
+ // Reset input so re-selecting the same file triggers onChange
36
+ e.target.value = '';
37
+ };
38
+
39
+ const handleCapture = async (e: React.MouseEvent) => {
40
+ e.stopPropagation();
41
+ if (!onCapture) return;
42
+ setIsCapturing(true);
43
+ try {
44
+ const result = await onCapture();
45
+ if (result) onImageChange(result);
46
+ } finally {
47
+ setIsCapturing(false);
48
+ }
49
+ };
50
+
51
+ const handleRemove = (e: React.MouseEvent) => {
52
+ e.stopPropagation();
53
+ onImageChange(undefined);
54
+ };
55
+
56
+ const handleUploadClick = (e: React.MouseEvent) => {
57
+ e.stopPropagation();
58
+ fileInputRef.current?.click();
59
+ };
60
+
61
+ const isSm = size === 'sm';
62
+
63
+ const btnBase = isSm
64
+ ? 'h-5 w-5 rounded'
65
+ : 'h-7 w-7 rounded-md';
66
+
67
+ const iconSize = isSm ? 'h-3 w-3' : 'h-3.5 w-3.5';
68
+
69
+ const stopProp = (e: React.MouseEvent) => e.stopPropagation();
70
+
71
+ if (image) {
72
+ return (
73
+ <div className={cn('relative group', className)} onClick={stopProp}>
74
+ {isSm ? (
75
+ <div className="w-16 h-12 rounded-md overflow-hidden bg-muted shrink-0">
76
+ <img src={image} alt="Thumbnail" className="w-full h-full object-cover" />
77
+ </div>
78
+ ) : (
79
+ <div className="w-full aspect-video bg-muted">
80
+ <img src={image} alt="Thumbnail" className="w-full h-full object-cover" />
81
+ </div>
82
+ )}
83
+ <button
84
+ type="button"
85
+ onClick={handleRemove}
86
+ className={cn(
87
+ 'absolute flex items-center justify-center bg-background/80 text-foreground opacity-0 group-hover:opacity-100 transition-opacity border border-border shadow-sm',
88
+ btnBase,
89
+ isSm ? 'top-0 right-0 -translate-y-1/3 translate-x-1/3' : 'top-1.5 right-1.5'
90
+ )}
91
+ title="Remove thumbnail"
92
+ >
93
+ <X className={iconSize} />
94
+ </button>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ // No image state
100
+ return (
101
+ <div className={cn('relative', className)} onClick={stopProp}>
102
+ {isSm ? (
103
+ <div className="w-16 h-12 rounded-md bg-muted flex items-center justify-center gap-1 shrink-0">
104
+ {onCapture && (
105
+ <button
106
+ type="button"
107
+ onClick={handleCapture}
108
+ disabled={isCapturing}
109
+ className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted-foreground/15 transition-colors"
110
+ title="Capture"
111
+ >
112
+ {isCapturing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Camera className="h-3 w-3" />}
113
+ </button>
114
+ )}
115
+ <button
116
+ type="button"
117
+ onClick={handleUploadClick}
118
+ className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted-foreground/15 transition-colors"
119
+ title="Upload image"
120
+ >
121
+ <ImageUp className="h-3 w-3" />
122
+ </button>
123
+ </div>
124
+ ) : (
125
+ <div className="w-full aspect-video bg-muted flex items-center justify-center gap-3">
126
+ {onCapture && (
127
+ <button
128
+ type="button"
129
+ onClick={handleCapture}
130
+ disabled={isCapturing}
131
+ className="h-9 w-9 rounded-lg flex items-center justify-center border border-border/60 bg-background/50 text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors shadow-sm"
132
+ title="Capture screenshot"
133
+ >
134
+ {isCapturing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
135
+ </button>
136
+ )}
137
+ <button
138
+ type="button"
139
+ onClick={handleUploadClick}
140
+ className="h-9 w-9 rounded-lg flex items-center justify-center border border-border/60 bg-background/50 text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors shadow-sm"
141
+ title="Upload image"
142
+ >
143
+ <ImageUp className="h-4 w-4" />
144
+ </button>
145
+ </div>
146
+ )}
147
+ <input
148
+ ref={fileInputRef}
149
+ type="file"
150
+ accept="image/*"
151
+ className="hidden"
152
+ onChange={handleUpload}
153
+ />
154
+ </div>
155
+ );
156
+ }
components/ui/toggle-group.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const toggleGroupVariants = cva(
10
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
11
+ {
12
+ variants: {
13
+ variant: {
14
+ default: "bg-transparent",
15
+ outline:
16
+ "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
17
+ },
18
+ size: {
19
+ default: "h-9 px-3",
20
+ sm: "h-8 px-2",
21
+ lg: "h-10 px-3",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ variant: "default",
26
+ size: "default",
27
+ },
28
+ }
29
+ )
30
+
31
+ const ToggleGroup = React.forwardRef<
32
+ React.ElementRef<typeof ToggleGroupPrimitive.Root>,
33
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
34
+ >(({ className, ...props }, ref) => (
35
+ <ToggleGroupPrimitive.Root
36
+ ref={ref}
37
+ className={cn("inline-flex items-center justify-center gap-1 rounded-md bg-muted p-1", className)}
38
+ {...props}
39
+ />
40
+ ))
41
+
42
+ ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
43
+
44
+ const ToggleGroupItem = React.forwardRef<
45
+ React.ElementRef<typeof ToggleGroupPrimitive.Item>,
46
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
47
+ VariantProps<typeof toggleGroupVariants>
48
+ >(({ className, variant, size, ...props }, ref) => (
49
+ <ToggleGroupPrimitive.Item
50
+ ref={ref}
51
+ className={cn(toggleGroupVariants({ variant, size }), className)}
52
+ {...props}
53
+ />
54
+ ))
55
+
56
+ ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
57
+
58
+ export { ToggleGroup, ToggleGroupItem, toggleGroupVariants }
components/ui/tooltip.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function TooltipProvider({
9
+ delayDuration = 0,
10
+ ...props
11
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
12
+ return (
13
+ <TooltipPrimitive.Provider
14
+ data-slot="tooltip-provider"
15
+ delayDuration={delayDuration}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ function Tooltip({
22
+ ...props
23
+ }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
24
+ return (
25
+ <TooltipProvider>
26
+ <TooltipPrimitive.Root data-slot="tooltip" {...props} />
27
+ </TooltipProvider>
28
+ )
29
+ }
30
+
31
+ function TooltipTrigger({
32
+ ...props
33
+ }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
34
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
35
+ }
36
+
37
+ function TooltipContent({
38
+ className,
39
+ sideOffset = 0,
40
+ children,
41
+ arrowStyle,
42
+ ...props
43
+ }: React.ComponentProps<typeof TooltipPrimitive.Content> & { arrowStyle?: React.CSSProperties }) {
44
+ return (
45
+ <TooltipPrimitive.Portal>
46
+ <TooltipPrimitive.Content
47
+ data-slot="tooltip-content"
48
+ sideOffset={sideOffset}
49
+ className={cn(
50
+ "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
51
+ className
52
+ )}
53
+ {...props}
54
+ >
55
+ {children}
56
+ <TooltipPrimitive.Arrow
57
+ className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]"
58
+ style={arrowStyle}
59
+ />
60
+ </TooltipPrimitive.Content>
61
+ </TooltipPrimitive.Portal>
62
+ )
63
+ }
64
+
65
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
components/views/content-area.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Project } from '@/lib/vfs/types';
5
+ import { logger } from '@/lib/utils';
6
+ import { ProjectsView } from './projects-view';
7
+ import { TemplatesView } from './templates-view';
8
+ import { SkillsView } from './skills-view';
9
+ import { InterviewsView } from './interviews-view';
10
+ import { DeploymentsView } from './deployments-view';
11
+ import { UsersView } from './users-view';
12
+ import { WorkspacesView } from './workspaces-view';
13
+ import { SettingsView } from './settings-view';
14
+ import { DocsView } from './docs-view';
15
+ import { DashboardView } from './dashboard-view';
16
+
17
+ interface ContentAreaProps {
18
+ view: string;
19
+ workspaceId?: string;
20
+ onProjectSelect: (project: Project) => void;
21
+ settingsTab?: 'model' | 'application';
22
+ onNavigate?: (view: string) => void;
23
+ onStartTour?: () => void;
24
+ autoCreateProject?: boolean;
25
+ }
26
+
27
+ export function ContentArea({
28
+ view,
29
+ workspaceId,
30
+ onProjectSelect,
31
+ settingsTab,
32
+ onNavigate,
33
+ onStartTour,
34
+ autoCreateProject,
35
+ }: ContentAreaProps) {
36
+ // Handler to select a project by ID (for dashboard recent projects click)
37
+ const handleProjectSelectById = async (projectId: string) => {
38
+ try {
39
+ const { vfs } = await import('@/lib/vfs');
40
+ await vfs.init();
41
+ const project = await vfs.getProject(projectId);
42
+ if (project) {
43
+ onProjectSelect(project);
44
+ } else {
45
+ logger.warn('[ContentArea] Project not found:', projectId);
46
+ }
47
+ } catch (err) {
48
+ logger.error('[ContentArea] Failed to load project:', err);
49
+ }
50
+ };
51
+
52
+ switch (view) {
53
+ case 'dashboard':
54
+ return (
55
+ <DashboardView
56
+ workspaceId={workspaceId}
57
+ onNavigate={onNavigate}
58
+ onProjectSelect={handleProjectSelectById}
59
+ onStartTour={onStartTour}
60
+ />
61
+ );
62
+ case 'projects':
63
+ return <ProjectsView onProjectSelect={onProjectSelect} autoCreate={autoCreateProject} workspaceId={workspaceId} />;
64
+ case 'deployments':
65
+ return <DeploymentsView onProjectSelect={onProjectSelect} workspaceId={workspaceId} />;
66
+ case 'users':
67
+ return <UsersView />;
68
+ case 'workspaces':
69
+ return <WorkspacesView />;
70
+ case 'templates':
71
+ return <TemplatesView onProjectSelect={(project) => handleProjectSelectById(project.id)} onNavigate={onNavigate} />;
72
+ case 'skills':
73
+ return <SkillsView />;
74
+ case 'interviews':
75
+ return <InterviewsView />;
76
+ case 'docs':
77
+ return <DocsView />;
78
+ case 'settings':
79
+ return <SettingsView tab={settingsTab} />;
80
+ default:
81
+ return <ProjectsView onProjectSelect={onProjectSelect} />;
82
+ }
83
+ }
components/views/dashboard-view.tsx ADDED
@@ -0,0 +1,849 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import {
6
+ RefreshCw,
7
+ Plus,
8
+ FolderOpen,
9
+ Globe,
10
+ Sparkles,
11
+ BookOpen,
12
+ AlertTriangle,
13
+ ExternalLink,
14
+ Newspaper,
15
+ Clock,
16
+ ChevronRight,
17
+ } from 'lucide-react';
18
+ import { DiscordIcon } from '@/components/ui/discord-icon';
19
+ import { Button } from '@/components/ui/button';
20
+ import { Spinner } from '@/components/ui/spinner';
21
+ import Link from 'next/link';
22
+ import { vfs } from '@/lib/vfs';
23
+ import { templateService } from '@/lib/vfs/template-service';
24
+ import { skillsService } from '@/lib/vfs/skills';
25
+ import pkg from '@/package.json';
26
+
27
+ const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';
28
+
29
+ interface DashboardData {
30
+ system: {
31
+ version: string;
32
+ nodeVersion: string;
33
+ uptime: number;
34
+ memoryUsed: number;
35
+ memoryTotal: number;
36
+ };
37
+ content: {
38
+ projects: number;
39
+ templates: number;
40
+ skills: number;
41
+ totalFiles: number;
42
+ };
43
+ hosting: {
44
+ publishedDeployments: number;
45
+ deploymentsWithDb: number;
46
+ storageUsed: number;
47
+ };
48
+ traffic: {
49
+ requestsLastHour: number;
50
+ requestsLastDay: number;
51
+ errorCount: number;
52
+ topDeployments: Array<{ deploymentId: string; deploymentName: string; count: number }>;
53
+ recentErrors: Array<{ deploymentId: string; path: string; statusCode: number; timestamp: string }>;
54
+ };
55
+ whatsNew: {
56
+ version: string;
57
+ title: string;
58
+ highlights: string[];
59
+ };
60
+ recentProjects: Array<{
61
+ id: string;
62
+ name: string;
63
+ description: string | null;
64
+ updatedAt: string;
65
+ }>;
66
+ recentDeployments: Array<{
67
+ id: string;
68
+ name: string;
69
+ slug: string;
70
+ enabled: boolean;
71
+ publishedAt: string | null;
72
+ updatedAt: string;
73
+ }>;
74
+ }
75
+
76
+ // Browser mode data (subset of server mode)
77
+ interface BrowserDashboardData {
78
+ content: {
79
+ projects: number;
80
+ templates: number;
81
+ skills: number;
82
+ };
83
+ whatsNew: {
84
+ version: string;
85
+ title: string;
86
+ highlights: string[];
87
+ } | null;
88
+ recentProjects: Array<{
89
+ id: string;
90
+ name: string;
91
+ description: string | null;
92
+ updatedAt: string;
93
+ }>;
94
+ }
95
+
96
+ // Fetch and parse the latest What's New entry from WHATS_NEW.md
97
+ async function fetchWhatsNew(): Promise<BrowserDashboardData['whatsNew']> {
98
+ try {
99
+ const response = await fetch('/api/docs/WHATS_NEW.md');
100
+ if (!response.ok) return null;
101
+
102
+ const content = await response.text();
103
+ const versionMatch = content.match(/^## v(\d+\.\d+\.\d+)\s*-\s*(.+)$/m);
104
+ if (!versionMatch) return null;
105
+
106
+ const version = versionMatch[1];
107
+ const title = versionMatch[2].trim();
108
+ const versionIndex = content.indexOf(versionMatch[0]);
109
+ const afterVersion = content.substring(versionIndex + versionMatch[0].length);
110
+ const nextSectionMatch = afterVersion.match(/^(?:## |---)/m);
111
+ const sectionContent = nextSectionMatch
112
+ ? afterVersion.substring(0, nextSectionMatch.index)
113
+ : afterVersion;
114
+
115
+ const bulletRegex = /^[-*]\s+\*\*(.+?)\*\*\s*[-–]?\s*(.*)$/gm;
116
+ const highlights: string[] = [];
117
+ let match;
118
+
119
+ while ((match = bulletRegex.exec(sectionContent)) !== null && highlights.length < 4) {
120
+ const boldTitle = match[1].trim();
121
+ const description = match[2]?.trim();
122
+ highlights.push(description ? `${boldTitle} - ${description}` : boldTitle);
123
+ }
124
+
125
+ if (highlights.length === 0) {
126
+ const simpleBulletRegex = /^[-*]\s+(.+)$/gm;
127
+ while ((match = simpleBulletRegex.exec(sectionContent)) !== null && highlights.length < 4) {
128
+ const text = match[1].trim();
129
+ if (!text.match(/^\[.*\]\(.*\)$/)) {
130
+ highlights.push(text.replace(/\*\*/g, ''));
131
+ }
132
+ }
133
+ }
134
+
135
+ return { version, title, highlights };
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ // Fetch dashboard data for browser mode (IndexedDB)
142
+ async function fetchBrowserModeData(): Promise<BrowserDashboardData> {
143
+ await vfs.init();
144
+
145
+ const projects = await vfs.listProjects();
146
+ const templates = await templateService.listCustomTemplates();
147
+ const skills = await skillsService.getAllSkills();
148
+ const whatsNew = await fetchWhatsNew();
149
+
150
+ return {
151
+ content: {
152
+ projects: projects.length,
153
+ templates: templates.length,
154
+ skills: skills.length,
155
+ },
156
+ whatsNew,
157
+ recentProjects: projects
158
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
159
+ .slice(0, 3)
160
+ .map((p) => ({
161
+ id: p.id,
162
+ name: p.name,
163
+ description: p.description || null,
164
+ updatedAt: p.updatedAt instanceof Date ? p.updatedAt.toISOString() : p.updatedAt,
165
+ })),
166
+ };
167
+ }
168
+
169
+ function formatUptime(seconds: number): string {
170
+ const days = Math.floor(seconds / 86400);
171
+ const hours = Math.floor((seconds % 86400) / 3600);
172
+ const minutes = Math.floor((seconds % 3600) / 60);
173
+
174
+ if (days > 0) {
175
+ return `${days}d ${hours}h`;
176
+ }
177
+ if (hours > 0) {
178
+ return `${hours}h ${minutes}m`;
179
+ }
180
+ return `${minutes}m`;
181
+ }
182
+
183
+ function formatBytes(bytes: number): string {
184
+ if (bytes === 0) return '0 B';
185
+ const k = 1024;
186
+ const sizes = ['B', 'KB', 'MB', 'GB'];
187
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
188
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
189
+ }
190
+
191
+ function formatNumber(num: number): string {
192
+ if (num >= 1000000) {
193
+ return (num / 1000000).toFixed(1) + 'M';
194
+ }
195
+ if (num >= 1000) {
196
+ return (num / 1000).toFixed(1) + 'K';
197
+ }
198
+ return num.toString();
199
+ }
200
+
201
+ function formatRelativeTime(dateString: string): string {
202
+ const date = new Date(dateString);
203
+ const now = new Date();
204
+ const diffMs = now.getTime() - date.getTime();
205
+ const diffMins = Math.floor(diffMs / 60000);
206
+ const diffHours = Math.floor(diffMs / 3600000);
207
+ const diffDays = Math.floor(diffMs / 86400000);
208
+
209
+ if (diffMins < 1) return 'just now';
210
+ if (diffMins < 60) return `${diffMins}m ago`;
211
+ if (diffHours < 24) return `${diffHours}h ago`;
212
+ if (diffDays < 7) return `${diffDays}d ago`;
213
+ return date.toLocaleDateString();
214
+ }
215
+
216
+ // Quick Actions Bar
217
+ function QuickActionsBar({
218
+ onStartTour,
219
+ onNavigate,
220
+ }: {
221
+ onStartTour: () => void;
222
+ onNavigate?: (view: string) => void;
223
+ }) {
224
+ // In browser mode, use onNavigate callback; in server mode, use Link
225
+ const projectsHref = isServerMode ? '/admin/projects?action=create' : '#';
226
+ const projectsListHref = isServerMode ? '/admin/projects' : '#';
227
+ const docsHref = isServerMode ? '/admin/docs' : '#';
228
+
229
+ const handleNewProjectClick = (e: React.MouseEvent) => {
230
+ if (!isServerMode && onNavigate) {
231
+ e.preventDefault();
232
+ onNavigate('projects:create');
233
+ }
234
+ };
235
+
236
+ const handleProjectsClick = (e: React.MouseEvent) => {
237
+ if (!isServerMode && onNavigate) {
238
+ e.preventDefault();
239
+ onNavigate('projects');
240
+ }
241
+ };
242
+
243
+ const handleDocsClick = (e: React.MouseEvent) => {
244
+ if (!isServerMode && onNavigate) {
245
+ e.preventDefault();
246
+ onNavigate('docs');
247
+ }
248
+ };
249
+
250
+ return (
251
+ <div className="bg-card rounded-xl border border-zinc-800 p-4 mb-6">
252
+ <div className="flex flex-wrap gap-2">
253
+ <Button variant="default" size="sm" asChild className="gap-1.5">
254
+ <Link href={projectsHref} onClick={handleNewProjectClick}>
255
+ <Plus className="w-4 h-4" />
256
+ New Project
257
+ </Link>
258
+ </Button>
259
+ <Button variant="outline" size="sm" asChild className="gap-1.5">
260
+ <Link href={projectsListHref} onClick={handleProjectsClick}>
261
+ <FolderOpen className="w-4 h-4" />
262
+ Projects
263
+ </Link>
264
+ </Button>
265
+ {isServerMode && (
266
+ <Button variant="outline" size="sm" asChild className="gap-1.5">
267
+ <Link href="/admin/deployments">
268
+ <Globe className="w-4 h-4" />
269
+ Deployments
270
+ </Link>
271
+ </Button>
272
+ )}
273
+ <Button variant="outline" size="sm" onClick={onStartTour} className="gap-1.5">
274
+ <Sparkles className="w-4 h-4" />
275
+ Guided Tour
276
+ </Button>
277
+ <Button variant="outline" size="sm" asChild className="gap-1.5">
278
+ <a href="https://discord.gg/mAJ8Ss4u" target="_blank" rel="noopener noreferrer">
279
+ <DiscordIcon className="w-4 h-4" />
280
+ Discord
281
+ </a>
282
+ </Button>
283
+ <Button variant="outline" size="sm" asChild className="gap-1.5">
284
+ <Link href={docsHref} onClick={handleDocsClick}>
285
+ <BookOpen className="w-4 h-4" />
286
+ Docs
287
+ </Link>
288
+ </Button>
289
+ </div>
290
+ </div>
291
+ );
292
+ }
293
+
294
+ // What's New Card - always visible, links to full changelog
295
+ type WhatsNewProps = {
296
+ version: string;
297
+ title: string;
298
+ highlights?: string[];
299
+ };
300
+
301
+ function WhatsNewCard({
302
+ whatsNew,
303
+ onNavigate,
304
+ }: {
305
+ whatsNew: WhatsNewProps;
306
+ onNavigate?: (view: string) => void;
307
+ }) {
308
+ if (!whatsNew) return null;
309
+
310
+ const handleReadAll = (e: React.MouseEvent) => {
311
+ if (!isServerMode && onNavigate) {
312
+ e.preventDefault();
313
+ window.history.pushState({}, '', '/?doc=whats-new');
314
+ onNavigate('docs');
315
+ }
316
+ };
317
+
318
+ const docsHref = isServerMode ? '/admin/docs?doc=whats-new' : '#';
319
+
320
+ return (
321
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
322
+ <div className="flex items-start justify-between gap-4 mb-3">
323
+ <div className="flex items-center gap-2">
324
+ <Newspaper className="w-4 h-4 text-orange-500" />
325
+ <h3 className="text-sm font-medium text-zinc-200">
326
+ What&apos;s New in v{whatsNew.version}
327
+ </h3>
328
+ </div>
329
+ <Link
330
+ href={docsHref}
331
+ onClick={handleReadAll}
332
+ className="text-xs text-orange-500 hover:text-orange-400 flex items-center gap-1"
333
+ >
334
+ Read all
335
+ <ExternalLink className="w-3 h-3" />
336
+ </Link>
337
+ </div>
338
+ <p className="text-sm font-medium text-zinc-200 mb-2">{whatsNew.title}</p>
339
+ {whatsNew.highlights && whatsNew.highlights.length > 0 && (
340
+ <ul className="space-y-1 flex-1">
341
+ {whatsNew.highlights.map((highlight, i) => (
342
+ <li key={i} className="text-xs text-zinc-300 flex items-start gap-2">
343
+ <span className="text-orange-500/70 mt-0.5">•</span>
344
+ <span>{highlight}</span>
345
+ </li>
346
+ ))}
347
+ </ul>
348
+ )}
349
+ </div>
350
+ );
351
+ }
352
+
353
+ // Compact System Overview (server mode - full stats)
354
+ function CompactOverview({
355
+ data,
356
+ loading,
357
+ onRefresh,
358
+ }: {
359
+ data: DashboardData;
360
+ loading: boolean;
361
+ onRefresh: () => void;
362
+ }) {
363
+ const stats = [
364
+ { label: 'Version', value: `v${data.system.version}` },
365
+ { label: 'Projects', value: formatNumber(data.content.projects) },
366
+ { label: 'Deployments', value: formatNumber(data.hosting.publishedDeployments) },
367
+ { label: 'Traffic/h', value: formatNumber(data.traffic.requestsLastHour) },
368
+ { label: 'Traffic/d', value: formatNumber(data.traffic.requestsLastDay) },
369
+ { label: 'Errors', value: formatNumber(data.traffic.errorCount), highlight: data.traffic.errorCount > 0 },
370
+ { label: 'Memory', value: formatBytes(data.system.memoryUsed) },
371
+ { label: 'Uptime', value: formatUptime(data.system.uptime) },
372
+ ];
373
+
374
+ const half = Math.ceil(stats.length / 2);
375
+ const leftColumn = stats.slice(0, half);
376
+ const rightColumn = stats.slice(half);
377
+
378
+ return (
379
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
380
+ <div className="flex items-center justify-between mb-3">
381
+ <h3 className="text-sm font-medium text-zinc-300">System Overview</h3>
382
+ <Button
383
+ variant="ghost"
384
+ size="sm"
385
+ onClick={onRefresh}
386
+ disabled={loading}
387
+ className="h-7 px-2"
388
+ >
389
+ <RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
390
+ </Button>
391
+ </div>
392
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5 flex-1 content-start">
393
+ <div className="space-y-1.5">
394
+ {leftColumn.map((stat) => (
395
+ <div key={stat.label} className="flex items-center justify-between">
396
+ <span className="text-xs text-zinc-500">{stat.label}</span>
397
+ <span className={`text-sm font-medium ${stat.highlight ? 'text-yellow-500' : 'text-zinc-200'}`}>
398
+ {stat.value}
399
+ </span>
400
+ </div>
401
+ ))}
402
+ </div>
403
+ <div className="space-y-1.5">
404
+ {rightColumn.map((stat) => (
405
+ <div key={stat.label} className="flex items-center justify-between">
406
+ <span className="text-xs text-zinc-500">{stat.label}</span>
407
+ <span className={`text-sm font-medium ${stat.highlight ? 'text-yellow-500' : 'text-zinc-200'}`}>
408
+ {stat.value}
409
+ </span>
410
+ </div>
411
+ ))}
412
+ </div>
413
+ </div>
414
+ </div>
415
+ );
416
+ }
417
+
418
+ // Browser mode Overview (subset of stats)
419
+ function BrowserOverview({
420
+ data,
421
+ loading,
422
+ onRefresh,
423
+ }: {
424
+ data: BrowserDashboardData;
425
+ loading: boolean;
426
+ onRefresh: () => void;
427
+ }) {
428
+ const stats = [
429
+ { label: 'Version', value: `v${pkg.version}` },
430
+ { label: 'Projects', value: formatNumber(data.content.projects) },
431
+ { label: 'Templates', value: formatNumber(data.content.templates) },
432
+ { label: 'Skills', value: formatNumber(data.content.skills) },
433
+ ];
434
+
435
+ const half = Math.ceil(stats.length / 2);
436
+ const leftColumn = stats.slice(0, half);
437
+ const rightColumn = stats.slice(half);
438
+
439
+ return (
440
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
441
+ <div className="flex items-center justify-between mb-3">
442
+ <h3 className="text-sm font-medium text-zinc-300">Content Overview</h3>
443
+ <Button
444
+ variant="ghost"
445
+ size="sm"
446
+ onClick={onRefresh}
447
+ disabled={loading}
448
+ className="h-7 px-2"
449
+ >
450
+ <RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
451
+ </Button>
452
+ </div>
453
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5 flex-1 content-start">
454
+ <div className="space-y-1.5">
455
+ {leftColumn.map((stat) => (
456
+ <div key={stat.label} className="flex items-center justify-between">
457
+ <span className="text-xs text-zinc-500">{stat.label}</span>
458
+ <span className="text-sm font-medium text-zinc-200">{stat.value}</span>
459
+ </div>
460
+ ))}
461
+ </div>
462
+ <div className="space-y-1.5">
463
+ {rightColumn.map((stat) => (
464
+ <div key={stat.label} className="flex items-center justify-between">
465
+ <span className="text-xs text-zinc-500">{stat.label}</span>
466
+ <span className="text-sm font-medium text-zinc-200">{stat.value}</span>
467
+ </div>
468
+ ))}
469
+ </div>
470
+ </div>
471
+ </div>
472
+ );
473
+ }
474
+
475
+ // Recent Projects Card
476
+ function RecentProjectsCard({
477
+ projects,
478
+ onNavigate,
479
+ onProjectSelect,
480
+ }: {
481
+ projects: DashboardData['recentProjects'] | BrowserDashboardData['recentProjects'];
482
+ onNavigate?: (view: string) => void;
483
+ onProjectSelect?: (projectId: string) => void;
484
+ }) {
485
+ const handleViewAll = (e: React.MouseEvent) => {
486
+ if (!isServerMode && onNavigate) {
487
+ e.preventDefault();
488
+ onNavigate('projects');
489
+ }
490
+ };
491
+
492
+ const handleProjectClick = (e: React.MouseEvent, projectId: string) => {
493
+ if (!isServerMode && onProjectSelect) {
494
+ e.preventDefault();
495
+ onProjectSelect(projectId);
496
+ }
497
+ };
498
+
499
+ const viewAllHref = isServerMode ? '/admin/projects' : '#';
500
+
501
+ return (
502
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
503
+ <div className="flex items-center justify-between mb-3">
504
+ <div className="flex items-center gap-2">
505
+ <FolderOpen className="w-4 h-4 text-orange-500" />
506
+ <h3 className="text-sm font-medium text-zinc-300">Recent Projects</h3>
507
+ </div>
508
+ <Link
509
+ href={viewAllHref}
510
+ onClick={handleViewAll}
511
+ className="text-xs text-zinc-500 hover:text-zinc-300 flex items-center gap-0.5"
512
+ >
513
+ View all
514
+ <ChevronRight className="w-3 h-3" />
515
+ </Link>
516
+ </div>
517
+ {projects.length === 0 ? (
518
+ <p className="text-xs text-zinc-500 text-center py-2 flex-1 flex items-center justify-center">
519
+ No projects yet
520
+ </p>
521
+ ) : (
522
+ <div className="space-y-1.5 flex-1">
523
+ {projects.slice(0, 3).map((project) => (
524
+ <Link
525
+ key={project.id}
526
+ href={isServerMode ? `/admin/projects?open=${project.id}` : '#'}
527
+ onClick={(e) => handleProjectClick(e, project.id)}
528
+ className="flex items-center justify-between text-xs py-1.5 px-2 bg-zinc-900/50 rounded hover:bg-zinc-800/50 transition-colors"
529
+ >
530
+ <span className="text-zinc-300 truncate flex-1 mr-2">{project.name}</span>
531
+ <span className="text-zinc-500 shrink-0 flex items-center gap-1">
532
+ <Clock className="w-3 h-3" />
533
+ {formatRelativeTime(project.updatedAt)}
534
+ </span>
535
+ </Link>
536
+ ))}
537
+ </div>
538
+ )}
539
+ </div>
540
+ );
541
+ }
542
+
543
+ // Recent Deployments Card
544
+ function RecentDeploymentsCard({ deployments }: { deployments: DashboardData['recentDeployments'] }) {
545
+ return (
546
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
547
+ <div className="flex items-center justify-between mb-3">
548
+ <div className="flex items-center gap-2">
549
+ <Globe className="w-4 h-4 text-orange-500" />
550
+ <h3 className="text-sm font-medium text-zinc-300">Recent Deployments</h3>
551
+ </div>
552
+ <Link
553
+ href="/admin/deployments"
554
+ className="text-xs text-zinc-500 hover:text-zinc-300 flex items-center gap-0.5"
555
+ >
556
+ View all
557
+ <ChevronRight className="w-3 h-3" />
558
+ </Link>
559
+ </div>
560
+ {deployments.length === 0 ? (
561
+ <p className="text-xs text-zinc-500 text-center py-2 flex-1 flex items-center justify-center">
562
+ No deployments yet
563
+ </p>
564
+ ) : (
565
+ <div className="space-y-1.5 flex-1">
566
+ {deployments.slice(0, 3).map((deployment) => (
567
+ <Link
568
+ key={deployment.id}
569
+ href={`/admin/deployments?open=${deployment.id}`}
570
+ className="flex items-center justify-between text-xs py-1.5 px-2 bg-zinc-900/50 rounded hover:bg-zinc-800/50 transition-colors"
571
+ >
572
+ <div className="flex items-center gap-2 min-w-0 flex-1">
573
+ <span
574
+ className={`w-1.5 h-1.5 rounded-full shrink-0 ${
575
+ deployment.enabled ? 'bg-green-500' : 'bg-zinc-500'
576
+ }`}
577
+ />
578
+ <span className="text-zinc-300 truncate">{deployment.name}</span>
579
+ </div>
580
+ <span className="text-zinc-500 shrink-0 flex items-center gap-1">
581
+ <Clock className="w-3 h-3" />
582
+ {formatRelativeTime(deployment.updatedAt)}
583
+ </span>
584
+ </Link>
585
+ ))}
586
+ </div>
587
+ )}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ // Compact Top Deployments & Errors
593
+ function TrafficLists({ data }: { data: DashboardData }) {
594
+ return (
595
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
596
+ {/* Top Deployments */}
597
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
598
+ <div className="flex items-center gap-2 mb-3">
599
+ <Globe className="w-4 h-4 text-orange-500" />
600
+ <h3 className="text-sm font-medium text-zinc-300">Top Deployments (24h)</h3>
601
+ </div>
602
+ {data.traffic.topDeployments.length === 0 ? (
603
+ <p className="text-xs text-zinc-500 text-center py-2 flex-1 flex items-center justify-center">
604
+ No traffic recorded yet
605
+ </p>
606
+ ) : (
607
+ <div className="space-y-1.5 flex-1">
608
+ {data.traffic.topDeployments.slice(0, 5).map((deployment, i) => (
609
+ <div
610
+ key={deployment.deploymentId}
611
+ className="flex items-center justify-between text-xs py-1 px-2 bg-zinc-900/50 rounded"
612
+ >
613
+ <div className="flex items-center gap-2 min-w-0">
614
+ <span className="text-zinc-500 w-4">{i + 1}.</span>
615
+ <span className="text-zinc-300 truncate">{deployment.deploymentName}</span>
616
+ </div>
617
+ <span className="text-zinc-500 shrink-0">{formatNumber(deployment.count)}</span>
618
+ </div>
619
+ ))}
620
+ </div>
621
+ )}
622
+ </div>
623
+
624
+ {/* Recent Errors */}
625
+ <div className="bg-zinc-900/30 rounded-xl border border-zinc-800 p-4 flex flex-col">
626
+ <div className="flex items-center gap-2 mb-3">
627
+ <AlertTriangle className="w-4 h-4 text-orange-500" />
628
+ <h3 className="text-sm font-medium text-zinc-300">Recent Errors</h3>
629
+ </div>
630
+ {data.traffic.recentErrors.length === 0 ? (
631
+ <p className="text-xs text-zinc-500 text-center py-2 flex-1 flex items-center justify-center">
632
+ No errors recorded
633
+ </p>
634
+ ) : (
635
+ <div className="space-y-1.5 flex-1">
636
+ {data.traffic.recentErrors.slice(0, 5).map((error, i) => (
637
+ <div
638
+ key={`${error.deploymentId}-${error.path}-${i}`}
639
+ className="flex items-center justify-between text-xs py-1 px-2 bg-zinc-900/50 rounded"
640
+ >
641
+ <div className="flex items-center gap-2 min-w-0">
642
+ <span
643
+ className={`font-mono px-1 py-0.5 rounded text-[10px] ${
644
+ error.statusCode >= 500
645
+ ? 'bg-red-500/20 text-red-400'
646
+ : 'bg-yellow-500/20 text-yellow-400'
647
+ }`}
648
+ >
649
+ {error.statusCode}
650
+ </span>
651
+ <span className="text-zinc-400 truncate max-w-[140px]">{error.path}</span>
652
+ </div>
653
+ <span className="text-zinc-500 shrink-0 text-[10px]">
654
+ {new Date(error.timestamp).toLocaleTimeString([], {
655
+ hour: '2-digit',
656
+ minute: '2-digit',
657
+ })}
658
+ </span>
659
+ </div>
660
+ ))}
661
+ </div>
662
+ )}
663
+ </div>
664
+ </div>
665
+ );
666
+ }
667
+
668
+ interface DashboardViewProps {
669
+ workspaceId?: string;
670
+ onNavigate?: (view: string) => void;
671
+ onProjectSelect?: (projectId: string) => void;
672
+ onStartTour?: () => void;
673
+ }
674
+
675
+ // Fetch workspace-scoped data for non-admin server mode users
676
+ async function fetchWorkspaceData(workspaceId: string): Promise<BrowserDashboardData> {
677
+ const response = await fetch(`/api/w/${workspaceId}/sync/status`);
678
+ if (!response.ok) throw new Error('Failed to fetch workspace data');
679
+ const data = await response.json();
680
+ const whatsNew = await fetchWhatsNew();
681
+
682
+ return {
683
+ content: {
684
+ projects: data.summary?.projectCount || 0,
685
+ templates: data.summary?.templateCount || 0,
686
+ skills: data.summary?.skillCount || 0,
687
+ },
688
+ whatsNew,
689
+ recentProjects: (data.projects || [])
690
+ .sort((a: { updatedAt: string }, b: { updatedAt: string }) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
691
+ .slice(0, 3)
692
+ .map((p: { id: string; name: string; updatedAt: string }) => ({
693
+ id: p.id,
694
+ name: p.name,
695
+ description: null,
696
+ updatedAt: p.updatedAt,
697
+ })),
698
+ };
699
+ }
700
+
701
+ export function DashboardView({ workspaceId, onNavigate, onProjectSelect, onStartTour }: DashboardViewProps) {
702
+ const router = useRouter();
703
+ // Server mode admin data
704
+ const [serverData, setServerData] = useState<DashboardData | null>(null);
705
+ // Browser mode / workspace mode data
706
+ const [browserData, setBrowserData] = useState<BrowserDashboardData | null>(null);
707
+ const [loading, setLoading] = useState(true);
708
+ const [error, setError] = useState<string | null>(null);
709
+
710
+ const fetchData = useCallback(async () => {
711
+ setLoading(true);
712
+ setError(null);
713
+
714
+ try {
715
+ if (isServerMode && workspaceId) {
716
+ // Server mode with workspace: use workspace-scoped data
717
+ const result = await fetchWorkspaceData(workspaceId);
718
+ setBrowserData(result);
719
+ } else if (isServerMode) {
720
+ // Server mode without workspace (legacy admin path)
721
+ const response = await fetch('/api/admin/dashboard');
722
+ if (!response.ok) throw new Error('Failed to fetch dashboard data');
723
+ const result = await response.json();
724
+ setServerData(result);
725
+ } else {
726
+ // Browser mode: fetch from IndexedDB
727
+ const result = await fetchBrowserModeData();
728
+ setBrowserData(result);
729
+ }
730
+ } catch (err) {
731
+ setError(err instanceof Error ? err.message : 'Unknown error');
732
+ } finally {
733
+ setLoading(false);
734
+ }
735
+ }, [workspaceId]);
736
+
737
+ useEffect(() => {
738
+ fetchData();
739
+ }, [fetchData]);
740
+
741
+ const handleStartTour = useCallback(() => {
742
+ if (onStartTour) {
743
+ // Browser mode: use callback
744
+ onStartTour();
745
+ } else {
746
+ // Server mode: use router
747
+ router.push('/admin/projects?tour=start');
748
+ }
749
+ }, [router, onStartTour]);
750
+
751
+ const handleProjectSelect = useCallback((projectId: string) => {
752
+ if (!isServerMode && onProjectSelect) {
753
+ onProjectSelect(projectId);
754
+ }
755
+ }, [onProjectSelect]);
756
+
757
+ // Loading state
758
+ const hasData = !!serverData || !!browserData;
759
+ if (loading && !hasData) {
760
+ return (
761
+ <div className="h-full flex items-center justify-center">
762
+ <div className="text-center">
763
+ <Spinner size={32} color="#f97316" className="mx-auto" />
764
+ <p className="mt-3 text-sm text-zinc-400">Loading dashboard...</p>
765
+ </div>
766
+ </div>
767
+ );
768
+ }
769
+
770
+ // Error state
771
+ if (error && !hasData) {
772
+ return (
773
+ <div className="h-full flex items-center justify-center">
774
+ <div className="text-center">
775
+ <AlertTriangle className="w-8 h-8 text-red-500 mx-auto mb-2" />
776
+ <p className="text-zinc-400 text-sm">{error}</p>
777
+ <Button variant="outline" size="sm" onClick={fetchData} className="mt-4">
778
+ Retry
779
+ </Button>
780
+ </div>
781
+ </div>
782
+ );
783
+ }
784
+
785
+ if (!hasData) return null;
786
+
787
+ // Server mode admin render (full system dashboard)
788
+ if (isServerMode && serverData) {
789
+ const hasWhatsNew = serverData.whatsNew?.highlights?.length > 0;
790
+
791
+ return (
792
+ <div className="h-full overflow-y-auto p-6">
793
+ {/* Quick Actions */}
794
+ <QuickActionsBar onStartTour={handleStartTour} />
795
+
796
+ {/* Row 1: System Overview + What's New */}
797
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4 [&>*]:min-h-[160px]">
798
+ <CompactOverview data={serverData} loading={loading} onRefresh={fetchData} />
799
+ {hasWhatsNew && <WhatsNewCard whatsNew={serverData.whatsNew} />}
800
+ </div>
801
+
802
+ {/* Row 2: Recent Projects + Recent Deployments */}
803
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4 [&>*]:min-h-[160px]">
804
+ <RecentProjectsCard projects={serverData.recentProjects} />
805
+ <RecentDeploymentsCard deployments={serverData.recentDeployments} />
806
+ </div>
807
+
808
+ {/* Row 3: Top Deployments + Recent Errors */}
809
+ <div className="[&>*>*]:min-h-[140px]">
810
+ <TrafficLists data={serverData} />
811
+ </div>
812
+ </div>
813
+ );
814
+ }
815
+
816
+ // Browser mode render OR server mode non-admin workspace render
817
+ if (browserData) {
818
+ const hasWhatsNew = browserData.whatsNew !== null;
819
+
820
+ return (
821
+ <div className="h-full overflow-y-auto p-6">
822
+ {/* Quick Actions */}
823
+ <QuickActionsBar onStartTour={handleStartTour} onNavigate={onNavigate} />
824
+
825
+ {/* Row 1: Content Overview + What's New */}
826
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4 [&>*]:min-h-[160px]">
827
+ <BrowserOverview data={browserData} loading={loading} onRefresh={fetchData} />
828
+ {hasWhatsNew && (
829
+ <WhatsNewCard
830
+ whatsNew={browserData.whatsNew!}
831
+ onNavigate={onNavigate}
832
+ />
833
+ )}
834
+ </div>
835
+
836
+ {/* Row 2: Recent Projects (no Deployments in browser mode) */}
837
+ <div className="mb-4 lg:w-1/2">
838
+ <RecentProjectsCard
839
+ projects={browserData.recentProjects}
840
+ onNavigate={onNavigate}
841
+ onProjectSelect={handleProjectSelect}
842
+ />
843
+ </div>
844
+ </div>
845
+ );
846
+ }
847
+
848
+ return null;
849
+ }
components/views/deployments-view.tsx ADDED
@@ -0,0 +1,790 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo, useRef } from 'react';
4
+ import { Deployment, Project } from '@/lib/vfs/types';
5
+ import { vfs } from '@/lib/vfs';
6
+ import { getLoginUrl } from '@/lib/config/storage';
7
+ import { getSyncManager } from '@/lib/vfs/sync-manager';
8
+ import { DeploymentCard } from '../deployment-card';
9
+ import { DeploymentSettingsModal } from '../deployment-settings';
10
+ import { ServerSettingsModal } from '../server-settings';
11
+ import { CreateDeploymentModal } from '../create-deployment-modal';
12
+ import { AnalyticsDashboard } from '../analytics-dashboard';
13
+ import { TemplateExportDialog } from '../templates/template-export-dialog';
14
+ import { ProjectSwapDialog } from '../project-swap-dialog';
15
+ import { Globe, Plus, Search, ArrowUpDown } from 'lucide-react';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Spinner } from '@/components/ui/spinner';
18
+ import { Input } from '@/components/ui/input';
19
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
20
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
21
+ import { toast } from 'sonner';
22
+ import { logger } from '@/lib/utils';
23
+ import { track } from '@/lib/telemetry';
24
+ import { usePagination } from '@/lib/hooks/use-pagination';
25
+ import { Pagination, PaginationRange } from '@/components/ui/pagination';
26
+
27
+ type SortOption = 'updated' | 'created' | 'name' | 'published';
28
+
29
+ interface DeploymentsViewProps {
30
+ onProjectSelect: (project: Project) => void;
31
+ workspaceId?: string;
32
+ }
33
+
34
+ export function DeploymentsView({ onProjectSelect, workspaceId }: DeploymentsViewProps) {
35
+ const apiBase = workspaceId ? `/api/w/${workspaceId}` : '/api';
36
+ const [deployments, setDeployments] = useState<Deployment[]>([]);
37
+ const [projects, setProjects] = useState<Project[]>([]);
38
+ const [loading, setLoading] = useState(true);
39
+ const [publishingStates, setPublishingStates] = useState<Record<string, boolean>>({});
40
+ const [selectedDeployment, setSelectedDeployment] = useState<Deployment | null>(null);
41
+ const [showSettingsModal, setShowSettingsModal] = useState(false);
42
+ const [showServerSettingsModal, setShowServerSettingsModal] = useState(false);
43
+ const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
44
+ const [showCreateModal, setShowCreateModal] = useState(false);
45
+ const [showTemplateExportModal, setShowTemplateExportModal] = useState(false);
46
+ const [templateExportDeployment, setTemplateExportDeployment] = useState<Deployment | null>(null);
47
+ const [searchQuery, setSearchQuery] = useState('');
48
+ const [sortBy, setSortBy] = useState<SortOption>('updated');
49
+ const [swapDialogState, setSwapDialogState] = useState<{
50
+ deploymentId: string;
51
+ deploymentName: string;
52
+ currentProjectId: string;
53
+ newProjectId: string;
54
+ newProjectName: string;
55
+ pendingSettings: Partial<Deployment>;
56
+ } | null>(null);
57
+ const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';
58
+
59
+ useEffect(() => {
60
+ loadData();
61
+ }, []);
62
+
63
+ const loadData = async () => {
64
+ try {
65
+ setLoading(true);
66
+
67
+ if (!isServerMode) {
68
+ setLoading(false);
69
+ return;
70
+ }
71
+
72
+ const [deploymentsResponse, projectsResponse] = await Promise.all([
73
+ fetch(`${apiBase}/deployments`),
74
+ fetch(`${apiBase}/projects?fields=id,name`), // Only fetch id and name fields
75
+ ]);
76
+
77
+ // Redirect to login if unauthorized
78
+ if (deploymentsResponse.status === 401 || projectsResponse.status === 401) {
79
+ window.location.href = getLoginUrl();
80
+ return;
81
+ }
82
+
83
+ if (!deploymentsResponse.ok) {
84
+ throw new Error('Failed to fetch deployments');
85
+ }
86
+ if (!projectsResponse.ok) {
87
+ throw new Error('Failed to fetch projects');
88
+ }
89
+
90
+ const [fetchedDeployments, fetchedProjects] = await Promise.all([
91
+ deploymentsResponse.json(),
92
+ projectsResponse.json(),
93
+ ]);
94
+
95
+ setDeployments(fetchedDeployments);
96
+ setProjects(fetchedProjects);
97
+ } catch (error) {
98
+ logger.error('[DeploymentsView] Failed to load data:', error);
99
+ } finally {
100
+ setLoading(false);
101
+ }
102
+ };
103
+
104
+ // Helper function to update a single deployment in state (optimistic updates)
105
+ const updateDeploymentInState = (deploymentId: string, updates: Partial<Deployment>) => {
106
+ setDeployments(prevDeployments =>
107
+ prevDeployments.map(deployment =>
108
+ deployment.id === deploymentId ? { ...deployment, ...updates } : deployment
109
+ )
110
+ );
111
+ };
112
+
113
+ const handleOpenSettings = (deployment: Deployment) => {
114
+ setSelectedDeployment(deployment);
115
+ setShowSettingsModal(true);
116
+ };
117
+
118
+ const handleOpenServerSettings = (deployment: Deployment) => {
119
+ setSelectedDeployment(deployment);
120
+ setShowServerSettingsModal(true);
121
+ };
122
+
123
+ const handleViewAnalytics = (deployment: Deployment) => {
124
+ setSelectedDeployment(deployment);
125
+ setShowAnalyticsModal(true);
126
+ };
127
+
128
+ const handleEditProject = async (deployment: Deployment) => {
129
+ try {
130
+ await vfs.init();
131
+ const project = await vfs.getProject(deployment.projectId);
132
+ if (!project) {
133
+ toast.error('Project not found in local storage');
134
+ return;
135
+ }
136
+ onProjectSelect(project);
137
+ } catch (error) {
138
+ logger.error('[DeploymentsView] Failed to load project:', error);
139
+ toast.error('Failed to load project');
140
+ }
141
+ };
142
+
143
+ const [templateExportProject, setTemplateExportProject] = useState<Project | null>(null);
144
+
145
+ const handleExportAsTemplate = async (deployment: Deployment) => {
146
+ try {
147
+ await vfs.init();
148
+ const project = await vfs.getProject(deployment.projectId);
149
+ if (!project) {
150
+ toast.error('Project not found in local storage');
151
+ return;
152
+ }
153
+ setTemplateExportDeployment(deployment);
154
+ setTemplateExportProject(project);
155
+ setShowTemplateExportModal(true);
156
+ } catch (error) {
157
+ logger.error('[DeploymentsView] Failed to load project for template export:', error);
158
+ toast.error('Failed to load project');
159
+ }
160
+ };
161
+
162
+ const handleSaveSettings = async (settings: Partial<Deployment>) => {
163
+ if (!selectedDeployment) return;
164
+
165
+ try {
166
+ // If projectId changed in server mode, show swap dialog for conflict analysis
167
+ const projectIdChanged = settings.projectId && settings.projectId !== selectedDeployment.projectId;
168
+ if (projectIdChanged && isServerMode && selectedDeployment.publishedAt) {
169
+ const newProject = projects.find(p => p.id === settings.projectId);
170
+ setSwapDialogState({
171
+ deploymentId: selectedDeployment.id,
172
+ deploymentName: selectedDeployment.name,
173
+ currentProjectId: selectedDeployment.projectId,
174
+ newProjectId: settings.projectId!,
175
+ newProjectName: newProject?.name || 'Unknown',
176
+ pendingSettings: settings,
177
+ });
178
+ return; // Don't save yet — swap dialog will handle it
179
+ }
180
+
181
+ if (projectIdChanged) {
182
+ // Non-server mode or first deploy: update directly
183
+ const deploymentResponse = await fetch(`${apiBase}/deployments/${selectedDeployment.id}`, {
184
+ method: 'PUT',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({ projectId: settings.projectId }),
187
+ });
188
+
189
+ if (!deploymentResponse.ok) {
190
+ const error = await deploymentResponse.json();
191
+ throw new Error(error.error || 'Failed to update project');
192
+ }
193
+ }
194
+
195
+ // Save publishing settings (exclude projectId — handled above)
196
+ const { projectId: _projectId, ...publishSettings } = settings;
197
+
198
+ const response = await fetch(`${apiBase}/deployments/${selectedDeployment.id}/settings`, {
199
+ method: 'PUT',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ },
203
+ body: JSON.stringify(publishSettings),
204
+ });
205
+
206
+ if (!response.ok) {
207
+ const error = await response.json();
208
+ throw new Error(error.error || 'Failed to save settings');
209
+ }
210
+
211
+ const result = await response.json();
212
+
213
+ // Fire only on the empty -> set transition, never on every save and
214
+ // never with the domain value itself.
215
+ if (!selectedDeployment.customDomain && settings.customDomain) {
216
+ track('custom_domain_set');
217
+ }
218
+
219
+ // Update local deployment state in the selected deployment modal
220
+ setSelectedDeployment({
221
+ ...selectedDeployment,
222
+ ...settings,
223
+ settingsVersion: result.settingsVersion,
224
+ lastPublishedVersion: result.lastPublishedVersion,
225
+ });
226
+
227
+ // Update the deployment in the main list (optimistic update - no full reload)
228
+ updateDeploymentInState(selectedDeployment.id, {
229
+ ...settings,
230
+ settingsVersion: result.settingsVersion,
231
+ updatedAt: new Date(),
232
+ });
233
+ } catch (error) {
234
+ logger.error('[DeploymentsView] Failed to save deployment settings:', error);
235
+ throw error;
236
+ }
237
+ };
238
+
239
+ const handleSwapComplete = async () => {
240
+ if (!swapDialogState) return;
241
+
242
+ // Swap + republish completed via the swap API.
243
+ // Now save any remaining publishing settings.
244
+ const { deploymentId, pendingSettings } = swapDialogState;
245
+ const { projectId: _projectId, ...publishSettings } = pendingSettings;
246
+
247
+ try {
248
+ const response = await fetch(`${apiBase}/deployments/${deploymentId}/settings`, {
249
+ method: 'PUT',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify(publishSettings),
252
+ });
253
+
254
+ if (response.ok) {
255
+ const result = await response.json();
256
+ updateDeploymentInState(deploymentId, {
257
+ ...pendingSettings,
258
+ settingsVersion: result.settingsVersion,
259
+ updatedAt: new Date(),
260
+ publishedAt: new Date(),
261
+ });
262
+ }
263
+ } catch (err) {
264
+ logger.error('[DeploymentsView] Failed to save settings after swap:', err);
265
+ }
266
+
267
+ setSwapDialogState(null);
268
+ setShowSettingsModal(false);
269
+ setSelectedDeployment(null);
270
+ toast.success('Project swapped and deployment republished');
271
+ await loadData();
272
+ };
273
+
274
+ const handlePublish = async (deploymentId: string) => {
275
+ const deployment = deployments.find(s => s.id === deploymentId);
276
+ if (!deployment) return;
277
+
278
+ if (!confirm('Publish this deployment with the current settings?')) {
279
+ return;
280
+ }
281
+
282
+ // Set publishing state
283
+ setPublishingStates(prev => ({ ...prev, [deploymentId]: true }));
284
+
285
+ let trackedRuntime = 'unknown';
286
+
287
+ try {
288
+ // First, sync files from IndexedDB to server
289
+ toast.info('Syncing project files...');
290
+
291
+ await vfs.init();
292
+ const project = await vfs.getProject(deployment.projectId);
293
+ if (!project) {
294
+ throw new Error('Project not found in local storage');
295
+ }
296
+
297
+ // Block publishing for terminal-mode runtimes (Python, Lua) — they can't be
298
+ // served as static sites. Use ZIP export instead.
299
+ const { isRuntimeBundled, getRuntimeConfig } = await import('@/lib/runtimes/registry');
300
+ const runtime = project.settings?.runtime;
301
+ trackedRuntime = runtime ?? 'unknown';
302
+ if (runtime && getRuntimeConfig(runtime).previewMode === 'terminal') {
303
+ throw new Error(`${getRuntimeConfig(runtime).label} projects cannot be published as static sites. Use ZIP export instead.`);
304
+ }
305
+
306
+ let files = await vfs.listFiles(deployment.projectId);
307
+
308
+ if (runtime && isRuntimeBundled(runtime)) {
309
+ // Always compile fresh — generated files may be stale or absent
310
+ // (e.g. user publishes without opening the preview first)
311
+ toast.info('Compiling project bundles...');
312
+ const { VirtualServer } = await import('@/lib/preview/virtual-server');
313
+ const vs = new VirtualServer(vfs as any, deployment.projectId, { runtime });
314
+ await vs.compileProject();
315
+ vs.cleanupBlobUrls();
316
+
317
+ const generatedFiles = vfs.getGeneratedFiles();
318
+ if (generatedFiles.length > 0) {
319
+ const withProjectId = generatedFiles.map(f => ({ ...f, projectId: deployment.projectId }));
320
+ files = [...files, ...withProjectId];
321
+ }
322
+ }
323
+
324
+ const syncManager = getSyncManager();
325
+
326
+ // Push project and files to server
327
+ const syncResult = await syncManager.pushProjectWithFiles(project, files);
328
+ if (!syncResult.success) {
329
+ throw new Error(syncResult.error || 'Failed to sync files to server');
330
+ }
331
+
332
+ // Sync backend features from IndexedDB to server
333
+ const adapter = await vfs.getStorageAdapter();
334
+ const edgeFunctions = adapter.listEdgeFunctions ? await adapter.listEdgeFunctions(deployment.projectId) : [];
335
+ const serverFunctions = adapter.listServerFunctions ? await adapter.listServerFunctions(deployment.projectId) : [];
336
+ const secrets = adapter.listSecrets ? await adapter.listSecrets(deployment.projectId) : [];
337
+ const scheduledFunctions = adapter.listScheduledFunctions ? await adapter.listScheduledFunctions(deployment.projectId) : [];
338
+
339
+ if (edgeFunctions.length > 0 || serverFunctions.length > 0 ||
340
+ secrets.length > 0 || scheduledFunctions.length > 0) {
341
+ toast.info('Syncing backend features...');
342
+ const featuresSyncResult = await syncManager.pushBackendFeatures(deployment.projectId, {
343
+ edgeFunctions,
344
+ serverFunctions,
345
+ secrets,
346
+ scheduledFunctions,
347
+ });
348
+ if (!featuresSyncResult.success) {
349
+ throw new Error(featuresSyncResult.error || 'Failed to sync backend features');
350
+ }
351
+ }
352
+
353
+ toast.info('Building deployment...');
354
+
355
+ // Call publish API to trigger build
356
+ const response = await fetch(`${apiBase}/deployments/${deploymentId}/publish`, {
357
+ method: 'POST',
358
+ });
359
+
360
+ if (!response.ok) {
361
+ const error = await response.json();
362
+ throw new Error(error.error || 'Failed to publish');
363
+ }
364
+
365
+ const result = await response.json();
366
+
367
+ track('deployment_publish', {
368
+ runtime: runtime ?? 'unknown',
369
+ result: 'success',
370
+ has_custom_domain: !!deployment.customDomain,
371
+ });
372
+
373
+ toast.success(`Deployment published! ${result.filesWritten} files written.`);
374
+
375
+ // Update state optimistically with publish data
376
+ updateDeploymentInState(deploymentId, {
377
+ lastPublishedVersion: result.lastPublishedVersion,
378
+ publishedAt: new Date(),
379
+ updatedAt: new Date(),
380
+ databaseEnabled: true,
381
+ ...(result.slug && { slug: result.slug }),
382
+ });
383
+
384
+ // Clear publishing state immediately after successful publish
385
+ setPublishingStates(prev => ({ ...prev, [deploymentId]: false }));
386
+ } catch (error) {
387
+ logger.error('Failed to publish:', error);
388
+ track('deployment_publish', {
389
+ runtime: trackedRuntime,
390
+ result: 'fail',
391
+ has_custom_domain: !!deployment.customDomain,
392
+ });
393
+ toast.error(error instanceof Error ? error.message : 'Failed to publish. Please try again.');
394
+ // Clear publishing state on error
395
+ setPublishingStates(prev => ({ ...prev, [deploymentId]: false }));
396
+ }
397
+ };
398
+
399
+ const handleDeploymentThumbnailChange = async (deploymentId: string, image: string | undefined) => {
400
+ try {
401
+ const response = await fetch(`${apiBase}/deployments/${deploymentId}/thumbnail`, {
402
+ method: 'PUT',
403
+ headers: { 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({ previewImage: image ?? null }),
405
+ });
406
+ if (!response.ok) throw new Error('Failed to update thumbnail');
407
+
408
+ updateDeploymentInState(deploymentId, {
409
+ previewImage: image,
410
+ previewUpdatedAt: image ? new Date() : undefined,
411
+ });
412
+ } catch (err) {
413
+ logger.error('[DeploymentsView] Failed to update deployment thumbnail:', err);
414
+ toast.error('Failed to update thumbnail');
415
+ }
416
+ };
417
+
418
+ const handleDisable = async (deploymentId: string) => {
419
+ const deployment = deployments.find(s => s.id === deploymentId);
420
+ if (!deployment) return;
421
+
422
+ if (!confirm('Disable this deployment? It will no longer be publicly accessible.')) {
423
+ return;
424
+ }
425
+
426
+ try {
427
+ const response = await fetch(`${apiBase}/deployments/${deploymentId}`, {
428
+ method: 'PUT',
429
+ headers: {
430
+ 'Content-Type': 'application/json',
431
+ },
432
+ body: JSON.stringify({
433
+ enabled: false,
434
+ }),
435
+ });
436
+
437
+ if (!response.ok) {
438
+ const error = await response.json();
439
+ throw new Error(error.error || 'Failed to disable deployment');
440
+ }
441
+
442
+ // Update state optimistically (no full reload)
443
+ updateDeploymentInState(deploymentId, {
444
+ enabled: false,
445
+ updatedAt: new Date(),
446
+ });
447
+ } catch (error) {
448
+ logger.error('Failed to disable deployment:', error);
449
+ alert('Failed to disable deployment. Please try again.');
450
+ }
451
+ };
452
+
453
+ const handleEnable = async (deploymentId: string) => {
454
+ const deployment = deployments.find(s => s.id === deploymentId);
455
+ if (!deployment) return;
456
+
457
+ try {
458
+ const response = await fetch(`${apiBase}/deployments/${deploymentId}`, {
459
+ method: 'PUT',
460
+ headers: {
461
+ 'Content-Type': 'application/json',
462
+ },
463
+ body: JSON.stringify({
464
+ enabled: true,
465
+ }),
466
+ });
467
+
468
+ if (!response.ok) {
469
+ const error = await response.json();
470
+ throw new Error(error.error || 'Failed to enable deployment');
471
+ }
472
+
473
+ // Update state optimistically (no full reload)
474
+ updateDeploymentInState(deploymentId, {
475
+ enabled: true,
476
+ updatedAt: new Date(),
477
+ });
478
+ } catch (error) {
479
+ logger.error('Failed to enable deployment:', error);
480
+ alert('Failed to enable deployment. Please try again.');
481
+ }
482
+ };
483
+
484
+ const handleDelete = async (deploymentId: string) => {
485
+ const deployment = deployments.find(s => s.id === deploymentId);
486
+ if (!deployment) return;
487
+
488
+ if (!confirm(`Delete deployment "${deployment.name}"? This cannot be undone.`)) {
489
+ return;
490
+ }
491
+
492
+ try {
493
+ const response = await fetch(`${apiBase}/deployments/${deploymentId}`, {
494
+ method: 'DELETE',
495
+ });
496
+
497
+ if (!response.ok) {
498
+ const error = await response.json();
499
+ throw new Error(error.error || 'Failed to delete deployment');
500
+ }
501
+
502
+ track('deployment_delete');
503
+
504
+ await loadData();
505
+ } catch (error) {
506
+ logger.error('Failed to delete deployment:', error);
507
+ alert('Failed to delete deployment. Please try again.');
508
+ }
509
+ };
510
+
511
+ const handleCreateDeployment = async (data: { projectId: string; name: string; slug?: string }) => {
512
+ try {
513
+ const response = await fetch(`${apiBase}/deployments`, {
514
+ method: 'POST',
515
+ headers: {
516
+ 'Content-Type': 'application/json',
517
+ },
518
+ body: JSON.stringify(data),
519
+ });
520
+
521
+ if (!response.ok) {
522
+ const error = await response.json();
523
+ throw new Error(error.error || 'Failed to create deployment');
524
+ }
525
+
526
+ track('deployment_create');
527
+
528
+ await loadData();
529
+ setShowCreateModal(false);
530
+ } catch (error) {
531
+ logger.error('Failed to create deployment:', error);
532
+ throw error;
533
+ }
534
+ };
535
+
536
+ // Filter and sort deployments
537
+ const filteredAndSortedDeployments = useMemo(() => {
538
+ let filtered = deployments;
539
+
540
+ // Apply search filter
541
+ if (searchQuery) {
542
+ const query = searchQuery.toLowerCase();
543
+ filtered = deployments.filter(deployment => {
544
+ const project = projects.find(p => p.id === deployment.projectId);
545
+ return (
546
+ deployment.name.toLowerCase().includes(query) ||
547
+ deployment.slug?.toLowerCase().includes(query) ||
548
+ project?.name.toLowerCase().includes(query)
549
+ );
550
+ });
551
+ }
552
+
553
+ // Apply sorting
554
+ const sorted = [...filtered].sort((a, b) => {
555
+ switch (sortBy) {
556
+ case 'name':
557
+ return a.name.localeCompare(b.name);
558
+ case 'created':
559
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
560
+ case 'published':
561
+ if (!a.publishedAt && !b.publishedAt) return 0;
562
+ if (!a.publishedAt) return 1;
563
+ if (!b.publishedAt) return -1;
564
+ return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
565
+ case 'updated':
566
+ default:
567
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
568
+ }
569
+ });
570
+
571
+ return sorted;
572
+ }, [deployments, projects, searchQuery, sortBy]);
573
+
574
+ const deploymentsPagination = usePagination(filteredAndSortedDeployments, {
575
+ perPage: 24,
576
+ resetOn: [searchQuery, sortBy],
577
+ });
578
+ const listScrollRef = useRef<HTMLDivElement | null>(null);
579
+
580
+ if (!isServerMode) {
581
+ return (
582
+ <div className="h-full flex items-center justify-center">
583
+ <div className="text-center text-muted-foreground">
584
+ <p>Deployments feature is only available in Server Mode</p>
585
+ </div>
586
+ </div>
587
+ );
588
+ }
589
+
590
+ if (loading) {
591
+ return (
592
+ <div className="h-full flex items-center justify-center">
593
+ <div className="text-center">
594
+ <Spinner size={48} color="#f97316" className="mx-auto" />
595
+ <p className="mt-4">Loading deployments...</p>
596
+ </div>
597
+ </div>
598
+ );
599
+ }
600
+
601
+ return (
602
+ <>
603
+ <div className="h-full flex flex-col">
604
+ {/* Toolbar */}
605
+ <div className="pt-4 px-4 pb-3 sm:pt-6 sm:px-6 sm:pb-3 shrink-0">
606
+ <div className="mx-auto max-w-7xl flex flex-col sm:flex-row gap-3">
607
+ {/* Search */}
608
+ <div className="relative flex-1">
609
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
610
+ <Input
611
+ placeholder="Search deployments..."
612
+ value={searchQuery}
613
+ onChange={(e) => setSearchQuery(e.target.value)}
614
+ className="pl-9"
615
+ />
616
+ </div>
617
+
618
+ {/* Controls */}
619
+ <div className="flex items-center gap-2">
620
+ {/* Sort */}
621
+ <Popover>
622
+ <PopoverTrigger asChild>
623
+ <Button variant="outline" size="sm" className="gap-2">
624
+ <ArrowUpDown className="h-4 w-4" />
625
+ <span className="hidden sm:inline">Sort</span>
626
+ </Button>
627
+ </PopoverTrigger>
628
+ <PopoverContent className="w-48" align="end">
629
+ <div className="space-y-2">
630
+ <h4 className="font-semibold text-sm">Sort by</h4>
631
+ <Select value={sortBy} onValueChange={(value) => setSortBy(value as SortOption)}>
632
+ <SelectTrigger>
633
+ <SelectValue />
634
+ </SelectTrigger>
635
+ <SelectContent>
636
+ <SelectItem value="updated">Last Updated</SelectItem>
637
+ <SelectItem value="published">Last Published</SelectItem>
638
+ <SelectItem value="created">Date Created</SelectItem>
639
+ <SelectItem value="name">Name</SelectItem>
640
+ </SelectContent>
641
+ </Select>
642
+ </div>
643
+ </PopoverContent>
644
+ </Popover>
645
+
646
+ {/* New Deployment */}
647
+ <Button onClick={() => setShowCreateModal(true)} size="sm" className="gap-2">
648
+ <Plus className="h-4 w-4" />
649
+ <span>New</span>
650
+ </Button>
651
+ </div>
652
+ </div>
653
+ </div>
654
+
655
+ {/* Deployments Grid/List */}
656
+ <div ref={listScrollRef} className="flex-1 px-4 pt-3 pb-4 sm:px-6 sm:pt-3 sm:pb-6 overflow-auto">
657
+ <div className="mx-auto max-w-7xl">
658
+ {filteredAndSortedDeployments.length === 0 ? (
659
+ <div className="flex flex-col items-center justify-center py-16 text-center">
660
+ <Globe className="h-16 w-16 text-muted-foreground mb-4" />
661
+ {deployments.length === 0 ? (
662
+ <>
663
+ <h2 className="text-xl font-semibold mb-2">No Deployments Yet</h2>
664
+ <p className="text-muted-foreground mb-4 max-w-md">
665
+ Create your first deployment by clicking the "New" button above.
666
+ Deployments let you publish projects and manage their public settings independently.
667
+ </p>
668
+ </>
669
+ ) : (
670
+ <>
671
+ <h2 className="text-xl font-semibold mb-2">No deployments found</h2>
672
+ <p className="text-muted-foreground mb-4 max-w-md">
673
+ Try adjusting your search or filter criteria
674
+ </p>
675
+ </>
676
+ )}
677
+ </div>
678
+ ) : (
679
+ <>
680
+ <PaginationRange
681
+ total={deploymentsPagination.total}
682
+ rangeStart={deploymentsPagination.rangeStart}
683
+ rangeEnd={deploymentsPagination.rangeEnd}
684
+ totalPages={deploymentsPagination.totalPages}
685
+ className="mb-2"
686
+ />
687
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
688
+ {deploymentsPagination.pageItems.map((deployment) => {
689
+ const project = projects.find(p => p.id === deployment.projectId);
690
+ return (
691
+ <DeploymentCard
692
+ key={deployment.id}
693
+ deployment={deployment}
694
+ project={project}
695
+ isPublishing={publishingStates[deployment.id] || false}
696
+ onOpenSettings={handleOpenSettings}
697
+ onOpenServerSettings={handleOpenServerSettings}
698
+ onViewAnalytics={handleViewAnalytics}
699
+ onEditProject={handleEditProject}
700
+ onPublish={handlePublish}
701
+ onDisable={handleDisable}
702
+ onEnable={handleEnable}
703
+ onDelete={handleDelete}
704
+ onExportAsTemplate={handleExportAsTemplate}
705
+ onThumbnailChange={handleDeploymentThumbnailChange}
706
+ />
707
+ );
708
+ })}
709
+ </div>
710
+ <Pagination
711
+ page={deploymentsPagination.page}
712
+ totalPages={deploymentsPagination.totalPages}
713
+ onPageChange={deploymentsPagination.setPage}
714
+ scrollTarget={listScrollRef}
715
+ />
716
+ </>
717
+ )}
718
+ </div>
719
+ </div>
720
+ </div>
721
+
722
+ {selectedDeployment && (
723
+ <>
724
+ <DeploymentSettingsModal
725
+ deployment={selectedDeployment}
726
+ projects={projects}
727
+ isOpen={showSettingsModal}
728
+ onClose={() => {
729
+ setShowSettingsModal(false);
730
+ setSelectedDeployment(null);
731
+ }}
732
+ onSave={handleSaveSettings}
733
+ />
734
+
735
+ <ServerSettingsModal
736
+ deployment={selectedDeployment}
737
+ isOpen={showServerSettingsModal}
738
+ onClose={() => {
739
+ setShowServerSettingsModal(false);
740
+ setSelectedDeployment(null);
741
+ }}
742
+ workspaceId={workspaceId}
743
+ />
744
+
745
+ <AnalyticsDashboard
746
+ deployment={selectedDeployment}
747
+ isOpen={showAnalyticsModal}
748
+ onClose={() => {
749
+ setShowAnalyticsModal(false);
750
+ setSelectedDeployment(null);
751
+ }}
752
+ />
753
+ </>
754
+ )}
755
+
756
+ <CreateDeploymentModal
757
+ projects={projects}
758
+ isOpen={showCreateModal}
759
+ onClose={() => setShowCreateModal(false)}
760
+ onCreate={handleCreateDeployment}
761
+ />
762
+
763
+ <TemplateExportDialog
764
+ project={templateExportProject}
765
+ open={showTemplateExportModal}
766
+ onOpenChange={(open) => {
767
+ setShowTemplateExportModal(open);
768
+ if (!open) {
769
+ setTemplateExportDeployment(null);
770
+ setTemplateExportProject(null);
771
+ }
772
+ }}
773
+ />
774
+
775
+ {swapDialogState && (
776
+ <ProjectSwapDialog
777
+ isOpen={true}
778
+ onClose={() => setSwapDialogState(null)}
779
+ deploymentId={swapDialogState.deploymentId}
780
+ deploymentName={swapDialogState.deploymentName}
781
+ currentProjectId={swapDialogState.currentProjectId}
782
+ newProjectId={swapDialogState.newProjectId}
783
+ newProjectName={swapDialogState.newProjectName}
784
+ onSwapComplete={handleSwapComplete}
785
+ workspaceId={workspaceId}
786
+ />
787
+ )}
788
+ </>
789
+ );
790
+ }
components/views/docs-view.tsx ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef, useCallback, Suspense } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { MarkdownRenderer } from '@/components/markdown-renderer';
6
+ import { TableOfContents } from '@/components/table-of-contents';
7
+ import { useTableOfContents } from '@/lib/hooks/use-table-of-contents';
8
+ import { AlertCircle } from 'lucide-react';
9
+ import { Spinner } from '@/components/ui/spinner';
10
+ import { DOCS_ITEMS } from '@/lib/constants/docs';
11
+
12
+ function DocsViewContent() {
13
+ const searchParams = useSearchParams();
14
+ const docId = searchParams.get('doc') || 'overview';
15
+
16
+ const selectedDoc = DOCS_ITEMS.find(d => d.id === docId) || DOCS_ITEMS[0];
17
+ const [content, setContent] = useState<string>('');
18
+ const [loading, setLoading] = useState(false);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [activeId, setActiveId] = useState<string>('');
21
+ const [visibleIds, setVisibleIds] = useState<string[]>([]);
22
+
23
+ const isManualScrolling = useRef(false);
24
+ const scrollDebounceTimer = useRef<NodeJS.Timeout | null>(null);
25
+
26
+ const tocItems = useTableOfContents(content);
27
+
28
+ useEffect(() => {
29
+ async function loadDoc() {
30
+ setLoading(true);
31
+ setError(null);
32
+
33
+ try {
34
+ const response = await fetch(`/api/docs/${selectedDoc.file}`);
35
+ if (!response.ok) {
36
+ throw new Error(`Failed to load document: ${response.statusText}`);
37
+ }
38
+ const text = await response.text();
39
+ setContent(text);
40
+
41
+ // Scroll to hash if present in URL
42
+ setTimeout(() => {
43
+ if (window.location.hash) {
44
+ const element = document.getElementById(window.location.hash.slice(1));
45
+ if (element) {
46
+ element.scrollIntoView({ behavior: 'smooth' });
47
+ }
48
+ } else {
49
+ // Scroll to top if no hash
50
+ const contentArea = document.querySelector('.docs-content-area');
51
+ if (contentArea) {
52
+ contentArea.scrollTop = 0;
53
+ }
54
+ }
55
+ }, 100);
56
+ } catch (err) {
57
+ console.error('Failed to load doc:', err);
58
+ setError(err instanceof Error ? err.message : 'Failed to load document');
59
+ setContent('');
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ }
64
+
65
+ loadDoc();
66
+ }, [selectedDoc]);
67
+
68
+ // Handle TOC item clicks
69
+ const handleTocClick = useCallback((id: string) => {
70
+ // Set active immediately when user clicks
71
+ setActiveId(id);
72
+
73
+ // Immediately update visible IDs to include only the clicked item
74
+ // This prevents stale blue highlights during the scroll
75
+ setVisibleIds([id]);
76
+
77
+ // Disable auto-tracking during manual scroll
78
+ isManualScrolling.current = true;
79
+
80
+ // Re-enable auto-tracking after scroll animation completes
81
+ // and then trigger an immediate update
82
+ setTimeout(() => {
83
+ isManualScrolling.current = false;
84
+
85
+ // Trigger immediate recalculation of visible items after scroll completes
86
+ const scrollContainer = document.querySelector('.docs-content-area');
87
+ if (!scrollContainer) return;
88
+
89
+ const headings = document.querySelectorAll('.docs-content-area [data-heading-index]');
90
+ if (headings.length === 0) return;
91
+
92
+ const containerTop = scrollContainer.getBoundingClientRect().top;
93
+ const visible: string[] = [];
94
+
95
+ headings.forEach((heading) => {
96
+ const rect = heading.getBoundingClientRect();
97
+ const viewportTop = containerTop;
98
+ const viewportBottom = viewportTop + scrollContainer.clientHeight;
99
+
100
+ if (rect.top >= viewportTop && rect.bottom <= viewportBottom) {
101
+ const index = heading.getAttribute('data-heading-index');
102
+ if (index) {
103
+ visible.push(index);
104
+ }
105
+ }
106
+ });
107
+
108
+ setVisibleIds(visible);
109
+ }, 1000); // Smooth scroll takes ~500-800ms, add buffer
110
+ }, []);
111
+
112
+ // Scroll-based active section tracking with debouncing
113
+ useEffect(() => {
114
+ if (tocItems.length === 0) return;
115
+
116
+ const scrollContainer = document.querySelector('.docs-content-area');
117
+ if (!scrollContainer) return;
118
+
119
+ const updateActiveSection = () => {
120
+ // Skip if user is manually scrolling from TOC click
121
+ if (isManualScrolling.current) {
122
+ return;
123
+ }
124
+
125
+ // Select headings by data-heading-index attribute for unique identification
126
+ const headings = document.querySelectorAll('.docs-content-area [data-heading-index]');
127
+ if (headings.length === 0) return;
128
+
129
+ const containerTop = scrollContainer.getBoundingClientRect().top;
130
+
131
+ // Find the heading that's currently at the top of the viewport
132
+ let activeHeading = headings[0];
133
+ let minDistance = Infinity;
134
+
135
+ headings.forEach((heading) => {
136
+ const rect = heading.getBoundingClientRect();
137
+ const distance = Math.abs(rect.top - containerTop - 100); // 100px offset
138
+
139
+ if (rect.top - containerTop < 200 && distance < minDistance) {
140
+ minDistance = distance;
141
+ activeHeading = heading;
142
+ }
143
+ });
144
+
145
+ // Collect IDs of all headings in viewport
146
+ const visible: string[] = [];
147
+ headings.forEach((heading) => {
148
+ const rect = heading.getBoundingClientRect();
149
+ const viewportTop = containerTop;
150
+ const viewportBottom = viewportTop + scrollContainer.clientHeight;
151
+
152
+ // Check if heading is in viewport
153
+ if (rect.top >= viewportTop && rect.bottom <= viewportBottom) {
154
+ const index = heading.getAttribute('data-heading-index');
155
+ if (index) {
156
+ visible.push(index);
157
+ }
158
+ }
159
+ });
160
+
161
+ // Use data-heading-index as the unique identifier
162
+ const headingIndex = activeHeading?.getAttribute('data-heading-index');
163
+ if (headingIndex) {
164
+ setActiveId(headingIndex);
165
+ }
166
+
167
+ // Update visible IDs for TOC range highlighting
168
+ setVisibleIds(visible);
169
+ };
170
+
171
+ // Debounced scroll handler
172
+ const handleScroll = () => {
173
+ // Clear existing timer
174
+ if (scrollDebounceTimer.current) {
175
+ clearTimeout(scrollDebounceTimer.current);
176
+ }
177
+
178
+ // Set new timer - only update after scrolling stops for 50ms
179
+ scrollDebounceTimer.current = setTimeout(updateActiveSection, 50);
180
+ };
181
+
182
+ // Initial update - delay to ensure markdown heading indices are set
183
+ // Increase delay to 500ms to ensure MarkdownRenderer's useEffect completes
184
+ const timeout = setTimeout(() => {
185
+ updateActiveSection();
186
+ }, 500);
187
+
188
+ // Update on scroll with debouncing
189
+ scrollContainer.addEventListener('scroll', handleScroll);
190
+
191
+ return () => {
192
+ clearTimeout(timeout);
193
+ if (scrollDebounceTimer.current) {
194
+ clearTimeout(scrollDebounceTimer.current);
195
+ }
196
+ scrollContainer.removeEventListener('scroll', handleScroll);
197
+ };
198
+ }, [tocItems, content]);
199
+
200
+ const showToc = tocItems.length >= 3;
201
+
202
+ return (
203
+ <div className="h-full flex flex-col">
204
+ {/* Two-column layout: Content + TOC */}
205
+ <div className={`flex-1 overflow-hidden ${showToc ? 'lg:grid lg:grid-cols-[1fr_280px]' : ''}`}>
206
+ {/* Main Content Area - scrollable */}
207
+ <div className="h-full overflow-y-auto docs-content-area bg-background">
208
+ <div
209
+ className="p-6 sm:p-8 max-w-4xl mx-auto"
210
+ onClick={(e) => {
211
+ // Handle anchor link clicks
212
+ const target = e.target as HTMLElement;
213
+ if (target.tagName === 'A') {
214
+ const href = target.getAttribute('href');
215
+ if (href?.startsWith('#')) {
216
+ e.preventDefault();
217
+ const element = document.getElementById(href.slice(1));
218
+ if (element) {
219
+ element.scrollIntoView({ behavior: 'smooth' });
220
+ window.history.pushState(null, '', href);
221
+ }
222
+ }
223
+ }
224
+ }}
225
+ >
226
+ {loading && (
227
+ <div className="flex items-center justify-center h-screen">
228
+ <div className="text-center">
229
+ <Spinner size={48} className="mx-auto text-primary" />
230
+ <p className="mt-4 text-muted-foreground">Loading documentation...</p>
231
+ </div>
232
+ </div>
233
+ )}
234
+
235
+ {error && (
236
+ <div className="flex items-center gap-3 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
237
+ <AlertCircle className="h-5 w-5 flex-shrink-0" />
238
+ <div>
239
+ <p className="font-semibold">Error loading document</p>
240
+ <p className="text-sm">{error}</p>
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ {!loading && !error && content && (
246
+ <>
247
+ {/* Document Title */}
248
+ <div className="mb-6 pb-4 border-b">
249
+ <div className="flex items-center gap-3 mb-2">
250
+ <selectedDoc.icon className="h-8 w-8 text-primary" />
251
+ <h1 className="text-3xl font-bold">{selectedDoc.title}</h1>
252
+ </div>
253
+ </div>
254
+
255
+ {/* Markdown Content */}
256
+ <MarkdownRenderer content={content} />
257
+ </>
258
+ )}
259
+ </div>
260
+ </div>
261
+
262
+ {/* Table of Contents Sidebar - independent scrollable column */}
263
+ {showToc && (
264
+ <div className="hidden lg:block h-full overflow-y-auto border-l border-border bg-muted/30">
265
+ <div className="p-6 sticky top-0">
266
+ <TableOfContents items={tocItems} activeId={activeId} visibleIds={visibleIds} onItemClick={handleTocClick} />
267
+ </div>
268
+ </div>
269
+ )}
270
+ </div>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ // Wrapper component with Suspense boundary for Next.js 15
276
+ export function DocsView() {
277
+ return (
278
+ <Suspense fallback={<div className="flex items-center justify-center h-full">Loading documentation...</div>}>
279
+ <DocsViewContent />
280
+ </Suspense>
281
+ );
282
+ }
components/views/interviews-view.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { InterviewTemplatesPanel } from '@/components/interview/InterviewTemplatesPanel';
4
+
5
+ export function InterviewsView() {
6
+ return (
7
+ <div className="h-full">
8
+ <InterviewTemplatesPanel />
9
+ </div>
10
+ );
11
+ }
components/views/projects-view.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Project } from '@/lib/vfs/types';
5
+ import { ProjectManager } from '@/components/project-manager';
6
+
7
+ interface ProjectsViewProps {
8
+ onProjectSelect: (project: Project) => void;
9
+ autoCreate?: boolean;
10
+ workspaceId?: string;
11
+ }
12
+
13
+ export function ProjectsView({ onProjectSelect, autoCreate, workspaceId }: ProjectsViewProps) {
14
+ // Use ProjectManager but hide its header and footer since PageLayout provides them
15
+ return (
16
+ <ProjectManager
17
+ onProjectSelect={onProjectSelect}
18
+ hideHeader={true}
19
+ hideFooter={true}
20
+ autoCreate={autoCreate}
21
+ workspaceId={workspaceId}
22
+ />
23
+ );
24
+ }
components/views/settings-view.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { Suspense } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { ProvidersModelsView } from '@/components/providers-models';
6
+ import { SettingsPanel } from '@/components/settings';
7
+
8
+ interface SettingsViewProps {
9
+ tab?: 'model' | 'application';
10
+ }
11
+
12
+ function SettingsViewInner({ tab }: SettingsViewProps) {
13
+ const searchParams = useSearchParams();
14
+ // URL param takes precedence, then prop, then default to 'model'
15
+ const settingsTab = searchParams.get('settings') as 'model' | 'application' | null;
16
+ const activeTab = settingsTab || tab || 'model';
17
+
18
+ return (
19
+ <div className="h-full flex flex-col">
20
+ <div className={`flex-1 overflow-auto${activeTab === 'application' ? ' p-6' : ''}`}>
21
+ {activeTab === 'application' ? <SettingsPanel /> : <ProvidersModelsView />}
22
+ </div>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ export function SettingsView({ tab }: SettingsViewProps) {
28
+ return (
29
+ <Suspense fallback={<div className="h-full flex items-center justify-center"><p className="text-muted-foreground">Loading...</p></div>}>
30
+ <SettingsViewInner tab={tab} />
31
+ </Suspense>
32
+ );
33
+ }
components/views/skills-view.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { SkillsManager } from '@/components/skills/SkillsManager';
4
+
5
+ export function SkillsView() {
6
+ return <SkillsManager />;
7
+ }
components/views/templates-view.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { TemplateManager } from '@/components/template-manager';
4
+ import { useRouter } from 'next/navigation';
5
+
6
+ interface TemplatesViewProps {
7
+ onProjectSelect?: (project: { id: string }) => void;
8
+ onNavigate?: (view: string) => void;
9
+ }
10
+
11
+ export function TemplatesView({ onProjectSelect, onNavigate }: TemplatesViewProps) {
12
+ const router = useRouter();
13
+
14
+
15
+ const handleProjectCreated = (projectId: string, _hasBackendFeatures: boolean) => {
16
+ if (onProjectSelect) {
17
+ onProjectSelect({ id: projectId });
18
+ } else {
19
+ router.push(`/workspace/${projectId}`);
20
+ }
21
+ };
22
+
23
+ return <TemplateManager onProjectCreated={handleProjectCreated} />;
24
+ }
components/views/users-view.tsx ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo } from 'react';
4
+ import { getLoginUrl } from '@/lib/config/storage';
5
+ import { Users, Plus, Search, MoreHorizontal, UserCheck, UserX, Trash2, Pencil, ChevronRight, ChevronDown, HardDrive, Eye, EyeOff } from 'lucide-react';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Spinner } from '@/components/ui/spinner';
8
+ import { Input } from '@/components/ui/input';
9
+ import { Badge } from '@/components/ui/badge';
10
+ import { Label } from '@/components/ui/label';
11
+ import { Switch } from '@/components/ui/switch';
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogFooter,
18
+ } from '@/components/ui/dialog';
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from '@/components/ui/select';
26
+ import {
27
+ DropdownMenu,
28
+ DropdownMenuContent,
29
+ DropdownMenuItem,
30
+ DropdownMenuTrigger,
31
+ } from '@/components/ui/dropdown-menu';
32
+ import { toast } from 'sonner';
33
+ import { logger } from '@/lib/utils';
34
+
35
+ interface WorkspaceInfo {
36
+ id: string;
37
+ name: string;
38
+ owner_id: string;
39
+ max_projects: number;
40
+ max_deployments: number;
41
+ max_storage_mb: number;
42
+ role: string;
43
+ created_at: string;
44
+ updated_at: string;
45
+ }
46
+
47
+ interface UserInfo {
48
+ id: string;
49
+ email: string;
50
+ displayName: string | null;
51
+ isAdmin: boolean;
52
+ active: boolean;
53
+ workspaces: WorkspaceInfo[];
54
+ projectCount: number;
55
+ storageMb: number;
56
+ lastActive: string | null;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ }
60
+
61
+
62
+ export function UsersView() {
63
+ const [users, setUsers] = useState<UserInfo[]>([]);
64
+ const [loading, setLoading] = useState(true);
65
+ const [searchQuery, setSearchQuery] = useState('');
66
+ const [editUser, setEditUser] = useState<UserInfo | null>(null);
67
+ const [showEditDialog, setShowEditDialog] = useState(false);
68
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
69
+ const [saving, setSaving] = useState(false);
70
+
71
+ // Edit form state
72
+ const [editForm, setEditForm] = useState({
73
+ displayName: '',
74
+ active: true,
75
+ });
76
+
77
+ // Create form state
78
+ const [createForm, setCreateForm] = useState({
79
+ email: '',
80
+ password: '',
81
+ displayName: '',
82
+ workspaceAssignment: 'new' as 'new' | 'existing' | 'none',
83
+ workspaceId: '',
84
+ });
85
+ const [showPassword, setShowPassword] = useState(false);
86
+
87
+ const [availableWorkspaces, setAvailableWorkspaces] = useState<Array<{ id: string; name: string }>>([]);
88
+
89
+ // Expandable workspace detail state
90
+ const [expandedUserId, setExpandedUserId] = useState<string | null>(null);
91
+
92
+ const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';
93
+ const isManagedMode = !!process.env.NEXT_PUBLIC_GATEWAY_URL;
94
+
95
+ useEffect(() => {
96
+ loadUsers();
97
+ }, []);
98
+
99
+ useEffect(() => {
100
+ if (showCreateDialog) {
101
+ fetch('/api/admin/workspaces')
102
+ .then(res => res.ok ? res.json() : null)
103
+ .then(data => {
104
+ if (data?.workspaces) {
105
+ setAvailableWorkspaces(data.workspaces.map((w: { id: string; name: string }) => ({ id: w.id, name: w.name })));
106
+ }
107
+ })
108
+ .catch(() => {});
109
+ }
110
+ }, [showCreateDialog]);
111
+
112
+ const loadUsers = async () => {
113
+ setLoading(true);
114
+ try {
115
+ const res = await fetch('/api/admin/users');
116
+ if (res.status === 401) {
117
+ window.location.href = getLoginUrl();
118
+ return;
119
+ }
120
+ if (!res.ok) throw new Error('Failed to load users');
121
+ const data = await res.json();
122
+ setUsers(data.users);
123
+ } catch (err) {
124
+ logger.error('[UsersView] Failed to load users:', err);
125
+ toast.error('Failed to load users');
126
+ } finally {
127
+ setLoading(false);
128
+ }
129
+ };
130
+
131
+ const handleOpenEdit = (user: UserInfo) => {
132
+ setEditUser(user);
133
+ setEditForm({
134
+ displayName: user.displayName || '',
135
+ active: user.active,
136
+ });
137
+ setShowEditDialog(true);
138
+ };
139
+
140
+ const handleSaveEdit = async () => {
141
+ if (!editUser) return;
142
+ setSaving(true);
143
+ try {
144
+ const res = await fetch(`/api/admin/users/${editUser.id}`, {
145
+ method: 'PUT',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({
148
+ displayName: editForm.displayName || undefined,
149
+ active: editForm.active,
150
+ }),
151
+ });
152
+ if (!res.ok) {
153
+ const error = await res.json();
154
+ throw new Error(error.error || 'Failed to update user');
155
+ }
156
+ toast.success('User updated');
157
+ setShowEditDialog(false);
158
+ setEditUser(null);
159
+ await loadUsers();
160
+ } catch (err) {
161
+ logger.error('[UsersView] Failed to update user:', err);
162
+ toast.error(err instanceof Error ? err.message : 'Failed to update user');
163
+ } finally {
164
+ setSaving(false);
165
+ }
166
+ };
167
+
168
+ const handleToggleActive = async (user: UserInfo) => {
169
+ try {
170
+ const res = await fetch(`/api/admin/users/${user.id}`, {
171
+ method: 'PUT',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({ active: !user.active }),
174
+ });
175
+ if (!res.ok) {
176
+ const error = await res.json();
177
+ throw new Error(error.error || 'Failed to update user');
178
+ }
179
+ toast.success(user.active ? 'User deactivated' : 'User reactivated');
180
+ await loadUsers();
181
+ } catch (err) {
182
+ logger.error('[UsersView] Failed to toggle user status:', err);
183
+ toast.error(err instanceof Error ? err.message : 'Failed to update user');
184
+ }
185
+ };
186
+
187
+ const handleDelete = async (user: UserInfo) => {
188
+ if (!confirm(`Deactivate user "${user.email}"? This will soft-delete their account.`)) {
189
+ return;
190
+ }
191
+ try {
192
+ const res = await fetch(`/api/admin/users/${user.id}`, {
193
+ method: 'DELETE',
194
+ });
195
+ if (!res.ok) {
196
+ const error = await res.json();
197
+ throw new Error(error.error || 'Failed to delete user');
198
+ }
199
+ toast.success('User deactivated');
200
+ await loadUsers();
201
+ } catch (err) {
202
+ logger.error('[UsersView] Failed to delete user:', err);
203
+ toast.error(err instanceof Error ? err.message : 'Failed to delete user');
204
+ }
205
+ };
206
+
207
+ const handleCreate = async () => {
208
+ if (!createForm.email || !createForm.password) {
209
+ toast.error('Email and password are required');
210
+ return;
211
+ }
212
+ setSaving(true);
213
+ try {
214
+ const res = await fetch('/api/admin/users', {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({
218
+ email: createForm.email,
219
+ password: createForm.password,
220
+ displayName: createForm.displayName || undefined,
221
+ workspaceAssignment: createForm.workspaceAssignment,
222
+ workspaceId: createForm.workspaceId || undefined,
223
+ }),
224
+ });
225
+ if (!res.ok) {
226
+ const error = await res.json();
227
+ throw new Error(error.error || 'Failed to create user');
228
+ }
229
+ toast.success('User created');
230
+ setShowCreateDialog(false);
231
+ setCreateForm({ email: '', password: '', displayName: '', workspaceAssignment: 'new', workspaceId: '' });
232
+ setShowPassword(false);
233
+ await loadUsers();
234
+ } catch (err) {
235
+ logger.error('[UsersView] Failed to create user:', err);
236
+ toast.error(err instanceof Error ? err.message : 'Failed to create user');
237
+ } finally {
238
+ setSaving(false);
239
+ }
240
+ };
241
+
242
+ const handleToggleExpand = (userId: string) => {
243
+ setExpandedUserId(expandedUserId === userId ? null : userId);
244
+ };
245
+
246
+ const filteredUsers = useMemo(() => {
247
+ if (!searchQuery) return users;
248
+ const query = searchQuery.toLowerCase();
249
+ return users.filter(
250
+ user =>
251
+ user.email.toLowerCase().includes(query) ||
252
+ user.displayName?.toLowerCase().includes(query)
253
+ );
254
+ }, [users, searchQuery]);
255
+
256
+ const formatDate = (dateStr: string) => {
257
+ try {
258
+ return new Date(dateStr).toLocaleDateString(undefined, {
259
+ year: 'numeric',
260
+ month: 'short',
261
+ day: 'numeric',
262
+ });
263
+ } catch {
264
+ return dateStr;
265
+ }
266
+ };
267
+
268
+ const roleBadgeVariant = (role: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
269
+ switch (role) {
270
+ case 'owner': return 'default';
271
+ case 'editor': return 'secondary';
272
+ default: return 'outline';
273
+ }
274
+ };
275
+
276
+ if (!isServerMode) {
277
+ return (
278
+ <div className="h-full flex items-center justify-center">
279
+ <div className="text-center text-muted-foreground">
280
+ <p>User management is only available in Server Mode</p>
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ if (loading) {
287
+ return (
288
+ <div className="h-full flex items-center justify-center">
289
+ <div className="text-center">
290
+ <Spinner size={48} color="#f97316" className="mx-auto" />
291
+ <p className="mt-4">Loading users...</p>
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ return (
298
+ <>
299
+ <div className="h-full flex flex-col">
300
+ {/* Toolbar */}
301
+ <div className="pt-4 px-4 pb-3 sm:pt-6 sm:px-6 sm:pb-3 shrink-0">
302
+ <div className="mx-auto max-w-7xl flex flex-col sm:flex-row gap-3">
303
+ {/* Search */}
304
+ <div className="relative flex-1">
305
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
306
+ <Input
307
+ placeholder="Search users..."
308
+ value={searchQuery}
309
+ onChange={(e) => setSearchQuery(e.target.value)}
310
+ className="pl-9"
311
+ />
312
+ </div>
313
+
314
+ {/* Controls */}
315
+ <div className="flex items-center gap-2">
316
+ {isManagedMode ? (
317
+ <p className="text-sm text-muted-foreground">
318
+ Users are managed externally
319
+ </p>
320
+ ) : (
321
+ <Button onClick={() => setShowCreateDialog(true)} size="sm" className="gap-2">
322
+ <Plus className="h-4 w-4" />
323
+ <span>New User</span>
324
+ </Button>
325
+ )}
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ {/* User List */}
331
+ <div className="flex-1 px-4 pt-3 pb-4 sm:px-6 sm:pt-3 sm:pb-6 overflow-auto">
332
+ <div className="mx-auto max-w-7xl">
333
+ {filteredUsers.length === 0 ? (
334
+ <div className="flex flex-col items-center justify-center py-16 text-center">
335
+ <Users className="h-16 w-16 text-muted-foreground mb-4" />
336
+ {users.length === 0 ? (
337
+ <>
338
+ <h2 className="text-xl font-semibold mb-2">No Users Yet</h2>
339
+ <p className="text-muted-foreground mb-4 max-w-md">
340
+ Create your first user by clicking the &quot;New User&quot; button above.
341
+ </p>
342
+ </>
343
+ ) : (
344
+ <>
345
+ <h2 className="text-xl font-semibold mb-2">No users found</h2>
346
+ <p className="text-muted-foreground mb-4 max-w-md">
347
+ Try adjusting your search criteria
348
+ </p>
349
+ </>
350
+ )}
351
+ </div>
352
+ ) : (
353
+ <div className="space-y-2">
354
+ {filteredUsers.map((user) => {
355
+ const isExpanded = expandedUserId === user.id;
356
+ return (
357
+ <div key={user.id} className="rounded-lg border bg-card overflow-hidden">
358
+ <div
359
+ className="flex items-center gap-4 p-4 hover:bg-accent/50 transition-colors cursor-pointer"
360
+ onClick={() => handleToggleExpand(user.id)}
361
+ >
362
+ {/* Expand chevron */}
363
+ <div className="shrink-0 text-muted-foreground">
364
+ {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
365
+ </div>
366
+
367
+ {/* User Info */}
368
+ <div className="flex-1 min-w-0">
369
+ <div className="flex items-center gap-2 flex-wrap">
370
+ <span className="font-medium truncate">{user.email}</span>
371
+ {user.isAdmin && (
372
+ <Badge variant="destructive" className="text-xs">admin</Badge>
373
+ )}
374
+ {!user.active && (
375
+ <Badge variant="outline" className="text-xs text-muted-foreground">inactive</Badge>
376
+ )}
377
+ {user.workspaces.map(ws => (
378
+ <Badge key={ws.id} variant={roleBadgeVariant(ws.role)} className="text-xs">
379
+ {ws.name} ({ws.role})
380
+ </Badge>
381
+ ))}
382
+ </div>
383
+ <div className="text-sm text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">
384
+ {user.displayName && (
385
+ <span>{user.displayName}</span>
386
+ )}
387
+ <span>{user.projectCount} projects</span>
388
+ <span>
389
+ <HardDrive className="inline h-3 w-3 mr-0.5 relative -top-px" />
390
+ {user.storageMb} MB
391
+ </span>
392
+ {user.lastActive ? (
393
+ <span>Active {formatDate(user.lastActive)}</span>
394
+ ) : (
395
+ <span>Created {formatDate(user.createdAt)}</span>
396
+ )}
397
+ </div>
398
+ </div>
399
+
400
+ {/* Actions */}
401
+ <DropdownMenu>
402
+ <DropdownMenuTrigger asChild>
403
+ <Button
404
+ variant="ghost"
405
+ size="sm"
406
+ className="h-8 w-8 p-0"
407
+ onClick={(e) => e.stopPropagation()}
408
+ >
409
+ <MoreHorizontal className="h-4 w-4" />
410
+ </Button>
411
+ </DropdownMenuTrigger>
412
+ <DropdownMenuContent align="end">
413
+ <DropdownMenuItem onClick={() => handleOpenEdit(user)}>
414
+ <Pencil className="h-4 w-4 mr-2" />
415
+ Edit
416
+ </DropdownMenuItem>
417
+ <DropdownMenuItem onClick={() => handleToggleActive(user)}>
418
+ {user.active ? (
419
+ <>
420
+ <UserX className="h-4 w-4 mr-2" />
421
+ Deactivate
422
+ </>
423
+ ) : (
424
+ <>
425
+ <UserCheck className="h-4 w-4 mr-2" />
426
+ Reactivate
427
+ </>
428
+ )}
429
+ </DropdownMenuItem>
430
+ <DropdownMenuItem
431
+ onClick={() => handleDelete(user)}
432
+ className="text-destructive focus:text-destructive"
433
+ >
434
+ <Trash2 className="h-4 w-4 mr-2" />
435
+ Delete
436
+ </DropdownMenuItem>
437
+ </DropdownMenuContent>
438
+ </DropdownMenu>
439
+ </div>
440
+
441
+ {/* Expanded workspace details */}
442
+ {isExpanded && (
443
+ <div className="border-t bg-muted/30 px-4 py-3 pl-12">
444
+ {user.workspaces.length > 0 ? (
445
+ <div className="space-y-2">
446
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
447
+ Workspaces ({user.workspaces.length})
448
+ </div>
449
+ {user.workspaces.map((ws) => (
450
+ <div
451
+ key={ws.id}
452
+ className="flex items-center gap-3 text-sm p-2 rounded bg-background/60"
453
+ >
454
+ <HardDrive className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
455
+ <div className="flex-1 min-w-0">
456
+ <div className="flex items-center gap-2 flex-wrap">
457
+ <span className="font-medium truncate">{ws.name}</span>
458
+ </div>
459
+ </div>
460
+ <div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
461
+ <span>{ws.max_projects} projects</span>
462
+ <span>{ws.max_deployments} deployments</span>
463
+ <span>Created {formatDate(ws.created_at)}</span>
464
+ </div>
465
+ </div>
466
+ ))}
467
+ </div>
468
+ ) : (
469
+ <div className="text-sm text-muted-foreground py-2">
470
+ No workspaces assigned
471
+ </div>
472
+ )}
473
+ </div>
474
+ )}
475
+ </div>
476
+ );
477
+ })}
478
+ </div>
479
+ )}
480
+ </div>
481
+ </div>
482
+ </div>
483
+
484
+ {/* Edit User Dialog */}
485
+ <Dialog open={showEditDialog} onOpenChange={(open) => { setShowEditDialog(open); if (!open) setEditUser(null); }}>
486
+ <DialogContent className="sm:max-w-md">
487
+ <DialogHeader>
488
+ <DialogTitle>Edit User</DialogTitle>
489
+ </DialogHeader>
490
+ <div className="space-y-4 py-4">
491
+ <div className="space-y-2">
492
+ <Label htmlFor="edit-displayName">Display Name</Label>
493
+ <Input
494
+ id="edit-displayName"
495
+ value={editForm.displayName}
496
+ onChange={(e) => setEditForm(f => ({ ...f, displayName: e.target.value }))}
497
+ placeholder="Display name"
498
+ />
499
+ </div>
500
+ <div className="flex items-center gap-2">
501
+ <Switch
502
+ id="edit-active"
503
+ checked={editForm.active}
504
+ onCheckedChange={(checked) => setEditForm(f => ({ ...f, active: checked }))}
505
+ />
506
+ <Label htmlFor="edit-active">Active</Label>
507
+ </div>
508
+ </div>
509
+ <DialogFooter>
510
+ <Button variant="outline" onClick={() => { setShowEditDialog(false); setEditUser(null); }}>Cancel</Button>
511
+ <Button onClick={handleSaveEdit} disabled={saving}>
512
+ {saving ? 'Saving...' : 'Save Changes'}
513
+ </Button>
514
+ </DialogFooter>
515
+ </DialogContent>
516
+ </Dialog>
517
+
518
+ {/* Create User Dialog */}
519
+ <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
520
+ <DialogContent className="sm:max-w-md">
521
+ <DialogHeader>
522
+ <DialogTitle>Create User</DialogTitle>
523
+ </DialogHeader>
524
+ <div className="space-y-4 py-4">
525
+ <div className="space-y-2">
526
+ <Label htmlFor="create-email">Email</Label>
527
+ <Input
528
+ id="create-email"
529
+ type="email"
530
+ value={createForm.email}
531
+ onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
532
+ placeholder="user@example.com"
533
+ />
534
+ </div>
535
+ <div className="space-y-2">
536
+ <div className="flex items-center justify-between">
537
+ <Label htmlFor="create-password">Password</Label>
538
+ <button
539
+ type="button"
540
+ className="text-xs text-primary hover:text-primary/80 transition-colors"
541
+ onClick={() => {
542
+ const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*';
543
+ let pw = '';
544
+ const rng = new Uint32Array(16); crypto.getRandomValues(rng);
545
+ for (let i = 0; i < 16; i++) pw += chars[rng[i] % chars.length];
546
+ setCreateForm(f => ({ ...f, password: pw }));
547
+ setShowPassword(true);
548
+ }}
549
+ >
550
+ Generate
551
+ </button>
552
+ </div>
553
+ <div className="relative">
554
+ <Input
555
+ id="create-password"
556
+ type={showPassword ? 'text' : 'password'}
557
+ value={createForm.password}
558
+ onChange={(e) => setCreateForm(f => ({ ...f, password: e.target.value }))}
559
+ placeholder="Minimum 8 characters"
560
+ className="pr-9"
561
+ />
562
+ <button
563
+ type="button"
564
+ onClick={() => setShowPassword(!showPassword)}
565
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
566
+ >
567
+ {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
568
+ </button>
569
+ </div>
570
+ </div>
571
+ <div className="space-y-2">
572
+ <Label htmlFor="create-displayName">Display Name</Label>
573
+ <Input
574
+ id="create-displayName"
575
+ value={createForm.displayName}
576
+ onChange={(e) => setCreateForm(f => ({ ...f, displayName: e.target.value }))}
577
+ placeholder="Display name (optional)"
578
+ />
579
+ </div>
580
+
581
+ {/* Workspace Assignment */}
582
+ <div className="space-y-2">
583
+ <Label>Workspace</Label>
584
+ <Select
585
+ value={createForm.workspaceAssignment}
586
+ onValueChange={(value) => setCreateForm(f => ({
587
+ ...f,
588
+ workspaceAssignment: value as 'new' | 'existing' | 'none',
589
+ workspaceId: '',
590
+ }))}
591
+ >
592
+ <SelectTrigger>
593
+ <SelectValue />
594
+ </SelectTrigger>
595
+ <SelectContent>
596
+ <SelectItem value="new">Create new workspace</SelectItem>
597
+ <SelectItem value="existing">Assign to existing workspace</SelectItem>
598
+ <SelectItem value="none">No workspace</SelectItem>
599
+ </SelectContent>
600
+ </Select>
601
+ </div>
602
+
603
+ {createForm.workspaceAssignment === 'existing' && (
604
+ <div className="space-y-2">
605
+ <Label>Select Workspace</Label>
606
+ <Select
607
+ value={createForm.workspaceId}
608
+ onValueChange={(value) => setCreateForm(f => ({ ...f, workspaceId: value }))}
609
+ >
610
+ <SelectTrigger>
611
+ <SelectValue placeholder="Choose a workspace..." />
612
+ </SelectTrigger>
613
+ <SelectContent>
614
+ {availableWorkspaces.map(ws => (
615
+ <SelectItem key={ws.id} value={ws.id}>{ws.name}</SelectItem>
616
+ ))}
617
+ </SelectContent>
618
+ </Select>
619
+ </div>
620
+ )}
621
+ </div>
622
+ <DialogFooter>
623
+ <Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>
624
+ <Button onClick={handleCreate} disabled={saving}>
625
+ {saving ? 'Creating...' : 'Create User'}
626
+ </Button>
627
+ </DialogFooter>
628
+ </DialogContent>
629
+ </Dialog>
630
+ </>
631
+ );
632
+ }
components/views/workspaces-view.tsx ADDED
@@ -0,0 +1,678 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo } from 'react';
4
+ import { getLoginUrl } from '@/lib/config/storage';
5
+ import {
6
+ Building2,
7
+ Plus,
8
+ Search,
9
+ MoreHorizontal,
10
+ Trash2,
11
+ Pencil,
12
+ ChevronRight,
13
+ ChevronDown,
14
+ UserPlus,
15
+ UserMinus,
16
+ } from 'lucide-react';
17
+ import { Button } from '@/components/ui/button';
18
+ import { Spinner } from '@/components/ui/spinner';
19
+ import { Input } from '@/components/ui/input';
20
+ import { Badge } from '@/components/ui/badge';
21
+ import { Label } from '@/components/ui/label';
22
+ import {
23
+ Dialog,
24
+ DialogContent,
25
+ DialogHeader,
26
+ DialogTitle,
27
+ DialogFooter,
28
+ } from '@/components/ui/dialog';
29
+ import {
30
+ DropdownMenu,
31
+ DropdownMenuContent,
32
+ DropdownMenuItem,
33
+ DropdownMenuTrigger,
34
+ } from '@/components/ui/dropdown-menu';
35
+ import { toast } from 'sonner';
36
+ import { logger } from '@/lib/utils';
37
+
38
+ interface WorkspaceMember {
39
+ userId: string;
40
+ email: string;
41
+ displayName: string | null;
42
+ role: string;
43
+ joinedAt: string;
44
+ }
45
+
46
+ interface WorkspaceInfo {
47
+ id: string;
48
+ name: string;
49
+ ownerId: string;
50
+ ownerEmail: string | null;
51
+ maxProjects: number;
52
+ maxDeployments: number;
53
+ maxStorageMb: number;
54
+ memberCount: number;
55
+ projectCount: number;
56
+ deploymentCount: number;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ }
60
+
61
+ interface WorkspaceDetail extends WorkspaceInfo {
62
+ members: WorkspaceMember[];
63
+ }
64
+
65
+ export function WorkspacesView() {
66
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
67
+ const [loading, setLoading] = useState(true);
68
+ const [searchQuery, setSearchQuery] = useState('');
69
+ const [saving, setSaving] = useState(false);
70
+
71
+ // Edit dialog
72
+ const [editWorkspace, setEditWorkspace] = useState<WorkspaceInfo | null>(null);
73
+ const [showEditDialog, setShowEditDialog] = useState(false);
74
+ const [editForm, setEditForm] = useState({
75
+ name: '',
76
+ maxProjects: 3,
77
+ maxDeployments: 1,
78
+ maxStorageMb: 100,
79
+ });
80
+
81
+ // Create dialog
82
+ const [showCreateDialog, setShowCreateDialog] = useState(false);
83
+ const [createForm, setCreateForm] = useState({
84
+ name: '',
85
+ ownerEmail: '',
86
+ });
87
+
88
+ // Expand / member detail
89
+ const [expandedId, setExpandedId] = useState<string | null>(null);
90
+ const [workspaceDetail, setWorkspaceDetail] = useState<WorkspaceDetail | null>(null);
91
+ const [loadingDetail, setLoadingDetail] = useState(false);
92
+
93
+ // Add member dialog
94
+ const [showAddMemberDialog, setShowAddMemberDialog] = useState(false);
95
+ const [addMemberForm, setAddMemberForm] = useState({ email: '', role: 'editor' });
96
+ const [addingMember, setAddingMember] = useState(false);
97
+
98
+ const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';
99
+
100
+ useEffect(() => {
101
+ loadWorkspaces();
102
+ }, []);
103
+
104
+ const loadWorkspaces = async () => {
105
+ setLoading(true);
106
+ try {
107
+ const res = await fetch('/api/admin/workspaces');
108
+ if (res.status === 401) {
109
+ window.location.href = getLoginUrl();
110
+ return;
111
+ }
112
+ if (!res.ok) throw new Error('Failed to load workspaces');
113
+ const data = await res.json();
114
+ setWorkspaces(data.workspaces);
115
+ } catch (err) {
116
+ logger.error('[WorkspacesView] Failed to load workspaces:', err);
117
+ toast.error('Failed to load workspaces');
118
+ } finally {
119
+ setLoading(false);
120
+ }
121
+ };
122
+
123
+ const handleOpenEdit = (ws: WorkspaceInfo) => {
124
+ setEditWorkspace(ws);
125
+ setEditForm({
126
+ name: ws.name,
127
+ maxProjects: ws.maxProjects,
128
+ maxDeployments: ws.maxDeployments,
129
+ maxStorageMb: ws.maxStorageMb,
130
+ });
131
+ setShowEditDialog(true);
132
+ };
133
+
134
+ const handleSaveEdit = async () => {
135
+ if (!editWorkspace) return;
136
+ setSaving(true);
137
+ try {
138
+ const res = await fetch(`/api/admin/workspaces/${editWorkspace.id}`, {
139
+ method: 'PUT',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({
142
+ name: editForm.name || undefined,
143
+ maxProjects: editForm.maxProjects,
144
+ maxDeployments: editForm.maxDeployments,
145
+ maxStorageMb: editForm.maxStorageMb,
146
+ }),
147
+ });
148
+ if (!res.ok) {
149
+ const error = await res.json();
150
+ throw new Error(error.error || 'Failed to update workspace');
151
+ }
152
+ toast.success('Workspace updated');
153
+ setShowEditDialog(false);
154
+ setEditWorkspace(null);
155
+ await loadWorkspaces();
156
+ // Refresh detail if expanded
157
+ if (expandedId === editWorkspace.id) {
158
+ await loadDetail(editWorkspace.id);
159
+ }
160
+ } catch (err) {
161
+ logger.error('[WorkspacesView] Failed to update workspace:', err);
162
+ toast.error(err instanceof Error ? err.message : 'Failed to update workspace');
163
+ } finally {
164
+ setSaving(false);
165
+ }
166
+ };
167
+
168
+ const handleCreate = async () => {
169
+ if (!createForm.name || !createForm.ownerEmail) {
170
+ toast.error('Workspace name and owner email are required');
171
+ return;
172
+ }
173
+ setSaving(true);
174
+ try {
175
+ const res = await fetch('/api/admin/workspaces', {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({
179
+ name: createForm.name,
180
+ ownerEmail: createForm.ownerEmail,
181
+ }),
182
+ });
183
+ if (!res.ok) {
184
+ const error = await res.json();
185
+ throw new Error(error.error || 'Failed to create workspace');
186
+ }
187
+ toast.success('Workspace created');
188
+ setShowCreateDialog(false);
189
+ setCreateForm({ name: '', ownerEmail: '' });
190
+ await loadWorkspaces();
191
+ } catch (err) {
192
+ logger.error('[WorkspacesView] Failed to create workspace:', err);
193
+ toast.error(err instanceof Error ? err.message : 'Failed to create workspace');
194
+ } finally {
195
+ setSaving(false);
196
+ }
197
+ };
198
+
199
+ const handleDelete = async (ws: WorkspaceInfo) => {
200
+ if (!confirm(`Delete workspace "${ws.name}"? This cannot be undone.`)) return;
201
+ try {
202
+ const res = await fetch(`/api/admin/workspaces/${ws.id}`, { method: 'DELETE' });
203
+ if (!res.ok) {
204
+ const error = await res.json();
205
+ throw new Error(error.error || 'Failed to delete workspace');
206
+ }
207
+ toast.success('Workspace deleted');
208
+ if (expandedId === ws.id) {
209
+ setExpandedId(null);
210
+ setWorkspaceDetail(null);
211
+ }
212
+ await loadWorkspaces();
213
+ } catch (err) {
214
+ logger.error('[WorkspacesView] Failed to delete workspace:', err);
215
+ toast.error(err instanceof Error ? err.message : 'Failed to delete workspace');
216
+ }
217
+ };
218
+
219
+ const loadDetail = async (wsId: string) => {
220
+ setLoadingDetail(true);
221
+ try {
222
+ const res = await fetch(`/api/admin/workspaces/${wsId}`);
223
+ if (!res.ok) throw new Error('Failed to load workspace details');
224
+ const data = await res.json();
225
+ setWorkspaceDetail(data);
226
+ } catch (err) {
227
+ logger.error('[WorkspacesView] Failed to load workspace details:', err);
228
+ toast.error('Failed to load workspace details');
229
+ setExpandedId(null);
230
+ } finally {
231
+ setLoadingDetail(false);
232
+ }
233
+ };
234
+
235
+ const handleToggleExpand = async (wsId: string) => {
236
+ if (expandedId === wsId) {
237
+ setExpandedId(null);
238
+ setWorkspaceDetail(null);
239
+ return;
240
+ }
241
+ setExpandedId(wsId);
242
+ await loadDetail(wsId);
243
+ };
244
+
245
+ const handleAddMember = async () => {
246
+ if (!expandedId || !addMemberForm.email) {
247
+ toast.error('Email is required');
248
+ return;
249
+ }
250
+ setAddingMember(true);
251
+ try {
252
+ const res = await fetch(`/api/admin/workspaces/${expandedId}/access`, {
253
+ method: 'POST',
254
+ headers: { 'Content-Type': 'application/json' },
255
+ body: JSON.stringify({ email: addMemberForm.email, role: addMemberForm.role }),
256
+ });
257
+ if (!res.ok) {
258
+ const error = await res.json();
259
+ throw new Error(error.error || 'Failed to add member');
260
+ }
261
+ toast.success('Member added');
262
+ setShowAddMemberDialog(false);
263
+ setAddMemberForm({ email: '', role: 'editor' });
264
+ await loadDetail(expandedId);
265
+ await loadWorkspaces();
266
+ } catch (err) {
267
+ logger.error('[WorkspacesView] Failed to add member:', err);
268
+ toast.error(err instanceof Error ? err.message : 'Failed to add member');
269
+ } finally {
270
+ setAddingMember(false);
271
+ }
272
+ };
273
+
274
+ const handleRemoveMember = async (wsId: string, userId: string, memberEmail: string) => {
275
+ if (!confirm(`Remove ${memberEmail} from this workspace?`)) return;
276
+ try {
277
+ const res = await fetch(`/api/admin/workspaces/${wsId}/access`, {
278
+ method: 'DELETE',
279
+ headers: { 'Content-Type': 'application/json' },
280
+ body: JSON.stringify({ userId }),
281
+ });
282
+ if (!res.ok) {
283
+ const error = await res.json();
284
+ throw new Error(error.error || 'Failed to remove member');
285
+ }
286
+ toast.success('Member removed');
287
+ await loadDetail(wsId);
288
+ await loadWorkspaces();
289
+ } catch (err) {
290
+ logger.error('[WorkspacesView] Failed to remove member:', err);
291
+ toast.error(err instanceof Error ? err.message : 'Failed to remove member');
292
+ }
293
+ };
294
+
295
+ const filteredWorkspaces = useMemo(() => {
296
+ if (!searchQuery) return workspaces;
297
+ const query = searchQuery.toLowerCase();
298
+ return workspaces.filter(
299
+ ws =>
300
+ ws.name.toLowerCase().includes(query) ||
301
+ ws.ownerEmail?.toLowerCase().includes(query)
302
+ );
303
+ }, [workspaces, searchQuery]);
304
+
305
+ const formatDate = (dateStr: string) => {
306
+ try {
307
+ return new Date(dateStr).toLocaleDateString(undefined, {
308
+ year: 'numeric',
309
+ month: 'short',
310
+ day: 'numeric',
311
+ });
312
+ } catch {
313
+ return dateStr;
314
+ }
315
+ };
316
+
317
+ const roleBadgeVariant = (role: string): 'default' | 'secondary' | 'outline' => {
318
+ switch (role) {
319
+ case 'owner': return 'default';
320
+ case 'editor': return 'secondary';
321
+ default: return 'outline';
322
+ }
323
+ };
324
+
325
+ if (!isServerMode) {
326
+ return (
327
+ <div className="h-full flex items-center justify-center">
328
+ <div className="text-center text-muted-foreground">
329
+ <p>Workspace management is only available in Server Mode</p>
330
+ </div>
331
+ </div>
332
+ );
333
+ }
334
+
335
+ if (loading) {
336
+ return (
337
+ <div className="h-full flex items-center justify-center">
338
+ <div className="text-center">
339
+ <Spinner size={48} color="#f97316" className="mx-auto" />
340
+ <p className="mt-4">Loading workspaces...</p>
341
+ </div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ return (
347
+ <>
348
+ <div className="h-full flex flex-col">
349
+ {/* Toolbar */}
350
+ <div className="pt-4 px-4 pb-3 sm:pt-6 sm:px-6 sm:pb-3 shrink-0">
351
+ <div className="mx-auto max-w-7xl flex flex-col sm:flex-row gap-3">
352
+ <div className="relative flex-1">
353
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
354
+ <Input
355
+ placeholder="Search workspaces..."
356
+ value={searchQuery}
357
+ onChange={(e) => setSearchQuery(e.target.value)}
358
+ className="pl-9"
359
+ />
360
+ </div>
361
+ <div className="flex items-center gap-2">
362
+ <Button onClick={() => setShowCreateDialog(true)} size="sm" className="gap-2">
363
+ <Plus className="h-4 w-4" />
364
+ <span>New Workspace</span>
365
+ </Button>
366
+ </div>
367
+ </div>
368
+ </div>
369
+
370
+ {/* Workspace List */}
371
+ <div className="flex-1 px-4 pt-3 pb-4 sm:px-6 sm:pt-3 sm:pb-6 overflow-auto">
372
+ <div className="mx-auto max-w-7xl">
373
+ {filteredWorkspaces.length === 0 ? (
374
+ <div className="flex flex-col items-center justify-center py-16 text-center">
375
+ <Building2 className="h-16 w-16 text-muted-foreground mb-4" />
376
+ {workspaces.length === 0 ? (
377
+ <>
378
+ <h2 className="text-xl font-semibold mb-2">No Workspaces Yet</h2>
379
+ <p className="text-muted-foreground mb-4 max-w-md">
380
+ Create your first workspace by clicking the &quot;New Workspace&quot; button above.
381
+ </p>
382
+ </>
383
+ ) : (
384
+ <>
385
+ <h2 className="text-xl font-semibold mb-2">No workspaces found</h2>
386
+ <p className="text-muted-foreground mb-4 max-w-md">
387
+ Try adjusting your search criteria
388
+ </p>
389
+ </>
390
+ )}
391
+ </div>
392
+ ) : (
393
+ <div className="space-y-2">
394
+ {filteredWorkspaces.map((ws) => {
395
+ const isExpanded = expandedId === ws.id;
396
+ return (
397
+ <div key={ws.id} className="rounded-lg border bg-card overflow-hidden">
398
+ <div
399
+ className="flex items-center gap-4 p-4 hover:bg-accent/50 transition-colors cursor-pointer"
400
+ onClick={() => handleToggleExpand(ws.id)}
401
+ >
402
+ {/* Expand chevron */}
403
+ <div className="shrink-0 text-muted-foreground">
404
+ {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
405
+ </div>
406
+
407
+ {/* Workspace Info */}
408
+ <div className="flex-1 min-w-0">
409
+ <div className="flex items-center gap-2 flex-wrap">
410
+ <span className="font-medium truncate">{ws.name}</span>
411
+ </div>
412
+ <div className="text-sm text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">
413
+ {ws.ownerEmail && (
414
+ <span>Owner: {ws.ownerEmail}</span>
415
+ )}
416
+ <span>{ws.memberCount} {ws.memberCount === 1 ? 'member' : 'members'}</span>
417
+ <span>{ws.projectCount} {ws.projectCount === 1 ? 'project' : 'projects'}</span>
418
+ <span>Created {formatDate(ws.createdAt)}</span>
419
+ </div>
420
+ </div>
421
+
422
+ {/* Actions */}
423
+ <DropdownMenu>
424
+ <DropdownMenuTrigger asChild>
425
+ <Button
426
+ variant="ghost"
427
+ size="sm"
428
+ className="h-8 w-8 p-0"
429
+ onClick={(e) => e.stopPropagation()}
430
+ >
431
+ <MoreHorizontal className="h-4 w-4" />
432
+ </Button>
433
+ </DropdownMenuTrigger>
434
+ <DropdownMenuContent align="end">
435
+ <DropdownMenuItem onClick={() => handleOpenEdit(ws)}>
436
+ <Pencil className="h-4 w-4 mr-2" />
437
+ Edit
438
+ </DropdownMenuItem>
439
+ <DropdownMenuItem
440
+ onClick={() => handleDelete(ws)}
441
+ className="text-destructive focus:text-destructive"
442
+ >
443
+ <Trash2 className="h-4 w-4 mr-2" />
444
+ Delete
445
+ </DropdownMenuItem>
446
+ </DropdownMenuContent>
447
+ </DropdownMenu>
448
+ </div>
449
+
450
+ {/* Expanded members section */}
451
+ {isExpanded && (
452
+ <div className="border-t bg-muted/30 px-4 py-3 pl-12">
453
+ {loadingDetail ? (
454
+ <div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
455
+ <Spinner size={16} color="#f97316" />
456
+ <span>Loading members...</span>
457
+ </div>
458
+ ) : workspaceDetail ? (
459
+ <div className="space-y-3">
460
+ {/* Stats row */}
461
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
462
+ <span>Max projects: {workspaceDetail.maxProjects}</span>
463
+ <span>Max deployments: {workspaceDetail.maxDeployments}</span>
464
+ <span>Max storage: {workspaceDetail.maxStorageMb} MB</span>
465
+ <span>Deployments: {workspaceDetail.deploymentCount}</span>
466
+ </div>
467
+
468
+ {/* Members */}
469
+ <div>
470
+ <div className="flex items-center justify-between mb-2">
471
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
472
+ Members ({workspaceDetail.members.length})
473
+ </div>
474
+ <Button
475
+ size="sm"
476
+ variant="outline"
477
+ className="h-7 text-xs gap-1"
478
+ onClick={(e) => {
479
+ e.stopPropagation();
480
+ setShowAddMemberDialog(true);
481
+ }}
482
+ >
483
+ <UserPlus className="h-3 w-3" />
484
+ Add Member
485
+ </Button>
486
+ </div>
487
+ {workspaceDetail.members.length > 0 ? (
488
+ <div className="space-y-1">
489
+ {workspaceDetail.members.map((member) => (
490
+ <div
491
+ key={member.userId}
492
+ className="flex items-center gap-3 text-sm p-2 rounded bg-background/60"
493
+ onClick={(e) => e.stopPropagation()}
494
+ >
495
+ <div className="flex-1 min-w-0">
496
+ <div className="flex items-center gap-2 flex-wrap">
497
+ <span className="font-medium truncate">{member.email}</span>
498
+ <Badge variant={roleBadgeVariant(member.role)} className="text-xs">
499
+ {member.role}
500
+ </Badge>
501
+ </div>
502
+ {member.displayName && (
503
+ <div className="text-xs text-muted-foreground">{member.displayName}</div>
504
+ )}
505
+ </div>
506
+ <div className="text-xs text-muted-foreground shrink-0">
507
+ {formatDate(member.joinedAt)}
508
+ </div>
509
+ {member.userId !== workspaceDetail.ownerId && (
510
+ <Button
511
+ variant="ghost"
512
+ size="sm"
513
+ className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
514
+ onClick={(e) => {
515
+ e.stopPropagation();
516
+ handleRemoveMember(ws.id, member.userId, member.email);
517
+ }}
518
+ title="Remove member"
519
+ >
520
+ <UserMinus className="h-3.5 w-3.5" />
521
+ </Button>
522
+ )}
523
+ </div>
524
+ ))}
525
+ </div>
526
+ ) : (
527
+ <div className="text-sm text-muted-foreground py-1">No members</div>
528
+ )}
529
+ </div>
530
+ </div>
531
+ ) : null}
532
+ </div>
533
+ )}
534
+ </div>
535
+ );
536
+ })}
537
+ </div>
538
+ )}
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ {/* Edit Workspace Dialog */}
544
+ <Dialog open={showEditDialog} onOpenChange={(open) => { setShowEditDialog(open); if (!open) setEditWorkspace(null); }}>
545
+ <DialogContent className="sm:max-w-md">
546
+ <DialogHeader>
547
+ <DialogTitle>Edit Workspace</DialogTitle>
548
+ </DialogHeader>
549
+ <div className="space-y-4 py-4">
550
+ <div className="space-y-2">
551
+ <Label htmlFor="edit-name">Name</Label>
552
+ <Input
553
+ id="edit-name"
554
+ value={editForm.name}
555
+ onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
556
+ placeholder="Workspace name"
557
+ />
558
+ </div>
559
+ <div className="grid grid-cols-3 gap-3">
560
+ <div className="space-y-2">
561
+ <Label htmlFor="edit-maxProjects">Max Projects</Label>
562
+ <Input
563
+ id="edit-maxProjects"
564
+ type="number"
565
+ min={0}
566
+ value={editForm.maxProjects}
567
+ onChange={(e) => setEditForm(f => ({ ...f, maxProjects: Number(e.target.value) }))}
568
+ />
569
+ </div>
570
+ <div className="space-y-2">
571
+ <Label htmlFor="edit-maxDeployments">Max Deploys</Label>
572
+ <Input
573
+ id="edit-maxDeployments"
574
+ type="number"
575
+ min={0}
576
+ value={editForm.maxDeployments}
577
+ onChange={(e) => setEditForm(f => ({ ...f, maxDeployments: Number(e.target.value) }))}
578
+ />
579
+ </div>
580
+ <div className="space-y-2">
581
+ <Label htmlFor="edit-maxStorage">Storage (MB)</Label>
582
+ <Input
583
+ id="edit-maxStorage"
584
+ type="number"
585
+ min={0}
586
+ value={editForm.maxStorageMb}
587
+ onChange={(e) => setEditForm(f => ({ ...f, maxStorageMb: Number(e.target.value) }))}
588
+ />
589
+ </div>
590
+ </div>
591
+ </div>
592
+ <DialogFooter>
593
+ <Button variant="outline" onClick={() => { setShowEditDialog(false); setEditWorkspace(null); }}>Cancel</Button>
594
+ <Button onClick={handleSaveEdit} disabled={saving}>
595
+ {saving ? 'Saving...' : 'Save Changes'}
596
+ </Button>
597
+ </DialogFooter>
598
+ </DialogContent>
599
+ </Dialog>
600
+
601
+ {/* Create Workspace Dialog */}
602
+ <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
603
+ <DialogContent className="sm:max-w-md">
604
+ <DialogHeader>
605
+ <DialogTitle>Create Workspace</DialogTitle>
606
+ </DialogHeader>
607
+ <div className="space-y-4 py-4">
608
+ <div className="space-y-2">
609
+ <Label htmlFor="create-name">Name</Label>
610
+ <Input
611
+ id="create-name"
612
+ value={createForm.name}
613
+ onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
614
+ placeholder="Workspace name"
615
+ />
616
+ </div>
617
+ <div className="space-y-2">
618
+ <Label htmlFor="create-ownerEmail">Owner Email</Label>
619
+ <Input
620
+ id="create-ownerEmail"
621
+ type="email"
622
+ value={createForm.ownerEmail}
623
+ onChange={(e) => setCreateForm(f => ({ ...f, ownerEmail: e.target.value }))}
624
+ placeholder="owner@example.com"
625
+ />
626
+ </div>
627
+ </div>
628
+ <DialogFooter>
629
+ <Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>
630
+ <Button onClick={handleCreate} disabled={saving}>
631
+ {saving ? 'Creating...' : 'Create Workspace'}
632
+ </Button>
633
+ </DialogFooter>
634
+ </DialogContent>
635
+ </Dialog>
636
+
637
+ {/* Add Member Dialog */}
638
+ <Dialog open={showAddMemberDialog} onOpenChange={setShowAddMemberDialog}>
639
+ <DialogContent className="sm:max-w-md">
640
+ <DialogHeader>
641
+ <DialogTitle>Add Member</DialogTitle>
642
+ </DialogHeader>
643
+ <div className="space-y-4 py-4">
644
+ <div className="space-y-2">
645
+ <Label htmlFor="add-member-email">User Email</Label>
646
+ <Input
647
+ id="add-member-email"
648
+ type="email"
649
+ value={addMemberForm.email}
650
+ onChange={(e) => setAddMemberForm(f => ({ ...f, email: e.target.value }))}
651
+ placeholder="user@example.com"
652
+ />
653
+ </div>
654
+ <div className="space-y-2">
655
+ <Label htmlFor="add-member-role">Role</Label>
656
+ <select
657
+ id="add-member-role"
658
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
659
+ value={addMemberForm.role}
660
+ onChange={(e) => setAddMemberForm(f => ({ ...f, role: e.target.value }))}
661
+ >
662
+ <option value="viewer">viewer</option>
663
+ <option value="editor">editor</option>
664
+ <option value="owner">owner</option>
665
+ </select>
666
+ </div>
667
+ </div>
668
+ <DialogFooter>
669
+ <Button variant="outline" onClick={() => setShowAddMemberDialog(false)}>Cancel</Button>
670
+ <Button onClick={handleAddMember} disabled={addingMember}>
671
+ {addingMember ? 'Adding...' : 'Add Member'}
672
+ </Button>
673
+ </DialogFooter>
674
+ </DialogContent>
675
+ </Dialog>
676
+ </>
677
+ );
678
+ }
components/workspace-switcher/index.tsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useRouter, usePathname } from 'next/navigation';
5
+ import { ChevronDown, Building2, Settings } from 'lucide-react';
6
+ import { Button } from '@/components/ui/button';
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from '@/components/ui/dropdown-menu';
14
+ import { Badge } from '@/components/ui/badge';
15
+
16
+ interface WorkspaceInfo {
17
+ id: string;
18
+ name: string;
19
+ owner_id: string;
20
+ role: string;
21
+ }
22
+
23
+ interface WorkspaceSwitcherProps {
24
+ workspaceId?: string;
25
+ }
26
+
27
+ export function WorkspaceSwitcher({ workspaceId }: WorkspaceSwitcherProps) {
28
+ // Read cached name from localStorage after mount (avoids hydration mismatch)
29
+ const [cachedName, setCachedName] = useState<string | null>(null);
30
+ useEffect(() => {
31
+ try {
32
+ const name = localStorage.getItem('osw-workspace-name');
33
+ if (name) setCachedName(name);
34
+ } catch {}
35
+ }, []);
36
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
37
+ const [isAdmin, setIsAdmin] = useState(false);
38
+ const [loading, setLoading] = useState(true);
39
+ const router = useRouter();
40
+ const pathname = usePathname();
41
+
42
+ useEffect(() => {
43
+ Promise.all([
44
+ fetch('/api/workspaces').then(res => res.ok ? res.json() : null),
45
+ fetch('/api/auth/me').then(res => res.ok ? res.json() : null),
46
+ ])
47
+ .then(([wsData, authData]) => {
48
+ if (wsData?.workspaces) {
49
+ setWorkspaces(wsData.workspaces);
50
+ // Update cached name from fresh data
51
+ const current = wsData.workspaces.find((w: WorkspaceInfo) => w.id === workspaceId);
52
+ if (current) {
53
+ try { localStorage.setItem('osw-workspace-name', current.name); } catch {}
54
+ }
55
+ }
56
+ if (authData?.user?.isAdmin) setIsAdmin(true);
57
+ })
58
+ .catch(() => {})
59
+ .finally(() => setLoading(false));
60
+ }, [workspaceId]);
61
+
62
+ const currentWorkspace = workspaces.find(w => w.id === workspaceId);
63
+
64
+ const handleSwitch = (newWorkspaceId: string) => {
65
+ const viewMatch = pathname?.match(/\/w\/[^/]+\/(.+)/);
66
+ const currentView = viewMatch?.[1] || 'projects';
67
+ document.cookie = `osw_workspace=${newWorkspaceId};path=/;max-age=${60 * 60 * 24 * 365}`;
68
+ const ws = workspaces.find(w => w.id === newWorkspaceId);
69
+ if (ws) {
70
+ try { localStorage.setItem('osw-workspace-name', ws.name); } catch {}
71
+ }
72
+ router.push(`/w/${newWorkspaceId}/${currentView}`);
73
+ };
74
+
75
+ // While loading, show cached name if available (prevents flash)
76
+ if (loading) {
77
+ if (!cachedName) return null;
78
+ return (
79
+ <div className="border-b border-border/50">
80
+ <Button variant="ghost" className="w-full justify-start gap-2 px-3 py-2.5 h-auto rounded-none" disabled>
81
+ <Building2 className="h-4 w-4 text-foreground shrink-0" />
82
+ <span className="truncate font-medium text-sm text-foreground">{cachedName}</span>
83
+ <ChevronDown className="h-3 w-3 ml-auto text-muted-foreground shrink-0" />
84
+ </Button>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ if (workspaces.length === 0) return null;
90
+
91
+ // Always show as dropdown — single workspace gets manage option, multiple gets switcher
92
+ return (
93
+ <div className="border-b border-border/50">
94
+ <DropdownMenu>
95
+ <DropdownMenuTrigger asChild>
96
+ <Button variant="ghost" className="w-full justify-start gap-2 px-3 py-2.5 h-auto rounded-none hover:bg-accent/50">
97
+ <Building2 className="h-4 w-4 text-foreground shrink-0" />
98
+ <span className="truncate font-medium text-sm text-foreground">
99
+ {currentWorkspace?.name || cachedName || workspaces[0]?.name || 'Workspace'}
100
+ </span>
101
+ <ChevronDown className="h-3 w-3 ml-auto text-muted-foreground shrink-0" />
102
+ </Button>
103
+ </DropdownMenuTrigger>
104
+ <DropdownMenuContent align="start" className="w-64">
105
+ {workspaces.map(ws => (
106
+ <DropdownMenuItem
107
+ key={ws.id}
108
+ onClick={() => handleSwitch(ws.id)}
109
+ className={ws.id === workspaceId ? 'bg-accent' : ''}
110
+ >
111
+ <div className="flex items-center gap-2 w-full min-w-0">
112
+ <span className="truncate flex-1">{ws.name}</span>
113
+ <Badge variant="outline" className="text-[10px] shrink-0">{ws.role}</Badge>
114
+ </div>
115
+ </DropdownMenuItem>
116
+ ))}
117
+ {isAdmin && (
118
+ <>
119
+ <DropdownMenuSeparator />
120
+ <DropdownMenuItem onClick={() => router.push('/admin/workspaces')}>
121
+ <Settings className="h-3.5 w-3.5 mr-2" />
122
+ Manage Workspaces
123
+ </DropdownMenuItem>
124
+ </>
125
+ )}
126
+ </DropdownMenuContent>
127
+ </DropdownMenu>
128
+ </div>
129
+ );
130
+ }
components/workspace/deployment-selector.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { Deployment } from '@/lib/vfs/types';
5
+ import { Server, Loader2 } from 'lucide-react';
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@/components/ui/select';
13
+ import { cn } from '@/lib/utils';
14
+
15
+ interface DeploymentSelectorProps {
16
+ projectId: string;
17
+ selectedDeploymentId: string | null;
18
+ onDeploymentChange: (deploymentId: string | null, deploymentName: string | null) => void;
19
+ className?: string;
20
+ workspaceId?: string;
21
+ }
22
+
23
+ export function DeploymentSelector({
24
+ projectId,
25
+ selectedDeploymentId,
26
+ onDeploymentChange,
27
+ className,
28
+ workspaceId,
29
+ }: DeploymentSelectorProps) {
30
+ const apiBase = workspaceId ? `/api/w/${workspaceId}` : '/api';
31
+ const [deployments, setDeployments] = useState<Deployment[]>([]);
32
+ const [loading, setLoading] = useState(true);
33
+ const [error, setError] = useState<string | null>(null);
34
+
35
+ // Fetch deployments for this project
36
+ useEffect(() => {
37
+ const fetchDeployments = async () => {
38
+ // Only fetch in server mode
39
+ if (process.env.NEXT_PUBLIC_SERVER_MODE !== 'true') {
40
+ setLoading(false);
41
+ return;
42
+ }
43
+
44
+ try {
45
+ setLoading(true);
46
+ setError(null);
47
+
48
+ const response = await fetch(`${apiBase}/projects/${projectId}/deployments`);
49
+ if (!response.ok) {
50
+ throw new Error('Failed to fetch deployments');
51
+ }
52
+
53
+ const data = await response.json();
54
+ setDeployments(data.deployments || []);
55
+
56
+ // Auto-select first deployment if only one exists and nothing is selected
57
+ if (data.deployments?.length === 1 && !selectedDeploymentId) {
58
+ const deployment = data.deployments[0];
59
+ if (deployment.databaseEnabled) {
60
+ onDeploymentChange(deployment.id, deployment.name);
61
+ }
62
+ }
63
+ } catch {
64
+ // Expected in browser mode or when project hasn't been synced to server yet
65
+ // Don't show error — deployments are optional
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ };
70
+
71
+ fetchDeployments();
72
+ }, [projectId]); // Only refetch when project changes
73
+
74
+ // Don't render in browser mode
75
+ if (process.env.NEXT_PUBLIC_SERVER_MODE !== 'true') {
76
+ return null;
77
+ }
78
+
79
+ // Don't render if no deployments with database enabled
80
+ const databaseEnabledDeployments = deployments.filter(s => s.databaseEnabled);
81
+ if (!loading && databaseEnabledDeployments.length === 0) {
82
+ return null;
83
+ }
84
+
85
+ if (loading) {
86
+ return (
87
+ <div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
88
+ <Loader2 className="h-4 w-4 animate-spin" />
89
+ <span>Loading deployments...</span>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ if (error) {
95
+ return (
96
+ <div className={cn('flex items-center gap-2 text-sm text-destructive', className)}>
97
+ <Server className="h-4 w-4" />
98
+ <span>{error}</span>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <div className={cn('flex items-center gap-2', className)}>
105
+ <Select
106
+ value={selectedDeploymentId || 'none'}
107
+ onValueChange={(value) => {
108
+ if (value === 'none') {
109
+ onDeploymentChange(null, null);
110
+ } else {
111
+ const deployment = deployments.find(s => s.id === value);
112
+ onDeploymentChange(value, deployment?.name || null);
113
+ }
114
+ }}
115
+ >
116
+ <SelectTrigger size="sm" className="w-[180px] h-8">
117
+ <SelectValue placeholder="No deployment" />
118
+ </SelectTrigger>
119
+ <SelectContent>
120
+ <SelectItem value="none">
121
+ <span className="text-muted-foreground">No deployment</span>
122
+ </SelectItem>
123
+ {databaseEnabledDeployments.map((deployment) => (
124
+ <SelectItem key={deployment.id} value={deployment.id}>
125
+ <div className="flex items-center gap-2">
126
+ <Server className="h-3.5 w-3.5" />
127
+ <span>{deployment.name}</span>
128
+ </div>
129
+ </SelectItem>
130
+ ))}
131
+ </SelectContent>
132
+ </Select>
133
+ </div>
134
+ );
135
+ }
components/workspace/index.tsx ADDED
@@ -0,0 +1,2257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useCallback, useEffect, useRef, useMemo } from 'react';
4
+ import { Project, VirtualFile } from '@/lib/vfs/types';
5
+ import { vfs } from '@/lib/vfs';
6
+ import { logger } from '@/lib/utils';
7
+ import { FileExplorer } from '@/components/file-explorer';
8
+ import { MultiTabEditor, openFileInEditor } from '@/components/editor/multi-tab-editor';
9
+ import { MultipagePreview, MultipagePreviewHandle } from '@/components/preview/multipage-preview';
10
+ import { Button } from '@/components/ui/button';
11
+ import { ArrowLeft, MessageSquare, FolderTree, Code2, Eye, Settings, Save, Bug, RotateCcw, History, Settings2, Terminal as TerminalIcon, Sparkles, ChevronDown, ChevronUp, EllipsisVertical } from 'lucide-react';
12
+ import { AppHeader, HeaderAction } from '@/components/ui/app-header';
13
+ import { PendingImage, PendingAudio, PendingFile } from '@/lib/llm/multi-agent-orchestrator';
14
+ import { configManager, migrateBackendKey } from '@/lib/config/storage';
15
+ import { useWorkspaceStore } from '@/lib/stores/workspace';
16
+ import type { InterviewTemplate, InterviewHandoff } from '@/lib/interview/types';
17
+ import { track } from '@/lib/telemetry';
18
+ import { bucketInterviewTemplateId } from '@/lib/telemetry/tool-analytics';
19
+ import { PANEL_MAP } from '@/lib/stores/slices/layout';
20
+ import { useCostSettings } from '@/lib/hooks/use-cost-settings';
21
+ import { getModelInputModalities } from '@/lib/llm/providers/registry';
22
+ import { isProjectProviderReady } from '@/lib/llm/models/project-assignment';
23
+ import { resolveActiveAssignment } from '@/lib/llm/models/template-store';
24
+ import { toast } from 'sonner';
25
+ import { debugEventsState } from '@/lib/llm/debug-events-state';
26
+ import {
27
+ ResizableHandle,
28
+ ResizablePanel,
29
+ ResizablePanelGroup,
30
+ } from '@/components/ui/resizable';
31
+ import {
32
+ Tooltip,
33
+ TooltipContent,
34
+ TooltipProvider,
35
+ TooltipTrigger,
36
+ } from '@/components/ui/tooltip';
37
+ import { checkpointManager } from '@/lib/vfs/checkpoint';
38
+ import { saveManager } from '@/lib/vfs/save-manager';
39
+ import {
40
+ Popover,
41
+ PopoverContent,
42
+ PopoverTrigger,
43
+ } from '@/components/ui/popover';
44
+ import { SettingsPanel } from '@/components/settings';
45
+ import { GuidedTourOverlay } from '@/components/guided-tour/overlay';
46
+ import { useGuidedTour } from '@/components/guided-tour/context';
47
+ import { GuidedTourTranscriptEvent } from '@/components/guided-tour/types';
48
+ import { FocusContextPayload } from '@/lib/preview/types';
49
+ import type { PlacedBlock } from '@/lib/semantic-blocks/types';
50
+ import type { PlacementResult } from '@/lib/preview/types';
51
+ import { getBlockById } from '@/lib/semantic-blocks/registry';
52
+ import { DebugPanel } from '@/components/debug-panel';
53
+ import { ChatPanel } from '@/components/chat-panel';
54
+ import { DeploymentSelector } from '@/components/workspace/deployment-selector';
55
+ import { CheckpointPanel } from '@/components/checkpoint-panel';
56
+ import { ProjectSettingsModal } from '@/components/project-backend';
57
+ import { SkillsPanel } from '@/components/workspace/skills-panel';
58
+ import { PanelDragProvider } from '@/components/ui/panel';
59
+ import { ConsolePanel } from '@/components/console';
60
+ import { drainRuntimeErrors, peekRuntimeErrors, formatRuntimeErrors } from '@/lib/preview/runtime-errors';
61
+
62
+ interface WorkspaceProps {
63
+ project: Project;
64
+ onBack: () => void;
65
+ workspaceId?: string;
66
+ }
67
+
68
+ type FocusTarget = FocusContextPayload & { timestamp: number };
69
+
70
+ export function Workspace({ project, onBack, workspaceId }: WorkspaceProps) {
71
+ const refreshTrigger = useWorkspaceStore(s => s.refreshTrigger);
72
+ const generating = useWorkspaceStore(s => s.generating);
73
+ const debugEvents = useWorkspaceStore(s => s.debugEvents);
74
+ const currentModel = useWorkspaceStore(s => s.currentModel);
75
+ const modelConfigVersion = useWorkspaceStore(s => s.modelConfigVersion);
76
+ const projectCost = useWorkspaceStore(s => s.projectCost);
77
+ const addDebugEvent = useWorkspaceStore(s => s.addDebugEvent);
78
+ const isDirty = useWorkspaceStore(s => s.isDirty);
79
+ const saveInProgress = useWorkspaceStore(s => s.saveInProgress);
80
+ const entryPoint = useWorkspaceStore(s => s.entryPoint);
81
+ const projectRuntime = useWorkspaceStore(s => s.projectRuntime);
82
+ const focusContext = useWorkspaceStore(s => s.focusContext);
83
+ const mode = useWorkspaceStore(s => s.mode);
84
+ const activeInterview = useWorkspaceStore(s => s.activeInterview);
85
+ const runtimeErrors = useWorkspaceStore(s => s.runtimeErrors);
86
+ const initialCheckpointId = useWorkspaceStore(s => s.initialCheckpointId);
87
+ const checkpointRefreshKey = useWorkspaceStore(s => s.checkpointRefreshKey);
88
+ const backendEnabled = useWorkspaceStore(s => s.backendEnabled);
89
+ const selectedDeploymentId = useWorkspaceStore(s => s.selectedDeploymentId);
90
+ const activeMobilePanel = useWorkspaceStore(s => s.activeMobilePanel);
91
+ const mobileOverflowOpen = useWorkspaceStore(s => s.mobileOverflowOpen);
92
+ const placedBlocks = useWorkspaceStore(s => s.placedBlocks);
93
+ const paletteOpen = useWorkspaceStore(s => s.paletteOpen);
94
+ const lastFocusSignatureRef = useRef<{ signature: string; timestamp: number } | null>(null);
95
+ const previewRef = useRef<MultipagePreviewHandle>(null);
96
+ const generatingRef = useRef(false);
97
+ const handleGenerateRef = useRef<((promptText?: string, images?: PendingImage[], audio?: PendingAudio[], files?: PendingFile[]) => Promise<void>) | null>(null);
98
+ const storeSetMode = useWorkspaceStore(s => s.setMode);
99
+ const storeSetActiveInterview = useWorkspaceStore(s => s.setActiveInterview);
100
+ const storeFocusContext = useWorkspaceStore(s => s.setFocusContext);
101
+ const { state: tourState, setWorkspaceHandler } = useGuidedTour();
102
+ const tourStep = tourState.currentStep?.id;
103
+ const tourRunning = tourState.status === 'running';
104
+ const isTourLockingInput = tourRunning && tourStep !== 'wrap-up';
105
+
106
+ // Keep generatingRef in sync for runtime error listener
107
+ useEffect(() => { generatingRef.current = generating; }, [generating]);
108
+
109
+ // Guard against accidental navigation away with unsaved changes
110
+ // During generation, let the user leave freely — the generation shelf handles status
111
+ const guardedBack = useCallback(() => {
112
+ if (!generating && isDirty) {
113
+ if (!window.confirm('You have unsaved changes. Leave anyway?')) return;
114
+ }
115
+ onBack();
116
+ }, [generating, isDirty, onBack]);
117
+
118
+ // Reattach to any server-side tasks that were running before page reload
119
+ useEffect(() => {
120
+ useWorkspaceStore.getState().reattachServerTasks();
121
+ }, []);
122
+
123
+ // Browser beforeunload — warn when dirty or generating
124
+ useEffect(() => {
125
+ const handler = (e: BeforeUnloadEvent) => {
126
+ if (useWorkspaceStore.getState().isAnyGenerating() || isDirty) {
127
+ e.preventDefault();
128
+ }
129
+ };
130
+ window.addEventListener('beforeunload', handler);
131
+ return () => window.removeEventListener('beforeunload', handler);
132
+ }, [isDirty]);
133
+
134
+ // Subscribe to runtime errors that arrive after generation completes
135
+ useEffect(() => {
136
+ const handler = () => {
137
+ if (!generatingRef.current) {
138
+ useWorkspaceStore.getState().setRuntimeErrors(peekRuntimeErrors());
139
+ }
140
+ };
141
+ window.addEventListener('runtimeErrorsChanged', handler);
142
+ return () => window.removeEventListener('runtimeErrorsChanged', handler);
143
+ }, []);
144
+
145
+ // The reactive model-config signal bump (apiKeyUpdated + modelConfigChanged -> bumpModelConfig)
146
+ // lives in the useModelConfigSignal hook, mounted at the always-present mode roots (StudioInner
147
+ // and PageWrapperInner). Mounting it there (not here) makes modelConfigVersion bump globally so
148
+ // ChatPanels rendered outside the Workspace subtree (describe-mode, project-manager) also react.
149
+
150
+ // Listen for runtime changes from the CLI (e.g., LLM runs `runtime handlebars`)
151
+ useEffect(() => {
152
+ const handler = (e: Event) => {
153
+ const runtime = (e as CustomEvent).detail?.runtime;
154
+ if (runtime) useWorkspaceStore.getState().updateProjectSettings({ runtime });
155
+ };
156
+ window.addEventListener('runtimeChanged', handler);
157
+ return () => window.removeEventListener('runtimeChanged', handler);
158
+ }, []);
159
+
160
+ // The GLOBAL model auto-assign on provider connect (apiKeyUpdated -> activateProviderAsGlobalDefault)
161
+ // lives in the useProviderAutoAssign hook, mounted at the always-present mode roots (StudioInner and
162
+ // PageWrapperInner). It cannot live here: the Connections UI is reachable outside a workspace
163
+ // (dashboard -> Settings -> Connections), where Workspace is not mounted.
164
+
165
+ // Get cost settings for conditional display
166
+ const { shouldShowCosts } = useCostSettings();
167
+
168
+ // Input modalities of the active agent model (drives image/voice affordances).
169
+ // Resolve from the globally-resolved active assignment, falling back to the
170
+ // global selectedProvider/currentModel.
171
+ const inputModalities = useMemo(() => {
172
+ const agent = resolveActiveAssignment().agent;
173
+ const currentProvider = agent?.provider ?? configManager.getSelectedProvider();
174
+ const modelId = agent?.model || currentModel || configManager.getDefaultModel();
175
+
176
+ // Check cached discovered models first (has accurate modality data from API)
177
+ const cached = configManager.getCachedModels(currentProvider);
178
+ if (cached) {
179
+ const model = (cached.models as import('@/lib/llm/providers/types').ProviderModel[])
180
+ .find(m => m.id === modelId);
181
+ if (model?.inputModalities) return model.inputModalities;
182
+ if (model?.supportsVision !== undefined) {
183
+ return model.supportsVision ? ['text' as const, 'image' as const] : ['text' as const];
184
+ }
185
+ }
186
+
187
+ return getModelInputModalities(currentProvider, modelId);
188
+ }, [modelConfigVersion, currentModel]);
189
+
190
+ const supportsVision = inputModalities.includes('image');
191
+
192
+ // Readiness keys off the globally-resolved active agent provider
193
+ // (see isProjectProviderReady), recomputed on modelConfigVersion bumps.
194
+ const providerReady = useMemo(
195
+ () => isProjectProviderReady(),
196
+ [modelConfigVersion],
197
+ );
198
+
199
+ // Console panel — visible by default for terminal-mode runtimes (Python, Lua), togglable for all
200
+
201
+ const showChat = useWorkspaceStore(s => s.showChat);
202
+ const showFiles = useWorkspaceStore(s => s.showFiles);
203
+ const showEditor = useWorkspaceStore(s => s.showEditor);
204
+ const showPreview = useWorkspaceStore(s => s.showPreview);
205
+ const showCheckpoints = useWorkspaceStore(s => s.showCheckpoints);
206
+ const showDebugPanel = useWorkspaceStore(s => s.showDebugPanel);
207
+ const showProjectSettingsModal = useWorkspaceStore(s => s.showProjectSettingsModal);
208
+ const showSkillsPanel = useWorkspaceStore(s => s.showSkillsPanel);
209
+ const showConsole = useWorkspaceStore(s => s.showConsole);
210
+ const fullscreenPreview = useWorkspaceStore(s => s.fullscreenPreview);
211
+ const panelReplacePreview = useWorkspaceStore(s => s.panelReplacePreview);
212
+ const panelInsertPreview = useWorkspaceStore(s => s.panelInsertPreview);
213
+ const hasUnreadConsole = useWorkspaceStore(s => s.hasUnreadConsole);
214
+ // Ref to imperatively reset panel sizes after reorder
215
+ const panelGroupRef = useRef<import('react-resizable-panels').ImperativePanelGroupHandle | null>(null);
216
+
217
+ const panelOrder = useWorkspaceStore(s => s.panelOrder);
218
+ const draggingPanel = useWorkspaceStore(s => s.draggingPanel);
219
+ const dropTarget = useWorkspaceStore(s => s.dropTarget);
220
+
221
+ const handlePanelDragStart = useCallback((panelKey: string) => {
222
+ // Capture the dragged panel's center X for "stay put" distance comparison
223
+ const container = panelContainerRef.current;
224
+ if (container) {
225
+ const panelEl = container.querySelector(`[data-panel-id="${panelKey}"]`);
226
+ if (panelEl) {
227
+ const rect = panelEl.getBoundingClientRect();
228
+ draggedPanelCenter.current = rect.left + rect.width / 2;
229
+ }
230
+ }
231
+ document.body.style.cursor = 'grabbing';
232
+ useWorkspaceStore.getState().startDrag(panelKey);
233
+ }, []);
234
+
235
+ const handlePanelDragEnd = useCallback(() => {
236
+ if (draggingPanel && dropTarget !== null) {
237
+ // Capture current sizes keyed by panel identity before reordering
238
+ const visibleBefore = panelOrder.filter(k => {
239
+ if (k === 'chat') return showChat;
240
+ if (k === 'files') return showFiles;
241
+ if (k === 'editor') return showEditor;
242
+ if (k === 'console') return showConsole;
243
+ if (k === 'preview') return showPreview;
244
+ if (k === 'checkpoints') return showCheckpoints;
245
+ if (k === 'debug') return showDebugPanel;
246
+ if (k === 'skills') return showSkillsPanel;
247
+ return false;
248
+ });
249
+ const currentLayout = panelGroupRef.current?.getLayout() || [];
250
+ const sizeByKey: Record<string, number> = {};
251
+ visibleBefore.forEach((key, i) => {
252
+ if (i < currentLayout.length) sizeByKey[key] = currentLayout[i];
253
+ });
254
+
255
+ {
256
+ const prevOrder = panelOrder;
257
+ const newOrder = prevOrder.filter(k => k !== draggingPanel);
258
+ const targetKey = visibleBefore[dropTarget];
259
+ if (targetKey) {
260
+ const insertIdx = newOrder.indexOf(targetKey);
261
+ newOrder.splice(insertIdx, 0, draggingPanel);
262
+ } else {
263
+ newOrder.push(draggingPanel);
264
+ }
265
+ useWorkspaceStore.getState().setPanelOrder(newOrder);
266
+ }
267
+
268
+ // Restore sizes in the new order after React re-renders
269
+ requestAnimationFrame(() => {
270
+ if (panelGroupRef.current && visibleBefore.length > 0) {
271
+ // Compute new visible order
272
+ const newVisible = [...visibleBefore];
273
+ const dragIdx = newVisible.indexOf(draggingPanel);
274
+ if (dragIdx >= 0) newVisible.splice(dragIdx, 1);
275
+ const targetKey = visibleBefore[dropTarget];
276
+ const insertIdx = targetKey ? newVisible.indexOf(targetKey) : newVisible.length;
277
+ newVisible.splice(insertIdx >= 0 ? insertIdx : newVisible.length, 0, draggingPanel);
278
+
279
+ const sizes = newVisible.map(k => sizeByKey[k] || Math.floor(100 / newVisible.length));
280
+ // Normalize to exactly 100
281
+ const total = sizes.reduce((a, b) => a + b, 0);
282
+ if (total !== 100 && sizes.length > 0) {
283
+ sizes[sizes.length - 1] += 100 - total;
284
+ }
285
+ panelGroupRef.current.setLayout(sizes);
286
+ }
287
+ });
288
+ }
289
+ useWorkspaceStore.getState().endDrag();
290
+ draggedPanelCenter.current = null;
291
+ document.body.style.cursor = '';
292
+ }, [draggingPanel, dropTarget, panelOrder, showChat, showFiles, showEditor, showConsole, showPreview, showCheckpoints, showDebugPanel, showSkillsPanel]);
293
+
294
+ // Document-level mouseup listener — ends drag whether inside or outside the container.
295
+ // If mouseUp is inside the container, the container's own onMouseUp handles it (with drop logic).
296
+ // If mouseUp is outside, this fires and cancels the drag.
297
+ useEffect(() => {
298
+ if (!draggingPanel) return;
299
+ const handleDocumentMouseUp = () => {
300
+ // Only cancel if still dragging — the container's onMouseUp may have already handled it
301
+ if (useWorkspaceStore.getState().draggingPanel) {
302
+ useWorkspaceStore.getState().endDrag();
303
+ draggedPanelCenter.current = null;
304
+ document.body.style.cursor = '';
305
+ }
306
+ };
307
+ document.addEventListener('mouseup', handleDocumentMouseUp);
308
+ return () => document.removeEventListener('mouseup', handleDocumentMouseUp);
309
+ }, [draggingPanel]);
310
+
311
+ // During drag: track mouse X and find closest drop zone
312
+ const panelContainerRef = useRef<HTMLDivElement>(null);
313
+ const dropZonePositions = useRef<Map<number, number>>(new Map()); // position index → X center
314
+ const draggedPanelCenter = useRef<number | null>(null); // X center of the panel being dragged
315
+
316
+ const registerDropZone = useCallback((position: number, el: HTMLDivElement | null) => {
317
+ if (el) {
318
+ const rect = el.getBoundingClientRect();
319
+ dropZonePositions.current.set(position, rect.left + rect.width / 2);
320
+ } else {
321
+ dropZonePositions.current.delete(position);
322
+ }
323
+ }, []);
324
+
325
+ const handleDragMouseMove = useCallback((e: React.MouseEvent) => {
326
+ if (!draggingPanel) return;
327
+ const positions = dropZonePositions.current;
328
+ if (positions.size === 0) return;
329
+
330
+ // Check distance to each drop zone
331
+ let closest: number | null = null;
332
+ let closestDist = Infinity;
333
+ for (const [pos, x] of positions) {
334
+ const dist = Math.abs(e.clientX - x);
335
+ if (dist < closestDist) {
336
+ closestDist = dist;
337
+ closest = pos;
338
+ }
339
+ }
340
+
341
+ // If the mouse is closer to the dragged panel's own center, stay put (no move)
342
+ if (draggedPanelCenter.current !== null) {
343
+ const distToSelf = Math.abs(e.clientX - draggedPanelCenter.current);
344
+ if (distToSelf <= closestDist) {
345
+ useWorkspaceStore.getState().setDropTarget(null);
346
+ return;
347
+ }
348
+ }
349
+
350
+ useWorkspaceStore.getState().setDropTarget(closest);
351
+ }, [draggingPanel]);
352
+
353
+ const togglePanel = useWorkspaceStore(s => s.togglePanel);
354
+
355
+ const handleSidebarHover = useCallback((key: string | null) => {
356
+ const store = useWorkspaceStore.getState();
357
+ if (!key) { store.setPanelReplacePreview(null); store.setPanelInsertPreview(null); return; }
358
+ const panelStateKey = PANEL_MAP[key];
359
+ if (!panelStateKey || store[panelStateKey]) { store.setPanelReplacePreview(null); store.setPanelInsertPreview(null); return; }
360
+ const allPanels = store.panelOrder
361
+ .filter(k => PANEL_MAP[k] !== undefined)
362
+ .map(k => ({ key: k, open: !!store[PANEL_MAP[k]] }));
363
+ const visibleCount = allPanels.filter(p => p.open).length;
364
+ const MAX_VISIBLE_PANELS = 3;
365
+ if (visibleCount < MAX_VISIBLE_PANELS) {
366
+ store.setPanelInsertPreview(visibleCount);
367
+ store.setPanelReplacePreview(null);
368
+ return;
369
+ }
370
+ store.setPanelInsertPreview(null);
371
+ for (let i = allPanels.length - 1; i >= 0; i--) {
372
+ if (allPanels[i].open && allPanels[i].key !== key) {
373
+ store.setPanelReplacePreview(allPanels[i].key);
374
+ return;
375
+ }
376
+ }
377
+ store.setPanelReplacePreview(null);
378
+ }, []);
379
+
380
+ const consoleBufferRef = useRef<{ level: string; text: string }[]>([]);
381
+ const showConsoleRef = useRef(showConsole);
382
+
383
+ // Keep showConsoleRef in sync for buffering logic (tracks both desktop and mobile)
384
+ useEffect(() => {
385
+ showConsoleRef.current = showConsole || activeMobilePanel === 'console';
386
+ }, [showConsole, activeMobilePanel]);
387
+
388
+ // Buffer previewConsole events when console is hidden
389
+ useEffect(() => {
390
+ const handler = (e: Event) => {
391
+ if (!showConsoleRef.current) {
392
+ const { level, args } = (e as CustomEvent<{ level: string; args: string[] }>).detail;
393
+ consoleBufferRef.current.push({ level, text: args.join(' ') });
394
+ useWorkspaceStore.getState().setHasUnreadConsole(true);
395
+ }
396
+ };
397
+ window.addEventListener('previewConsole', handler);
398
+ return () => window.removeEventListener('previewConsole', handler);
399
+ }, []);
400
+
401
+ // Clear unread flag when console opens (desktop or mobile)
402
+ useEffect(() => {
403
+ if (showConsole || activeMobilePanel === 'console') {
404
+ useWorkspaceStore.getState().setHasUnreadConsole(false);
405
+ }
406
+ }, [showConsole, activeMobilePanel]);
407
+
408
+ const clearDebugEvents = useCallback(async () => {
409
+ await useWorkspaceStore.getState().clearChat(project.id);
410
+ // Clear auto-checkpoints when conversation is cleared (keep manual saves)
411
+ await checkpointManager.clearAutoCheckpoints(project.id);
412
+ // Clearing the chat also ends any active interview (returns to the picker).
413
+ // Note: this counts as abandoned even if the interview had already completed
414
+ // and the user is just clearing up afterwards; acceptable minor over-count.
415
+ const endingInterview = useWorkspaceStore.getState().activeInterview;
416
+ if (endingInterview) {
417
+ track('interview_abandoned', { template: bucketInterviewTemplateId(endingInterview.templateId) });
418
+ }
419
+ useWorkspaceStore.getState().setActiveInterview(null);
420
+ }, [project.id]);
421
+
422
+ const visiblePanelCount = [showChat, showFiles, showEditor, showConsole, showPreview, showCheckpoints, showDebugPanel, showSkillsPanel].filter(Boolean).length;
423
+ const baseSize = visiblePanelCount > 0 ? Math.floor(100 / visiblePanelCount) : 100;
424
+
425
+ const getModelDisplayName = (modelId: string) => {
426
+ if (!modelId) return 'Select Model';
427
+ const parts = modelId.split('/');
428
+ const modelName = parts[parts.length - 1];
429
+ return modelName
430
+ .split('-')
431
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
432
+ .join(' ');
433
+ };
434
+
435
+ const truncateHtmlSnippet = useCallback((html: string, maxLength: number = 1200) => {
436
+ if (!html) {
437
+ return '';
438
+ }
439
+ if (html.length <= maxLength) {
440
+ return html;
441
+ }
442
+ const headLength = Math.max(0, Math.floor(maxLength * 0.6));
443
+ const tailLength = Math.max(0, Math.floor(maxLength * 0.3));
444
+ const head = html.slice(0, headLength);
445
+ const tail = tailLength > 0 ? html.slice(-tailLength) : '';
446
+ return `${head}\n (...truncated...)\n${tail}`;
447
+ }, []);
448
+
449
+ // Truncate an HTML snippet so the given marker comment stays visible with
450
+ // surrounding context on both sides. Used for semantic block drops where the
451
+ // marker's position is the whole point of the snippet — head/tail truncation
452
+ // would drop the marker whenever it falls in the middle of a large parent.
453
+ const truncateHtmlAroundMarker = useCallback((html: string, marker: string, maxLength: number = 1200) => {
454
+ if (!html) return '';
455
+ if (html.length <= maxLength) return html;
456
+ const markerIdx = html.indexOf(marker);
457
+ if (markerIdx === -1) {
458
+ // Fall back to head/tail when the marker isn't present
459
+ const headLength = Math.max(0, Math.floor(maxLength * 0.6));
460
+ const tailLength = Math.max(0, Math.floor(maxLength * 0.3));
461
+ const head = html.slice(0, headLength);
462
+ const tail = tailLength > 0 ? html.slice(-tailLength) : '';
463
+ return `${head}\n (...truncated...)\n${tail}`;
464
+ }
465
+ const half = Math.max(0, Math.floor((maxLength - marker.length) / 2));
466
+ const start = Math.max(0, markerIdx - half);
467
+ const end = Math.min(html.length, markerIdx + marker.length + half);
468
+ const prefix = start > 0 ? '(...truncated...)\n' : '';
469
+ const suffix = end < html.length ? '\n(...truncated...)' : '';
470
+ return `${prefix}${html.slice(start, end)}${suffix}`;
471
+ }, []);
472
+
473
+ const describeFocusTarget = useCallback((target: FocusTarget) => {
474
+ const attributeEntries = Object.entries(target.attributes || {}).slice(0, 6);
475
+ if (attributeEntries.length === 0) {
476
+ return `<${target.tagName}>`;
477
+ }
478
+ const summary = attributeEntries
479
+ .map(([key, value]) => {
480
+ const trimmed = value.length > 40 ? `${value.slice(0, 37)}…` : value;
481
+ return `${key}="${trimmed}"`;
482
+ })
483
+ .join(' ');
484
+ return `<${target.tagName} ${summary}>`;
485
+ }, []);
486
+
487
+ const formatFocusContextBlock = useCallback((target: FocusTarget) => {
488
+ const descriptor = describeFocusTarget(target);
489
+ const snippet = truncateHtmlSnippet(target.outerHTML, 1200);
490
+ const domPath = target.domPath || '(unknown path)';
491
+ return [
492
+ 'Focus context:',
493
+ `- Target: ${descriptor}`,
494
+ `- DOM path: ${domPath}`,
495
+ '- HTML snippet:',
496
+ '```html',
497
+ snippet,
498
+ '```'
499
+ ].join('\n');
500
+ }, [describeFocusTarget, truncateHtmlSnippet]);
501
+
502
+ const formatPlacedBlocksContext = useCallback((blocks: PlacedBlock[]) => {
503
+ if (blocks.length === 0) return '';
504
+ const lines = [
505
+ 'Semantic blocks to implement:',
506
+ 'The user has placed the following semantic blocks at specific positions in the preview. Each block\'s HTML context below contains an HTML comment marker of the form `<!-- INSERT <block name> HERE -->` at the exact drop position — implement the block at that location. The user chose this position intentionally, so honor it precisely; adapt the block\'s layout and content to fit the surrounding context, and match the existing project\'s styling, colors, fonts, and conventions. Use placeholder/sample content where needed.',
507
+ '',
508
+ ];
509
+ blocks.forEach((placed, index) => {
510
+ const block = getBlockById(placed.blockId);
511
+ if (!block) return;
512
+ lines.push(`[${index + 1}] ${block.name} (page: ${placed.page})`);
513
+ lines.push(` Description: ${block.description}`);
514
+ if (placed.htmlContext) {
515
+ const marker = `<!-- INSERT ${block.name} HERE -->`;
516
+ const snippet = truncateHtmlAroundMarker(placed.htmlContext, marker, 1200);
517
+ lines.push(` Insert position in context (look for \`${marker}\`):`);
518
+ lines.push(' ```html');
519
+ lines.push(` ${snippet}`);
520
+ lines.push(' ```');
521
+ } else {
522
+ lines.push(` Position: insert ${placed.position} ${placed.domPath}`);
523
+ }
524
+ lines.push('');
525
+ });
526
+ return lines.join('\n');
527
+ }, [truncateHtmlAroundMarker]);
528
+
529
+ const handleFocusSelection = useCallback((selection: FocusContextPayload | null) => {
530
+ if (!selection) {
531
+ useWorkspaceStore.getState().setFocusContext(null);
532
+ lastFocusSignatureRef.current = null;
533
+ return;
534
+ }
535
+ const signature = `${selection.domPath || ''}::${selection.tagName || ''}::${selection.outerHTML ? selection.outerHTML.length : 0}`;
536
+ const now = Date.now();
537
+ if (lastFocusSignatureRef.current && lastFocusSignatureRef.current.signature === signature && (now - lastFocusSignatureRef.current.timestamp) < 400) {
538
+ return;
539
+ }
540
+ const nextTarget: FocusTarget = {
541
+ ...selection,
542
+ timestamp: now
543
+ };
544
+ useWorkspaceStore.getState().setFocusContext(nextTarget);
545
+ toast.info('Focus context set', {
546
+ description: describeFocusTarget(nextTarget)
547
+ });
548
+ lastFocusSignatureRef.current = { signature, timestamp: now };
549
+ }, [describeFocusTarget]);
550
+
551
+ const handlePlacementToggle = useCallback(() => {
552
+ useWorkspaceStore.getState().setPaletteOpen(!useWorkspaceStore.getState().paletteOpen);
553
+ }, []);
554
+
555
+ const handlePlacementComplete = useCallback((payload: PlacementResult) => {
556
+ const currentPage = previewRef.current?.getActivePath?.() || '/';
557
+ useWorkspaceStore.setState(s => ({ placedBlocks: [...s.placedBlocks, {
558
+ blockId: payload.blockId,
559
+ placementId: payload.placementId,
560
+ domPath: payload.domPath,
561
+ position: payload.position,
562
+ page: currentPage,
563
+ htmlContext: payload.htmlContext,
564
+ }] }));
565
+ }, []);
566
+
567
+ const handleRemovePlacedBlock = useCallback((placementId: string) => {
568
+ useWorkspaceStore.setState(s => ({ placedBlocks: s.placedBlocks.filter(b => b.placementId !== placementId) }));
569
+ previewRef.current?.removePlaceholder(placementId);
570
+ }, []);
571
+
572
+ const handleClearPlacedBlocks = useCallback(() => {
573
+ placedBlocks.forEach(b => previewRef.current?.removePlaceholder(b.placementId));
574
+ useWorkspaceStore.setState({ placedBlocks: [] });
575
+ }, [placedBlocks]);
576
+
577
+ const handleClosePreview = useCallback(() => {
578
+ useWorkspaceStore.getState().togglePanel('preview');
579
+ }, []);
580
+
581
+ const handleEnterFullscreen = useCallback(() => {
582
+ useWorkspaceStore.getState().setFullscreenPreview(true);
583
+ }, []);
584
+
585
+ const handleExitFullscreen = useCallback(() => {
586
+ useWorkspaceStore.getState().setFullscreenPreview(false);
587
+ }, []);
588
+
589
+ // Listen for showPreview event (dispatched by AI preview command).
590
+ // Only opens the panel — never toggles it closed if already open.
591
+ const showPreviewRef = useRef(showPreview);
592
+ showPreviewRef.current = showPreview;
593
+ useEffect(() => {
594
+ const handler = () => {
595
+ if (!showPreviewRef.current) togglePanel('preview');
596
+ };
597
+ window.addEventListener('showPreview', handler);
598
+ return () => window.removeEventListener('showPreview', handler);
599
+ }, [togglePanel]);
600
+
601
+ const handleSetEntryPoint = useCallback(async (path: string) => {
602
+ try {
603
+ const proj = await vfs.getProject(project.id);
604
+ proj.settings = { ...proj.settings, previewEntryPoint: path };
605
+ await vfs.updateProject(proj);
606
+ useWorkspaceStore.getState().updateProjectSettings({ previewEntryPoint: path });
607
+ toast.success(`Entry point set to ${path}`);
608
+ } catch (err) {
609
+ logger.error('Failed to set entry point:', err);
610
+ toast.error('Failed to set entry point');
611
+ }
612
+ }, [project.id]);
613
+
614
+ const handleAddPromptFile = useCallback(async () => {
615
+ try {
616
+ const { getDomainPrompt } = await import('@/lib/llm/prompts');
617
+ const runtime = projectRuntime || 'handlebars';
618
+ await vfs.createFile(project.id, '/.PROMPT.md', getDomainPrompt(runtime));
619
+ window.dispatchEvent(new CustomEvent('filesChanged', { detail: { projectId: project.id } }));
620
+ toast.success('.PROMPT.md added to project');
621
+ } catch (err) {
622
+ logger.error('Failed to add .PROMPT.md:', err);
623
+ toast.error('Failed to add .PROMPT.md');
624
+ }
625
+ }, [project.id, projectRuntime]);
626
+
627
+ const focusPreviewSnippet = focusContext ? truncateHtmlSnippet(focusContext.outerHTML, 240) : '';
628
+
629
+ useEffect(() => {
630
+ const dirty = saveManager.isDirty(project.id);
631
+ if (dirty) useWorkspaceStore.getState().markDirty();
632
+ else useWorkspaceStore.getState().markClean();
633
+ const unsubscribe = saveManager.subscribe(({ projectId, dirty: d }) => {
634
+ if (projectId === project.id) {
635
+ if (d) useWorkspaceStore.getState().markDirty();
636
+ else useWorkspaceStore.getState().markClean();
637
+ }
638
+ });
639
+ return () => unsubscribe();
640
+ }, [project.id]);
641
+
642
+ useEffect(() => {
643
+ let isMounted = true;
644
+
645
+ const initializeWorkspace = async () => {
646
+ // Dismiss immediately so the generation shelf clears even if async init is slow
647
+ useWorkspaceStore.getState().dismissGenerationResult(project.id);
648
+
649
+ try {
650
+ // In server mode, check if server has newer version before loading
651
+ if (process.env.NEXT_PUBLIC_SERVER_MODE === 'true') {
652
+ try {
653
+ const { checkServerUpdates, pullServerUpdates, setAutoSyncWorkspaceId } = await import('@/lib/vfs/auto-sync');
654
+ if (workspaceId) {
655
+ setAutoSyncWorkspaceId(workspaceId);
656
+ }
657
+ const hasUpdates = await checkServerUpdates(project.id);
658
+ if (hasUpdates) {
659
+ await pullServerUpdates(project.id, false);
660
+ logger.debug(`[Workspace] Pulled server updates for project ${project.id}`);
661
+ }
662
+ } catch (syncErr) {
663
+ logger.warn('[Workspace] Server check failed, using local state:', syncErr);
664
+ }
665
+ }
666
+
667
+ // Skip checkpoint restore if an orchestrator session is active
668
+ // (generation ran or is running while the workspace was unmounted)
669
+ if (!useWorkspaceStore.getState().isProjectGenerating(project.id)) {
670
+ await saveManager.syncProjectSaveState(project.id);
671
+ const savedCheckpointId = saveManager.getSavedCheckpointId(project.id);
672
+
673
+ if (savedCheckpointId) {
674
+ const exists = await checkpointManager.checkpointExists(savedCheckpointId);
675
+ if (exists) {
676
+ const restored = await saveManager.restoreLastSaved(project.id);
677
+ if (restored) {
678
+ if (!isMounted) return;
679
+ useWorkspaceStore.setState({ initialCheckpointId: savedCheckpointId });
680
+ }
681
+ } else {
682
+ // Stale reference — checkpoint was pruned or deleted
683
+ const proj = await vfs.getProject(project.id);
684
+ proj.lastSavedCheckpointId = null;
685
+ await vfs.updateProject(proj);
686
+ }
687
+ }
688
+
689
+ if (!isMounted) return;
690
+ }
691
+
692
+ const latestProject = await vfs.getProject(project.id);
693
+ if (!isMounted) return;
694
+ useWorkspaceStore.getState().initProject(latestProject);
695
+ if (saveManager.isDirty(project.id)) useWorkspaceStore.getState().markDirty();
696
+
697
+ // Ensure a starting-point checkpoint exists so the user can always roll back
698
+ if (!latestProject.lastSavedCheckpointId) {
699
+ try {
700
+ const cp = await checkpointManager.createCheckpoint(project.id, 'Project opened', { kind: 'auto' });
701
+ latestProject.lastSavedCheckpointId = cp.id;
702
+ await vfs.updateProject(latestProject);
703
+ useWorkspaceStore.setState({ initialCheckpointId: cp.id });
704
+ } catch { /* non-fatal */ }
705
+ }
706
+ // Initialize mode from localStorage (migrating the legacy chat-mode key)
707
+ if (typeof window !== 'undefined') {
708
+ const storedMode = localStorage.getItem('osw-studio-mode');
709
+ if (storedMode === 'chat' || storedMode === 'interview' || storedMode === 'code') {
710
+ useWorkspaceStore.setState({ mode: storedMode });
711
+ } else if (localStorage.getItem('osw-studio-chat-mode') === 'true') {
712
+ useWorkspaceStore.setState({ mode: 'chat' });
713
+ localStorage.setItem('osw-studio-mode', 'chat');
714
+ }
715
+ }
716
+ // Initialize the active interview from localStorage (per project)
717
+ if (typeof window !== 'undefined') {
718
+ let restored = null;
719
+ const stored = localStorage.getItem(`osw-interview-${project.id}`);
720
+ if (stored) {
721
+ try {
722
+ const parsed = JSON.parse(stored);
723
+ if (parsed && typeof parsed.templateId === 'string' && typeof parsed.title === 'string') {
724
+ restored = { templateId: parsed.templateId, title: parsed.title };
725
+ }
726
+ } catch { /* ignore malformed */ }
727
+ }
728
+ useWorkspaceStore.setState({ activeInterview: restored });
729
+ }
730
+ // Initialize backendEnabled from localStorage
731
+ if (migrateBackendKey(project.id)) {
732
+ useWorkspaceStore.getState().setBackendEnabled(true);
733
+ }
734
+
735
+ logger.debug(`[Workspace] Initializing workspace for project: ${project.id}`);
736
+
737
+ // Initialize store persistence and load debug events
738
+ useWorkspaceStore.getState().initPersistence(project.id);
739
+ useWorkspaceStore.getState().initLayout();
740
+ useWorkspaceStore.getState().setCurrentModel(configManager.getDefaultModel());
741
+ try {
742
+ await useWorkspaceStore.getState().loadDebugEvents(project.id);
743
+ if (!isMounted) return;
744
+ } catch (error) {
745
+ if (!isMounted) return;
746
+ logger.error('Failed to load debug events:', error);
747
+ }
748
+ useWorkspaceStore.setState({ workspaceReady: true });
749
+ } catch (error) {
750
+ if (!isMounted) return;
751
+ logger.error('Failed to initialize workspace:', error);
752
+ useWorkspaceStore.setState({ workspaceReady: true });
753
+ }
754
+ };
755
+
756
+ initializeWorkspace();
757
+
758
+ const updateProjectCost = async () => {
759
+ try {
760
+ const currentProject = await vfs.getProject(project.id);
761
+ if (!isMounted) return;
762
+ if (currentProject?.costTracking?.totalCost) {
763
+ useWorkspaceStore.getState().setProjectCost(currentProject.costTracking.totalCost);
764
+ } else {
765
+ useWorkspaceStore.getState().setProjectCost(0);
766
+ }
767
+ } catch {
768
+ if (!isMounted) return;
769
+ useWorkspaceStore.getState().setProjectCost(0);
770
+ }
771
+ };
772
+
773
+ updateProjectCost();
774
+ const interval = setInterval(updateProjectCost, 2000);
775
+
776
+ return () => {
777
+ isMounted = false;
778
+ clearInterval(interval);
779
+ };
780
+ }, [project.id]);
781
+
782
+ useEffect(() => {
783
+ if (!tourRunning) return;
784
+
785
+ if (tourStep === 'provider-settings') {
786
+ if (typeof window !== 'undefined') {
787
+ window.dispatchEvent(new CustomEvent('tour-open-provider-settings'));
788
+ }
789
+ }
790
+ }, [tourRunning, tourStep]);
791
+
792
+ useEffect(() => {
793
+ if (!tourRunning) {
794
+ setWorkspaceHandler(null);
795
+ return;
796
+ }
797
+
798
+ const handler = async (event: GuidedTourTranscriptEvent) => {
799
+ // Convert tour events to debug events for ChatPanel display
800
+ if (event.role === 'clear' && event.action === 'conversation') {
801
+ await clearDebugEvents();
802
+ return;
803
+ }
804
+
805
+ if (event.role === 'user') {
806
+ // User message → conversation_message event
807
+ await addDebugEvent('conversation_message', {
808
+ message: {
809
+ role: 'user',
810
+ content: event.content
811
+ }
812
+ });
813
+ } else if (event.role === 'assistant') {
814
+ // Assistant message → conversation_message event
815
+ const message: any = {
816
+ role: 'assistant',
817
+ content: event.content
818
+ };
819
+
820
+ // Store checkpoint ID in UI metadata if present
821
+ if (event.checkpointId) {
822
+ message.ui_metadata = {
823
+ checkpointId: event.checkpointId
824
+ };
825
+ }
826
+
827
+ await addDebugEvent('conversation_message', { message });
828
+
829
+ // Emit checkpoint_created event if checkpoint ID is present
830
+ if (event.checkpointId) {
831
+ await addDebugEvent('checkpoint_created', {
832
+ checkpointId: event.checkpointId,
833
+ description: `Tour checkpoint: ${event.content.substring(0, 60)}`
834
+ });
835
+ }
836
+ } else if (event.role === 'tool') {
837
+ // Tool call → simulate tool execution sequence
838
+ // 1. Tool call initiated
839
+ const toolCall = {
840
+ id: `tour-tool-${Date.now()}`,
841
+ function: {
842
+ name: event.name,
843
+ arguments: JSON.stringify({ command: event.command })
844
+ }
845
+ };
846
+
847
+ await addDebugEvent('toolCalls', {
848
+ toolCalls: [toolCall]
849
+ });
850
+
851
+ // 2. Tool status (executing)
852
+ await addDebugEvent('tool_status', {
853
+ toolId: toolCall.id,
854
+ name: event.name,
855
+ status: 'executing'
856
+ });
857
+
858
+ // 3. Tool result
859
+ await addDebugEvent('tool_result', {
860
+ toolId: toolCall.id,
861
+ name: event.name,
862
+ result: event.output,
863
+ status: 'completed'
864
+ });
865
+
866
+ // 4. Tool message in conversation
867
+ await addDebugEvent('conversation_message', {
868
+ message: {
869
+ role: 'tool',
870
+ content: event.output,
871
+ tool_call_id: toolCall.id
872
+ }
873
+ });
874
+ }
875
+ };
876
+
877
+ setWorkspaceHandler(handler);
878
+
879
+ return () => {
880
+ setWorkspaceHandler(null);
881
+ };
882
+ }, [tourRunning, tourStep, tourState.isBusy, setWorkspaceHandler, clearDebugEvents, addDebugEvent]);
883
+
884
+ // Clear orchestrator when project changes (chatMode changes handled inside setChatMode action)
885
+ useEffect(() => {
886
+ if (!useWorkspaceStore.getState().generating) {
887
+ useWorkspaceStore.getState().resetOrchestrator();
888
+ }
889
+ }, [project.id]);
890
+
891
+ // Auto-mount/unmount project backend context based on enabled toggle
892
+ useEffect(() => {
893
+ let cancelled = false;
894
+ if (process.env.NEXT_PUBLIC_SERVER_MODE === 'true' && backendEnabled) {
895
+ vfs.mountProjectBackendContext(project.id).then(() => {
896
+ if (!cancelled) {
897
+ useWorkspaceStore.getState().bumpRefreshTrigger();
898
+ }
899
+ });
900
+ } else {
901
+ vfs.unmountBackendContext();
902
+ useWorkspaceStore.getState().bumpRefreshTrigger();
903
+ }
904
+ return () => { cancelled = true; };
905
+ }, [project.id, backendEnabled]);
906
+
907
+ // MEMORY CLEANUP: Unload project data from singletons when leaving the workspace
908
+ // This prevents memory accumulation across project switches
909
+ useEffect(() => {
910
+ const projectId = project.id;
911
+
912
+ return () => {
913
+ const thisProjectGenerating = useWorkspaceStore.getState().isProjectGenerating(projectId);
914
+
915
+ // Stash foreground events to background buffer before clearing, so the
916
+ // generation shelf and re-entry can access them.
917
+ if (thisProjectGenerating) {
918
+ useWorkspaceStore.getState().stashForegroundEvents(projectId);
919
+ }
920
+
921
+ // Clear caches only if not generating (data still needed for active generation)
922
+ if (!thisProjectGenerating) {
923
+ checkpointManager.unloadProject(projectId);
924
+ debugEventsState.unloadProject(projectId);
925
+ }
926
+
927
+ // Clean up store persistence only if not generating (debounce timer still needed)
928
+ if (!thisProjectGenerating) {
929
+ useWorkspaceStore.getState().cleanupPersistence();
930
+ useWorkspaceStore.getState().resetOrchestrator();
931
+ useWorkspaceStore.getState().clearDebugEvents();
932
+ }
933
+ useWorkspaceStore.getState().resetLayout();
934
+
935
+ // Flush any pending sync for this project before leaving
936
+ vfs.flushSyncTimeout(projectId);
937
+
938
+ // Unmount backend context when leaving workspace
939
+ vfs.unmountBackendContext();
940
+
941
+ // Reset project slice state
942
+ if (!thisProjectGenerating) {
943
+ useWorkspaceStore.getState().resetProject();
944
+ } else {
945
+ // During generation, still reset workspaceReady so the next project shows loading spinners
946
+ useWorkspaceStore.setState({ workspaceReady: false });
947
+ }
948
+
949
+ logger.debug(`[Workspace] Cleaned up memory for project ${projectId}`);
950
+ };
951
+ }, [project.id]);
952
+
953
+ // Handle deployment selection change - mount/unmount backend context
954
+ const handleDeploymentChange = useCallback(async (deploymentId: string | null, deploymentName: string | null) => {
955
+ useWorkspaceStore.getState().setDeployment(deploymentId);
956
+
957
+ // Reset orchestrator so it picks up new backend context on next message
958
+ useWorkspaceStore.getState().resetOrchestrator();
959
+
960
+ if (deploymentId && deploymentName) {
961
+ await vfs.mountDeploymentRuntimeContext(deploymentId);
962
+ logger.info(`[Workspace] Connected deployment runtime: ${deploymentName}`);
963
+ } else {
964
+ vfs.unmountDeploymentRuntimeContext();
965
+ logger.info('[Workspace] Disconnected deployment runtime');
966
+ }
967
+
968
+ // Refresh file tree
969
+ useWorkspaceStore.getState().bumpRefreshTrigger();
970
+ }, []);
971
+
972
+ // Handle backend toggle
973
+ const handleBackendToggle = useCallback((enabled: boolean) => {
974
+ useWorkspaceStore.getState().setBackendEnabled(enabled);
975
+ }, []);
976
+
977
+ // Handle project settings updates (runtime, entry point)
978
+ const handleProjectSettingsUpdate = useCallback((updated: Project) => {
979
+ useWorkspaceStore.getState().updateProjectSettings({
980
+ runtime: updated.settings?.runtime,
981
+ previewEntryPoint: updated.settings?.previewEntryPoint,
982
+ });
983
+ }, []);
984
+
985
+ const handleFileSelect = useCallback((file: VirtualFile) => {
986
+ // Check if we're on mobile (matches Tailwind's md breakpoint)
987
+ const isMobile = window.innerWidth < 768;
988
+
989
+ if (isMobile) {
990
+ // On mobile, switch to editor panel and open file
991
+ useWorkspaceStore.getState().setActiveMobilePanel('editor');
992
+ setTimeout(() => {
993
+ openFileInEditor(file);
994
+ }, 0);
995
+ } else {
996
+ // Desktop behavior remains the same
997
+ if (!useWorkspaceStore.getState().showEditor) {
998
+ useWorkspaceStore.getState().togglePanel('editor');
999
+ setTimeout(() => {
1000
+ openFileInEditor(file);
1001
+ }, 0);
1002
+ } else {
1003
+ openFileInEditor(file);
1004
+ }
1005
+ }
1006
+ }, []);
1007
+
1008
+ const handleFilesChange = useCallback(() => {
1009
+ useWorkspaceStore.getState().bumpRefreshTrigger();
1010
+ }, []);
1011
+
1012
+ const handleSave = useCallback(async () => {
1013
+ if (useWorkspaceStore.getState().saveInProgress) {
1014
+ return;
1015
+ }
1016
+
1017
+ useWorkspaceStore.setState({ saveInProgress: true });
1018
+ try {
1019
+ const checkpoint = await saveManager.save(project.id);
1020
+ const latestProject = await vfs.getProject(project.id);
1021
+
1022
+ useWorkspaceStore.setState({ lastSavedAt: latestProject.lastSavedAt ?? new Date(checkpoint.timestamp) });
1023
+ useWorkspaceStore.getState().incrementCheckpointRefresh();
1024
+ toast.success('Project saved');
1025
+ } catch (error) {
1026
+ logger.error('Failed to save project', error);
1027
+ toast.error('Failed to save project');
1028
+ } finally {
1029
+ useWorkspaceStore.setState({ saveInProgress: false });
1030
+ }
1031
+
1032
+ }, [project.id]);
1033
+
1034
+ const handleCaptureScreenshot = useCallback(async (screenshot: string) => {
1035
+ try {
1036
+ const proj = await vfs.getProject(project.id);
1037
+ proj.previewImage = screenshot;
1038
+ proj.previewUpdatedAt = new Date();
1039
+ await vfs.updateProject(proj);
1040
+ toast.success('Thumbnail updated');
1041
+ } catch (err) {
1042
+ logger.error('Failed to save screenshot:', err);
1043
+ toast.error('Failed to save thumbnail');
1044
+ }
1045
+ }, [project.id]);
1046
+
1047
+ useEffect(() => {
1048
+ const handleKeyDown = (event: KeyboardEvent) => {
1049
+ const isMac = navigator.platform?.toLowerCase().includes('mac');
1050
+ const modifierPressed = isMac ? event.metaKey : event.ctrlKey;
1051
+ if (!modifierPressed) return;
1052
+
1053
+ if (event.key.toLowerCase() === 's') {
1054
+ // Check if Monaco editor has focus - if so, let Monaco handle the save
1055
+ const activeElement = document.activeElement;
1056
+ const isMonacoFocused = activeElement?.closest('.monaco-editor') !== null;
1057
+
1058
+ if (isMonacoFocused) {
1059
+ // Monaco editor will handle file save
1060
+ return;
1061
+ }
1062
+
1063
+ // Otherwise, save the project
1064
+ event.preventDefault();
1065
+ handleSave();
1066
+ }
1067
+ };
1068
+
1069
+ window.addEventListener('keydown', handleKeyDown);
1070
+ return () => window.removeEventListener('keydown', handleKeyDown);
1071
+ }, [handleSave]);
1072
+
1073
+ const handleRestoreCheckpoint = useCallback(async (checkpointId: string, description?: string, options?: { isDiscard?: boolean }) => {
1074
+ try {
1075
+ // First check if checkpoint exists
1076
+ const exists = await checkpointManager.checkpointExists(checkpointId);
1077
+ if (!exists) {
1078
+ toast.error('Checkpoint no longer exists - it may have been cleaned up');
1079
+ logger.warn(`[Workspace] Checkpoint ${checkpointId} no longer exists`);
1080
+ return;
1081
+ }
1082
+
1083
+ const success = await saveManager.runWithSuppressedDirty(project.id, () =>
1084
+ checkpointManager.restoreCheckpoint(checkpointId)
1085
+ );
1086
+ if (success) {
1087
+ toast.success(`Restored to: ${description || 'checkpoint'}`);
1088
+ track(options?.isDiscard ? 'changes_discarded' : 'checkpoint_restore');
1089
+ handleFilesChange();
1090
+
1091
+ const savedId = saveManager.getSavedCheckpointId(project.id);
1092
+ if (savedId && savedId === checkpointId) {
1093
+ saveManager.markClean(project.id);
1094
+ const latestProject = await vfs.getProject(project.id);
1095
+ useWorkspaceStore.setState({ lastSavedAt: latestProject.lastSavedAt ?? null });
1096
+ } else {
1097
+ saveManager.markDirty(project.id);
1098
+ }
1099
+ } else {
1100
+ toast.error('Failed to restore checkpoint');
1101
+ }
1102
+ } catch (error) {
1103
+ logger.error('Error restoring checkpoint:', error);
1104
+ toast.error('Failed to restore checkpoint');
1105
+ }
1106
+ }, [handleFilesChange, project.id]);
1107
+
1108
+ const handleScrollToCheckpoint = useCallback((checkpointId: string) => {
1109
+ if (!useWorkspaceStore.getState().showChat) useWorkspaceStore.getState().togglePanel('chat');
1110
+ requestAnimationFrame(() => {
1111
+ const el = document.querySelector(`[data-checkpoint-id="${checkpointId}"]`);
1112
+ if (el) {
1113
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
1114
+ el.classList.add('ring-2', 'ring-primary/50');
1115
+ setTimeout(() => el.classList.remove('ring-2', 'ring-primary/50'), 2000);
1116
+ }
1117
+ });
1118
+ }, []);
1119
+
1120
+ const handleRetry = useCallback(async (checkpointId: string) => {
1121
+ try {
1122
+ // First check if checkpoint exists
1123
+ const exists = await checkpointManager.checkpointExists(checkpointId);
1124
+ if (!exists) {
1125
+ toast.error('Checkpoint no longer exists - cannot retry');
1126
+ logger.warn(`[Workspace] Checkpoint ${checkpointId} no longer exists`);
1127
+ return;
1128
+ }
1129
+
1130
+ // Find the user message associated with this checkpoint
1131
+ // Search backwards from the checkpoint to find the most recent user message
1132
+ let userMessageContent = null;
1133
+ const checkpointEventIndex = debugEvents.findIndex(
1134
+ e => e.event === 'checkpoint_created' && e.data?.checkpointId === checkpointId
1135
+ );
1136
+
1137
+ if (checkpointEventIndex >= 0) {
1138
+ // Search backwards from checkpoint event to find user message (in conversation_message events)
1139
+ for (let i = checkpointEventIndex - 1; i >= 0; i--) {
1140
+ if (debugEvents[i].event === 'conversation_message' &&
1141
+ debugEvents[i].data?.message?.role === 'user') {
1142
+ userMessageContent = debugEvents[i].data.message.content;
1143
+ break;
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ if (!userMessageContent) {
1149
+ toast.error('Cannot find original user message to retry');
1150
+ logger.warn('[Workspace] No user message found before checkpoint');
1151
+ return;
1152
+ }
1153
+
1154
+ // Find the user message event index to truncate debug events
1155
+ let userMessageIndex = -1;
1156
+ for (let i = checkpointEventIndex - 1; i >= 0; i--) {
1157
+ if (debugEvents[i].event === 'conversation_message' &&
1158
+ debugEvents[i].data?.message?.role === 'user' &&
1159
+ debugEvents[i].data.message.content === userMessageContent) {
1160
+ userMessageIndex = i;
1161
+ break;
1162
+ }
1163
+ }
1164
+
1165
+ if (userMessageIndex === -1) {
1166
+ toast.error('Cannot find user message event to truncate');
1167
+ logger.warn('[Workspace] User message event not found in debug events');
1168
+ return;
1169
+ }
1170
+
1171
+ // Restore the checkpoint
1172
+ const success = await saveManager.runWithSuppressedDirty(project.id, () =>
1173
+ checkpointManager.restoreCheckpoint(checkpointId)
1174
+ );
1175
+ if (!success) {
1176
+ toast.error('Failed to restore checkpoint');
1177
+ return;
1178
+ }
1179
+
1180
+ const savedId = saveManager.getSavedCheckpointId(project.id);
1181
+ if (savedId && savedId === checkpointId) {
1182
+ saveManager.markClean(project.id);
1183
+ const latestProject = await vfs.getProject(project.id);
1184
+ useWorkspaceStore.setState({ lastSavedAt: latestProject.lastSavedAt ?? null });
1185
+ } else {
1186
+ saveManager.markDirty(project.id);
1187
+ }
1188
+
1189
+ // Truncate debug events to remove the user message and all subsequent events
1190
+ // The user message will be re-added by the orchestrator when generation runs
1191
+ const truncatedEvents = debugEvents.slice(0, userMessageIndex);
1192
+ useWorkspaceStore.setState({ debugEvents: truncatedEvents });
1193
+ useWorkspaceStore.getState().resetOrchestrator();
1194
+ await debugEventsState.truncateEvents(project.id, truncatedEvents);
1195
+
1196
+ toast.success('Restored checkpoint and retrying...');
1197
+ handleFilesChange();
1198
+
1199
+ // Retry generation with the original user message.
1200
+ // Use setTimeout so handleGenerate (declared below) is available.
1201
+ setTimeout(() => handleGenerateRef.current?.(userMessageContent), 0);
1202
+
1203
+ } catch (error) {
1204
+ logger.error('Error during retry:', error);
1205
+ toast.error('Failed to retry');
1206
+ }
1207
+ }, [handleFilesChange, project.id, debugEvents]);
1208
+
1209
+ const storeStartGeneration = useWorkspaceStore(s => s.startGeneration);
1210
+
1211
+ const handleGenerate = useCallback(async (promptText?: string, images?: PendingImage[], audio?: PendingAudio[], files?: PendingFile[]) => {
1212
+ // Clear runtime errors
1213
+ useWorkspaceStore.getState().setRuntimeErrors([]);
1214
+
1215
+ let messageContent = (promptText ?? '').trim();
1216
+ const contextParts: string[] = [];
1217
+ if (focusContext) contextParts.push(formatFocusContextBlock(focusContext));
1218
+ if (placedBlocks.length > 0) contextParts.push(formatPlacedBlocksContext(placedBlocks));
1219
+ if (contextParts.length > 0) messageContent = contextParts.join('\n\n') + '\n\n' + messageContent;
1220
+
1221
+ await storeStartGeneration(messageContent, images, {
1222
+ mode,
1223
+ chatMode: mode === 'chat',
1224
+ projectId: project.id,
1225
+ focusContext,
1226
+ placedBlocks,
1227
+ isTourLockingInput,
1228
+ displayPrompt: (promptText ?? '').trim(),
1229
+ // Resuming an interview: keep the template's agenda available so it is
1230
+ // re-injected if the system message ever has to be rebuilt fresh.
1231
+ templateId: mode === 'interview' ? activeInterview?.templateId : undefined,
1232
+ audio,
1233
+ files,
1234
+ });
1235
+
1236
+ // Post-generation UI cleanup
1237
+ handleFilesChange();
1238
+ if (focusContext) useWorkspaceStore.getState().setFocusContext(null);
1239
+ if (placedBlocks.length > 0) {
1240
+ placedBlocks.forEach(b => previewRef.current?.removePlaceholder(b.placementId));
1241
+ useWorkspaceStore.setState({ placedBlocks: [] });
1242
+ }
1243
+ }, [storeStartGeneration, mode, activeInterview, project.id, focusContext, placedBlocks, isTourLockingInput, handleFilesChange, formatFocusContextBlock, formatPlacedBlocksContext]);
1244
+
1245
+ const handleStartInterview = useCallback(async (template: InterviewTemplate) => {
1246
+ if (!project.id) return;
1247
+ track('interview_started', { template: bucketInterviewTemplateId(template.id) });
1248
+ // Start fresh: an interview should not append onto a prior conversation.
1249
+ await useWorkspaceStore.getState().clearChat(project.id);
1250
+ storeSetActiveInterview({ templateId: template.id, title: template.title });
1251
+ await storeStartGeneration(template.title, undefined, {
1252
+ mode: 'interview',
1253
+ projectId: project.id,
1254
+ templateId: template.id,
1255
+ });
1256
+ }, [project.id, storeSetActiveInterview, storeStartGeneration]);
1257
+
1258
+ const handleHandoff = useCallback(async (handoff: InterviewHandoff) => {
1259
+ if (!project.id) return;
1260
+ track('handoff_used', { mode: handoff.mode });
1261
+ // End the interview and start the follow-up task fresh in its target mode.
1262
+ storeSetActiveInterview(null);
1263
+ storeSetMode(handoff.mode);
1264
+ await useWorkspaceStore.getState().clearChat(project.id);
1265
+ await storeStartGeneration(handoff.prompt, undefined, {
1266
+ mode: handoff.mode,
1267
+ chatMode: handoff.mode === 'chat',
1268
+ projectId: project.id,
1269
+ });
1270
+ }, [project.id, storeSetActiveInterview, storeSetMode, storeStartGeneration]);
1271
+
1272
+ handleGenerateRef.current = handleGenerate;
1273
+
1274
+ const stopGeneration = useWorkspaceStore(s => s.stopGeneration);
1275
+ const continueGeneration = useWorkspaceStore(s => s.continueGeneration);
1276
+
1277
+ const handleStop = useCallback(() => {
1278
+ stopGeneration();
1279
+ }, [stopGeneration]);
1280
+
1281
+ const handleContinue = useCallback(() => {
1282
+ continueGeneration();
1283
+ }, [continueGeneration]);
1284
+
1285
+ const handleSendRuntimeErrors = useCallback(() => {
1286
+ const storeErrors = useWorkspaceStore.getState().runtimeErrors;
1287
+ const bufferErrors = drainRuntimeErrors(); // also clears the live buffer
1288
+ const errors = storeErrors.length > 0 ? storeErrors : bufferErrors;
1289
+ if (errors.length === 0) return;
1290
+ useWorkspaceStore.getState().setRuntimeErrors([]);
1291
+ handleGenerate(formatRuntimeErrors(errors));
1292
+ }, [handleGenerate]);
1293
+
1294
+ const handleClearRuntimeErrors = useCallback(() => {
1295
+ drainRuntimeErrors();
1296
+ useWorkspaceStore.getState().setRuntimeErrors([]);
1297
+ }, []);
1298
+
1299
+ const headerActions: HeaderAction[] = [
1300
+ {
1301
+ id: 'back',
1302
+ label: 'Back to projects',
1303
+ icon: ArrowLeft,
1304
+ onClick: guardedBack,
1305
+ variant: 'outline'
1306
+ }
1307
+ ];
1308
+
1309
+ headerActions.push({
1310
+ id: 'save',
1311
+ label: saveInProgress ? 'Saving…' : isDirty ? 'Save' : 'Saved',
1312
+ icon: Save,
1313
+ onClick: handleSave,
1314
+ variant: isDirty ? 'default' : 'outline',
1315
+ disabled: !isDirty || saveInProgress
1316
+ });
1317
+
1318
+ if (initialCheckpointId) {
1319
+ const discardDisabled = saveInProgress || !isDirty;
1320
+ headerActions.push({
1321
+ id: 'discard',
1322
+ label: 'Discard Changes',
1323
+ onClick: () => {},
1324
+ content: (
1325
+ <div className="flex items-center" data-tour-id="discard-changes-button">
1326
+ <Button
1327
+ variant="outline"
1328
+ size="sm"
1329
+ onClick={() => handleRestoreCheckpoint(initialCheckpointId, 'Last saved state', { isDiscard: true })}
1330
+ disabled={discardDisabled}
1331
+ className="rounded-r-none border-r-0"
1332
+ >
1333
+ <RotateCcw className="h-4 w-4 mr-2" />
1334
+ Discard Changes
1335
+ </Button>
1336
+ <Tooltip>
1337
+ <TooltipTrigger asChild>
1338
+ <Button
1339
+ variant="outline"
1340
+ size="sm"
1341
+ onClick={() => togglePanel('checkpoints')}
1342
+ onMouseEnter={() => {
1343
+ if (showCheckpoints) {
1344
+ useWorkspaceStore.getState().setPanelReplacePreview('checkpoints');
1345
+ } else {
1346
+ handleSidebarHover('checkpoints');
1347
+ }
1348
+ }}
1349
+ onMouseLeave={() => {
1350
+ useWorkspaceStore.getState().setPanelReplacePreview(null);
1351
+ useWorkspaceStore.getState().setPanelInsertPreview(null);
1352
+ }}
1353
+ disabled={saveInProgress}
1354
+ className="rounded-l-none px-2"
1355
+ aria-label={showCheckpoints ? 'Close checkpoints panel' : 'Open checkpoints panel'}
1356
+ >
1357
+ {showCheckpoints ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
1358
+ </Button>
1359
+ </TooltipTrigger>
1360
+ <TooltipContent side="bottom">
1361
+ {showCheckpoints ? 'Close checkpoints' : 'All checkpoints'}
1362
+ </TooltipContent>
1363
+ </Tooltip>
1364
+ </div>
1365
+ )
1366
+ });
1367
+ }
1368
+
1369
+ // Desktop header content: Deployment selector + Settings
1370
+ const desktopHeaderContent = (
1371
+ <div className="flex items-center gap-3">
1372
+ {/* Deployment selector for backend context */}
1373
+ <DeploymentSelector
1374
+ projectId={project.id}
1375
+ selectedDeploymentId={selectedDeploymentId}
1376
+ onDeploymentChange={handleDeploymentChange}
1377
+ workspaceId={workspaceId}
1378
+ />
1379
+
1380
+ {/* Project settings button */}
1381
+ <Button
1382
+ variant="outline"
1383
+ size="sm"
1384
+ className="h-8 px-3 flex items-center gap-2"
1385
+ onClick={() => useWorkspaceStore.getState().setShowProjectSettingsModal(true)}
1386
+ title="Project Settings"
1387
+ >
1388
+ <Settings2 className="h-4 w-4" />
1389
+ <span className="text-sm hidden lg:inline">Project</span>
1390
+ </Button>
1391
+
1392
+ {/* Settings popover */}
1393
+ <Popover>
1394
+ <PopoverTrigger asChild>
1395
+ <Button
1396
+ variant="outline"
1397
+ size="sm"
1398
+ className="h-8 px-3 flex items-center gap-2"
1399
+ title="Project cost and settings"
1400
+ >
1401
+ {shouldShowCosts && (
1402
+ <span className="text-sm font-medium">
1403
+ ${projectCost.toFixed(3)}
1404
+ </span>
1405
+ )}
1406
+ <Settings className="h-4 w-4" />
1407
+ </Button>
1408
+ </PopoverTrigger>
1409
+ <PopoverContent className="w-[460px] max-h-[min(720px,calc(100vh-5rem))] overflow-hidden flex flex-col" align="end">
1410
+ <SettingsPanel />
1411
+ </PopoverContent>
1412
+ </Popover>
1413
+ </div>
1414
+ );
1415
+
1416
+ const mobileMenuContent = (
1417
+ <div className="space-y-2">
1418
+ {shouldShowCosts && (
1419
+ <div className="pb-2 border-b border-border/50">
1420
+ <span className="text-sm font-medium">
1421
+ Project cost: ${projectCost.toFixed(projectCost >= 10 ? 2 : 3)}
1422
+ </span>
1423
+ </div>
1424
+ )}
1425
+ <Button
1426
+ variant="outline"
1427
+ size="sm"
1428
+ className="w-full justify-start"
1429
+ onClick={() => useWorkspaceStore.getState().setShowProjectSettingsModal(true)}
1430
+ >
1431
+ <Settings2 className="h-4 w-4 mr-2" />
1432
+ Project Settings
1433
+ </Button>
1434
+ <Popover>
1435
+ <PopoverTrigger asChild>
1436
+ <Button
1437
+ variant="outline"
1438
+ size="sm"
1439
+ className="w-full justify-start"
1440
+ >
1441
+ <Settings className="h-4 w-4 mr-2" />
1442
+ Settings
1443
+ </Button>
1444
+ </PopoverTrigger>
1445
+ <PopoverContent className="w-[460px] max-w-[calc(100vw-2rem)] max-h-[min(720px,calc(100vh-5rem))] overflow-hidden flex flex-col" align="start">
1446
+ <SettingsPanel />
1447
+ </PopoverContent>
1448
+ </Popover>
1449
+ </div>
1450
+ );
1451
+
1452
+ return (
1453
+ <TooltipProvider>
1454
+ <div className="h-[100dvh] flex flex-col">
1455
+ {/* Header */}
1456
+ <AppHeader
1457
+ leftText={project.name}
1458
+ leftSubtext={{ chat: 'Chat', files: 'Files', editor: 'Editor', preview: 'Preview', checkpoints: 'Checkpoints', console: 'Console', skills: 'Skills', debug: 'Debug' }[activeMobilePanel]}
1459
+ onLogoClick={guardedBack}
1460
+ actions={headerActions}
1461
+ mobileMenuContent={mobileMenuContent}
1462
+ desktopOnlyContent={desktopHeaderContent}
1463
+ mobileVisibleActions={isDirty ? ['save'] : []}
1464
+ />
1465
+
1466
+ {/* Desktop Workspace */}
1467
+ <div className="hidden md:flex flex-1 overflow-hidden bg-background">
1468
+ {/* Left sidebar for panel toggles */}
1469
+ <div className="w-10 bg-muted/70 border-r border-border flex flex-col items-center py-3 gap-1.5">
1470
+ <Tooltip>
1471
+ <TooltipTrigger asChild>
1472
+ <button
1473
+ className={`h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1474
+ showChat
1475
+ ? 'shadow-sm'
1476
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1477
+ }`}
1478
+ style={{
1479
+ backgroundColor: showChat ? 'var(--button-assistant-active)' : undefined,
1480
+ color: showChat ? 'white' : undefined
1481
+ }}
1482
+ onClick={() => togglePanel('chat')}
1483
+ onMouseEnter={() => handleSidebarHover('chat')}
1484
+ onMouseLeave={() => handleSidebarHover(null)}
1485
+ >
1486
+ <MessageSquare className="h-3.5 w-3.5" />
1487
+ </button>
1488
+ </TooltipTrigger>
1489
+ <TooltipContent
1490
+ side="right"
1491
+ className="border-0"
1492
+ style={{
1493
+ backgroundColor: 'var(--button-assistant-active)',
1494
+ color: 'white'
1495
+ }}
1496
+ arrowStyle={{
1497
+ backgroundColor: 'var(--button-assistant-active)',
1498
+ fill: 'var(--button-assistant-active)'
1499
+ }}
1500
+ >
1501
+ <p>Chat</p>
1502
+ </TooltipContent>
1503
+ </Tooltip>
1504
+
1505
+ <Tooltip>
1506
+ <TooltipTrigger asChild>
1507
+ <button
1508
+ className={`h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1509
+ showFiles
1510
+ ? 'shadow-sm'
1511
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1512
+ }`}
1513
+ style={{
1514
+ backgroundColor: showFiles ? 'var(--button-files-active)' : undefined,
1515
+ color: showFiles ? 'white' : undefined
1516
+ }}
1517
+ onClick={() => togglePanel('files')}
1518
+ onMouseEnter={() => handleSidebarHover('files')}
1519
+ onMouseLeave={() => handleSidebarHover(null)}
1520
+ >
1521
+ <FolderTree className="h-3.5 w-3.5" />
1522
+ </button>
1523
+ </TooltipTrigger>
1524
+ <TooltipContent
1525
+ side="right"
1526
+ className="border-0"
1527
+ style={{
1528
+ backgroundColor: 'var(--button-files-active)',
1529
+ color: 'white'
1530
+ }}
1531
+ arrowStyle={{
1532
+ backgroundColor: 'var(--button-files-active)',
1533
+ fill: 'var(--button-files-active)'
1534
+ }}
1535
+ >
1536
+ <p>File Explorer</p>
1537
+ </TooltipContent>
1538
+ </Tooltip>
1539
+
1540
+ <Tooltip>
1541
+ <TooltipTrigger asChild>
1542
+ <button
1543
+ className={`h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1544
+ showEditor
1545
+ ? 'shadow-sm'
1546
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1547
+ }`}
1548
+ style={{
1549
+ backgroundColor: showEditor ? 'var(--button-editor-active)' : undefined,
1550
+ color: showEditor ? 'white' : undefined
1551
+ }}
1552
+ onClick={() => togglePanel('editor')}
1553
+ onMouseEnter={() => handleSidebarHover('editor')}
1554
+ onMouseLeave={() => handleSidebarHover(null)}
1555
+ >
1556
+ <Code2 className="h-3.5 w-3.5" />
1557
+ </button>
1558
+ </TooltipTrigger>
1559
+ <TooltipContent
1560
+ side="right"
1561
+ className="border-0"
1562
+ style={{
1563
+ backgroundColor: 'var(--button-editor-active)',
1564
+ color: 'white'
1565
+ }}
1566
+ arrowStyle={{
1567
+ backgroundColor: 'var(--button-editor-active)',
1568
+ fill: 'var(--button-editor-active)'
1569
+ }}
1570
+ >
1571
+ <p>Code Editor</p>
1572
+ </TooltipContent>
1573
+ </Tooltip>
1574
+
1575
+ <Tooltip>
1576
+ <TooltipTrigger asChild>
1577
+ <button
1578
+ className={`h-5 w-5 mx-1 rounded-sm flex items-center justify-center transition-all ${
1579
+ showPreview
1580
+ ? 'shadow-sm'
1581
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1582
+ }`}
1583
+ style={{
1584
+ backgroundColor: showPreview ? 'var(--button-preview-active)' : undefined,
1585
+ color: showPreview ? 'white' : undefined
1586
+ }}
1587
+ onClick={() => togglePanel('preview')}
1588
+ onMouseEnter={() => handleSidebarHover('preview')}
1589
+ onMouseLeave={() => handleSidebarHover(null)}
1590
+ >
1591
+ <Eye className="h-3.5 w-3.5" />
1592
+ </button>
1593
+ </TooltipTrigger>
1594
+ <TooltipContent
1595
+ side="right"
1596
+ className="border-0"
1597
+ style={{
1598
+ backgroundColor: 'var(--button-preview-active)',
1599
+ color: 'white'
1600
+ }}
1601
+ arrowStyle={{
1602
+ backgroundColor: 'var(--button-preview-active)',
1603
+ fill: 'var(--button-preview-active)'
1604
+ }}
1605
+ >
1606
+ <p>Preview</p>
1607
+ </TooltipContent>
1608
+ </Tooltip>
1609
+
1610
+ <Tooltip>
1611
+ <TooltipTrigger asChild>
1612
+ <button
1613
+ className={`h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1614
+ showSkillsPanel
1615
+ ? 'shadow-sm'
1616
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1617
+ }`}
1618
+ style={{
1619
+ backgroundColor: showSkillsPanel ? 'var(--button-skills-active, #a855f7)' : undefined,
1620
+ color: showSkillsPanel ? 'white' : undefined
1621
+ }}
1622
+ onClick={() => togglePanel('skills')}
1623
+ onMouseEnter={() => handleSidebarHover('skills')}
1624
+ onMouseLeave={() => handleSidebarHover(null)}
1625
+ >
1626
+ <Sparkles className="h-3.5 w-3.5" />
1627
+ </button>
1628
+ </TooltipTrigger>
1629
+ <TooltipContent
1630
+ side="right"
1631
+ className="border-0"
1632
+ style={{
1633
+ backgroundColor: 'var(--button-skills-active, #a855f7)',
1634
+ color: 'white'
1635
+ }}
1636
+ arrowStyle={{
1637
+ backgroundColor: 'var(--button-skills-active, #a855f7)',
1638
+ fill: 'var(--button-skills-active, #a855f7)'
1639
+ }}
1640
+ >
1641
+ <p>Skills</p>
1642
+ </TooltipContent>
1643
+ </Tooltip>
1644
+
1645
+ <Tooltip>
1646
+ <TooltipTrigger asChild>
1647
+ <button
1648
+ className={`relative h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1649
+ showConsole
1650
+ ? 'shadow-sm'
1651
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1652
+ }`}
1653
+ style={{
1654
+ backgroundColor: showConsole ? 'var(--button-terminal-active, #22c55e)' : undefined,
1655
+ color: showConsole ? 'white' : undefined
1656
+ }}
1657
+ onClick={() => togglePanel('console')}
1658
+ onMouseEnter={() => handleSidebarHover('console')}
1659
+ onMouseLeave={() => handleSidebarHover(null)}
1660
+ >
1661
+ <TerminalIcon className="h-3.5 w-3.5" />
1662
+ {hasUnreadConsole && !showConsole && (
1663
+ <span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-[var(--button-terminal-active,#22c55e)]" />
1664
+ )}
1665
+ </button>
1666
+ </TooltipTrigger>
1667
+ <TooltipContent
1668
+ side="right"
1669
+ className="border-0"
1670
+ style={{
1671
+ backgroundColor: 'var(--button-terminal-active, #22c55e)',
1672
+ color: 'white'
1673
+ }}
1674
+ arrowStyle={{
1675
+ backgroundColor: 'var(--button-terminal-active, #22c55e)',
1676
+ fill: 'var(--button-terminal-active, #22c55e)'
1677
+ }}
1678
+ >
1679
+ <p>Console</p>
1680
+ </TooltipContent>
1681
+ </Tooltip>
1682
+
1683
+ <Tooltip>
1684
+ <TooltipTrigger asChild>
1685
+ <button
1686
+ className={`h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1687
+ showCheckpoints
1688
+ ? 'shadow-sm'
1689
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1690
+ }`}
1691
+ style={{
1692
+ backgroundColor: showCheckpoints ? 'var(--button-checkpoint-active)' : undefined,
1693
+ color: showCheckpoints ? 'white' : undefined
1694
+ }}
1695
+ onClick={() => togglePanel('checkpoints')}
1696
+ onMouseEnter={() => handleSidebarHover('checkpoints')}
1697
+ onMouseLeave={() => handleSidebarHover(null)}
1698
+ >
1699
+ <History className="h-3.5 w-3.5" />
1700
+ </button>
1701
+ </TooltipTrigger>
1702
+ <TooltipContent
1703
+ side="right"
1704
+ className="border-0"
1705
+ style={{
1706
+ backgroundColor: 'var(--button-checkpoint-active)',
1707
+ color: 'white'
1708
+ }}
1709
+ arrowStyle={{
1710
+ backgroundColor: 'var(--button-checkpoint-active)',
1711
+ fill: 'var(--button-checkpoint-active)'
1712
+ }}
1713
+ >
1714
+ <p>Checkpoints</p>
1715
+ </TooltipContent>
1716
+ </Tooltip>
1717
+
1718
+ <Tooltip>
1719
+ <TooltipTrigger asChild>
1720
+ <button
1721
+ className={`h-5 w-5 px-1 rounded-sm flex items-center justify-center transition-all ${
1722
+ showDebugPanel
1723
+ ? 'bg-foreground shadow-sm'
1724
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
1725
+ }`}
1726
+ style={{
1727
+ color: showDebugPanel ? 'var(--background)' : undefined
1728
+ }}
1729
+ onClick={() => togglePanel('debug')}
1730
+ onMouseEnter={() => handleSidebarHover('debug')}
1731
+ onMouseLeave={() => handleSidebarHover(null)}
1732
+ >
1733
+ <Bug className="h-3.5 w-3.5" />
1734
+ </button>
1735
+ </TooltipTrigger>
1736
+ <TooltipContent
1737
+ side="right"
1738
+ className="border-0 bg-foreground text-background"
1739
+ arrowStyle={{
1740
+ backgroundColor: 'var(--foreground)',
1741
+ fill: 'var(--foreground)'
1742
+ }}
1743
+ >
1744
+ <p>Debug Events</p>
1745
+ </TooltipContent>
1746
+ </Tooltip>
1747
+
1748
+ </div>
1749
+
1750
+ {/* Main content area — slot-based layout (max 3 panels) */}
1751
+ <div
1752
+ ref={panelContainerRef}
1753
+ className="flex-1 p-2 overflow-hidden"
1754
+ data-tour-id="workspace-panels"
1755
+ onMouseMove={draggingPanel ? handleDragMouseMove : undefined}
1756
+ onMouseUp={draggingPanel ? handlePanelDragEnd : undefined}
1757
+ >
1758
+ <PanelDragProvider value={{ onDragStart: handlePanelDragStart, draggingPanel }}>
1759
+ <ResizablePanelGroup ref={panelGroupRef} direction="horizontal" autoSaveId="workspace-slots">
1760
+ {(() => {
1761
+ // Build ordered list of visible panels using panelOrder
1762
+ const panelMap: Record<string, { minSize: number; content: React.ReactNode }> = {};
1763
+
1764
+ if (showChat) panelMap['chat'] = { minSize: 15, content: (
1765
+ <ChatPanel
1766
+ events={debugEvents}
1767
+ onRestore={handleRestoreCheckpoint}
1768
+ onRetry={handleRetry}
1769
+ generating={generating}
1770
+ onGenerate={handleGenerate}
1771
+ onStop={handleStop}
1772
+ onContinue={handleContinue}
1773
+ focusContext={focusContext}
1774
+ setFocusContext={storeFocusContext}
1775
+ focusPreviewSnippet={focusPreviewSnippet}
1776
+ mode={mode}
1777
+ setMode={storeSetMode}
1778
+ activeInterview={activeInterview}
1779
+ onStartInterview={handleStartInterview}
1780
+ onHandoff={handleHandoff}
1781
+ currentModel={currentModel}
1782
+ getModelDisplayName={getModelDisplayName}
1783
+ isTourLockingInput={isTourLockingInput}
1784
+ onClearChat={clearDebugEvents}
1785
+ onClose={() => useWorkspaceStore.getState().togglePanel('chat')}
1786
+ supportsVision={supportsVision}
1787
+ inputModalities={inputModalities}
1788
+ providerReady={providerReady}
1789
+ runtimeErrors={runtimeErrors}
1790
+ onSendRuntimeErrors={handleSendRuntimeErrors}
1791
+ onClearRuntimeErrors={handleClearRuntimeErrors}
1792
+ placedBlocks={placedBlocks}
1793
+ onRemovePlacedBlock={handleRemovePlacedBlock}
1794
+ onClearPlacedBlocks={handleClearPlacedBlocks}
1795
+ />
1796
+ )};
1797
+
1798
+ if (showFiles) panelMap['files'] = { minSize: 14, content: (
1799
+ <div className="h-full border border-border rounded-lg shadow-sm overflow-hidden relative" style={{ background: `linear-gradient(0deg, rgba(var(--panel-files-rgb), 0.01), rgba(var(--panel-files-rgb), 0.01)), var(--card)`, minWidth: '240px' }}>
1800
+ <FileExplorer
1801
+ projectId={project.id}
1802
+ onFileSelect={handleFileSelect}
1803
+ onClose={() => useWorkspaceStore.getState().togglePanel('files')}
1804
+ entryPoint={entryPoint}
1805
+ onSetEntryPoint={handleSetEntryPoint}
1806
+ onAddPromptFile={handleAddPromptFile}
1807
+ />
1808
+ </div>
1809
+ )};
1810
+
1811
+ if (showEditor) panelMap['editor'] = { minSize: 20, content: (
1812
+ <div className="h-full border border-border rounded-lg shadow-sm overflow-hidden relative" style={{ background: `linear-gradient(0deg, rgba(var(--panel-editor-rgb), 0.01), rgba(var(--panel-editor-rgb), 0.01)), var(--card)`, minWidth: '240px' }}>
1813
+ <MultiTabEditor
1814
+ projectId={project.id}
1815
+ runtime={projectRuntime}
1816
+ onClose={() => useWorkspaceStore.getState().togglePanel('editor')}
1817
+ />
1818
+ </div>
1819
+ )};
1820
+
1821
+ if (showConsole) panelMap['console'] = { minSize: 15, content: (
1822
+ <div className="h-full border border-border rounded-lg shadow-sm overflow-hidden relative" style={{ minWidth: '240px' }}>
1823
+ <ConsolePanel
1824
+ projectId={project.id}
1825
+ runtime={projectRuntime || 'handlebars'}
1826
+ bufferedMessages={consoleBufferRef.current}
1827
+ onBufferConsumed={() => { consoleBufferRef.current = []; }}
1828
+ onClose={() => useWorkspaceStore.getState().togglePanel('console')}
1829
+ />
1830
+ </div>
1831
+ )};
1832
+
1833
+ if (showPreview) panelMap['preview'] = { minSize: 20, content: (
1834
+ <div
1835
+ className={fullscreenPreview
1836
+ ? "fixed inset-0 z-50 bg-background flex flex-col"
1837
+ : "h-full border border-border rounded-lg shadow-sm overflow-hidden relative"}
1838
+ style={fullscreenPreview ? undefined : { background: `linear-gradient(0deg, rgba(var(--panel-preview-rgb), 0.01), rgba(var(--panel-preview-rgb), 0.01)), var(--card)`, minWidth: '240px' }}
1839
+ >
1840
+ <MultipagePreview
1841
+ ref={previewRef}
1842
+ projectId={project.id}
1843
+ refreshTrigger={refreshTrigger}
1844
+ onFocusSelection={handleFocusSelection}
1845
+ hasFocusTarget={Boolean(focusContext)}
1846
+ onClose={fullscreenPreview ? handleExitFullscreen : handleClosePreview}
1847
+ deploymentId={selectedDeploymentId}
1848
+ onCaptureScreenshot={handleCaptureScreenshot}
1849
+ entryPoint={entryPoint}
1850
+ runtime={projectRuntime}
1851
+ placementActive={paletteOpen}
1852
+ onPlacementToggle={handlePlacementToggle}
1853
+ onPlacementComplete={handlePlacementComplete}
1854
+ onFullscreen={handleEnterFullscreen}
1855
+ isFullscreen={fullscreenPreview}
1856
+ />
1857
+ </div>
1858
+ )};
1859
+
1860
+ if (showCheckpoints) panelMap['checkpoints'] = { minSize: 12, content: (
1861
+ <CheckpointPanel
1862
+ projectId={project.id}
1863
+ events={debugEvents}
1864
+ currentCheckpointId={checkpointManager.getCurrentCheckpoint()?.id}
1865
+ onRestore={handleRestoreCheckpoint}
1866
+ onScrollToTurn={handleScrollToCheckpoint}
1867
+ onClose={() => useWorkspaceStore.getState().togglePanel('checkpoints')}
1868
+ refreshKey={checkpointRefreshKey}
1869
+ />
1870
+ )};
1871
+
1872
+ if (showDebugPanel) panelMap['debug'] = { minSize: 15, content: (
1873
+ <DebugPanel events={debugEvents} onClear={clearDebugEvents} onClose={() => useWorkspaceStore.getState().togglePanel('debug')} />
1874
+ )};
1875
+
1876
+ if (showSkillsPanel) panelMap['skills'] = { minSize: 10, content: (
1877
+ <SkillsPanel onClose={() => useWorkspaceStore.getState().togglePanel('skills')} />
1878
+ )};
1879
+
1880
+ // Order visible panels by panelOrder
1881
+ const visiblePanels = panelOrder
1882
+ .filter(key => key in panelMap)
1883
+ .map(key => ({ key, ...panelMap[key] }));
1884
+
1885
+ // Render panels with either resize handles (normal) or drop zones (during drag)
1886
+ const elements: React.ReactNode[] = [];
1887
+ const isDragging = !!draggingPanel;
1888
+
1889
+ // Helper: render a drop zone that matches resize handle dimensions (w-2 mx-1)
1890
+ const dropZone = (position: number) => (
1891
+ <div
1892
+ key={`drop-${position}`}
1893
+ ref={(el) => registerDropZone(position, el)}
1894
+ className={`shrink-0 rounded-[3px] border border-dashed animate-expand-indicator ${
1895
+ dropTarget === position
1896
+ ? 'bg-primary/40 border-primary/60'
1897
+ : 'bg-muted/50 border-muted-foreground'
1898
+ }`}
1899
+ />
1900
+ );
1901
+
1902
+ const dragIdx = isDragging ? visiblePanels.findIndex(p => p.key === draggingPanel) : -1;
1903
+
1904
+ // Insert-position indicator (shown when hovering sidebar to add a panel when there's room)
1905
+ const insertIndicator = (position: number) => (
1906
+ <div
1907
+ key={`insert-${position}`}
1908
+ className="shrink-0 rounded-[3px] bg-primary/40 border border-dashed border-primary/60 animate-expand-indicator"
1909
+ />
1910
+ );
1911
+
1912
+ visiblePanels.forEach((panel, idx) => {
1913
+ // Left edge drop zone (before first panel)
1914
+ if (isDragging && idx === 0 && dragIdx !== 0) {
1915
+ elements.push(dropZone(0));
1916
+ }
1917
+ if (idx > 0) {
1918
+ if (isDragging) {
1919
+ const isDroppable = !(idx === dragIdx || idx === dragIdx + 1);
1920
+ // Hide resize handle inside a collapsing wrapper (animates out as drop zone animates in)
1921
+ elements.push(
1922
+ <div key={`handle-wrap-${idx}`} className="animate-collapse-indicator shrink-0 overflow-hidden">
1923
+ <ResizableHandle key={`handle-${idx}`} withHandle className="pointer-events-none opacity-0" />
1924
+ </div>
1925
+ );
1926
+ if (isDroppable) {
1927
+ elements.push(dropZone(idx));
1928
+ } else {
1929
+ elements.push(<div key={`spacer-${idx}`} className="w-2 mx-1 shrink-0" />);
1930
+ }
1931
+ } else {
1932
+ elements.push(<ResizableHandle key={`handle-${idx}`} withHandle />);
1933
+ }
1934
+ }
1935
+
1936
+ elements.push(
1937
+ <ResizablePanel
1938
+ key={panel.key}
1939
+ id={`panel-${panel.key}`}
1940
+ order={idx + 1}
1941
+ defaultSize={baseSize}
1942
+ minSize={panel.minSize}
1943
+ >
1944
+ <div
1945
+ className="h-full rounded-lg relative"
1946
+ data-panel-id={panel.key}
1947
+ >
1948
+ {/* Replace-preview / drag highlight overlay — renders on top of panel border */}
1949
+ {((isDragging && panel.key === draggingPanel) || panelReplacePreview === panel.key) && (
1950
+ <div
1951
+ className="absolute inset-0 rounded-lg pointer-events-none z-50"
1952
+ style={{
1953
+ border: `1px dashed ${
1954
+ (isDragging && panel.key === draggingPanel && dropTarget !== null)
1955
+ ? 'var(--color-muted-foreground)'
1956
+ : 'var(--color-primary)'
1957
+ }`,
1958
+ }}
1959
+ />
1960
+ )}
1961
+ {panel.content}
1962
+ </div>
1963
+ </ResizablePanel>
1964
+ );
1965
+
1966
+ // Insert preview after last panel
1967
+ if (!isDragging && panelInsertPreview === idx + 1 && idx === visiblePanels.length - 1) {
1968
+ elements.push(insertIndicator(idx + 1));
1969
+ }
1970
+ });
1971
+
1972
+ // Right edge drop zone
1973
+ if (isDragging && dragIdx !== visiblePanels.length - 1) {
1974
+ elements.push(dropZone(visiblePanels.length));
1975
+ }
1976
+
1977
+ return elements;
1978
+ })()}
1979
+
1980
+ </ResizablePanelGroup>
1981
+ </PanelDragProvider>
1982
+ </div>
1983
+ </div>
1984
+
1985
+ {/* Mobile Workspace */}
1986
+ <div className="flex md:hidden flex-1 overflow-hidden bg-background flex-col">
1987
+ {/* Single active panel */}
1988
+ <div className="flex-1 pb-12 overflow-hidden">
1989
+ {activeMobilePanel === 'chat' && (
1990
+ <ChatPanel
1991
+ events={debugEvents}
1992
+ onRestore={handleRestoreCheckpoint}
1993
+ onRetry={handleRetry}
1994
+ generating={generating}
1995
+ onGenerate={handleGenerate}
1996
+ onStop={handleStop}
1997
+ onContinue={handleContinue}
1998
+ focusContext={focusContext}
1999
+ setFocusContext={storeFocusContext}
2000
+ focusPreviewSnippet={focusPreviewSnippet}
2001
+ mode={mode}
2002
+ setMode={storeSetMode}
2003
+ activeInterview={activeInterview}
2004
+ onStartInterview={handleStartInterview}
2005
+ onHandoff={handleHandoff}
2006
+ currentModel={currentModel}
2007
+ getModelDisplayName={getModelDisplayName}
2008
+ isTourLockingInput={isTourLockingInput}
2009
+ onClearChat={clearDebugEvents}
2010
+ supportsVision={supportsVision}
2011
+ inputModalities={inputModalities}
2012
+ providerReady={providerReady}
2013
+ runtimeErrors={runtimeErrors}
2014
+ onSendRuntimeErrors={handleSendRuntimeErrors}
2015
+ onClearRuntimeErrors={handleClearRuntimeErrors}
2016
+ placedBlocks={placedBlocks}
2017
+ onRemovePlacedBlock={handleRemovePlacedBlock}
2018
+ onClearPlacedBlocks={handleClearPlacedBlocks}
2019
+ />
2020
+ )}
2021
+
2022
+ {activeMobilePanel === 'files' && (
2023
+ <div className="h-full overflow-hidden relative" style={{ background: `linear-gradient(0deg, rgba(var(--panel-files-rgb), 0.01), rgba(var(--panel-files-rgb), 0.01)), var(--card)` }}>
2024
+ <FileExplorer
2025
+ projectId={project.id}
2026
+ onFileSelect={handleFileSelect}
2027
+ onClose={() => useWorkspaceStore.getState().togglePanel('files')}
2028
+ entryPoint={entryPoint}
2029
+ onSetEntryPoint={handleSetEntryPoint}
2030
+ onAddPromptFile={handleAddPromptFile}
2031
+ />
2032
+ </div>
2033
+ )}
2034
+
2035
+ {activeMobilePanel === 'editor' && (
2036
+ <div className="h-full overflow-hidden relative" style={{ background: `linear-gradient(0deg, rgba(var(--panel-editor-rgb), 0.01), rgba(var(--panel-editor-rgb), 0.01)), var(--card)` }}>
2037
+ <MultiTabEditor
2038
+ projectId={project.id}
2039
+ runtime={projectRuntime}
2040
+ onClose={() => useWorkspaceStore.getState().togglePanel('editor')}
2041
+ />
2042
+ </div>
2043
+ )}
2044
+
2045
+ {activeMobilePanel === 'preview' && (
2046
+ <div className="h-full overflow-hidden relative" style={{ background: `linear-gradient(0deg, rgba(var(--panel-preview-rgb), 0.01), rgba(var(--panel-preview-rgb), 0.01)), var(--card)` }}>
2047
+ <MultipagePreview
2048
+ ref={previewRef}
2049
+ projectId={project.id}
2050
+ refreshTrigger={refreshTrigger}
2051
+ onFocusSelection={handleFocusSelection}
2052
+ hasFocusTarget={Boolean(focusContext)}
2053
+ onClose={handleClosePreview}
2054
+ deploymentId={selectedDeploymentId}
2055
+ onCaptureScreenshot={handleCaptureScreenshot}
2056
+ entryPoint={entryPoint}
2057
+ runtime={projectRuntime}
2058
+ placementActive={paletteOpen}
2059
+ onPlacementToggle={handlePlacementToggle}
2060
+ onPlacementComplete={handlePlacementComplete}
2061
+ />
2062
+ </div>
2063
+ )}
2064
+
2065
+ {activeMobilePanel === 'checkpoints' && (
2066
+ <div className="h-full overflow-hidden relative">
2067
+ <CheckpointPanel
2068
+ projectId={project.id}
2069
+ events={debugEvents}
2070
+ currentCheckpointId={checkpointManager.getCurrentCheckpoint()?.id}
2071
+ onRestore={handleRestoreCheckpoint}
2072
+ onScrollToTurn={handleScrollToCheckpoint}
2073
+ onClose={() => useWorkspaceStore.getState().setActiveMobilePanel('chat')}
2074
+ refreshKey={checkpointRefreshKey}
2075
+ />
2076
+ </div>
2077
+ )}
2078
+
2079
+ {activeMobilePanel === 'console' && (
2080
+ <div className="h-full overflow-hidden relative">
2081
+ <ConsolePanel
2082
+ projectId={project.id}
2083
+ runtime={projectRuntime || 'handlebars'}
2084
+ bufferedMessages={consoleBufferRef.current}
2085
+ onBufferConsumed={() => { consoleBufferRef.current = []; }}
2086
+ />
2087
+ </div>
2088
+ )}
2089
+
2090
+ {activeMobilePanel === 'skills' && (
2091
+ <div className="h-full overflow-hidden relative">
2092
+ <SkillsPanel onClose={() => useWorkspaceStore.getState().setActiveMobilePanel('chat')} />
2093
+ </div>
2094
+ )}
2095
+
2096
+ {activeMobilePanel === 'debug' && (
2097
+ <div className="h-full overflow-hidden relative">
2098
+ <DebugPanel events={debugEvents} onClear={clearDebugEvents} onClose={() => useWorkspaceStore.getState().setActiveMobilePanel('chat')} />
2099
+ </div>
2100
+ )}
2101
+ </div>
2102
+
2103
+ {/* Bottom Navigation Bar */}
2104
+ <div className="fixed bottom-0 left-0 right-0 z-20 bg-card border-t border-border">
2105
+ <div className="flex justify-center items-center p-2 gap-2">
2106
+ <button
2107
+ className={`flex items-center justify-center py-2 px-2 rounded-lg transition-all shadow-sm ${
2108
+ activeMobilePanel === 'chat'
2109
+ ? 'text-white'
2110
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
2111
+ }`}
2112
+ style={{
2113
+ backgroundColor: activeMobilePanel === 'chat' ? 'var(--button-assistant-active)' : undefined,
2114
+ }}
2115
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('chat'); }}
2116
+ >
2117
+ <MessageSquare className="h-4 w-4" />
2118
+ </button>
2119
+
2120
+ <button
2121
+ className={`flex items-center justify-center py-2 px-2 rounded-lg transition-all shadow-sm ${
2122
+ activeMobilePanel === 'files'
2123
+ ? 'text-white'
2124
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
2125
+ }`}
2126
+ style={{
2127
+ backgroundColor: activeMobilePanel === 'files' ? 'var(--button-files-active)' : undefined,
2128
+ }}
2129
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('files'); }}
2130
+ >
2131
+ <FolderTree className="h-4 w-4" />
2132
+ </button>
2133
+
2134
+ <button
2135
+ className={`flex items-center justify-center py-2 px-2 rounded-lg transition-all shadow-sm ${
2136
+ activeMobilePanel === 'editor'
2137
+ ? 'text-white'
2138
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
2139
+ }`}
2140
+ style={{
2141
+ backgroundColor: activeMobilePanel === 'editor' ? 'var(--button-editor-active)' : undefined,
2142
+ }}
2143
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('editor'); }}
2144
+ >
2145
+ <Code2 className="h-4 w-4" />
2146
+ </button>
2147
+
2148
+ <button
2149
+ className={`flex items-center justify-center py-2 px-2 rounded-lg transition-all shadow-sm ${
2150
+ activeMobilePanel === 'preview'
2151
+ ? 'text-white'
2152
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
2153
+ }`}
2154
+ style={{
2155
+ backgroundColor: activeMobilePanel === 'preview' ? 'var(--button-preview-active)' : undefined,
2156
+ }}
2157
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('preview'); }}
2158
+ >
2159
+ <Eye className="h-4 w-4" />
2160
+ </button>
2161
+
2162
+ {/* Overflow menu */}
2163
+ <div className="relative">
2164
+ <button
2165
+ className={`relative flex items-center justify-center py-2 px-2 rounded-lg transition-all shadow-sm ${
2166
+ mobileOverflowOpen || ['checkpoints', 'console', 'skills', 'debug'].includes(activeMobilePanel)
2167
+ ? 'text-white bg-muted'
2168
+ : 'bg-transparent text-muted-foreground hover:bg-muted/80 hover:text-foreground'
2169
+ }`}
2170
+ onClick={() => useWorkspaceStore.getState().setMobileOverflowOpen(!mobileOverflowOpen)}
2171
+ >
2172
+ <EllipsisVertical className="h-4 w-4" />
2173
+ {hasUnreadConsole && activeMobilePanel !== 'console' && (
2174
+ <span className="absolute top-1 right-0.5 h-2 w-2 rounded-full bg-[var(--button-terminal-active,#22c55e)]" />
2175
+ )}
2176
+ </button>
2177
+
2178
+ {mobileOverflowOpen && (
2179
+ <>
2180
+ <div className="fixed inset-0 z-30" onClick={() => useWorkspaceStore.getState().setMobileOverflowOpen(false)} />
2181
+ <div className="absolute bottom-full right-0 mb-2 z-40 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[140px]">
2182
+ <button
2183
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
2184
+ activeMobilePanel === 'checkpoints' ? 'text-white' : 'text-foreground hover:bg-muted'
2185
+ }`}
2186
+ style={{
2187
+ backgroundColor: activeMobilePanel === 'checkpoints' ? 'var(--button-checkpoint-active)' : undefined,
2188
+ }}
2189
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('checkpoints'); }}
2190
+ >
2191
+ <History className="h-4 w-4" />
2192
+ <span>Checkpoints</span>
2193
+ </button>
2194
+ <button
2195
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
2196
+ activeMobilePanel === 'console' ? 'text-white' : 'text-foreground hover:bg-muted'
2197
+ }`}
2198
+ style={{
2199
+ backgroundColor: activeMobilePanel === 'console' ? 'var(--button-terminal-active, #22c55e)' : undefined,
2200
+ }}
2201
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('console'); }}
2202
+ >
2203
+ <TerminalIcon className="h-4 w-4" />
2204
+ <span>Console</span>
2205
+ {hasUnreadConsole && activeMobilePanel !== 'console' && (
2206
+ <span className="ml-auto h-2 w-2 rounded-full bg-[var(--button-terminal-active,#22c55e)]" />
2207
+ )}
2208
+ </button>
2209
+ <button
2210
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
2211
+ activeMobilePanel === 'skills' ? 'text-white' : 'text-foreground hover:bg-muted'
2212
+ }`}
2213
+ style={{
2214
+ backgroundColor: activeMobilePanel === 'skills' ? 'var(--button-skills-active, #a855f7)' : undefined,
2215
+ }}
2216
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('skills'); }}
2217
+ >
2218
+ <Sparkles className="h-4 w-4" />
2219
+ <span>Skills</span>
2220
+ </button>
2221
+ <button
2222
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
2223
+ activeMobilePanel === 'debug' ? 'text-white' : 'text-foreground hover:bg-muted'
2224
+ }`}
2225
+ style={{
2226
+ backgroundColor: activeMobilePanel === 'debug' ? 'var(--button-debug-active, #ef4444)' : undefined,
2227
+ }}
2228
+ onClick={() => { useWorkspaceStore.getState().setActiveMobilePanel('debug'); }}
2229
+ >
2230
+ <Bug className="h-4 w-4" />
2231
+ <span>Debug</span>
2232
+ </button>
2233
+ </div>
2234
+ </>
2235
+ )}
2236
+ </div>
2237
+ </div>
2238
+ </div>
2239
+ </div>
2240
+ </div>
2241
+
2242
+ <GuidedTourOverlay location="workspace" />
2243
+ <GuidedTourOverlay location="settings" />
2244
+
2245
+ <ProjectSettingsModal
2246
+ project={project}
2247
+ isOpen={showProjectSettingsModal}
2248
+ onClose={() => useWorkspaceStore.getState().setShowProjectSettingsModal(false)}
2249
+ onProjectUpdate={handleProjectSettingsUpdate}
2250
+ enabled={backendEnabled}
2251
+ onToggleEnabled={handleBackendToggle}
2252
+ workspaceId={workspaceId}
2253
+ />
2254
+
2255
+ </TooltipProvider>
2256
+ );
2257
+ }
components/workspace/skills-panel.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback } from 'react';
4
+ import { Skill, SkillGroup } from '@/lib/vfs/skills/types';
5
+ import { skillsService } from '@/lib/vfs/skills';
6
+ import { vfs } from '@/lib/vfs';
7
+ import { logger } from '@/lib/utils';
8
+ import { Switch } from '@/components/ui/switch';
9
+ import { Button } from '@/components/ui/button';
10
+ import { Sparkles, Plus, FolderTree } from 'lucide-react';
11
+ import { PanelContainer, PanelHeader } from '@/components/ui/panel';
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ } from '@/components/ui/dialog';
16
+ import { SkillEditor } from '@/components/skills/SkillEditor';
17
+
18
+ interface SkillsPanelProps {
19
+ onClose?: () => void;
20
+ }
21
+
22
+ export function SkillsPanel({ onClose }: SkillsPanelProps) {
23
+ const [skills, setSkills] = useState<Skill[]>([]);
24
+ const [groups, setGroups] = useState<SkillGroup[]>([]);
25
+ const [globalEnabled, setGlobalEnabled] = useState(true);
26
+ const [enabledSkills, setEnabledSkills] = useState<Set<string>>(new Set());
27
+ const [enabledGroups, setEnabledGroups] = useState<Set<string>>(new Set());
28
+ const [loading, setLoading] = useState(true);
29
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
30
+
31
+ const loadState = useCallback(async () => {
32
+ try {
33
+ const [allSkills, allGroups, global] = await Promise.all([
34
+ skillsService.getAllSkills(),
35
+ skillsService.getAllGroups(),
36
+ skillsService.isGloballyEnabled(),
37
+ ]);
38
+ setSkills(allSkills);
39
+ setGroups(allGroups);
40
+ setGlobalEnabled(global);
41
+
42
+ const enabled = new Set<string>();
43
+ for (const skill of allSkills) {
44
+ if (await skillsService.isSkillEnabled(skill.id)) {
45
+ enabled.add(skill.id);
46
+ }
47
+ }
48
+ setEnabledSkills(enabled);
49
+
50
+ const enabledG = new Set<string>();
51
+ for (const g of allGroups) {
52
+ if (await skillsService.isGroupEnabled(g.id)) {
53
+ enabledG.add(g.id);
54
+ }
55
+ }
56
+ setEnabledGroups(enabledG);
57
+ } catch (err) {
58
+ logger.error('Failed to load skills:', err);
59
+ } finally {
60
+ setLoading(false);
61
+ }
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ loadState();
66
+ }, [loadState]);
67
+
68
+ const handleGlobalToggle = async (enabled: boolean) => {
69
+ setGlobalEnabled(enabled);
70
+ await skillsService.setGlobalEnabled(enabled);
71
+ await vfs.reloadTransientSkills();
72
+ };
73
+
74
+ const handleSkillToggle = async (skillId: string, enabled: boolean) => {
75
+ const updated = new Set(enabledSkills);
76
+ if (enabled) {
77
+ updated.add(skillId);
78
+ await skillsService.enableSkill(skillId);
79
+ } else {
80
+ updated.delete(skillId);
81
+ await skillsService.disableSkill(skillId);
82
+ }
83
+ setEnabledSkills(updated);
84
+ await vfs.reloadTransientSkills();
85
+ };
86
+
87
+ const handleGroupToggle = async (groupId: string, enabled: boolean) => {
88
+ const updated = new Set(enabledGroups);
89
+ if (enabled) {
90
+ updated.add(groupId);
91
+ await skillsService.enableGroup(groupId);
92
+ } else {
93
+ updated.delete(groupId);
94
+ await skillsService.disableGroup(groupId);
95
+ }
96
+ setEnabledGroups(updated);
97
+ await vfs.reloadTransientSkills();
98
+ };
99
+
100
+ const builtInSkills = skills.filter(s => s.isBuiltIn);
101
+ const customSkills = skills.filter(s => !s.isBuiltIn);
102
+
103
+ return (
104
+ <PanelContainer>
105
+ <PanelHeader
106
+ icon={Sparkles}
107
+ title="Skills"
108
+ color="var(--button-skills-active, #a855f7)"
109
+ onClose={onClose}
110
+ panelKey="skills"
111
+ actions={
112
+ <Button
113
+ size="sm"
114
+ variant="ghost"
115
+ className="h-6 rounded-full border border-border/60 bg-muted/50 px-2.5 gap-1.5 md:h-5 md:w-5 md:px-0 md:border-0 md:bg-transparent md:rounded-md"
116
+ onClick={() => setCreateDialogOpen(true)}
117
+ title="Create new skill"
118
+ >
119
+ <Plus className="h-2.5 w-2.5 md:h-3 md:w-3" />
120
+ <span className="text-xs md:hidden">Add skill</span>
121
+ </Button>
122
+ }
123
+ />
124
+
125
+ <div className="flex-1 overflow-y-auto p-3 space-y-4">
126
+ {/* Global toggle */}
127
+ <div className="flex items-center justify-between">
128
+ <div>
129
+ <div className="text-sm font-medium">Enable skills</div>
130
+ <p className="text-xs text-muted-foreground mt-0.5">
131
+ Domain-specific AI instructions
132
+ </p>
133
+ </div>
134
+ <Switch
135
+ checked={globalEnabled}
136
+ onCheckedChange={handleGlobalToggle}
137
+ disabled={loading}
138
+ />
139
+ </div>
140
+
141
+ {globalEnabled && !loading && (
142
+ <>
143
+ {groups.length > 0 && (
144
+ <div>
145
+ <div className="flex items-center gap-1.5 mb-2">
146
+ <FolderTree className="w-3 h-3 text-muted-foreground" />
147
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
148
+ Groups
149
+ </div>
150
+ </div>
151
+ <div className="space-y-0.5">
152
+ {groups.map(group => (
153
+ <div key={group.id} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50">
154
+ <div className="flex-1 min-w-0 mr-3">
155
+ <div className="text-sm truncate">{group.name}</div>
156
+ <div className="text-[10px] text-muted-foreground">
157
+ {group.memberIds.length} {group.memberIds.length === 1 ? 'skill' : 'skills'}
158
+ </div>
159
+ </div>
160
+ <Switch
161
+ checked={enabledGroups.has(group.id)}
162
+ onCheckedChange={(checked) => handleGroupToggle(group.id, checked)}
163
+ />
164
+ </div>
165
+ ))}
166
+ </div>
167
+ </div>
168
+ )}
169
+
170
+ {builtInSkills.length > 0 && (
171
+ <div>
172
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
173
+ Built-in
174
+ </div>
175
+ <div className="space-y-0.5">
176
+ {builtInSkills.map(skill => (
177
+ <div key={skill.id} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50">
178
+ <div className="flex-1 min-w-0 mr-3">
179
+ <div className="text-sm truncate">{skill.name}</div>
180
+ </div>
181
+ <Switch
182
+ checked={enabledSkills.has(skill.id)}
183
+ onCheckedChange={(checked) => handleSkillToggle(skill.id, checked)}
184
+ />
185
+ </div>
186
+ ))}
187
+ </div>
188
+ </div>
189
+ )}
190
+
191
+ {customSkills.length > 0 && (
192
+ <div>
193
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
194
+ Custom
195
+ </div>
196
+ <div className="space-y-0.5">
197
+ {customSkills.map(skill => (
198
+ <div key={skill.id} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50">
199
+ <div className="flex-1 min-w-0 mr-3">
200
+ <div className="text-sm truncate">{skill.name}</div>
201
+ </div>
202
+ <Switch
203
+ checked={enabledSkills.has(skill.id)}
204
+ onCheckedChange={(checked) => handleSkillToggle(skill.id, checked)}
205
+ />
206
+ </div>
207
+ ))}
208
+ </div>
209
+ </div>
210
+ )}
211
+
212
+ {skills.length === 0 && (
213
+ <p className="text-xs text-muted-foreground text-center py-4">
214
+ No skills available.
215
+ </p>
216
+ )}
217
+ </>
218
+ )}
219
+ </div>
220
+
221
+ <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
222
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
223
+ <SkillEditor
224
+ skill={null}
225
+ mode="create"
226
+ onSave={async () => {
227
+ setCreateDialogOpen(false);
228
+ await loadState();
229
+ await vfs.reloadTransientSkills();
230
+ }}
231
+ onCancel={() => setCreateDialogOpen(false)}
232
+ />
233
+ </DialogContent>
234
+ </Dialog>
235
+ </PanelContainer>
236
+ );
237
+ }
desktop/.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules/
2
+ app/
3
+ dist/
desktop/README.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OSW Studio Desktop
2
+
3
+ Electron shell around the OSW Studio Next.js standalone server (full Server
4
+ Mode: SQLite storage, deployments, server functions). This directory is the
5
+ single source of truth for the desktop app — CI releases and local builds both
6
+ use these exact files.
7
+
8
+ ## Layout
9
+
10
+ - `electron/main.ts` — the entire main process: boots the bundled server,
11
+ failure diagnostics, consent-based updates, menu
12
+ - `electron/icons/` — committed app icons (no build-time generation)
13
+ - `electron-builder.yml` — packaging config (dmg / nsis / AppImage / deb)
14
+ - `assemble-app.sh` — copies a finished Next.js standalone build into `app/`;
15
+ shared verbatim by CI and local builds
16
+ - `tsup.config.ts` — compiles `main.ts` into `app/main.js`
17
+ - `package.json` — version is a `0.0.0` placeholder, set at build time from
18
+ the repo root `package.json`
19
+
20
+ ## Releasing
21
+
22
+ Releases are built and published by CI (`.github/workflows/desktop-release.yml`),
23
+ triggered by pushing a `desktop-v{version}` tag — normally via
24
+ `release-osw.sh` at the repo parent. The workflow builds all three platforms,
25
+ verifies the complete artifact set, generates release notes from
26
+ `.github/desktop-release-notes.md`, and publishes the GitHub release in a
27
+ single atomic step. If any platform build fails, nothing is published and
28
+ auto-updaters keep serving the previous complete release.
29
+
30
+ ## Local build
31
+
32
+ From the repo parent directory:
33
+
34
+ ```bash
35
+ ./deploy-osw-desktop.sh [mac|win|linux|all]
36
+ ```
37
+
38
+ Or manually from the repo root:
39
+
40
+ ```bash
41
+ NEXT_PUBLIC_SERVER_MODE=true NEXT_PUBLIC_DESKTOP=true npm run build
42
+ ./desktop/assemble-app.sh
43
+ cd desktop && npm ci && npm pkg set version=$(node -p "require('../package.json').version")
44
+ npx tsup && npx electron-builder --mac --publish never
45
+ ```
46
+
47
+ ## Dev mode
48
+
49
+ Run `npm run dev` in the repo root, then `npm run dev` in this directory —
50
+ the Electron window points at `localhost:3000` instead of booting the bundled
51
+ server.
52
+
53
+ ## Updates
54
+
55
+ Updates are consent-based: the app checks GitHub releases on launch and via
56
+ Help → Check for Updates, but only downloads and installs when the user agrees
57
+ (`Download` → `Restart now`). Users can skip a version. On macOS, in-place
58
+ updates require code signing (not set up), so the app links to the releases
59
+ page instead.
desktop/assemble-app.sh ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Assemble the desktop app/ directory from a completed Next.js standalone build.
4
+ # Shared by CI (.github/workflows/desktop-release.yml) and local builds
5
+ # (deploy-osw-desktop.sh) so the two can never drift.
6
+ #
7
+ # Expects to run from the repo root (osw-studio-git) AFTER:
8
+ # NEXT_PUBLIC_SERVER_MODE=true NEXT_PUBLIC_DESKTOP=true npm run build
9
+
10
+ set -e
11
+
12
+ if [ ! -d ".next/standalone" ]; then
13
+ echo "Error: .next/standalone not found — run the Next.js build first:"
14
+ echo " NEXT_PUBLIC_SERVER_MODE=true NEXT_PUBLIC_DESKTOP=true npm run build"
15
+ exit 1
16
+ fi
17
+
18
+ rm -rf desktop/app
19
+ mkdir -p desktop/app
20
+
21
+ # Standalone server + runtime
22
+ cp -r .next/standalone/. desktop/app/
23
+
24
+ # Scrub local server-mode state that Next's build-time file tracing can pull
25
+ # into the standalone output on dev machines (the build reads the dev database
26
+ # in Server Mode). Shipping these would leak local data into the installer.
27
+ rm -rf desktop/app/data desktop/app/deployments desktop/app/sites
28
+
29
+ # Static assets (not included in standalone)
30
+ mkdir -p desktop/app/.next/static
31
+ cp -r .next/static/. desktop/app/.next/static/
32
+
33
+ # Public files (excluding local sites/deployments)
34
+ cp -r public desktop/app/public
35
+ rm -rf desktop/app/public/sites desktop/app/public/deployments
36
+
37
+ # Docs for the documentation API
38
+ cp -r docs desktop/app/docs 2>/dev/null || true
39
+
40
+ echo "Desktop app directory assembled: desktop/app"
desktop/electron-builder.yml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ appId: com.oswstudio.app
2
+ productName: OSW Studio
3
+ copyright: Copyright © 2026 OSW Studio
4
+
5
+ directories:
6
+ output: dist
7
+ app: app
8
+
9
+ extraMetadata:
10
+ main: main.js
11
+ author:
12
+ name: OSW Studio
13
+ email: otst@oswstudio.com
14
+
15
+ asar: false
16
+
17
+ files:
18
+ - "**/*"
19
+ - "!**/.env"
20
+ - "!node_modules/@img/sharp-*/**"
21
+ - "!node_modules/sharp/**"
22
+
23
+ mac:
24
+ target:
25
+ - target: dmg
26
+ arch:
27
+ - universal
28
+ icon: electron/icons/icon.icns
29
+ category: public.app-category.developer-tools
30
+ darkModeSupport: true
31
+ x64ArchFiles: "**/*.node"
32
+
33
+ dmg:
34
+ artifactName: OSW-Studio-${version}-${arch}.${ext}
35
+
36
+ win:
37
+ target:
38
+ - nsis
39
+ icon: electron/icons/icon.png
40
+
41
+ nsis:
42
+ oneClick: false
43
+ allowToChangeInstallationDirectory: true
44
+ # Explicit, space-free name so the built file, the uploaded GitHub asset, and
45
+ # the generated latest.yml all agree. The default ("OSW Studio Setup x.y.z.exe")
46
+ # is sanitized differently by electron-builder (spaces to hyphens in latest.yml)
47
+ # and by GitHub (spaces to dots in the asset name), which broke the Windows
48
+ # auto-updater with a 404 on the download URL.
49
+ artifactName: OSW-Studio-Setup-${version}.${ext}
50
+
51
+ appImage:
52
+ artifactName: OSW-Studio-${version}-${arch}.${ext}
53
+
54
+ linux:
55
+ target:
56
+ - AppImage
57
+ - deb
58
+ icon: electron/icons/icon.png
59
+ category: Development
60
+
61
+ publish:
62
+ provider: github
63
+ owner: o-stahl
64
+ repo: osw-studio
desktop/electron/__tests__/migrate-legacy.test.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { migrateLegacyDir } from '../migrate-legacy';
6
+
7
+ let root: string;
8
+ const log = vi.fn();
9
+
10
+ function legacy(...parts: string[]) { return path.join(root, 'legacy', ...parts); }
11
+ function target(...parts: string[]) { return path.join(root, 'target', ...parts); }
12
+
13
+ function seedLegacy() {
14
+ fs.mkdirSync(legacy('workspaces', 'w1'), { recursive: true });
15
+ fs.writeFileSync(legacy('system.sqlite'), 'system-db');
16
+ fs.writeFileSync(legacy('workspaces', 'w1', 'osws.sqlite'), 'workspace-db');
17
+ }
18
+
19
+ beforeEach(() => {
20
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'osw-migrate-'));
21
+ log.mockClear();
22
+ });
23
+
24
+ afterEach(() => {
25
+ fs.rmSync(root, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('migrateLegacyDir', () => {
29
+ it('copies legacy data recursively when the target does not exist', () => {
30
+ seedLegacy();
31
+ migrateLegacyDir(legacy(), target(), log);
32
+
33
+ expect(fs.readFileSync(target('system.sqlite'), 'utf-8')).toBe('system-db');
34
+ expect(fs.readFileSync(target('workspaces', 'w1', 'osws.sqlite'), 'utf-8')).toBe('workspace-db');
35
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('Migrated legacy data'));
36
+ });
37
+
38
+ it('copies when the target exists but is empty (old shells pre-created it)', () => {
39
+ seedLegacy();
40
+ fs.mkdirSync(target(), { recursive: true });
41
+
42
+ migrateLegacyDir(legacy(), target(), log);
43
+
44
+ expect(fs.readFileSync(target('system.sqlite'), 'utf-8')).toBe('system-db');
45
+ });
46
+
47
+ it('never overwrites a target that already has data', () => {
48
+ seedLegacy();
49
+ fs.mkdirSync(target(), { recursive: true });
50
+ fs.writeFileSync(target('system.sqlite'), 'current-db');
51
+
52
+ migrateLegacyDir(legacy(), target(), log);
53
+
54
+ expect(fs.readFileSync(target('system.sqlite'), 'utf-8')).toBe('current-db');
55
+ expect(fs.existsSync(target('workspaces'))).toBe(false);
56
+ expect(log).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('does nothing when there is no legacy data', () => {
60
+ migrateLegacyDir(legacy(), target(), log);
61
+
62
+ expect(fs.existsSync(target())).toBe(false);
63
+ expect(log).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('logs and does not throw when migration fails', () => {
67
+ seedLegacy();
68
+ // Target path occupied by a FILE — readdir/copy into it must fail
69
+ fs.writeFileSync(target(), 'not-a-directory');
70
+
71
+ expect(() => migrateLegacyDir(legacy(), target(), log)).not.toThrow();
72
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('migration failed'));
73
+ });
74
+ });
desktop/electron/icons/icon.icns ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7404c256bd4ef2b591ed68396c3bb47c5b40ccd41d5e456f35aa1c89642b2e70
3
+ size 382441
desktop/electron/icons/icon.png ADDED
desktop/electron/main.ts ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * OSW Studio Desktop — Electron main process.
3
+ *
4
+ * Boots the bundled Next.js standalone server (Server Mode) on a free local
5
+ * port and points a BrowserWindow at it. This file is the single source of
6
+ * truth for the desktop shell: CI builds (.github/workflows/desktop-release.yml)
7
+ * and local builds (deploy-osw-desktop.sh) both compile it from here.
8
+ *
9
+ * Updates are consent-based: the app checks for new releases and asks before
10
+ * downloading or installing — it never switches versions on its own.
11
+ */
12
+
13
+ import { app, BrowserWindow, Menu, dialog, shell } from 'electron';
14
+ import { autoUpdater } from 'electron-updater';
15
+ import { migrateLegacyDir } from './migrate-legacy';
16
+ import * as path from 'path';
17
+ import * as fs from 'fs';
18
+ import * as http from 'http';
19
+ import * as https from 'https';
20
+ import * as crypto from 'crypto';
21
+
22
+ const isDev = !app.isPackaged;
23
+ const isMac = process.platform === 'darwin';
24
+ // Headless CI/diagnostic flag: runs the real boot path as a pass/fail command
25
+ // (no window). End users never pass it, so normal launch is unaffected.
26
+ const isSelfTest = process.argv.includes('--self-test') || process.env.OSW_SELFTEST === '1';
27
+ const RELEASES_URL = 'https://github.com/o-stahl/osw-studio/releases/latest';
28
+ const RELEASES_API_URL = 'https://api.github.com/repos/o-stahl/osw-studio/releases/latest';
29
+
30
+ let mainWindow: BrowserWindow | null = null;
31
+ let serverPort: number | null = null;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Logging — boot/update failures land in userData/logs/main.log so users can
35
+ // attach something useful to bug reports.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function logToFile(message: string): void {
39
+ try {
40
+ const logDir = path.join(app.getPath('userData'), 'logs');
41
+ fs.mkdirSync(logDir, { recursive: true });
42
+ fs.appendFileSync(path.join(logDir, 'main.log'), `[${new Date().toISOString()}] ${message}\n`);
43
+ } catch {
44
+ // Logging must never crash the app
45
+ }
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Next.js standalone server
50
+ // ---------------------------------------------------------------------------
51
+
52
+ async function startNextServer(host: string = 'localhost'): Promise<number> {
53
+ const { getPort } = await import('get-port-please');
54
+ const port = await getPort({ portRange: [30011, 50000] });
55
+
56
+ process.env.PORT = String(port);
57
+ process.env.HOSTNAME = host;
58
+ process.env.NEXT_PUBLIC_APP_URL = `http://${host}:${port}`;
59
+ process.env.OSW_DESKTOP = 'true';
60
+
61
+ // Platform-appropriate writable locations. The install dir is read-only on
62
+ // Linux (AppImage squashfs) and often on Windows — every server-side path
63
+ // that defaults to process.cwd() MUST be redirected here. The server reads
64
+ // DATA_DIR / DEPLOYMENTS_DIR; the CI smoke test asserts this contract.
65
+ const appDir = app.getAppPath();
66
+ const dataDir = path.join(app.getPath('userData'), 'data');
67
+ const deploymentsDir = path.join(app.getPath('userData'), 'deployments');
68
+
69
+ // One-time rescue: versions ≤1.75 stored data inside the install directory.
70
+ // If that data is still reachable (e.g. a Windows update that preserved it),
71
+ // copy it to userData before first use. Best-effort — on macOS a drag-install
72
+ // replaces the bundle (and the data inside it) before this code can run.
73
+ migrateLegacyDir(path.join(appDir, 'data'), dataDir, logToFile);
74
+ migrateLegacyDir(path.join(appDir, 'deployments'), deploymentsDir, logToFile);
75
+
76
+ fs.mkdirSync(dataDir, { recursive: true });
77
+ process.env.DATA_DIR = dataDir;
78
+
79
+ fs.mkdirSync(deploymentsDir, { recursive: true });
80
+ process.env.DEPLOYMENTS_DIR = deploymentsDir;
81
+
82
+ // Security secrets — generated on first launch, persisted across restarts
83
+ const secretsFile = path.join(app.getPath('userData'), '.secrets.json');
84
+ let secrets: { sessionSecret: string; encryptionKey: string };
85
+ try {
86
+ secrets = JSON.parse(fs.readFileSync(secretsFile, 'utf-8'));
87
+ } catch {
88
+ secrets = {
89
+ sessionSecret: crypto.randomBytes(32).toString('hex'),
90
+ encryptionKey: crypto.randomBytes(32).toString('base64'),
91
+ };
92
+ fs.writeFileSync(secretsFile, JSON.stringify(secrets), { mode: 0o600 });
93
+ }
94
+ process.env.SESSION_SECRET = secrets.sessionSecret;
95
+ process.env.SECRETS_ENCRYPTION_KEY = secrets.encryptionKey;
96
+ // No admin password for desktop — it's a local single-user app
97
+
98
+ // electron-builder's `directories.app: app` flattens the app directory,
99
+ // so appDir points at the directory containing server.js.
100
+ process.chdir(appDir);
101
+ require(path.join(appDir, 'server.js'));
102
+
103
+ return port;
104
+ }
105
+
106
+ async function waitForServer(url: string, maxRetries = 30): Promise<boolean> {
107
+ for (let i = 0; i < maxRetries; i++) {
108
+ const ok = await new Promise<boolean>((resolve) => {
109
+ const req = http.get(url, (res) => { res.resume(); resolve(true); });
110
+ req.on('error', () => resolve(false));
111
+ req.setTimeout(2000, () => { req.destroy(); resolve(false); });
112
+ });
113
+ if (ok) return true;
114
+ await new Promise(r => setTimeout(r, 1000));
115
+ }
116
+ return false;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Self-test — full headless boot, used by CI and manual distro checks.
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function postJson(url: string): Promise<{ status: number; body: string }> {
124
+ return new Promise((resolve, reject) => {
125
+ const req = http.request(url, { method: 'POST' }, (res) => {
126
+ let data = '';
127
+ res.on('data', (c) => { data += c; });
128
+ res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
129
+ });
130
+ req.on('error', reject);
131
+ req.setTimeout(15000, () => { req.destroy(new Error('request timed out')); });
132
+ req.end();
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Boots the real server and drives the workspace-init request, exiting 0/1.
138
+ * Exercises the native-module/filesystem paths under Electron's ABI that a
139
+ * system-Node smoke test misses.
140
+ */
141
+ async function runSelfTest(): Promise<void> {
142
+ console.log(`[self-test] OSW Studio Desktop v${app.getVersion()} on ${process.platform}/${process.arch}`);
143
+ console.log(`[self-test] electron=${process.versions.electron} node=${process.versions.node} abi(modules)=${process.versions.modules}`);
144
+ try {
145
+ // Bind + probe 127.0.0.1 explicitly: `localhost` can resolve to IPv6 in CI
146
+ // containers and miss the IPv4-bound server. The shipped app keeps localhost.
147
+ const port = await startNextServer('127.0.0.1');
148
+ const base = `http://127.0.0.1:${port}`;
149
+
150
+ const ready = await waitForServer(base);
151
+ if (!ready) {
152
+ console.error('[self-test] FAIL: server did not respond within the startup timeout');
153
+ app.exit(1);
154
+ return;
155
+ }
156
+
157
+ const { status, body } = await postJson(`${base}/api/auth/desktop-init`);
158
+ let parsed: { workspaceId?: string; error?: string; detail?: string } = {};
159
+ try { parsed = JSON.parse(body); } catch { /* keep raw body for the log */ }
160
+
161
+ if (status === 200 && parsed.workspaceId) {
162
+ console.log(`[self-test] PASS: workspace initialized (${parsed.workspaceId})`);
163
+ app.exit(0);
164
+ return;
165
+ }
166
+
167
+ console.error(`[self-test] FAIL: desktop-init returned HTTP ${status}`);
168
+ if (parsed.error) console.error(`[self-test] error: ${parsed.error}`);
169
+ if (parsed.detail) console.error(`[self-test] detail:\n${parsed.detail}`);
170
+ if (!parsed.error && body) console.error(`[self-test] body: ${body}`);
171
+ app.exit(1);
172
+ } catch (err) {
173
+ console.error(`[self-test] FAIL: ${(err as Error)?.stack || String(err)}`);
174
+ app.exit(1);
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Failure diagnostics — never leave the user staring at a blank window.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function escapeHtml(text: string): string {
183
+ return text
184
+ .replace(/&/g, '&amp;')
185
+ .replace(/</g, '&lt;')
186
+ .replace(/>/g, '&gt;')
187
+ .replace(/"/g, '&quot;');
188
+ }
189
+
190
+ function diagnosticPage(rawTitle: string, rawDetail: string): string {
191
+ const title = escapeHtml(rawTitle);
192
+ const detail = escapeHtml(rawDetail);
193
+ const logPath = escapeHtml(path.join(app.getPath('userData'), 'logs', 'main.log'));
194
+ const html = `<!DOCTYPE html>
195
+ <html><head><meta charset="utf-8"><title>OSW Studio — Problem</title>
196
+ <style>
197
+ body { font-family: system-ui, sans-serif; background: #121212; color: #eaeaea; margin: 0;
198
+ display: flex; align-items: center; justify-content: center; min-height: 100vh; }
199
+ .box { max-width: 560px; padding: 2rem; }
200
+ h1 { font-size: 1.3rem; } p, li { line-height: 1.6; color: #bbb; }
201
+ code { background: #1e1e1e; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85em; }
202
+ a { color: #7aa2f7; }
203
+ </style></head>
204
+ <body><div class="box">
205
+ <h1>${title}</h1>
206
+ <p>${detail}</p>
207
+ <p>OSW Studio Desktop v${app.getVersion()}</p>
208
+ <ul>
209
+ <li>Restarting the app usually resolves transient startup problems.</li>
210
+ <li>If this happened after an update, download the latest installer from the
211
+ <a href="${RELEASES_URL}" target="_blank">releases page</a> and reinstall — your projects and data are kept.</li>
212
+ <li>Log file for bug reports: <code>${logPath}</code></li>
213
+ </ul>
214
+ </div></body></html>`;
215
+ return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
216
+ }
217
+
218
+ function showDiagnostic(title: string, detail: string): void {
219
+ logToFile(`${title}: ${detail}`);
220
+ if (mainWindow && !mainWindow.isDestroyed()) {
221
+ mainWindow.loadURL(diagnosticPage(title, detail));
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Updates — check automatically, but download and install only on user consent.
227
+ // ---------------------------------------------------------------------------
228
+
229
+ interface UpdatePrefs { skippedVersion?: string }
230
+
231
+ function updatePrefsFile(): string {
232
+ return path.join(app.getPath('userData'), 'update-prefs.json');
233
+ }
234
+
235
+ function readUpdatePrefs(): UpdatePrefs {
236
+ try {
237
+ return JSON.parse(fs.readFileSync(updatePrefsFile(), 'utf-8'));
238
+ } catch {
239
+ return {};
240
+ }
241
+ }
242
+
243
+ function writeUpdatePrefs(prefs: UpdatePrefs): void {
244
+ try {
245
+ fs.writeFileSync(updatePrefsFile(), JSON.stringify(prefs));
246
+ } catch {
247
+ // Non-fatal
248
+ }
249
+ }
250
+
251
+ function isNewerVersion(candidate: string, current: string): boolean {
252
+ const a = candidate.split('.').map(n => parseInt(n, 10) || 0);
253
+ const b = current.split('.').map(n => parseInt(n, 10) || 0);
254
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
255
+ if ((a[i] || 0) > (b[i] || 0)) return true;
256
+ if ((a[i] || 0) < (b[i] || 0)) return false;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ let updaterWired = false;
262
+ let updateDownloaded = false;
263
+ let downloadedVersion = '';
264
+
265
+ async function promptRestartToUpdate(version: string): Promise<void> {
266
+ const { response } = await dialog.showMessageBox({
267
+ type: 'info',
268
+ title: 'Update ready',
269
+ message: `OSW Studio v${version} has been downloaded.`,
270
+ detail: 'Restart now to apply the update, or keep working — it will NOT install until you choose to.',
271
+ buttons: ['Restart now', 'Later'],
272
+ defaultId: 0,
273
+ cancelId: 1,
274
+ });
275
+ if (response === 0) {
276
+ autoUpdater.quitAndInstall();
277
+ }
278
+ }
279
+
280
+ async function showUpdateError(err: unknown): Promise<void> {
281
+ const logPath = path.join(app.getPath('userData'), 'logs', 'main.log');
282
+ const message = err instanceof Error ? err.message : String(err);
283
+ const { response } = await dialog.showMessageBox({
284
+ type: 'error',
285
+ title: 'Update failed',
286
+ message: 'The update could not be downloaded.',
287
+ detail: `${message}\n\nYou can download the latest version manually from the releases page. Details are saved to the log:\n${logPath}`,
288
+ buttons: ['Open releases page', 'Close'],
289
+ defaultId: 0,
290
+ cancelId: 1,
291
+ });
292
+ if (response === 0) shell.openExternal(RELEASES_URL);
293
+ }
294
+
295
+ function wireUpdater(): void {
296
+ if (updaterWired) return;
297
+ updaterWired = true;
298
+
299
+ autoUpdater.autoDownload = false;
300
+ autoUpdater.autoInstallOnAppQuit = false;
301
+
302
+ // Error dialogs are surfaced at the download call site (which owns the user
303
+ // interaction); here we only record the error and clear any progress state.
304
+ autoUpdater.on('error', (err) => {
305
+ logToFile(`Updater error: ${err?.message || err}`);
306
+ mainWindow?.setProgressBar(-1);
307
+ });
308
+
309
+ autoUpdater.on('download-progress', (progress) => {
310
+ mainWindow?.setProgressBar((progress?.percent ?? 0) / 100);
311
+ });
312
+
313
+ autoUpdater.on('update-downloaded', (info) => {
314
+ mainWindow?.setProgressBar(-1);
315
+ updateDownloaded = true;
316
+ downloadedVersion = info.version;
317
+ void promptRestartToUpdate(info.version);
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Windows/Linux: prompt to download via electron-updater.
323
+ * interactive = true for menu-triggered checks (always prompts, ignores skips).
324
+ */
325
+ async function checkForUpdatesElectron(interactive: boolean): Promise<void> {
326
+ wireUpdater();
327
+ // An update is already downloaded and waiting — re-offer the restart on
328
+ // manual checks instead of silently doing nothing.
329
+ if (updateDownloaded) {
330
+ if (interactive) await promptRestartToUpdate(downloadedVersion);
331
+ return;
332
+ }
333
+ try {
334
+ const result = await autoUpdater.checkForUpdates();
335
+ const version = result?.updateInfo?.version;
336
+ if (!version || !isNewerVersion(version, app.getVersion())) {
337
+ if (interactive) {
338
+ dialog.showMessageBox({ type: 'info', message: 'You are on the latest version.', detail: `OSW Studio v${app.getVersion()}` });
339
+ }
340
+ return;
341
+ }
342
+ if (!interactive && readUpdatePrefs().skippedVersion === version) return;
343
+
344
+ const { response } = await dialog.showMessageBox({
345
+ type: 'info',
346
+ title: 'Update available',
347
+ message: `OSW Studio v${version} is available.`,
348
+ detail: `You are on v${app.getVersion()}. If you download, it downloads in the background (progress shows on the taskbar) and prompts you to restart when it is ready. It will not install until you choose to.`,
349
+ buttons: ['Download', 'Skip this version', 'Later'],
350
+ defaultId: 0,
351
+ cancelId: 2,
352
+ });
353
+ if (response === 0) {
354
+ logToFile(`Downloading update v${version}`);
355
+ mainWindow?.setProgressBar(0);
356
+ autoUpdater.downloadUpdate().catch(err => {
357
+ mainWindow?.setProgressBar(-1);
358
+ logToFile(`Update download failed: ${err?.message || err}`);
359
+ void showUpdateError(err);
360
+ });
361
+ } else if (response === 1) {
362
+ writeUpdatePrefs({ skippedVersion: version });
363
+ }
364
+ } catch (err) {
365
+ logToFile(`Update check failed: ${(err as Error)?.message || err}`);
366
+ if (interactive) {
367
+ dialog.showMessageBox({ type: 'warning', message: 'Update check failed.', detail: 'See the log file for details, or check the releases page manually.' });
368
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * macOS: in-place updates require code signing, which isn't set up — check the
374
+ * GitHub API for a newer desktop release and point the user at the download.
375
+ */
376
+ async function checkForUpdatesMac(interactive: boolean): Promise<void> {
377
+ try {
378
+ const body = await new Promise<string>((resolve, reject) => {
379
+ const req = https.get(RELEASES_API_URL, { headers: { 'User-Agent': 'osw-studio-desktop' } }, (res) => {
380
+ let data = '';
381
+ res.on('data', (c) => { data += c; });
382
+ res.on('end', () => resolve(data));
383
+ });
384
+ req.on('error', reject);
385
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error('timeout')); });
386
+ });
387
+ const release = JSON.parse(body);
388
+ const version = String(release.tag_name || '').replace(/^desktop-v/, '');
389
+ if (!version || !isNewerVersion(version, app.getVersion())) {
390
+ if (interactive) {
391
+ dialog.showMessageBox({ type: 'info', message: 'You are on the latest version.', detail: `OSW Studio v${app.getVersion()}` });
392
+ }
393
+ return;
394
+ }
395
+ if (!interactive && readUpdatePrefs().skippedVersion === version) return;
396
+
397
+ const { response } = await dialog.showMessageBox({
398
+ type: 'info',
399
+ title: 'Update available',
400
+ message: `OSW Studio v${version} is available.`,
401
+ detail: `You are on v${app.getVersion()}. Auto-update is not available on macOS (the app is unsigned) — download the new version from the releases page. Your projects and data are kept.`,
402
+ buttons: ['Open releases page', 'Skip this version', 'Later'],
403
+ defaultId: 0,
404
+ cancelId: 2,
405
+ });
406
+ if (response === 0) {
407
+ shell.openExternal(RELEASES_URL);
408
+ } else if (response === 1) {
409
+ writeUpdatePrefs({ skippedVersion: version });
410
+ }
411
+ } catch (err) {
412
+ logToFile(`Update check failed: ${(err as Error)?.message || err}`);
413
+ if (interactive) {
414
+ dialog.showMessageBox({ type: 'warning', message: 'Update check failed.', detail: 'Check the releases page manually.' });
415
+ }
416
+ }
417
+ }
418
+
419
+ function checkForUpdates(interactive: boolean): void {
420
+ if (isDev) return;
421
+ if (isMac) {
422
+ void checkForUpdatesMac(interactive);
423
+ } else {
424
+ void checkForUpdatesElectron(interactive);
425
+ }
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Menu — standard roles plus update actions under Help.
430
+ // ---------------------------------------------------------------------------
431
+
432
+ function buildMenu(): void {
433
+ const template: Electron.MenuItemConstructorOptions[] = [
434
+ ...(isMac ? [{ role: 'appMenu' as const }] : []),
435
+ { role: 'fileMenu' as const },
436
+ { role: 'editMenu' as const },
437
+ { role: 'viewMenu' as const },
438
+ { role: 'windowMenu' as const },
439
+ {
440
+ role: 'help' as const,
441
+ submenu: [
442
+ { label: 'Check for Updates…', click: () => checkForUpdates(true) },
443
+ { label: 'Open Releases Page', click: () => shell.openExternal(RELEASES_URL) },
444
+ { type: 'separator' as const },
445
+ { label: 'Report an Issue', click: () => shell.openExternal('https://github.com/o-stahl/osw-studio/issues') },
446
+ { label: 'Open Logs Folder', click: () => {
447
+ const logDir = path.join(app.getPath('userData'), 'logs');
448
+ try { fs.mkdirSync(logDir, { recursive: true }); } catch { /* opening still surfaces the path */ }
449
+ shell.openPath(logDir);
450
+ } },
451
+ ],
452
+ },
453
+ ];
454
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template));
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Window
459
+ // ---------------------------------------------------------------------------
460
+
461
+ async function createWindow(port: number): Promise<void> {
462
+ mainWindow = new BrowserWindow({
463
+ width: 1400,
464
+ height: 900,
465
+ minWidth: 800,
466
+ minHeight: 600,
467
+ title: 'OSW Studio',
468
+ webPreferences: {
469
+ nodeIntegration: false,
470
+ contextIsolation: true,
471
+ },
472
+ });
473
+
474
+ const serverUrl = `http://localhost:${port}`;
475
+
476
+ mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
477
+ // -3 (ERR_ABORTED) fires on normal in-app navigation interruptions
478
+ if (errorCode === -3) return;
479
+ showDiagnostic('The app failed to load', `${errorDescription} (${errorCode}) while loading ${validatedURL}.`);
480
+ });
481
+
482
+ // Open external links in the default browser
483
+ mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
484
+ if (url.startsWith('http://localhost')) return { action: 'allow' as const };
485
+ shell.openExternal(url);
486
+ return { action: 'deny' as const };
487
+ });
488
+
489
+ mainWindow.on('closed', () => {
490
+ mainWindow = null;
491
+ });
492
+
493
+ if (!isDev) {
494
+ const ready = await waitForServer(serverUrl);
495
+ if (!ready) {
496
+ showDiagnostic('The local server did not start', 'The bundled server did not respond within 30 seconds.');
497
+ return;
498
+ }
499
+ }
500
+
501
+ mainWindow.loadURL(serverUrl);
502
+
503
+ if (isDev) {
504
+ mainWindow.webContents.openDevTools({ mode: 'detach' });
505
+ }
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // App lifecycle
510
+ // ---------------------------------------------------------------------------
511
+
512
+ app.whenReady().then(async () => {
513
+ if (isSelfTest) {
514
+ // Headless: boot, probe workspace init, exit. No window, no menu, no updater.
515
+ await runSelfTest();
516
+ return;
517
+ }
518
+
519
+ logToFile(`OSW Studio Desktop v${app.getVersion()} starting on ${process.platform}/${process.arch}`);
520
+ buildMenu();
521
+ try {
522
+ if (isDev) {
523
+ // Dev mode: run `npm run dev` in osw-studio-git and point at it
524
+ serverPort = 3000;
525
+ } else {
526
+ serverPort = await startNextServer();
527
+ }
528
+ await createWindow(serverPort);
529
+ checkForUpdates(false);
530
+ } catch (error) {
531
+ const message = (error as Error)?.message || String(error);
532
+ logToFile(`Failed to start: ${message}`);
533
+ if (!mainWindow) {
534
+ mainWindow = new BrowserWindow({ width: 900, height: 600, title: 'OSW Studio' });
535
+ }
536
+ mainWindow.loadURL(diagnosticPage('OSW Studio failed to start', message));
537
+ }
538
+ });
539
+
540
+ app.on('window-all-closed', () => {
541
+ app.quit();
542
+ });
543
+
544
+ app.on('activate', () => {
545
+ if (mainWindow === null && serverPort) {
546
+ createWindow(serverPort);
547
+ }
548
+ });
desktop/electron/migrate-legacy.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * One-time legacy data rescue. Versions ≤1.75 stored data inside the install
3
+ * directory; if that data is still reachable, copy it to the user-data
4
+ * location before first use.
5
+ *
6
+ * Electron-free so it can be unit tested — the caller injects logging.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+
11
+ export function migrateLegacyDir(
12
+ legacyDir: string,
13
+ targetDir: string,
14
+ log: (message: string) => void,
15
+ ): void {
16
+ try {
17
+ // Old shells pre-created the target as an EMPTY directory (without ever
18
+ // telling the server to use it) — treat empty as absent, or the migration
19
+ // would never run for anyone who launched an older version.
20
+ const targetHasData = fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0;
21
+ if (targetHasData || !fs.existsSync(legacyDir)) return;
22
+ fs.cpSync(legacyDir, targetDir, { recursive: true });
23
+ log(`Migrated legacy data: ${legacyDir} -> ${targetDir}`);
24
+ } catch (err) {
25
+ log(`Legacy data migration failed for ${legacyDir}: ${(err as Error)?.message || err}`);
26
+ }
27
+ }
desktop/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
desktop/package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "osw-studio-desktop",
3
+ "version": "1.83.0",
4
+ "private": true,
5
+ "description": "OSW Studio Desktop — Electron shell around the Next.js standalone server. The version is set at build time from the repo root package.json; 0.0.0 is a placeholder.",
6
+ "main": "app/main.js",
7
+ "homepage": "https://github.com/o-stahl/osw-studio",
8
+ "author": {
9
+ "name": "OSW Studio",
10
+ "email": "otst@oswstudio.com"
11
+ },
12
+ "scripts": {
13
+ "compile": "tsup",
14
+ "typecheck": "tsc --noEmit",
15
+ "dist:mac": "electron-builder --mac",
16
+ "dist:win": "electron-builder --win",
17
+ "dist:linux": "electron-builder --linux",
18
+ "dev": "tsup && electron ."
19
+ },
20
+ "dependencies": {
21
+ "electron-updater": "^6.3.0",
22
+ "get-port-please": "^3.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "@electron/rebuild": "^3.7.0",
26
+ "@types/node": "^20.0.0",
27
+ "electron": "^34.0.0",
28
+ "electron-builder": "^25.0.0",
29
+ "tsup": "^8.0.0",
30
+ "typescript": "^5.9.0"
31
+ }
32
+ }
desktop/tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "moduleResolution": "node",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["electron/**/*.ts"],
13
+ "exclude": ["electron/__tests__"]
14
+ }
desktop/tsup.config.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'tsup';
2
+
3
+ // Compiles the Electron main process into the assembled app/ directory
4
+ // (created by assemble-app.sh from the Next.js standalone build).
5
+ export default defineConfig({
6
+ entry: ['electron/main.ts'],
7
+ outDir: 'app',
8
+ format: ['cjs'],
9
+ target: 'node20',
10
+ platform: 'node',
11
+ clean: false,
12
+ external: ['electron', /^next\//],
13
+ noExternal: ['get-port-please', 'electron-updater'],
14
+ });
docs/BACKEND_FEATURES.md ADDED
@@ -0,0 +1,969 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Features
2
+
3
+ This guide covers OSW Studio's advanced backend features, available in **Server Mode** only.
4
+
5
+ ## Overview
6
+
7
+ Server Mode unlocks powerful backend capabilities for your published deployments, including serverless API endpoints, database management, and secure secrets storage.
8
+
9
+ **Key Features:**
10
+ - **Edge Functions** - REST API endpoints with JavaScript runtime
11
+ - **Database** - Per-deployment SQLite with SQL editor and schema browser
12
+ - **Server Functions** - Reusable helper code for edge functions
13
+ - **Scheduled Functions** - Run edge functions on cron schedules
14
+ - **Secrets** - Encrypted storage for API keys and tokens
15
+ - **Logs** - Execution history and debugging
16
+ - **AI Integration** - AI awareness of backend features via `/.server/` folder
17
+
18
+ ## Where Backend Features Live
19
+
20
+ Backend features are **project-scoped** — they belong to the project, not to a specific deployment. When you publish the project, its backend features are extracted into the deployment's runtime database. The same project can be published multiple times; each deployment gets a snapshot of the project's backend features at publish time.
21
+
22
+ You can manage backend features in two places:
23
+
24
+ 1. **Project Settings** (primary, in the workspace): Click **Project Settings** in the workspace header to open a modal with Schema, Functions, Helpers, Secrets, and Schedules tabs. Changes take effect in the project and will be included in the next publish.
25
+ 2. **Deployment Server Settings** (for an already-published deployment): Open the deployment in the Admin Dashboard to inspect and tweak the runtime copy of its backend. This is useful for rotating secrets or debugging a published deployment without re-publishing from the project.
26
+
27
+ ## Prerequisites
28
+
29
+ - OSW Studio running in **Server Mode**
30
+ - A project with Backend enabled (toggle in Project Settings)
31
+
32
+ ## Accessing Server Settings
33
+
34
+ **From the workspace (recommended):**
35
+ 1. Open your project
36
+ 2. Click **Project Settings** in the workspace header
37
+ 3. Toggle **Backend Enabled** if it isn't already
38
+ 4. Use the tabs to create edge functions, server functions, secrets, and schedules
39
+
40
+ **From the Admin Dashboard (for published deployments):**
41
+ 1. Open the **Admin Dashboard** (`/admin`) or `/w/{workspaceId}/deployments`
42
+ 2. Navigate to **Deployments** and select your deployment
43
+ 3. Click the **Server Settings** button (server icon) next to Deployment Settings
44
+ - The server icon only appears for published deployments with database enabled
45
+ - You can also access it via the "..." dropdown menu > "Server Settings"
46
+
47
+ The Server Settings modal contains seven tabs:
48
+ - **Schema** - Browse tables and columns
49
+ - **SQL** - Execute raw SQL queries
50
+ - **Functions** - Create and manage edge functions (HTTP endpoints)
51
+ - **Helpers** - Create and manage server functions (reusable code)
52
+ - **Secrets** - Store encrypted API keys and tokens
53
+ - **Schedules** - Create and manage scheduled functions (cron jobs)
54
+ - **Logs** - View function execution history
55
+
56
+ ---
57
+
58
+ ## Edge Functions
59
+
60
+ ### Creating a Function
61
+
62
+ 1. Go to **Server Settings > Functions**
63
+ 2. Click **New Function**
64
+ 3. Configure:
65
+ - **Name**: Lowercase letters, numbers, and hyphens (e.g., `get-users`)
66
+ - **HTTP Method**: GET, POST, PUT, DELETE, or ANY
67
+ - **Description**: Optional description
68
+ - **Timeout**: 1-30 seconds (default: 5s)
69
+ - **Code**: JavaScript function body
70
+
71
+ 4. Click **Create Function**
72
+
73
+ ### Function URL
74
+
75
+ Each function is accessible at:
76
+ ```
77
+ https://your-server.com/api/deployments/{deploymentId}/functions/{function-name}
78
+ ```
79
+
80
+ For example:
81
+ ```
82
+ https://your-instance.com/api/deployments/abc123/functions/get-products
83
+ ```
84
+
85
+ ### Calling Edge Functions from Published Sites
86
+
87
+ Published sites automatically route edge function calls! Your frontend JavaScript can call functions using simple paths:
88
+
89
+ ```javascript
90
+ // In your published site's JavaScript
91
+ const response = await fetch('/submit-contact', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ name: 'John', email: 'john@example.com' })
95
+ });
96
+ const result = await response.json();
97
+ ```
98
+
99
+ This works because OSW Studio injects a lightweight interceptor script (~1.5KB) into published HTML files that:
100
+ - Detects requests that look like edge function calls (paths without file extensions)
101
+ - Routes them to `/api/deployments/{deploymentId}/functions/{path}`
102
+ - Works with `fetch()`, `XMLHttpRequest`, and form submissions
103
+
104
+ **Form submissions** are also intercepted:
105
+ ```html
106
+ <form action="/submit-contact" method="POST">
107
+ <input name="email" type="email" required>
108
+ <button type="submit">Subscribe</button>
109
+ </form>
110
+ ```
111
+
112
+ The form data is automatically converted to JSON and sent to your edge function.
113
+
114
+ **Custom event handling:**
115
+ ```javascript
116
+ // Listen for edge function responses
117
+ document.addEventListener('edge-function-response', (e) => {
118
+ console.log('Result:', e.detail.result);
119
+ });
120
+
121
+ document.addEventListener('edge-function-error', (e) => {
122
+ console.error('Error:', e.detail.error);
123
+ });
124
+ ```
125
+
126
+ ### Available APIs
127
+
128
+ Your function code has access to these global objects:
129
+
130
+ #### `request` Object
131
+ ```javascript
132
+ request.method // HTTP method (GET, POST, etc.)
133
+ request.body // Parsed JSON body (POST/PUT/PATCH)
134
+ request.query // Query string parameters
135
+ request.headers // Request headers
136
+ request.path // URL path after function name
137
+ request.params // Path parameters (if any)
138
+ ```
139
+
140
+ #### `db` Object (Database)
141
+ ```javascript
142
+ // Execute SELECT queries
143
+ const users = db.query('SELECT * FROM users WHERE active = ?', [true]);
144
+ const user = db.all('SELECT * FROM users LIMIT 10'); // alias for query
145
+
146
+ // Execute INSERT/UPDATE/DELETE
147
+ const result = db.run('INSERT INTO users (name, email) VALUES (?, ?)', ['John', 'john@example.com']);
148
+ // result = { changes: 1 }
149
+ ```
150
+
151
+ #### `Response` Object
152
+ ```javascript
153
+ // Return JSON
154
+ Response.json({ users: [...] });
155
+ Response.json({ error: 'Not found' }, 404);
156
+
157
+ // Return plain text
158
+ Response.text('Hello World');
159
+ Response.text('Created', 201);
160
+
161
+ // Return error
162
+ Response.error('Something went wrong', 500);
163
+ Response.error('Unauthorized', 401);
164
+ ```
165
+
166
+ #### `fetch` Function
167
+ ```javascript
168
+ // Make external HTTP requests
169
+ const response = await fetch('https://api.example.com/data');
170
+ const data = await response.json();
171
+ Response.json(data);
172
+
173
+ // With options
174
+ const res = await fetch('https://api.example.com/users', {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: { name: 'John', email: 'john@example.com' }
178
+ });
179
+ ```
180
+
181
+ **Security Limits:**
182
+ - Max 10 requests per function execution
183
+ - 10 second timeout per request
184
+ - 5MB max response body
185
+ - Only `http://` and `https://` protocols allowed
186
+ - Private IPs blocked in production (localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x)
187
+ - Development mode allows local requests for testing
188
+
189
+ #### `atob` / `btoa` Functions
190
+ ```javascript
191
+ // Base64 encode
192
+ const encoded = btoa('Hello World'); // "SGVsbG8gV29ybGQ="
193
+
194
+ // Base64 decode
195
+ const decoded = atob('SGVsbG8gV29ybGQ='); // "Hello World"
196
+ ```
197
+
198
+ #### `server` Object (Helper Functions)
199
+ ```javascript
200
+ // Call server functions (helpers) defined in the Helpers tab
201
+ const auth = server.validateAuth(request.headers['x-api-key']);
202
+ const formatted = server.formatPrice(29.99, 'USD');
203
+ const user = server.getUserById(123);
204
+ ```
205
+
206
+ See [Server Functions (Helpers)](#server-functions-helpers) for more details.
207
+
208
+ #### `secrets` Object (Encrypted Secrets)
209
+ ```javascript
210
+ // Get secret value by name
211
+ const apiKey = secrets.get('STRIPE_API_KEY');
212
+ if (!apiKey) {
213
+ Response.error('Stripe not configured', 500);
214
+ return;
215
+ }
216
+
217
+ // Check if secret exists
218
+ if (secrets.has('SENDGRID_KEY')) {
219
+ // Use SendGrid
220
+ }
221
+
222
+ // List all available secret names
223
+ const allSecrets = secrets.list(); // ['STRIPE_API_KEY', 'SENDGRID_KEY', ...]
224
+ ```
225
+
226
+ See [Secrets](#secrets) for more details.
227
+
228
+ ### Example Functions
229
+
230
+ #### List Items (GET)
231
+ ```javascript
232
+ // GET /api/deployments/{deploymentId}/functions/list-items
233
+ const items = db.query('SELECT * FROM items ORDER BY created_at DESC LIMIT 20');
234
+ Response.json({ items });
235
+ ```
236
+
237
+ #### Create Item (POST)
238
+ ```javascript
239
+ // POST /api/deployments/{deploymentId}/functions/create-item
240
+ if (!request.body.name) {
241
+ Response.error('Name is required', 400);
242
+ return;
243
+ }
244
+
245
+ const result = db.run(
246
+ 'INSERT INTO items (name, description) VALUES (?, ?)',
247
+ [request.body.name, request.body.description || '']
248
+ );
249
+
250
+ Response.json({
251
+ id: result.lastInsertRowid,
252
+ message: 'Item created'
253
+ }, 201);
254
+ ```
255
+
256
+ #### Get Item by ID (GET with path)
257
+ ```javascript
258
+ // GET /api/deployments/{deploymentId}/functions/get-item/123
259
+ const id = request.path.split('/')[1];
260
+ if (!id) {
261
+ Response.error('ID required', 400);
262
+ return;
263
+ }
264
+
265
+ const item = db.query('SELECT * FROM items WHERE id = ?', [id]);
266
+ if (item.length === 0) {
267
+ Response.error('Item not found', 404);
268
+ return;
269
+ }
270
+
271
+ Response.json(item[0]);
272
+ ```
273
+
274
+ #### External API Proxy
275
+ ```javascript
276
+ // GET /api/deployments/{deploymentId}/functions/weather?city=London
277
+ const city = request.query.city || 'New York';
278
+ const apiKey = secrets.get('WEATHER_API_KEY');
279
+ if (!apiKey) {
280
+ Response.error('Weather API not configured', 500);
281
+ return;
282
+ }
283
+
284
+ const res = await fetch(
285
+ `https://api.weatherapi.com/v1/current.json?key=${apiKey}&q=${city}`
286
+ );
287
+ const data = await res.json();
288
+
289
+ Response.json({
290
+ city: data.location.name,
291
+ temp: data.current.temp_c,
292
+ condition: data.current.condition.text
293
+ });
294
+ ```
295
+
296
+ ### Security Considerations
297
+
298
+ #### Sandboxed Execution
299
+ - Functions run in a **QuickJS WebAssembly sandbox** - a completely separate JavaScript engine
300
+ - True isolation via WASM boundary: no shared memory or access to Node.js internals
301
+ - Memory limits enforced by WASM (64MB default)
302
+ - Execution time limits with interrupt handler (configurable 1-30 seconds)
303
+ - Allowed globals: `JSON`, `Date`, `Math`, `Array`, `Object`, `String`, `Number`, `Boolean`, `RegExp`, `Error`, `Map`, `Set`, `Promise`, `Symbol`, `console`
304
+ - Network: `fetch` (with security limits - see above)
305
+ - Utility functions: `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURIComponent`, `decodeURIComponent`, `encodeURI`, `decodeURI`
306
+ - Base64: `atob` (decode), `btoa` (encode)
307
+ - No access to: `require`, `process`, `__dirname`, `Buffer`, file system
308
+ - `setTimeout`/`setInterval` disabled (prevents runaway execution)
309
+
310
+ #### Database Protection
311
+ System tables are protected and cannot be accessed:
312
+ - `site_info`
313
+ - `files`
314
+ - `file_tree_nodes`
315
+ - `pageviews`
316
+ - `interactions`
317
+ - `sessions`
318
+ - `edge_functions`
319
+ - `function_logs`
320
+ - `server_functions`
321
+ - `secrets`
322
+
323
+ Only user-created tables are accessible via the `db` API.
324
+
325
+ #### Query Limits
326
+ - Maximum 100 database queries per function execution
327
+ - Timeout enforced (1-30 seconds, configurable)
328
+ - SQL keywords validated to prevent dangerous operations
329
+
330
+ ### Managing Functions
331
+
332
+ #### Enable/Disable
333
+ Click the dropdown menu on a function card and select **Enable** or **Disable**. Disabled functions return 404.
334
+
335
+ #### Edit
336
+ Click **Edit** in the dropdown to modify the function code, method, or timeout.
337
+
338
+ #### Delete
339
+ Click **Delete** in the dropdown. This cannot be undone.
340
+
341
+ #### Copy URL
342
+ Click **Copy URL** below the function card to copy the public endpoint URL.
343
+
344
+ ---
345
+
346
+ ## Server Functions (Helpers)
347
+
348
+ Server functions are reusable JavaScript helpers that can be called from your edge functions via the `server` object. They enable code reuse across multiple edge functions.
349
+
350
+ ### Creating a Server Function
351
+
352
+ 1. Go to **Server Settings > Helpers**
353
+ 2. Click **New Helper**
354
+ 3. Configure:
355
+ - **Name**: Valid JavaScript identifier (camelCase or snake_case, e.g., `validateAuth`, `format_price`)
356
+ - **Description**: Optional description
357
+ - **Code**: JavaScript function body
358
+ 4. Click **Create Function**
359
+
360
+ ### How Server Functions Work
361
+
362
+ Server functions receive arguments via an `args` array and have access to `db`, `fetch`, and `console`. They return a value that is passed back to the calling edge function.
363
+
364
+ ```javascript
365
+ // Server function "validateAuth"
366
+ const [apiKey] = args;
367
+ if (!apiKey) {
368
+ return { valid: false, error: 'No API key provided' };
369
+ }
370
+
371
+ const users = db.query('SELECT * FROM users WHERE api_key = ?', [apiKey]);
372
+ if (users.length === 0) {
373
+ return { valid: false, error: 'Invalid API key' };
374
+ }
375
+
376
+ return { valid: true, user: users[0] };
377
+ ```
378
+
379
+ ### Calling from Edge Functions
380
+
381
+ Server functions are available on the `server` object. Pass arguments as regular function parameters:
382
+
383
+ ```javascript
384
+ // Edge function code
385
+ const auth = server.validateAuth(request.headers['x-api-key']);
386
+ if (!auth.valid) {
387
+ Response.error(auth.error, 401);
388
+ return;
389
+ }
390
+
391
+ // User is authenticated
392
+ const products = db.query(
393
+ 'SELECT * FROM products WHERE user_id = ?',
394
+ [auth.user.id]
395
+ );
396
+ Response.json({ products });
397
+ ```
398
+
399
+ ### Available APIs in Server Functions
400
+
401
+ | API | Description |
402
+ |-----|-------------|
403
+ | `args` | Array of arguments passed from edge function |
404
+ | `db.query()` | Execute SELECT query |
405
+ | `db.run()` | Execute INSERT/UPDATE/DELETE |
406
+ | `db.all()` | Alias for query |
407
+ | `fetch()` | Make external HTTP requests |
408
+ | `console.log()` | Log messages (visible in function logs) |
409
+
410
+ ### Example Server Functions
411
+
412
+ #### Validate API Key
413
+ ```javascript
414
+ // Name: validateAuth
415
+ const [apiKey] = args;
416
+ if (!apiKey) return { valid: false };
417
+
418
+ const users = db.query('SELECT id, name, role FROM users WHERE api_key = ?', [apiKey]);
419
+ return users.length > 0 ? { valid: true, user: users[0] } : { valid: false };
420
+ ```
421
+
422
+ #### Format Price
423
+ ```javascript
424
+ // Name: formatPrice
425
+ const [amount, currency = 'USD'] = args;
426
+ const symbols = { USD: '$', EUR: '€', GBP: '£', JPY: '¥' };
427
+ const symbol = symbols[currency] || currency + ' ';
428
+ return symbol + amount.toFixed(2);
429
+ ```
430
+
431
+ #### Get User by ID
432
+ ```javascript
433
+ // Name: getUserById
434
+ const [id] = args;
435
+ if (!id) return null;
436
+
437
+ const users = db.query('SELECT * FROM users WHERE id = ?', [id]);
438
+ return users.length > 0 ? users[0] : null;
439
+ ```
440
+
441
+ #### Check Permission
442
+ ```javascript
443
+ // Name: hasPermission
444
+ const [userId, permission] = args;
445
+ if (!userId || !permission) return false;
446
+
447
+ const perms = db.query(
448
+ 'SELECT 1 FROM user_permissions WHERE user_id = ? AND permission = ?',
449
+ [userId, permission]
450
+ );
451
+ return perms.length > 0;
452
+ ```
453
+
454
+ ### Security Notes
455
+
456
+ - Server functions run in the same QuickJS WASM context as the parent edge function
457
+ - They share the total execution timeout (not additive)
458
+ - Recursive calls are possible but limited by timeout
459
+ - The `server_functions` table is protected and cannot be queried
460
+ - Only enabled server functions are available to edge functions
461
+
462
+ ### Managing Server Functions
463
+
464
+ - **Enable/Disable**: Toggle from the dropdown menu. Disabled functions are not available to edge functions.
465
+ - **Edit**: Click Edit to modify the code or description
466
+ - **Delete**: Click Delete to remove (cannot be undone)
467
+
468
+ ---
469
+
470
+ ## Secrets
471
+
472
+ Secrets provide secure, encrypted storage for sensitive values like API keys, tokens, and passwords. Edge functions can access secrets via the `secrets` object without exposing the actual values in your code.
473
+
474
+ ### Prerequisites
475
+
476
+ Before using secrets, you must set the `SECRETS_ENCRYPTION_KEY` environment variable:
477
+
478
+ ```bash
479
+ # Generate a secure 256-bit key
480
+ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
481
+
482
+ # Add to your environment
483
+ export SECRETS_ENCRYPTION_KEY="your-generated-key-here"
484
+ ```
485
+
486
+ ### Creating a Secret
487
+
488
+ 1. Go to **Server Settings > Secrets**
489
+ 2. Click **New Secret**
490
+ 3. Configure:
491
+ - **Name**: SCREAMING_SNAKE_CASE (e.g., `STRIPE_API_KEY`, `SENDGRID_TOKEN`)
492
+ - **Value**: The secret value (will be encrypted)
493
+ - **Description**: Optional description
494
+ 4. Click **Create Secret**
495
+
496
+ ### Using Secrets in Edge Functions
497
+
498
+ The `secrets` object is available in all edge functions:
499
+
500
+ ```javascript
501
+ // Get a secret value
502
+ const apiKey = secrets.get('STRIPE_API_KEY');
503
+
504
+ // Check if a secret exists
505
+ if (secrets.has('STRIPE_API_KEY')) {
506
+ // Use the secret
507
+ }
508
+
509
+ // List all available secret names (not values)
510
+ const names = secrets.list(); // ['STRIPE_API_KEY', 'SENDGRID_TOKEN', ...]
511
+ ```
512
+
513
+ ### Example: Stripe API Integration
514
+
515
+ ```javascript
516
+ // POST /api/deployments/{deploymentId}/functions/create-charge
517
+ const stripeKey = secrets.get('STRIPE_API_KEY');
518
+ if (!stripeKey) {
519
+ Response.error('Stripe not configured', 500);
520
+ return;
521
+ }
522
+
523
+ const { amount, currency, source } = request.body;
524
+ if (!amount || !source) {
525
+ Response.error('Amount and source are required', 400);
526
+ return;
527
+ }
528
+
529
+ const res = await fetch('https://api.stripe.com/v1/charges', {
530
+ method: 'POST',
531
+ headers: {
532
+ 'Authorization': `Bearer ${stripeKey}`,
533
+ 'Content-Type': 'application/x-www-form-urlencoded',
534
+ },
535
+ body: new URLSearchParams({
536
+ amount: String(amount),
537
+ currency: currency || 'usd',
538
+ source,
539
+ }),
540
+ });
541
+
542
+ const charge = await res.json();
543
+ Response.json({ charge });
544
+ ```
545
+
546
+ ### Secrets API Reference
547
+
548
+ | Method | Description |
549
+ |--------|-------------|
550
+ | `secrets.get(name)` | Get secret value, or `null` if not found |
551
+ | `secrets.has(name)` | Check if secret exists (returns boolean) |
552
+ | `secrets.list()` | Get array of all secret names (not values) |
553
+
554
+ ### Security Notes
555
+
556
+ - **Encryption**: Secrets are encrypted using AES-256-GCM with unique IVs per secret
557
+ - **Never logged**: Secret values are never written to logs or exposed in API responses
558
+ - **Admin-only**: Only authenticated admins can create, view (metadata only), or delete secrets
559
+ - **Protected table**: The `secrets` table cannot be queried directly from edge functions
560
+ - **Key management**: The master encryption key must be stored securely as an environment variable
561
+
562
+ ### Managing Secrets
563
+
564
+ - **Edit**: Click Edit in the dropdown to update the value or description (name cannot be changed)
565
+ - **Delete**: Click Delete to permanently remove (cannot be undone)
566
+ - **No value display**: Secret values are never displayed after creation for security
567
+
568
+ ---
569
+
570
+ ## Scheduled Functions (Cron Jobs)
571
+
572
+ Scheduled functions run edge functions automatically on a cron schedule. Use them for periodic tasks like database cleanup, report generation, cache warming, or external API syncing.
573
+
574
+ ### Creating a Scheduled Function
575
+
576
+ 1. Go to **Server Settings > Schedules**
577
+ 2. Click **New Schedule**
578
+ 3. Configure:
579
+ - **Name**: Lowercase letters, numbers, and hyphens (e.g., `daily-cleanup`)
580
+ - **Edge Function**: Select which edge function to invoke
581
+ - **Cron Expression**: Standard 5-field cron syntax (e.g., `0 8 * * *`)
582
+ - **Timezone**: IANA timezone (default: `UTC`)
583
+ - **Description**: Optional description
584
+ - **Config**: Optional JSON object passed as the request body
585
+ 4. Click **Create Schedule**
586
+
587
+ ### How It Works
588
+
589
+ When a scheduled function fires:
590
+ 1. The cron scheduler triggers at the specified time
591
+ 2. The linked edge function is invoked with the `config` object as `request.body`
592
+ 3. The execution result (success/error) and duration are recorded
593
+ 4. The next run time is calculated from the cron expression
594
+
595
+ The edge function runs in the same QuickJS sandbox as HTTP-triggered invocations, with full access to `db`, `fetch`, `secrets`, `server`, and `console`.
596
+
597
+ ### Cron Expression Reference
598
+
599
+ Cron expressions use 5 fields: `minute hour day-of-month month day-of-week`
600
+
601
+ **Minimum interval: 5 minutes.** Expressions that resolve to intervals shorter than 5 minutes will be rejected.
602
+
603
+ | Expression | Description |
604
+ |------------|-------------|
605
+ | `*/5 * * * *` | Every 5 minutes |
606
+ | `0 * * * *` | Every hour (at minute 0) |
607
+ | `0 8 * * *` | Daily at 8:00 AM |
608
+ | `0 0 * * *` | Daily at midnight |
609
+ | `30 9 * * 1-5` | Weekdays at 9:30 AM |
610
+ | `0 0 * * 1` | Every Monday at midnight |
611
+ | `0 0 1 * *` | First of every month at midnight |
612
+ | `0 0 1 1 *` | January 1st at midnight (yearly) |
613
+
614
+ **Field ranges:**
615
+ - Minute: 0-59
616
+ - Hour: 0-23
617
+ - Day of month: 1-31
618
+ - Month: 1-12
619
+ - Day of week: 0-7 (0 and 7 = Sunday)
620
+
621
+ ### Example Scheduled Functions
622
+
623
+ #### Daily Database Cleanup
624
+ Clean up old records every day at 3:00 AM UTC:
625
+
626
+ - **Edge Function** (`cleanup`):
627
+ ```javascript
628
+ const daysToKeep = request.body.daysToKeep || 30;
629
+ const cutoff = new Date(Date.now() - daysToKeep * 86400000).toISOString();
630
+
631
+ const result = db.run('DELETE FROM logs WHERE created_at < ?', [cutoff]);
632
+ Response.json({ deleted: result.changes, cutoff });
633
+ ```
634
+
635
+ - **Schedule config**:
636
+ - Cron: `0 3 * * *`
637
+ - Config: `{ "daysToKeep": 30 }`
638
+
639
+ #### Hourly Stats Aggregation
640
+ Aggregate analytics data every hour:
641
+
642
+ - **Edge Function** (`aggregate-stats`):
643
+ ```javascript
644
+ const hourAgo = new Date(Date.now() - 3600000).toISOString();
645
+ const stats = db.query('SELECT COUNT(*) as views FROM pageviews WHERE timestamp > ?', [hourAgo]);
646
+
647
+ db.run('INSERT INTO hourly_stats (hour, views) VALUES (?, ?)',
648
+ [new Date().toISOString().slice(0, 13), stats[0].views]);
649
+
650
+ Response.json({ aggregated: true, views: stats[0].views });
651
+ ```
652
+
653
+ - **Schedule config**:
654
+ - Cron: `0 * * * *`
655
+ - Config: `{}`
656
+
657
+ #### Weekly Report Email
658
+ Send a weekly summary every Monday at 9:00 AM:
659
+
660
+ - **Edge Function** (`send-weekly-report`):
661
+ ```javascript
662
+ const apiKey = secrets.get('SENDGRID_KEY');
663
+ if (!apiKey) { Response.error('Email not configured', 500); return; }
664
+
665
+ const stats = db.query('SELECT COUNT(*) as total FROM orders WHERE created_at > datetime("now", "-7 days")');
666
+
667
+ const res = await fetch('https://api.sendgrid.com/v3/mail/send', {
668
+ method: 'POST',
669
+ headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
670
+ body: JSON.stringify({
671
+ personalizations: [{ to: [{ email: request.body.recipient }] }],
672
+ from: { email: 'reports@example.com' },
673
+ subject: 'Weekly Report',
674
+ content: [{ type: 'text/plain', value: 'Orders this week: ' + stats[0].total }]
675
+ })
676
+ });
677
+
678
+ Response.json({ sent: res.ok });
679
+ ```
680
+
681
+ - **Schedule config**:
682
+ - Cron: `0 9 * * 1`
683
+ - Timezone: `America/New_York`
684
+ - Config: `{ "recipient": "admin@example.com" }`
685
+
686
+ ### Managing Scheduled Functions
687
+
688
+ - **Enable/Disable**: Toggle from the dropdown menu. Disabled schedules won't fire.
689
+ - **Edit**: Click Edit to modify the cron expression, linked function, timezone, or config.
690
+ - **Delete**: Click Delete to remove (cannot be undone).
691
+ - **Status tracking**: Each schedule card shows the next run time, last run status (success/error), and last run time.
692
+
693
+ ---
694
+
695
+ ## SQL Editor
696
+
697
+ The SQL Editor allows direct SQL query execution against your deployment's database.
698
+
699
+ ### Executing Queries
700
+
701
+ 1. Go to **Server Settings > SQL**
702
+ 2. Type your SQL query in the editor
703
+ 3. Click **Execute** or press `Ctrl/Cmd + Enter`
704
+ 4. View results in the table below
705
+
706
+ ### Query History
707
+
708
+ The editor maintains a history of your last 20 queries (stored in browser localStorage).
709
+
710
+ Click **History** to view and re-run previous queries.
711
+
712
+ ### Supported Operations
713
+
714
+ ```sql
715
+ -- SELECT queries
716
+ SELECT * FROM products WHERE price > 100;
717
+ SELECT COUNT(*) FROM orders;
718
+
719
+ -- INSERT
720
+ INSERT INTO products (name, price) VALUES ('Widget', 29.99);
721
+
722
+ -- UPDATE
723
+ UPDATE products SET price = 24.99 WHERE id = 1;
724
+
725
+ -- DELETE
726
+ DELETE FROM products WHERE discontinued = 1;
727
+
728
+ -- CREATE TABLE
729
+ CREATE TABLE products (
730
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
731
+ name TEXT NOT NULL,
732
+ price REAL DEFAULT 0,
733
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
734
+ );
735
+
736
+ -- ALTER TABLE
737
+ ALTER TABLE products ADD COLUMN stock INTEGER DEFAULT 0;
738
+
739
+ -- DROP TABLE (use with caution!)
740
+ DROP TABLE old_products;
741
+ ```
742
+
743
+ ### Query Safety
744
+
745
+ - System tables are accessible (read-only for some operations)
746
+ - All queries are executed with full permissions - use caution!
747
+ - No automatic transaction management - consider wrapping related operations
748
+
749
+ ---
750
+
751
+ ## Schema Viewer
752
+
753
+ The Schema Viewer displays your database structure.
754
+
755
+ ### Viewing Tables
756
+
757
+ 1. Go to **Server Settings > Schema**
758
+ 2. Click on a table name to expand and view columns
759
+ 3. Each column shows: name, type, nullable, default value, primary key status
760
+
761
+ ### System Tables
762
+
763
+ Toggle **Show System Tables** to view OSW Studio's internal tables:
764
+ - `site_info` - Deployment metadata
765
+ - `files` - File contents
766
+ - `file_tree_nodes` - File tree structure
767
+ - `pageviews` / `interactions` / `sessions` - Analytics data
768
+ - `edge_functions` - Function definitions
769
+ - `function_logs` - Execution logs
770
+
771
+ System tables are marked with a "(system)" label.
772
+
773
+ ---
774
+
775
+ ## Execution Logs
776
+
777
+ View function execution history in the **Logs** tab.
778
+
779
+ ### Log Information
780
+
781
+ Each log entry shows:
782
+ - **Status**: Success (2xx), redirect (3xx), or error (4xx/5xx)
783
+ - **Function**: Function name
784
+ - **Method**: HTTP method used
785
+ - **Path**: Request path
786
+ - **Duration**: Execution time in milliseconds
787
+ - **Time**: Timestamp
788
+
789
+ ### Managing Logs
790
+
791
+ - Click **Refresh** to load latest logs
792
+ - Click **Clear** to delete all logs (cannot be undone)
793
+ - Logs are limited to the most recent 200 entries
794
+
795
+ ---
796
+
797
+ ## Best Practices
798
+
799
+ ### Function Design
800
+
801
+ 1. **Keep functions focused** - One function per operation
802
+ 2. **Validate input** - Check `request.body` and `request.query` before use
803
+ 3. **Handle errors** - Return appropriate HTTP status codes
804
+ 4. **Use meaningful names** - `create-order` not `func1`
805
+
806
+ ### Database Usage
807
+
808
+ 1. **Use parameterized queries** - Prevents SQL injection
809
+ ```javascript
810
+ // Good
811
+ db.query('SELECT * FROM users WHERE id = ?', [userId]);
812
+
813
+ // Bad - SQL injection risk!
814
+ db.query(`SELECT * FROM users WHERE id = ${userId}`);
815
+ ```
816
+
817
+ 2. **Create indexes** for frequently queried columns:
818
+ ```sql
819
+ CREATE INDEX idx_orders_user_id ON orders(user_id);
820
+ ```
821
+
822
+ 3. **Limit result sets** to avoid memory issues:
823
+ ```javascript
824
+ db.query('SELECT * FROM products LIMIT 100');
825
+ ```
826
+
827
+ ### Performance
828
+
829
+ 1. **Set appropriate timeouts** - Don't use 30s if 5s is sufficient
830
+ 2. **Minimize external requests** - Each `fetch()` adds latency
831
+ 3. **Cache when possible** - Store API responses in your database
832
+
833
+ ---
834
+
835
+ ## Troubleshooting
836
+
837
+ ### Function Returns 404
838
+ - Check that the function is **enabled**
839
+ - Verify the function name in the URL is correct
840
+ - Ensure the deployment is published and has database enabled
841
+
842
+ ### Function Returns 500
843
+ - Check the **Logs** tab for error details
844
+ - Verify your SQL queries are valid
845
+ - Ensure external APIs are responding
846
+
847
+ ### Query Execution Failed
848
+ - Check SQL syntax
849
+ - Verify table and column names
850
+ - Look for constraint violations (unique, foreign key)
851
+
852
+ ### Cannot Access Table
853
+ - System tables are protected in edge functions
854
+ - Use the SQL Editor for system table access
855
+
856
+ ---
857
+
858
+ ## API Reference
859
+
860
+ ### Public Endpoints
861
+
862
+ | Method | URL | Description |
863
+ |--------|-----|-------------|
864
+ | * | `/api/deployments/{deploymentId}/functions/{name}` | Invoke edge function |
865
+ | * | `/api/deployments/{deploymentId}/functions/{name}/*` | Invoke with path params |
866
+
867
+ ### Admin Endpoints (Requires Authentication)
868
+
869
+ | Method | URL | Description |
870
+ |--------|-----|-------------|
871
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/functions` | List edge functions |
872
+ | POST | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/functions` | Create edge function |
873
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/functions/{id}` | Get edge function |
874
+ | PUT | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/functions/{id}` | Update edge function |
875
+ | DELETE | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/functions/{id}` | Delete edge function |
876
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/server-functions` | List server functions |
877
+ | POST | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/server-functions` | Create server function |
878
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/server-functions/{id}` | Get server function |
879
+ | PUT | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/server-functions/{id}` | Update server function |
880
+ | DELETE | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/server-functions/{id}` | Delete server function |
881
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/scheduled-functions` | List scheduled functions |
882
+ | POST | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/scheduled-functions` | Create scheduled function |
883
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/scheduled-functions/{id}` | Get scheduled function |
884
+ | PUT | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/scheduled-functions/{id}` | Update scheduled function |
885
+ | DELETE | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/scheduled-functions/{id}` | Delete scheduled function |
886
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/secrets` | List secrets (metadata only) |
887
+ | POST | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/secrets` | Create secret |
888
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/secrets/{id}` | Get secret (metadata only) |
889
+ | PUT | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/secrets/{id}` | Update secret |
890
+ | DELETE | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/secrets/{id}` | Delete secret |
891
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/database/schema` | Get schema |
892
+ | POST | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/database/query` | Execute SQL |
893
+ | GET | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/database/logs` | Get logs |
894
+ | DELETE | `/api/w/{workspaceId}/admin/deployments/{deploymentId}/database/logs` | Clear logs |
895
+
896
+ ---
897
+
898
+ ## AI Integration
899
+
900
+ OSW Studio's AI assistant can understand and work with your backend features when you select a deployment in the workspace.
901
+
902
+ ### How It Works
903
+
904
+ 1. **Project Backend Context** - When the project has Backend Enabled, its edge functions, helpers, secrets, schedules, and schema are mounted automatically in `/.server/`
905
+ 2. **Optional Deployment Overlay** - Selecting a deployment in the workspace header additionally layers in that deployment's runtime state (published functions, live schema, etc.)
906
+ 3. **AI Awareness** - The AI receives information about available:
907
+ - Edge functions (endpoints, methods)
908
+ - Database schema (tables, columns)
909
+ - Server functions (helpers)
910
+ - Scheduled functions (cron schedules)
911
+ - Secrets (names only, not values)
912
+
913
+ ### The `/.server/` Folder
914
+
915
+ When a deployment is selected, a hidden `/.server/` folder appears in the file explorer containing:
916
+
917
+ | Folder | Contents |
918
+ |--------|----------|
919
+ | `edge-functions/*.json` | Edge function endpoints |
920
+ | `server-functions/*.json` | Helper functions |
921
+ | `scheduled-functions/*.json` | Cron schedules |
922
+ | `secrets/*.json` | Secret names (not values) |
923
+ | `db/schema.sql` | Database schema |
924
+
925
+ These files reflect the project's backend feature state:
926
+ - **Schema** (`db/schema.sql`) is read-only — edit schema by running SQL in Project Settings or via the AI's `sqlite3` shell command
927
+ - **Functions, helpers, secrets, and schedules** can be created and edited by the AI using shell commands. Changes update the corresponding records in the project's database
928
+
929
+ Files are transient in the sense that they're regenerated from the project's backend state — you don't commit them manually.
930
+
931
+ ### Using AI with Backend Features
932
+
933
+ **Example prompts:**
934
+
935
+ ```
936
+ What tables are in the database?
937
+ ```
938
+
939
+ ```
940
+ Create an edge function to list all products
941
+ ```
942
+
943
+ ```
944
+ I need an endpoint that validates API keys using the STRIPE_KEY secret
945
+ ```
946
+
947
+ ```
948
+ Create a scheduled function to clean up old records every night
949
+ ```
950
+
951
+ ```
952
+ Help me design a schema for a blog with posts and comments
953
+ ```
954
+
955
+ The AI can:
956
+ - Read and explain your current schema
957
+ - Suggest edge function implementations
958
+ - Reference available secrets by name
959
+ - Help design database structures
960
+ - Debug function issues
961
+
962
+ ### Viewing Hidden Files
963
+
964
+ To see the `/.server/` folder:
965
+ 1. Right-click in the File Explorer
966
+ 2. Select **Show Hidden Files**
967
+ 3. Look for the folder with the orange server icon
968
+
969
+ See also: **[Server Mode > Server Context Integration](?doc=server-mode#server-context-integration)**
docs/CUSTOM_DOMAINS.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Custom Domains
2
+
3
+ ## Setup (2 steps)
4
+
5
+ 1. In OSW Studio, go to Deployment Settings and enter your domain (e.g., `sweetcandies.com`)
6
+ 2. In your DNS provider, add an A record pointing to your OSW Studio server's IP address
7
+
8
+ That's it. The server handles SSL certificates automatically via Let's Encrypt.
9
+
10
+ ## How It Works
11
+
12
+ When you publish a deployment with a custom domain:
13
+
14
+ 1. OSW Studio registers the domain in its routing table
15
+ 2. The reverse proxy (Caddy) intercepts requests to your domain
16
+ 3. Caddy provisions an SSL certificate automatically (on-demand TLS)
17
+ 4. Requests are routed to your deployment's static files
18
+ 5. `/api/` paths (edge functions, analytics) pass through without rewriting
19
+
20
+ ## What OSW Studio Handles
21
+
22
+ - Domain registration in the routing table
23
+ - Root-relative asset paths when a custom domain is set
24
+ - SEO meta tags, sitemaps, and canonical URLs using your domain
25
+ - Analytics origin validation for your domain
26
+
27
+ ## What OSW Studio Does Not Handle
28
+
29
+ - DNS configuration (you add the A record)
30
+ - SSL certificates (Caddy handles this automatically)
31
+ - Domain purchase or registration
32
+
33
+ ## For Self-Hosted Instances
34
+
35
+ Your OSW Studio instance needs a reverse proxy with TLS support. See `docs/SELF_HOSTED_DOMAINS.md` for API endpoints and proxy requirements.
36
+
37
+ ## For Hosted Instances (oswstudio.com)
38
+
39
+ Custom domains are handled automatically by the platform. Just add your DNS A record.
40
+
41
+ ## Troubleshooting
42
+
43
+ **Site not loading after adding DNS**
44
+ - DNS propagation can take up to 48 hours (usually minutes)
45
+ - Verify your A record: `dig sweetcandies.com` should show the server IP
46
+ - The first request may take a few seconds while the SSL certificate is provisioned
47
+
48
+ **SSL certificate not working**
49
+ - Ensure your DNS A record points directly to the server IP (not through a proxy)
50
+ - Caddy needs port 80 and 443 open for Let's Encrypt HTTP-01 challenges
51
+
52
+ **Assets loading from wrong path**
53
+ - Republish your deployment after setting the custom domain
54
+ - The asset path rewriting only takes effect on publish