From d657f4125f1b4e8a9586c4ad37c6f9bff12f980e Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 16 Jan 2026 10:49:22 +0900 Subject: [PATCH] bringing back ui components --- .vscode/settings.json | 40 + apps/editor/app/globals.css | 132 +++- apps/editor/app/page.module.css | 186 ----- apps/editor/components/editor.tsx | 5 +- .../ui/action-menu/control-modes.tsx | 129 ++++ .../components/ui/action-menu/index.tsx | 32 + .../ui/action-menu/phase-switcher.tsx | 90 +++ .../components/ui/primitives/button.tsx | 58 ++ apps/editor/components/ui/primitives/card.tsx | 75 ++ .../components/ui/primitives/context-menu.tsx | 224 ++++++ .../components/ui/primitives/dialog.tsx | 129 ++++ .../ui/primitives/dropdown-menu.tsx | 228 ++++++ .../ui/primitives/error-boundary.tsx | 53 ++ .../editor/components/ui/primitives/input.tsx | 21 + .../ui/primitives/opacity-control.tsx | 79 ++ .../components/ui/primitives/popover.tsx | 42 ++ .../components/ui/primitives/separator.tsx | 28 + .../editor/components/ui/primitives/sheet.tsx | 130 ++++ .../components/ui/primitives/sidebar.tsx | 692 ++++++++++++++++++ .../components/ui/primitives/skeleton.tsx | 13 + .../components/ui/primitives/slider.tsx | 58 ++ .../components/ui/primitives/switch.tsx | 30 + .../components/ui/primitives/tooltip.tsx | 57 ++ apps/editor/lib/utils.ts | 6 + apps/editor/package.json | 7 + apps/editor/store/use-editor.tsx | 55 ++ apps/editor/tsconfig.json | 9 +- bun.lock | 71 ++ 28 files changed, 2487 insertions(+), 192 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 apps/editor/app/page.module.css create mode 100644 apps/editor/components/ui/action-menu/control-modes.tsx create mode 100644 apps/editor/components/ui/action-menu/index.tsx create mode 100644 apps/editor/components/ui/action-menu/phase-switcher.tsx create mode 100644 apps/editor/components/ui/primitives/button.tsx create mode 100644 apps/editor/components/ui/primitives/card.tsx create mode 100644 apps/editor/components/ui/primitives/context-menu.tsx create mode 100644 apps/editor/components/ui/primitives/dialog.tsx create mode 100644 apps/editor/components/ui/primitives/dropdown-menu.tsx create mode 100644 apps/editor/components/ui/primitives/error-boundary.tsx create mode 100644 apps/editor/components/ui/primitives/input.tsx create mode 100644 apps/editor/components/ui/primitives/opacity-control.tsx create mode 100644 apps/editor/components/ui/primitives/popover.tsx create mode 100644 apps/editor/components/ui/primitives/separator.tsx create mode 100644 apps/editor/components/ui/primitives/sheet.tsx create mode 100644 apps/editor/components/ui/primitives/sidebar.tsx create mode 100644 apps/editor/components/ui/primitives/skeleton.tsx create mode 100644 apps/editor/components/ui/primitives/slider.tsx create mode 100644 apps/editor/components/ui/primitives/switch.tsx create mode 100644 apps/editor/components/ui/primitives/tooltip.tsx create mode 100644 apps/editor/lib/utils.ts create mode 100644 apps/editor/store/use-editor.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1630fab4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "biome.configPath": "./biome.jsonc", + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "emmet.showExpandedAbbreviation": "never", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit", + "source.organizeImports": "never" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnPaste": false + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "css.lint.unknownAtRules": "ignore", + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "evenBetterToml.formatter.allowedBlankLines": 4 +} diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index d4b50785..8a387107 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -1 +1,131 @@ -@import 'tailwindcss'; +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(0.998 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(0.998 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(0.998 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +.no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} diff --git a/apps/editor/app/page.module.css b/apps/editor/app/page.module.css deleted file mode 100644 index 6108b60b..00000000 --- a/apps/editor/app/page.module.css +++ /dev/null @@ -1,186 +0,0 @@ -.page { - --gray-rgb: 0, 0, 0; - --gray-alpha-200: rgba(var(--gray-rgb), 0.08); - --gray-alpha-100: rgba(var(--gray-rgb), 0.05); - - --button-primary-hover: #383838; - --button-secondary-hover: #f2f2f2; - - display: grid; - grid-template-rows: 20px 1fr 20px; - align-items: center; - justify-items: center; - min-height: 100svh; - padding: 80px; - gap: 64px; - font-synthesis: none; -} - -@media (prefers-color-scheme: dark) { - .page { - --gray-rgb: 255, 255, 255; - --gray-alpha-200: rgba(var(--gray-rgb), 0.145); - --gray-alpha-100: rgba(var(--gray-rgb), 0.06); - - --button-primary-hover: #ccc; - --button-secondary-hover: #1a1a1a; - } -} - -.main { - display: flex; - flex-direction: column; - gap: 32px; - grid-row-start: 2; -} - -.main ol { - font-family: var(--font-geist-mono); - padding-left: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.01em; - list-style-position: inside; -} - -.main li:not(:last-of-type) { - margin-bottom: 8px; -} - -.main code { - font-family: inherit; - background: var(--gray-alpha-100); - padding: 2px 4px; - border-radius: 4px; - font-weight: 600; -} - -.ctas { - display: flex; - gap: 16px; -} - -.ctas a { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; -} - -a.primary { - background: var(--foreground); - color: var(--background); - gap: 8px; -} - -a.secondary { - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -button.secondary { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - font-family: var(--font-geist-sans); - border: 1px solid transparent; - transition: background 0.2s, color 0.2s, border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; - background: transparent; - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -.footer { - font-family: var(--font-geist-sans); - grid-row-start: 3; - display: flex; - gap: 24px; -} - -.footer a { - display: flex; - align-items: center; - gap: 8px; -} - -.footer img { - flex-shrink: 0; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - a.primary:hover { - background: var(--button-primary-hover); - border-color: transparent; - } - - a.secondary:hover { - background: var(--button-secondary-hover); - border-color: transparent; - } - - .footer a:hover { - text-decoration: underline; - text-underline-offset: 4px; - } -} - -@media (max-width: 600px) { - .page { - padding: 32px; - padding-bottom: 80px; - } - - .main { - align-items: center; - } - - .main ol { - text-align: center; - } - - .ctas { - flex-direction: column; - } - - .ctas a { - font-size: 14px; - height: 40px; - padding: 0 16px; - } - - a.secondary { - min-width: auto; - } - - .footer { - flex-wrap: wrap; - align-items: center; - justify-content: center; - } -} - -@media (prefers-color-scheme: dark) { - .logo { - filter: invert(); - } -} diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx index 0365f853..efc76076 100644 --- a/apps/editor/components/editor.tsx +++ b/apps/editor/components/editor.tsx @@ -26,13 +26,16 @@ import { uniform, } from "three/tsl"; import { MeshBasicNodeMaterial, PostProcessing } from "three/webgpu"; +import { ActionMenu } from "./ui/action-menu"; const selectedObjects: Object3D[] = []; export default function Editor() { return (
- + {/* */} + + diff --git a/apps/editor/components/ui/action-menu/control-modes.tsx b/apps/editor/components/ui/action-menu/control-modes.tsx new file mode 100644 index 00000000..c4d09097 --- /dev/null +++ b/apps/editor/components/ui/action-menu/control-modes.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { Button } from "@/components/ui/primitives/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/primitives/tooltip"; +import { Hammer, MousePointer2, Pencil, Trash2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import useEditor, { Mode, Phase } from "@/store/use-editor"; + +type ModeConfig = { + id: Mode; + icon: typeof MousePointer2; + label: string; + shortcut: string; + color: string; + activeColor: string; +}; + +// All available control modes +const allModes: ModeConfig[] = [ + { + id: "select", + icon: MousePointer2, + label: "Select", + shortcut: "V", + color: "hover:bg-blue-500/20 hover:text-blue-400", + activeColor: "bg-blue-500/20 text-blue-400", + }, + { + id: "edit", + icon: Pencil, + label: "Edit", + shortcut: "E", + color: "hover:bg-orange-500/20 hover:text-orange-400", + activeColor: "bg-orange-500/20 text-orange-400", + }, + { + id: "build", + icon: Hammer, + label: "Build", + shortcut: "B", + color: "hover:bg-green-500/20 hover:text-green-400", + activeColor: "bg-green-500/20 text-green-400", + }, + { + id: "delete", + icon: Trash2, + label: "Delete", + shortcut: "D", + color: "hover:bg-red-500/20 hover:text-red-400", + activeColor: "bg-red-500/20 text-red-400", + }, + // { + // id: 'painting', + // icon: Paintbrush, + // label: 'Painting', + // shortcut: 'P', + // color: 'hover:bg-cyan-500/20 hover:text-cyan-400', + // activeColor: 'bg-cyan-500/20 text-cyan-400', + // }, + // { + // id: 'guide', + // icon: Image, + // label: 'Guide', + // shortcut: 'G', + // color: 'hover:bg-purple-500/20 hover:text-purple-400', + // activeColor: 'bg-purple-500/20 text-purple-400', + // }, +]; + +// Define which modes are available in each editor mode +const modesByPhase: Record = { + site: ["select", "edit"], + structure: ["select", "delete", "build"], + furnish: ["select", "delete", "build"], +}; + +export function ControlModes() { + const mode = useEditor((state) => state.mode); + const phase = useEditor((state) => state.phase); + const setMode = useEditor((state) => state.setMode); + + const availableModeIds = modesByPhase[phase]; + const availableModes = allModes.filter((m) => + availableModeIds.includes(m.id) + ); + + const handleModeClick = (mode: Mode) => { + setMode(mode); + }; + + return ( +
+ {availableModes.map((m) => { + const Icon = m.icon; + const isActive = mode === m.id; + + return ( + + + + + +

+ {m.label} ({m.shortcut}) +

+
+
+ ); + })} +
+ ); +} diff --git a/apps/editor/components/ui/action-menu/index.tsx b/apps/editor/components/ui/action-menu/index.tsx new file mode 100644 index 00000000..25084f5b --- /dev/null +++ b/apps/editor/components/ui/action-menu/index.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { TooltipProvider } from "@/components/ui/primitives/tooltip"; +import { cn } from "@/lib/utils"; + +import { ControlModes } from "./control-modes"; +import { PhaseSwitcher } from "./phase-switcher"; +// import { ViewToggles } from "./view-toggles"; + +export function ActionMenu({ className }: { className?: string }) { + return ( + +
+ {/* Control Mode Row - Always visible, centered */} +
+ +
+ + {/*
*/} + {/* */} +
+
+ + ); +} diff --git a/apps/editor/components/ui/action-menu/phase-switcher.tsx b/apps/editor/components/ui/action-menu/phase-switcher.tsx new file mode 100644 index 00000000..3065f26e --- /dev/null +++ b/apps/editor/components/ui/action-menu/phase-switcher.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Button } from "@/components/ui/primitives/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/primitives/tooltip"; +import { Building2, Map, Sofa } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import useEditor, { Phase } from "@/store/use-editor"; + +const editorModes: Array<{ + id: Phase; + icon: typeof Map; + label: string; + shortcut: string; + description: string; + color: string; + activeColor: string; +}> = [ + { + id: "site", + icon: Map, + label: "Site", + shortcut: "1", + description: "Edit terrain, place buildings, set boundaries", + color: "hover:bg-emerald-500/20 hover:text-emerald-400", + activeColor: "bg-emerald-500/20 text-emerald-400", + }, + { + id: "structure", + icon: Building2, + label: "Structure", + shortcut: "2", + description: "Walls, rooms, doors, windows, ...", + color: "hover:bg-blue-500/20 hover:text-blue-400", + activeColor: "bg-blue-500/20 text-blue-400", + }, + { + id: "furnish", + icon: Sofa, + label: "Furnish", + shortcut: "3", + description: "Place furniture, appliances, decorations", + color: "hover:bg-amber-500/20 hover:text-amber-400", + activeColor: "bg-amber-500/20 text-amber-400", + }, +]; + +export function PhaseSwitcher() { + const phase = useEditor((state) => state.phase); + const setPhase = useEditor((state) => state.setPhase); + + return ( +
+ {editorModes.map((mode) => { + const Icon = mode.icon; + const isActive = phase === mode.id; + + return ( + + + + + +

+ {mode.label} ({mode.shortcut}) +

+

{mode.description}

+
+
+ ); + })} +
+ ); +} diff --git a/apps/editor/components/ui/primitives/button.tsx b/apps/editor/components/ui/primitives/button.tsx new file mode 100644 index 00000000..afe62811 --- /dev/null +++ b/apps/editor/components/ui/primitives/button.tsx @@ -0,0 +1,58 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'button' + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/apps/editor/components/ui/primitives/card.tsx b/apps/editor/components/ui/primitives/card.tsx new file mode 100644 index 00000000..f536d5db --- /dev/null +++ b/apps/editor/components/ui/primitives/card.tsx @@ -0,0 +1,75 @@ +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/apps/editor/components/ui/primitives/context-menu.tsx b/apps/editor/components/ui/primitives/context-menu.tsx new file mode 100644 index 00000000..b5f079e1 --- /dev/null +++ b/apps/editor/components/ui/primitives/context-menu.tsx @@ -0,0 +1,224 @@ +'use client' + +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu' +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function ContextMenu({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/apps/editor/components/ui/primitives/dialog.tsx b/apps/editor/components/ui/primitives/dialog.tsx new file mode 100644 index 00000000..af0d5f93 --- /dev/null +++ b/apps/editor/components/ui/primitives/dialog.tsx @@ -0,0 +1,129 @@ +'use client' + +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Dialog({ ...props }: React.ComponentProps) { + return +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return +} + +function DialogClose({ ...props }: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/apps/editor/components/ui/primitives/dropdown-menu.tsx b/apps/editor/components/ui/primitives/dropdown-menu.tsx new file mode 100644 index 00000000..fbb2b0bf --- /dev/null +++ b/apps/editor/components/ui/primitives/dropdown-menu.tsx @@ -0,0 +1,228 @@ +'use client' + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function DropdownMenu({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/apps/editor/components/ui/primitives/error-boundary.tsx b/apps/editor/components/ui/primitives/error-boundary.tsx new file mode 100644 index 00000000..063d4a1b --- /dev/null +++ b/apps/editor/components/ui/primitives/error-boundary.tsx @@ -0,0 +1,53 @@ +'use client' + +import React, { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children?: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + } + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo) + } + + public render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + return ( +
+

