Upload folder using huggingface_hub (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- components.json +21 -0
- components/ui/discord-icon.tsx +18 -0
- components/ui/dropdown-menu.tsx +257 -0
- components/ui/input.tsx +21 -0
- components/ui/label.tsx +26 -0
- components/ui/logo.tsx +30 -0
- components/ui/pagination.tsx +145 -0
- components/ui/panel.tsx +90 -0
- components/ui/popover.tsx +50 -0
- components/ui/resizable.tsx +45 -0
- components/ui/select.tsx +185 -0
- components/ui/sonner.tsx +26 -0
- components/ui/spinner.tsx +34 -0
- components/ui/switch.tsx +31 -0
- components/ui/tabs.tsx +55 -0
- components/ui/textarea.tsx +22 -0
- components/ui/thumbnail-area.tsx +156 -0
- components/ui/toggle-group.tsx +58 -0
- components/ui/tooltip.tsx +65 -0
- components/views/content-area.tsx +83 -0
- components/views/dashboard-view.tsx +849 -0
- components/views/deployments-view.tsx +790 -0
- components/views/docs-view.tsx +282 -0
- components/views/interviews-view.tsx +11 -0
- components/views/projects-view.tsx +24 -0
- components/views/settings-view.tsx +33 -0
- components/views/skills-view.tsx +7 -0
- components/views/templates-view.tsx +24 -0
- components/views/users-view.tsx +632 -0
- components/views/workspaces-view.tsx +678 -0
- components/workspace-switcher/index.tsx +130 -0
- components/workspace/deployment-selector.tsx +135 -0
- components/workspace/index.tsx +2257 -0
- components/workspace/skills-panel.tsx +237 -0
- desktop/.gitignore +3 -0
- desktop/README.md +59 -0
- desktop/assemble-app.sh +40 -0
- desktop/electron-builder.yml +64 -0
- desktop/electron/__tests__/migrate-legacy.test.ts +74 -0
- desktop/electron/icons/icon.icns +3 -0
- desktop/electron/icons/icon.png +0 -0
- desktop/electron/main.ts +548 -0
- desktop/electron/migrate-legacy.ts +27 -0
- desktop/package-lock.json +0 -0
- desktop/package.json +32 -0
- desktop/tsconfig.json +14 -0
- desktop/tsup.config.ts +14 -0
- docs/BACKEND_FEATURES.md +969 -0
- 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'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 "New User" 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 "New Workspace" 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, '&')
|
| 185 |
+
.replace(/</g, '<')
|
| 186 |
+
.replace(/>/g, '>')
|
| 187 |
+
.replace(/"/g, '"');
|
| 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
|