Something went wrong

+
+            {this.state.error?.message}
+          
+ +
+ ) + } + + return this.props.children + } +} + diff --git a/apps/editor/components/ui/primitives/input.tsx b/apps/editor/components/ui/primitives/input.tsx new file mode 100644 index 00000000..cbec9c8a --- /dev/null +++ b/apps/editor/components/ui/primitives/input.tsx @@ -0,0 +1,21 @@ +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ) +} + +export { Input } diff --git a/apps/editor/components/ui/primitives/opacity-control.tsx b/apps/editor/components/ui/primitives/opacity-control.tsx new file mode 100644 index 00000000..bbb2b722 --- /dev/null +++ b/apps/editor/components/ui/primitives/opacity-control.tsx @@ -0,0 +1,79 @@ +'use client' + +import { Eye, EyeOff } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Slider } from '@/components/ui/slider' +import { cn } from '@/lib/utils' + +interface OpacityControlProps { + visible?: boolean + opacity?: number + onVisibilityToggle: () => void + onOpacityChange: (opacity: number) => void + className?: string +} + +export function OpacityControl({ + visible = true, + opacity = 100, + onVisibilityToggle, + onOpacityChange, + className, +}: OpacityControlProps) { + const [isOpen, setIsOpen] = useState(false) + const actualOpacity = opacity ?? 100 + const isHidden = visible === false || actualOpacity === 0 + + return ( + +
+ {!isHidden && actualOpacity < 100 && ( + {actualOpacity}% + )} + + + + e.stopPropagation()} + side="right" + > +
+
+ Opacity + {actualOpacity}% +
+ { + onOpacityChange(value) + }} + step={1} + value={[actualOpacity]} + /> +
+
+
+
+ ) +} diff --git a/apps/editor/components/ui/primitives/popover.tsx b/apps/editor/components/ui/primitives/popover.tsx new file mode 100644 index 00000000..73690aee --- /dev/null +++ b/apps/editor/components/ui/primitives/popover.tsx @@ -0,0 +1,42 @@ +'use client' + +import * as PopoverPrimitive from '@radix-ui/react-popover' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Popover({ ...props }: React.ComponentProps) { + return +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/apps/editor/components/ui/primitives/separator.tsx b/apps/editor/components/ui/primitives/separator.tsx new file mode 100644 index 00000000..595bc0f0 --- /dev/null +++ b/apps/editor/components/ui/primitives/separator.tsx @@ -0,0 +1,28 @@ +'use client' + +import * as SeparatorPrimitive from '@radix-ui/react-separator' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/apps/editor/components/ui/primitives/sheet.tsx b/apps/editor/components/ui/primitives/sheet.tsx new file mode 100644 index 00000000..26d6bb82 --- /dev/null +++ b/apps/editor/components/ui/primitives/sheet.tsx @@ -0,0 +1,130 @@ +'use client' + +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return +} + +function SheetClose({ ...props }: React.ComponentProps) { + return +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = 'right', + ...props +}: React.ComponentProps & { + side?: 'top' | 'right' | 'bottom' | 'left' +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/apps/editor/components/ui/primitives/sidebar.tsx b/apps/editor/components/ui/primitives/sidebar.tsx new file mode 100644 index 00000000..93137957 --- /dev/null +++ b/apps/editor/components/ui/primitives/sidebar.tsx @@ -0,0 +1,692 @@ +'use client' + +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { PanelLeftIcon } from 'lucide-react' +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { Skeleton } from '@/components/ui/skeleton' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useIsMobile } from '@/hooks/use-mobile' +import { cn } from '@/lib/utils' + +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '18rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +type SidebarContextProps = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open], + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback( + () => (isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)), + [isMobile, setOpen], + ) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +function Sidebar({ + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props +}: React.ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar() + + return ( +