basuicn 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ui-cli.cjs +281 -29
- package/package.json +1 -1
- package/registry.json +89 -5
- package/scripts/build-registry.ts +1 -1
- package/scripts/ui-cli.ts +334 -28
package/registry.json
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"path": "src/styles/index.css",
|
|
21
|
-
"content": "@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n\r\n/*\r\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\r\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\r\n*/\r\n::view-transition-old(root),\r\n::view-transition-new(root) {\r\n animation: none;\r\n mix-blend-mode: normal;\r\n}\r\n\r\n::view-transition-old(root) {\r\n z-index: 1;\r\n}\r\n\r\n::view-transition-new(root) {\r\n z-index: 9999;\r\n}\r\n\r\n\r\n@theme {\r\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\r\n\r\n\r\n\r\n --color-background: var(--background);\r\n --color-foreground: var(--foreground);\r\n\r\n --color-primary: var(--primary);\r\n --color-primary-foreground: var(--primary-foreground);\r\n\r\n --color-secondary: var(--secondary);\r\n --color-secondary-foreground: var(--secondary-foreground);\r\n\r\n --color-muted: var(--muted);\r\n --color-muted-foreground: var(--muted-foreground);\r\n\r\n --color-accent: var(--accent);\r\n --color-accent-foreground: var(--accent-foreground);\r\n\r\n --color-switch-background: var(--switch-background);\r\n\r\n --color-border: var(--border);\r\n\r\n --color-success: var(--success);\r\n --color-success-foreground: var(--success-foreground);\r\n\r\n --color-warning: var(--warning);\r\n --color-warning-foreground: var(--warning-foreground);\r\n\r\n --color-destructive: var(--danger);\r\n --color-destructive-foreground: var(--danger-foreground);\r\n\r\n --color-danger: var(--danger);\r\n --color-danger-foreground: var(--danger-foreground);\r\n\r\n --color-ring: var(--ring);\r\n --color-input: var(--input);\r\n\r\n --color-chart-1: var(--chart-1);\r\n --color-chart-2: var(--chart-2);\r\n --color-chart-3: var(--chart-3);\r\n --color-chart-4: var(--chart-4);\r\n --color-chart-5: var(--chart-5);\r\n\r\n --color-popover: var(--popover);\r\n --color-popover-foreground: var(--popover-foreground);\r\n\r\n --color-sidebar: var(--sidebar);\r\n --color-sidebar-foreground: var(--sidebar-foreground);\r\n --color-sidebar-border: var(--sidebar-border);\r\n --color-sidebar-accent: var(--sidebar-accent);\r\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\r\n --color-sidebar-ring: var(--sidebar-ring);\r\n\r\n --radius-sm: 0.125rem;\r\n --radius-md: 0.375rem;\r\n --radius-lg: 0.5rem;\r\n --radius-xl: 1rem;\r\n\r\n /* Z-index scale */\r\n --z-dropdown: 50;\r\n --z-sticky: 100;\r\n --z-overlay: 200;\r\n --z-modal: 300;\r\n --z-popover: 400;\r\n --z-toast: 500;\r\n\r\n --animate-spin-slow: spin 3s linear infinite;\r\n --animate-progress-stripes: progress-stripes 1s linear infinite;\r\n\r\n @keyframes progress-stripes {\r\n from { background-position: 1rem 0; }\r\n to { background-position: 0 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\r\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\r\n --background: #ffffff;\r\n --foreground: #0f172a;\r\n --primary: #2f27ce;\r\n --primary-foreground: #ffffff;\r\n --secondary: #dedcff;\r\n --secondary-foreground: #2f27ce;\r\n --muted: #f8fafc;\r\n --muted-foreground: #64748b;\r\n --accent: #f1f5f9;\r\n --accent-foreground: #0f172a;\r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n --destructive: #ef4444;\r\n --destructive-foreground: #ffffff;\r\n --border: #e2e8f0;\r\n --input: #e2e8f0;\r\n --ring: #2f27ce;\r\n --popover: #ffffff;\r\n --popover-foreground: #0f172a;\r\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #cbd5e1;\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #2f27ce;\r\n --chart-3: #10b981;\r\n --chart-4: #f59e0b;\r\n --chart-5: #8b5cf6;\r\n\r\n --sidebar: #f8fafc;\r\n --sidebar-foreground: #0f172a;\r\n --sidebar-border: #e2e8f0;\r\n --sidebar-accent: #f1f5f9;\r\n --sidebar-accent-foreground: #2f27ce;\r\n --sidebar-ring: #2f27ce;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme - Deep Space Slate (Chuyên nghiệp & Hiện đại) */\r\n --background: #09090b; /* Very deep zinc/slate */\r\n --foreground: #f8fafc;\r\n \r\n --primary: #6366f1; /* Bright Indigo for pop */\r\n --primary-foreground: #ffffff;\r\n \r\n --secondary: #1e293b; /* Slate 800 */\r\n --secondary-foreground: #f8fafc;\r\n \r\n --muted: #0f172a; /* Slate 900 for subdued bg */\r\n --muted-foreground: #94a3b8; /* Slate 400 for text */\r\n \r\n --accent: #1e293b;\r\n --accent-foreground: #f8fafc;\r\n \r\n --switch-background: #334155;\r\n --border: #334155; /* Slate 700 */\r\n \r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n \r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n \r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #6366f1;\r\n --input: #334155;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #6366f1;\r\n --chart-3: #34d399;\r\n --chart-4: #fbbf24;\r\n --chart-5: #818cf8;\r\n\r\n --popover: #0f172a;\r\n --popover-foreground: #f8fafc;\r\n\r\n --sidebar: #0f172a;\r\n --sidebar-foreground: #f8fafc;\r\n --sidebar-border: #334155;\r\n --sidebar-accent: #1e293b;\r\n --sidebar-accent-foreground: #818cf8;\r\n --sidebar-ring: #6366f1;\r\n }\r\n}\r\n\r\n\r\n@layer base {\r\n * {\r\n border-color: var(--border);\r\n }\r\n\r\n html, body {\r\n margin: 0;\r\n padding: 0;\r\n background-color: var(--background);\r\n color: var(--foreground);\r\n font-family: 'Inter', system-ui, sans-serif;\r\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\r\n height: 100%;\r\n color-scheme: light;\r\n transition: background-color 0.3s ease, color 0.3s ease;\r\n }\r\n\r\n html.dark {\r\n color-scheme: dark;\r\n }\r\n\r\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\r\n body[style*=\"overflow: hidden\"],\r\n body[data-scroll-locked] {\r\n padding-right: 0 !important;\r\n margin-right: 0 !important;\r\n }\r\n\r\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\r\n [data-base-ui-dialog-backdrop],\r\n .base-ui-backdrop,\r\n [role=\"presentation\"] > div[style*=\"fixed\"] {\r\n width: 100vw !important;\r\n height: 100vh !important;\r\n left: 0 !important;\r\n top: 0 !important;\r\n right: 0 !important;\r\n bottom: 0 !important;\r\n }\r\n\r\n /* Custom Scrollbar - Sleek & Modern */\r\n ::-webkit-scrollbar {\r\n width: 8px;\r\n height: 8px;\r\n }\r\n\r\n ::-webkit-scrollbar-track {\r\n background: transparent;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb {\r\n background: #cbd5e1; /* slate-300 */\r\n border-radius: 10px;\r\n border: 2px solid transparent;\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb {\r\n background: #334155; /* slate-700 */\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #94a3b8; /* slate-400 */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #475569; /* slate-600 */\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #cbd5e1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #334155 transparent;\r\n }\r\n}\r\n\r\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\r\n Respect prefers-reduced-motion for a11y.\r\n Disables all animations and transitions globally when the user's OS\r\n requests reduced motion. Individual components can opt-out via\r\n motion-reduce:* utilities if an animation is essential.\r\n*/\r\n@media (prefers-reduced-motion: reduce) {\r\n *, *::before, *::after {\r\n animation-duration: 0.01ms !important;\r\n animation-iteration-count: 1 !important;\r\n transition-duration: 0.01ms !important;\r\n scroll-behavior: auto !important;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
|
|
21
|
+
"content": "@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n\r\n/*\r\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\r\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\r\n*/\r\n::view-transition-old(root),\r\n::view-transition-new(root) {\r\n animation: none;\r\n mix-blend-mode: normal;\r\n}\r\n\r\n::view-transition-old(root) {\r\n z-index: 1;\r\n}\r\n\r\n::view-transition-new(root) {\r\n z-index: 9999;\r\n}\r\n\r\n\r\n@theme {\r\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\r\n\r\n\r\n\r\n --color-background: var(--background);\r\n --color-foreground: var(--foreground);\r\n\r\n --color-primary: var(--primary);\r\n --color-primary-foreground: var(--primary-foreground);\r\n\r\n --color-secondary: var(--secondary);\r\n --color-secondary-foreground: var(--secondary-foreground);\r\n\r\n --color-muted: var(--muted);\r\n --color-muted-foreground: var(--muted-foreground);\r\n\r\n --color-accent: var(--accent);\r\n --color-accent-foreground: var(--accent-foreground);\r\n\r\n --color-switch-background: var(--switch-background);\r\n\r\n --color-border: var(--border);\r\n\r\n --color-success: var(--success);\r\n --color-success-foreground: var(--success-foreground);\r\n\r\n --color-warning: var(--warning);\r\n --color-warning-foreground: var(--warning-foreground);\r\n\r\n --color-destructive: var(--danger);\r\n --color-destructive-foreground: var(--danger-foreground);\r\n\r\n --color-danger: var(--danger);\r\n --color-danger-foreground: var(--danger-foreground);\r\n\r\n --color-ring: var(--ring);\r\n --color-input: var(--input);\r\n\r\n --color-chart-1: var(--chart-1);\r\n --color-chart-2: var(--chart-2);\r\n --color-chart-3: var(--chart-3);\r\n --color-chart-4: var(--chart-4);\r\n --color-chart-5: var(--chart-5);\r\n\r\n --color-popover: var(--popover);\r\n --color-popover-foreground: var(--popover-foreground);\r\n\r\n --color-sidebar: var(--sidebar);\r\n --color-sidebar-foreground: var(--sidebar-foreground);\r\n --color-sidebar-border: var(--sidebar-border);\r\n --color-sidebar-accent: var(--sidebar-accent);\r\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\r\n --color-sidebar-ring: var(--sidebar-ring);\r\n\r\n --radius-sm: 0.125rem;\r\n --radius-md: 0.375rem;\r\n --radius-lg: 0.5rem;\r\n --radius-xl: 1rem;\r\n\r\n /* Z-index scale */\r\n --z-dropdown: 50;\r\n --z-sticky: 100;\r\n --z-overlay: 200;\r\n --z-modal: 300;\r\n --z-popover: 400;\r\n --z-toast: 500;\r\n\r\n --animate-spin-slow: spin 3s linear infinite;\r\n --animate-progress-stripes: progress-stripes 1s linear infinite;\r\n\r\n @keyframes progress-stripes {\r\n from { background-position: 1rem 0; }\r\n to { background-position: 0 0; }\r\n }\r\n\r\n --animate-blink: blink 1s step-end infinite;\r\n @keyframes blink {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\r\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\r\n --background: #ffffff;\r\n --foreground: #0f172a;\r\n --primary: #2f27ce;\r\n --primary-foreground: #ffffff;\r\n --secondary: #dedcff;\r\n --secondary-foreground: #2f27ce;\r\n --muted: #f8fafc;\r\n --muted-foreground: #64748b;\r\n --accent: #f1f5f9;\r\n --accent-foreground: #0f172a;\r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n --destructive: #ef4444;\r\n --destructive-foreground: #ffffff;\r\n --border: #e2e8f0;\r\n --input: #e2e8f0;\r\n --ring: #2f27ce;\r\n --popover: #ffffff;\r\n --popover-foreground: #0f172a;\r\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #cbd5e1;\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #2f27ce;\r\n --chart-3: #10b981;\r\n --chart-4: #f59e0b;\r\n --chart-5: #8b5cf6;\r\n\r\n --sidebar: #f8fafc;\r\n --sidebar-foreground: #0f172a;\r\n --sidebar-border: #e2e8f0;\r\n --sidebar-accent: #f1f5f9;\r\n --sidebar-accent-foreground: #2f27ce;\r\n --sidebar-ring: #2f27ce;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme - Deep Space Slate (Chuyên nghiệp & Hiện đại) */\r\n --background: #09090b; /* Very deep zinc/slate */\r\n --foreground: #f8fafc;\r\n \r\n --primary: #6366f1; /* Bright Indigo for pop */\r\n --primary-foreground: #ffffff;\r\n \r\n --secondary: #1e293b; /* Slate 800 */\r\n --secondary-foreground: #f8fafc;\r\n \r\n --muted: #0f172a; /* Slate 900 for subdued bg */\r\n --muted-foreground: #94a3b8; /* Slate 400 for text */\r\n \r\n --accent: #1e293b;\r\n --accent-foreground: #f8fafc;\r\n \r\n --switch-background: #334155;\r\n --border: #334155; /* Slate 700 */\r\n \r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n \r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n \r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #6366f1;\r\n --input: #334155;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #6366f1;\r\n --chart-3: #34d399;\r\n --chart-4: #fbbf24;\r\n --chart-5: #818cf8;\r\n\r\n --popover: #0f172a;\r\n --popover-foreground: #f8fafc;\r\n\r\n --sidebar: #0f172a;\r\n --sidebar-foreground: #f8fafc;\r\n --sidebar-border: #334155;\r\n --sidebar-accent: #1e293b;\r\n --sidebar-accent-foreground: #818cf8;\r\n --sidebar-ring: #6366f1;\r\n }\r\n}\r\n\r\n\r\n@layer base {\r\n * {\r\n border-color: var(--border);\r\n }\r\n\r\n html, body {\r\n margin: 0;\r\n padding: 0;\r\n background-color: var(--background);\r\n color: var(--foreground);\r\n font-family: 'Inter', system-ui, sans-serif;\r\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\r\n height: 100%;\r\n color-scheme: light;\r\n transition: background-color 0.3s ease, color 0.3s ease;\r\n }\r\n\r\n html.dark {\r\n color-scheme: dark;\r\n }\r\n\r\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\r\n body[style*=\"overflow: hidden\"],\r\n body[data-scroll-locked] {\r\n padding-right: 0 !important;\r\n margin-right: 0 !important;\r\n }\r\n\r\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\r\n [data-base-ui-dialog-backdrop],\r\n .base-ui-backdrop,\r\n [role=\"presentation\"] > div[style*=\"fixed\"] {\r\n width: 100vw !important;\r\n height: 100vh !important;\r\n left: 0 !important;\r\n top: 0 !important;\r\n right: 0 !important;\r\n bottom: 0 !important;\r\n }\r\n\r\n /* Custom Scrollbar - Sleek & Modern */\r\n ::-webkit-scrollbar {\r\n width: 8px;\r\n height: 8px;\r\n }\r\n\r\n ::-webkit-scrollbar-track {\r\n background: transparent;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb {\r\n background: #cbd5e1; /* slate-300 */\r\n border-radius: 10px;\r\n border: 2px solid transparent;\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb {\r\n background: #334155; /* slate-700 */\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #94a3b8; /* slate-400 */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #475569; /* slate-600 */\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #cbd5e1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #334155 transparent;\r\n }\r\n}\r\n\r\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\r\n Respect prefers-reduced-motion for a11y.\r\n Disables all animations and transitions globally when the user's OS\r\n requests reduced motion. Individual components can opt-out via\r\n motion-reduce:* utilities if an animation is essential.\r\n*/\r\n@media (prefers-reduced-motion: reduce) {\r\n *, *::before, *::after {\r\n animation-duration: 0.01ms !important;\r\n animation-iteration-count: 1 !important;\r\n transition-duration: 0.01ms !important;\r\n scroll-behavior: auto !important;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"path": "src/lib/theme/themes.ts",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"files": [
|
|
98
98
|
{
|
|
99
99
|
"path": "src/components/ui/autocomplete/Autocomplete.tsx",
|
|
100
|
-
"content": "
|
|
100
|
+
"content": "\"use client\"\r\nimport * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-md border border-border bg-background text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(newVal);\r\n if (newVal !== null) onValueChange?.(newVal);\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n // Khi base-ui cập nhật input sau khi chọn item, bỏ qua để tránh nháy popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\r\n // Chỉ mở popup khi người dùng đang gõ\r\n setOpen(val.length > 0);\r\n };\r\n\r\n // Block mọi lần mở từ focus/click — chỉ cho phép đóng từ bên ngoài (click-outside, select)\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (!newOpen) setOpen(false);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(null);\r\n setInputValue('');\r\n setOpen(false);\r\n };\r\n\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue) return options;\r\n if (activeValue) {\r\n const selected = options.find(o => o.value === activeValue);\r\n if (selected && inputValue === selected.label) return options;\r\n }\r\n return options.filter(o =>\r\n o.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n onInputValueChange={handleInputValueChange}\r\n open={open}\r\n onOpenChange={handleOpenChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n\r\n {isLoading ? (\r\n <Loader2 className=\"absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground\" />\r\n ) : activeValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Input\r\n ref={ref}\r\n placeholder={placeholder}\r\n className={cn(input(), (isLoading || activeValue) && 'pr-8')}\r\n />\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)' }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nAutocomplete.displayName = 'Autocomplete';\r\n\r\nexport { Autocomplete };\r\n"
|
|
101
101
|
}
|
|
102
102
|
]
|
|
103
103
|
},
|
|
@@ -214,6 +214,33 @@
|
|
|
214
214
|
}
|
|
215
215
|
]
|
|
216
216
|
},
|
|
217
|
+
"code-sandbox": {
|
|
218
|
+
"name": "code-sandbox",
|
|
219
|
+
"dependencies": [
|
|
220
|
+
"@codesandbox/sandpack-react",
|
|
221
|
+
"lucide-react",
|
|
222
|
+
"react-resizable-panels"
|
|
223
|
+
],
|
|
224
|
+
"internalDependencies": [],
|
|
225
|
+
"files": [
|
|
226
|
+
{
|
|
227
|
+
"path": "src/components/ui/code-sandbox/CodeSandbox.tsx",
|
|
228
|
+
"content": "import React, { useState, useEffect } from 'react';\nimport { SandpackProvider } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\nimport { SandboxLayout } from './SandboxLayout';\nimport { SANDBOX_TEMPLATES } from './templates';\nimport type { SandboxTemplate } from './templates';\n\n// ─── Dark mode detection ─────────────────────────────────────\n\nfunction useIsDark() {\n const [isDark, setIsDark] = useState(\n () => document.documentElement.classList.contains('dark'),\n );\n\n useEffect(() => {\n const observer = new MutationObserver(() => {\n setIsDark(document.documentElement.classList.contains('dark'));\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n });\n return () => observer.disconnect();\n }, []);\n\n return isDark;\n}\n\n// ─── Sandpack themes ─────────────────────────────────────────\n\nconst lightTheme = {\n colors: {\n surface1: '#ffffff',\n surface2: '#f8fafc',\n surface3: '#f1f5f9',\n clickable: '#64748b',\n base: '#0f172a',\n disabled: '#94a3b8',\n hover: '#0f172a',\n accent: '#2f27ce',\n error: '#ef4444',\n errorSurface: '#fef2f2',\n },\n syntax: {\n plain: '#0f172a',\n comment: { color: '#94a3b8', fontStyle: 'italic' as const },\n keyword: '#7c3aed',\n tag: '#2563eb',\n punctuation: '#64748b',\n definition: '#059669',\n property: '#0891b2',\n static: '#c2410c',\n string: '#16a34a',\n },\n font: {\n body: 'Inter, system-ui, sans-serif',\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\n size: '13px',\n lineHeight: '20px',\n },\n};\n\nconst darkTheme = {\n colors: {\n surface1: '#0f172a',\n surface2: '#1e293b',\n surface3: '#334155',\n clickable: '#94a3b8',\n base: '#e2e8f0',\n disabled: '#475569',\n hover: '#f8fafc',\n accent: '#6366f1',\n error: '#ef4444',\n errorSurface: '#450a0a',\n },\n syntax: {\n plain: '#e2e8f0',\n comment: { color: '#64748b', fontStyle: 'italic' as const },\n keyword: '#c084fc',\n tag: '#60a5fa',\n punctuation: '#94a3b8',\n definition: '#34d399',\n property: '#22d3ee',\n static: '#fb923c',\n string: '#4ade80',\n },\n font: {\n body: 'Inter, system-ui, sans-serif',\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\n size: '13px',\n lineHeight: '20px',\n },\n};\n\n// ─── Props ───────────────────────────────────────────────────\n\nexport interface CodeSandboxProps {\n /** Starting template id */\n defaultTemplate?: string;\n /** Custom initial files (overrides template) */\n files?: Record<string, string>;\n /** Extra dependencies */\n dependencies?: Record<string, string>;\n /** Container className */\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────\n\nexport function CodeSandbox({\n defaultTemplate = 'react-ts',\n files: customFiles,\n dependencies: extraDeps,\n className,\n}: CodeSandboxProps) {\n const isDark = useIsDark();\n const [templateId, setTemplateId] = useState(defaultTemplate);\n\n const template: SandboxTemplate =\n SANDBOX_TEMPLATES.find((t) => t.id === templateId) ??\n SANDBOX_TEMPLATES[0];\n\n const files = customFiles ?? template.files;\n const deps = {\n ...template.dependencies,\n ...extraDeps,\n };\n\n return (\n <div className={cn('h-full w-full', className)}>\n <SandpackProvider\n key={templateId}\n template={template.template}\n theme={isDark ? darkTheme : lightTheme}\n files={files}\n customSetup={{\n dependencies: {\n react: '^18.0.0',\n 'react-dom': '^18.0.0',\n 'react-scripts': '^5.0.0',\n ...deps,\n },\n }}\n options={{\n recompileMode: 'delayed',\n recompileDelay: 400,\n classes: {\n 'sp-wrapper': 'h-full w-full',\n },\n }}\n >\n <SandboxLayout\n templateId={templateId}\n onTemplateChange={setTemplateId}\n className=\"h-full w-full\"\n />\n </SandpackProvider>\n </div>\n );\n}\n"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"path": "src/components/ui/code-sandbox/FileTree.tsx",
|
|
232
|
+
"content": "import React, { useState, useMemo } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport {\n ChevronRight,\n ChevronDown,\n FilePlus,\n FolderPlus,\n Trash2,\n Folder,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Tree data structure ─────────────────────────────────────\n\ninterface TreeNode {\n name: string;\n path: string;\n type: 'file' | 'folder';\n children?: TreeNode[];\n}\n\nfunction buildFileTree(paths: string[]): TreeNode[] {\n const root: TreeNode[] = [];\n\n for (const filePath of paths) {\n const parts = filePath.split('/').filter(Boolean);\n let level = root;\n let currentPath = '';\n\n for (let i = 0; i < parts.length; i++) {\n currentPath += '/' + parts[i];\n const isLast = i === parts.length - 1;\n\n if (isLast) {\n level.push({ name: parts[i], path: currentPath, type: 'file' });\n } else {\n let folder = level.find(\n (n) => n.type === 'folder' && n.name === parts[i],\n );\n if (!folder) {\n folder = {\n name: parts[i],\n path: currentPath,\n type: 'folder',\n children: [],\n };\n level.push(folder);\n }\n level = folder.children!;\n }\n }\n }\n\n function sortNodes(nodes: TreeNode[]): TreeNode[] {\n return nodes\n .sort((a, b) => {\n if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;\n return a.name.localeCompare(b.name);\n })\n .map((n) => {\n if (n.children) n.children = sortNodes(n.children);\n return n;\n });\n }\n\n return sortNodes(root);\n}\n\n// ─── File icon by extension ──────────────────────────────────\n\nfunction getFileIcon(name: string): { label: string; color: string } {\n const ext = name.split('.').pop()?.toLowerCase();\n switch (ext) {\n case 'js':\n case 'mjs':\n return { label: 'JS', color: '#f7df1e' };\n case 'jsx':\n return { label: 'JSX', color: '#61dafb' };\n case 'ts':\n return { label: 'TS', color: '#3178c6' };\n case 'tsx':\n return { label: 'TSX', color: '#3178c6' };\n case 'css':\n case 'scss':\n return { label: '#', color: '#264de4' };\n case 'html':\n return { label: '<>', color: '#e34f26' };\n case 'json':\n return { label: '{ }', color: '#5b5b5b' };\n case 'md':\n return { label: 'M', color: '#083fa1' };\n case 'svg':\n return { label: 'SVG', color: '#ffb13b' };\n default:\n return { label: '\\u00B7', color: '#6b7280' };\n }\n}\n\n// ─── Single tree node ────────────────────────────────────────\n\nfunction TreeItem({\n node,\n depth,\n activeFile,\n openFile,\n deleteFile,\n expanded,\n toggleFolder,\n}: {\n node: TreeNode;\n depth: number;\n activeFile: string;\n openFile: (p: string) => void;\n deleteFile: (p: string) => void;\n expanded: Set<string>;\n toggleFolder: (p: string) => void;\n}) {\n const pl = 12 + depth * 16;\n\n if (node.type === 'folder') {\n const isOpen = expanded.has(node.path);\n return (\n <>\n <div\n className=\"flex items-center py-1 px-2 cursor-pointer hover:bg-muted/50 text-[13px] group\"\n style={{ paddingLeft: pl }}\n onClick={() => toggleFolder(node.path)}\n >\n {isOpen ? (\n <ChevronDown className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\n ) : (\n <ChevronRight className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\n )}\n <Folder className=\"w-3.5 h-3.5 mr-1.5 shrink-0 text-amber-500\" />\n <span className=\"truncate\">{node.name}</span>\n </div>\n {isOpen &&\n node.children?.map((child) => (\n <TreeItem\n key={child.path}\n node={child}\n depth={depth + 1}\n activeFile={activeFile}\n openFile={openFile}\n deleteFile={deleteFile}\n expanded={expanded}\n toggleFolder={toggleFolder}\n />\n ))}\n </>\n );\n }\n\n const icon = getFileIcon(node.name);\n const isActive = node.path === activeFile;\n\n return (\n <div\n className={cn(\n 'flex items-center py-1 px-2 cursor-pointer text-[13px] group',\n isActive\n ? 'bg-primary/10 text-primary font-medium'\n : 'hover:bg-muted/50',\n )}\n style={{ paddingLeft: pl }}\n onClick={() => openFile(node.path)}\n >\n <span\n className=\"w-5 mr-1.5 shrink-0 text-[9px] font-bold text-center leading-none\"\n style={{ color: icon.color }}\n >\n {icon.label}\n </span>\n <span className=\"truncate flex-1\">{node.name}</span>\n <button\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger shrink-0 transition-opacity\"\n onClick={(e) => {\n e.stopPropagation();\n deleteFile(node.path);\n }}\n >\n <Trash2 className=\"w-3 h-3\" />\n </button>\n </div>\n );\n}\n\n// ─── File Tree ───────────────────────────────────────────────\n\nexport function FileTree() {\n const { sandpack } = useSandpack();\n const { files, activeFile, openFile, addFile, deleteFile } = sandpack;\n\n const [expanded, setExpanded] = useState<Set<string>>(\n () =>\n new Set([\n '/',\n '/src',\n '/components',\n '/examples',\n '/public',\n ]),\n );\n const [creating, setCreating] = useState<'file' | 'folder' | null>(null);\n const [newName, setNewName] = useState('');\n\n const filePaths = Object.keys(files).filter((p) => !p.endsWith('.gitkeep'));\n const tree = useMemo(() => buildFileTree(filePaths), [filePaths]);\n\n const toggleFolder = (path: string) => {\n setExpanded((prev) => {\n const next = new Set(prev);\n if (next.has(path)) next.delete(path);\n else next.add(path);\n return next;\n });\n };\n\n const handleCreate = (e: React.FormEvent) => {\n e.preventDefault();\n if (!newName.trim()) return;\n const path = newName.startsWith('/') ? newName : '/' + newName;\n if (creating === 'file') {\n addFile(path, '');\n openFile(path);\n } else {\n addFile(`${path}/.gitkeep`, '');\n setExpanded((prev) => new Set([...prev, path]));\n }\n setNewName('');\n setCreating(null);\n };\n\n return (\n <div className=\"flex flex-col h-full text-foreground select-none\">\n {/* Header */}\n <div className=\"flex items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0 group\">\n <span>Explorer</span>\n <div className=\"flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity\">\n <button\n onClick={() => setCreating('file')}\n className=\"p-1 hover:bg-muted rounded\"\n title=\"New File\"\n >\n <FilePlus className=\"w-3.5 h-3.5\" />\n </button>\n <button\n onClick={() => setCreating('folder')}\n className=\"p-1 hover:bg-muted rounded\"\n title=\"New Folder\"\n >\n <FolderPlus className=\"w-3.5 h-3.5\" />\n </button>\n </div>\n </div>\n\n {/* Create input */}\n {creating && (\n <form onSubmit={handleCreate} className=\"px-3 pb-2\">\n <input\n autoFocus\n type=\"text\"\n placeholder={\n creating === 'file'\n ? '/src/NewFile.tsx'\n : '/src/newfolder'\n }\n className=\"w-full bg-muted border border-primary text-foreground text-[13px] px-2 py-1 rounded outline-none font-mono\"\n value={newName}\n onChange={(e) => setNewName(e.target.value)}\n onBlur={() => setCreating(null)}\n />\n </form>\n )}\n\n {/* Tree */}\n <div className=\"flex-1 overflow-y-auto\">\n {tree.map((node) => (\n <TreeItem\n key={node.path}\n node={node}\n depth={0}\n activeFile={activeFile}\n openFile={openFile}\n deleteFile={deleteFile}\n expanded={expanded}\n toggleFolder={toggleFolder}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"path": "src/components/ui/code-sandbox/SandboxLayout.tsx",
|
|
236
|
+
"content": "import React, { useState, useMemo } from 'react';\nimport {\n SandpackCodeEditor,\n SandpackPreview,\n SandpackConsole,\n useSandpack,\n} from '@codesandbox/sandpack-react';\nimport { Group, Panel, Separator } from 'react-resizable-panels';\nimport {\n Files,\n Search,\n Package,\n Settings,\n ChevronDown,\n Plus,\n X,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { FileTree } from './FileTree';\nimport { SANDBOX_TEMPLATES } from './templates';\n\n// ─── Types ───────────────────────────────────────────────────\n\ntype SidebarTab = 'explorer' | 'search' | 'dependencies';\ntype TerminalTab = 'console' | 'problems';\n\nexport interface SandboxLayoutProps {\n templateId: string;\n onTemplateChange: (id: string) => void;\n className?: string;\n}\n\n// ─── Search Panel ────────────────────────────────────────────\n\nfunction SearchPanel() {\n const { sandpack } = useSandpack();\n const [query, setQuery] = useState('');\n\n const results = useMemo(() => {\n if (!query.trim()) return [];\n const q = query.toLowerCase();\n const matches: { path: string; line: number; text: string }[] = [];\n for (const [path, file] of Object.entries(sandpack.files)) {\n file.code.split('\\n').forEach((line, i) => {\n if (line.toLowerCase().includes(q)) {\n matches.push({ path, line: i + 1, text: line.trim() });\n }\n });\n }\n return matches.slice(0, 100);\n }, [query, sandpack.files]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Search\n </div>\n <div className=\"px-3 pb-2\">\n <input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search in files...\"\n className=\"w-full bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n </div>\n <div className=\"flex-1 overflow-y-auto\">\n {results.length > 0 && (\n <div className=\"px-3 pb-1 text-[10px] text-muted-foreground\">\n {results.length} result{results.length !== 1 ? 's' : ''}\n </div>\n )}\n {results.map((r, i) => (\n <div\n key={`${r.path}:${r.line}:${i}`}\n className=\"px-3 py-1.5 cursor-pointer hover:bg-muted/50 text-xs border-b border-border/30\"\n onClick={() => sandpack.openFile(r.path)}\n >\n <div className=\"font-medium truncate\">\n {r.path.split('/').pop()}\n <span className=\"text-muted-foreground ml-1 font-normal\">\n {r.path}\n </span>\n </div>\n <div className=\"text-muted-foreground truncate font-mono\">\n <span className=\"text-primary mr-1\">{r.line}:</span>\n {r.text}\n </div>\n </div>\n ))}\n {query && results.length === 0 && (\n <div className=\"px-3 py-6 text-xs text-muted-foreground text-center\">\n No results found\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ─── Dependency Panel ────────────────────────────────────────\n\nconst POPULAR_PACKAGES = [\n 'axios',\n 'framer-motion',\n 'zustand',\n 'react-router-dom',\n 'date-fns',\n 'clsx',\n 'zod',\n 'react-hook-form',\n 'swr',\n 'lodash',\n];\n\nfunction DependencyPanel() {\n const { sandpack } = useSandpack();\n const [newDep, setNewDep] = useState('');\n\n let deps: Record<string, string> = {};\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n deps = pkg.dependencies || {};\n } catch {\n /* ignore parse error */\n }\n\n const addDep = (name: string) => {\n if (!name.trim() || deps[name]) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n pkg.dependencies = { ...pkg.dependencies, [name.trim()]: 'latest' };\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n setNewDep('');\n } catch {\n /* ignore */\n }\n };\n\n const removeDep = (name: string) => {\n if (['react', 'react-dom', 'react-scripts'].includes(name)) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n const { [name]: _, ...rest } = pkg.dependencies || {};\n pkg.dependencies = rest;\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n } catch {\n /* ignore */\n }\n };\n\n const available = POPULAR_PACKAGES.filter((p) => !deps[p]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Dependencies\n </div>\n\n {/* Add input */}\n <form\n onSubmit={(e) => {\n e.preventDefault();\n addDep(newDep);\n }}\n className=\"flex gap-1 px-3 pb-2\"\n >\n <input\n value={newDep}\n onChange={(e) => setNewDep(e.target.value)}\n placeholder=\"Package name...\"\n className=\"flex-1 min-w-0 bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n <button\n type=\"submit\"\n className=\"px-2 py-1.5 bg-primary text-primary-foreground rounded text-xs font-medium hover:bg-primary/90 transition-colors shrink-0\"\n >\n <Plus className=\"w-3.5 h-3.5\" />\n </button>\n </form>\n\n {/* Quick add */}\n {available.length > 0 && (\n <div className=\"px-3 pb-3\">\n <div className=\"text-[10px] text-muted-foreground mb-1.5\">\n Quick add:\n </div>\n <div className=\"flex flex-wrap gap-1\">\n {available.slice(0, 5).map((p) => (\n <button\n key={p}\n onClick={() => addDep(p)}\n className=\"text-[10px] px-1.5 py-0.5 bg-muted hover:bg-accent rounded border border-border transition-colors\"\n >\n + {p}\n </button>\n ))}\n </div>\n </div>\n )}\n\n {/* Installed list */}\n <div className=\"flex-1 overflow-y-auto border-t border-border\">\n <div className=\"px-3 py-1.5 text-[10px] text-muted-foreground\">\n Installed ({Object.keys(deps).length})\n </div>\n {Object.entries(deps).map(([name, version]) => (\n <div\n key={name}\n className=\"flex items-center justify-between px-3 py-1.5 hover:bg-muted/50 text-[13px] group\"\n >\n <div className=\"flex items-center gap-1.5 truncate min-w-0\">\n <Package className=\"w-3 h-3 text-muted-foreground shrink-0\" />\n <span className=\"truncate\">{name}</span>\n <span className=\"text-[10px] text-muted-foreground shrink-0\">\n {version as string}\n </span>\n </div>\n {!['react', 'react-dom', 'react-scripts'].includes(name) && (\n <button\n onClick={() => removeDep(name)}\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger transition-opacity shrink-0\"\n >\n <X className=\"w-3 h-3\" />\n </button>\n )}\n </div>\n ))}\n </div>\n </div>\n );\n}\n\n// ─── Activity Bar ────────────────────────────────────────────\n\nfunction ActivityBar({\n activeTab,\n onTabChange,\n}: {\n activeTab: SidebarTab | null;\n onTabChange: (tab: SidebarTab | null) => void;\n}) {\n const tabs: { id: SidebarTab; icon: React.ReactNode; label: string }[] = [\n {\n id: 'explorer',\n icon: <Files className=\"w-[18px] h-[18px]\" />,\n label: 'Explorer',\n },\n {\n id: 'search',\n icon: <Search className=\"w-[18px] h-[18px]\" />,\n label: 'Search',\n },\n {\n id: 'dependencies',\n icon: <Package className=\"w-[18px] h-[18px]\" />,\n label: 'Dependencies',\n },\n ];\n\n return (\n <div className=\"w-11 bg-muted/30 border-r border-border flex flex-col items-center py-1.5 shrink-0\">\n {tabs.map((tab) => (\n <button\n key={tab.id}\n onClick={() =>\n onTabChange(activeTab === tab.id ? null : tab.id)\n }\n className={cn(\n 'relative w-full h-9 flex items-center justify-center transition-colors',\n activeTab === tab.id\n ? 'text-foreground'\n : 'text-muted-foreground hover:text-foreground',\n )}\n title={tab.label}\n >\n {activeTab === tab.id && (\n <span className=\"absolute left-0 top-[20%] bottom-[20%] w-0.5 bg-primary rounded-r\" />\n )}\n {tab.icon}\n </button>\n ))}\n <div className=\"flex-1\" />\n <button\n className=\"w-full h-9 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors\"\n title=\"Settings\"\n >\n <Settings className=\"w-[18px] h-[18px]\" />\n </button>\n </div>\n );\n}\n\n// ─── Terminal Pane ───────────────────────────────────────────\n\nfunction TerminalPane() {\n const { sandpack } = useSandpack();\n const [tab, setTab] = useState<TerminalTab>('console');\n\n return (\n <div className=\"flex flex-col h-full bg-background\">\n {/* Tab bar */}\n <div className=\"h-7 shrink-0 bg-muted/30 border-t border-b border-border flex items-center px-3 gap-3 text-[10px] font-semibold tracking-wider\">\n {(['console', 'problems'] as TerminalTab[]).map((t) => (\n <button\n key={t}\n onClick={() => setTab(t)}\n className={cn(\n 'uppercase transition-colors pb-0.5 -mb-px border-b',\n tab === t\n ? 'text-primary border-primary'\n : 'text-muted-foreground border-transparent hover:text-foreground',\n )}\n >\n {t}\n {t === 'problems' && sandpack.error && (\n <span className=\"ml-1 text-danger font-bold\">1</span>\n )}\n </button>\n ))}\n </div>\n\n {/* Content */}\n <div className=\"flex-1 overflow-auto min-h-0\">\n {tab === 'console' && (\n <SandpackConsole\n standalone\n style={{ height: '100%' }}\n />\n )}\n {tab === 'problems' && (\n <div className=\"p-3 text-xs\">\n {sandpack.error ? (\n <div className=\"text-danger font-mono whitespace-pre-wrap break-words\">\n {sandpack.error.message}\n </div>\n ) : (\n <div className=\"text-muted-foreground text-center py-4\">\n No problems detected\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ─── Status Bar ──────────────────────────────────────────────\n\nfunction StatusBar() {\n const { sandpack } = useSandpack();\n const fileName = sandpack.activeFile?.split('/').pop() || '';\n const ext = fileName.split('.').pop()?.toUpperCase() || '';\n\n return (\n <div className=\"h-6 bg-primary text-primary-foreground flex items-center px-3 text-[10px] font-medium shrink-0 gap-3\">\n {/* Status */}\n <span className=\"flex items-center gap-1.5\">\n <span\n className={cn(\n 'w-1.5 h-1.5 rounded-full',\n sandpack.status === 'running'\n ? 'bg-green-400'\n : sandpack.status === 'idle'\n ? 'bg-yellow-400'\n : 'bg-white/50',\n )}\n />\n {sandpack.status === 'running'\n ? 'Running'\n : sandpack.status === 'idle'\n ? 'Ready'\n : 'Loading'}\n </span>\n\n {sandpack.error && (\n <span className=\"bg-white/20 px-1.5 py-0.5 rounded text-[9px]\">\n 1 Error\n </span>\n )}\n\n <div className=\"flex-1\" />\n\n <span className=\"opacity-75\">{fileName}</span>\n <span className=\"opacity-75\">UTF-8</span>\n {ext && <span className=\"opacity-75\">{ext}</span>}\n </div>\n );\n}\n\n// ─── Toolbar ─────────────────────────────────────────────────\n\nfunction Toolbar({\n templateId,\n onTemplateChange,\n}: {\n templateId: string;\n onTemplateChange: (id: string) => void;\n}) {\n const [open, setOpen] = useState(false);\n const current = SANDBOX_TEMPLATES.find((t) => t.id === templateId);\n\n return (\n <div className=\"h-10 bg-muted/30 border-b border-border flex items-center px-3 shrink-0 gap-3\">\n {/* Template picker */}\n <div className=\"relative\">\n <button\n onClick={() => setOpen(!open)}\n className=\"flex items-center gap-2 bg-background border border-border px-2.5 py-1.5 rounded-md hover:bg-muted transition-colors text-sm\"\n >\n <span>{current?.icon}</span>\n <span className=\"font-medium text-foreground\">\n {current?.label}\n </span>\n <ChevronDown className=\"w-3.5 h-3.5 text-muted-foreground\" />\n </button>\n\n {open && (\n <>\n <div\n className=\"fixed inset-0 z-40\"\n onClick={() => setOpen(false)}\n />\n <div className=\"absolute top-full left-0 mt-1 bg-background border border-border rounded-lg shadow-xl z-50 min-w-[260px] py-1 animate-in fade-in slide-in-from-top-2 duration-200\">\n {SANDBOX_TEMPLATES.map((t) => (\n <button\n key={t.id}\n onClick={() => {\n onTemplateChange(t.id);\n setOpen(false);\n }}\n className={cn(\n 'w-full text-left px-3 py-2.5 hover:bg-muted flex items-center gap-3 transition-colors',\n t.id === templateId && 'bg-primary/5',\n )}\n >\n <span className=\"text-lg shrink-0\">{t.icon}</span>\n <div className=\"min-w-0\">\n <div className=\"text-sm font-medium text-foreground\">\n {t.label}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {t.description}\n </div>\n </div>\n </button>\n ))}\n </div>\n </>\n )}\n </div>\n\n <div className=\"flex-1\" />\n\n {/* Project badge */}\n <div className=\"text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded hidden sm:block\">\n react-playground\n </div>\n </div>\n );\n}\n\n// ─── Resize Handle ───────────────────────────────────────────\n\nfunction ResizeHandle({ horizontal }: { horizontal?: boolean }) {\n return (\n <Separator\n className={cn(\n 'transition-colors shrink-0',\n horizontal\n ? 'h-[3px] hover:bg-primary/40 cursor-row-resize bg-border/60'\n : 'w-[3px] hover:bg-primary/40 cursor-col-resize bg-border/60',\n )}\n />\n );\n}\n\n// ─── Main Layout ─────────────────────────────────────────────\n\nexport function SandboxLayout({\n templateId,\n onTemplateChange,\n className,\n}: SandboxLayoutProps) {\n const [sidebarTab, setSidebarTab] = useState<SidebarTab | null>(\n 'explorer',\n );\n\n return (\n <div\n className={cn(\n 'flex flex-col h-full w-full bg-background text-foreground overflow-hidden',\n className,\n )}\n >\n <Toolbar\n templateId={templateId}\n onTemplateChange={onTemplateChange}\n />\n\n <div className=\"flex flex-1 min-h-0\">\n {/* Activity bar */}\n <ActivityBar activeTab={sidebarTab} onTabChange={setSidebarTab} />\n\n {/* Sidebar panel (fixed width, toggleable) */}\n {sidebarTab && (\n <div className=\"w-56 border-r border-border shrink-0 overflow-hidden bg-background\">\n {sidebarTab === 'explorer' && <FileTree />}\n {sidebarTab === 'search' && <SearchPanel />}\n {sidebarTab === 'dependencies' && <DependencyPanel />}\n </div>\n )}\n\n {/* Resizable main area: Editor + Preview */}\n <Group orientation=\"horizontal\" className=\"h-full flex-1 min-w-0\">\n {/* Editor + Terminal column */}\n <Panel defaultSize={55} minSize={25}>\n <Group orientation=\"vertical\">\n {/* Code editor */}\n <Panel defaultSize={70} minSize={20}>\n <SandpackCodeEditor\n showTabs\n showLineNumbers\n showInlineErrors\n wrapContent\n closableTabs\n style={{ height: '100%' }}\n />\n </Panel>\n\n <ResizeHandle horizontal />\n\n {/* Terminal */}\n <Panel defaultSize={30} minSize={8}>\n <TerminalPane />\n </Panel>\n </Group>\n </Panel>\n\n <ResizeHandle />\n\n {/* Preview */}\n <Panel defaultSize={45} minSize={20}>\n <SandpackPreview\n showNavigator\n showRefreshButton\n showOpenInCodeSandbox={false}\n style={{ height: '100%' }}\n />\n </Panel>\n </Group>\n </div>\n\n <StatusBar />\n </div>\n );\n}\n"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"path": "src/components/ui/code-sandbox/templates.ts",
|
|
240
|
+
"content": "export interface SandboxTemplate {\n id: string;\n label: string;\n description: string;\n icon: string;\n template: 'react' | 'react-ts';\n files: Record<string, string>;\n dependencies?: Record<string, string>;\n}\n\nexport const SANDBOX_TEMPLATES: SandboxTemplate[] = [\n {\n id: 'react',\n label: 'React',\n description: 'React with JavaScript',\n icon: '\\u269B\\uFE0F',\n template: 'react',\n files: {\n '/App.js': `import React, { useState } from 'react';\nimport './styles.css';\n\nexport default function App() {\n const [count, setCount] = useState(0);\n\n return (\n <div className=\"app\">\n <h1>React Playground</h1>\n <p className=\"count\">{count}</p>\n <div className=\"actions\">\n <button onClick={() => setCount(c => c - 1)}>-</button>\n <button onClick={() => setCount(0)}>Reset</button>\n <button onClick={() => setCount(c => c + 1)}>+</button>\n </div>\n </div>\n );\n}\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\nh1 { font-size: 24px; margin-bottom: 16px; }\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\n.actions { display: flex; gap: 8px; justify-content: center; }\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'react-ts',\n label: 'React + TypeScript',\n description: 'React with TypeScript strict mode',\n icon: '\\uD83D\\uDC8E',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React, { useState } from 'react';\nimport './styles.css';\n\ninterface AppProps {\n title?: string;\n}\n\nconst App: React.FC<AppProps> = ({ title = 'React + TypeScript' }) => {\n const [count, setCount] = useState<number>(0);\n\n return (\n <div className=\"app\">\n <h1>{title}</h1>\n <p className=\"count\">{count}</p>\n <div className=\"actions\">\n <button onClick={() => setCount(c => c - 1)}>-</button>\n <button onClick={() => setCount(0)}>Reset</button>\n <button onClick={() => setCount(c => c + 1)}>+</button>\n </div>\n </div>\n );\n};\n\nexport default App;\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\nh1 { font-size: 24px; margin-bottom: 16px; }\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\n.actions { display: flex; gap: 8px; justify-content: center; }\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'component',\n label: 'Component Workshop',\n description: 'Build & test React components',\n icon: '\\uD83E\\uDDE9',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React from 'react';\nimport { Button } from './components/Button';\nimport { Card } from './components/Card';\nimport './styles.css';\n\nexport default function App() {\n return (\n <div className=\"app\">\n <h1>Component Workshop</h1>\n <div className=\"grid\">\n <Card title=\"Button Variants\">\n <div className=\"row\">\n <Button variant=\"primary\">Primary</Button>\n <Button variant=\"secondary\">Secondary</Button>\n <Button variant=\"outline\">Outline</Button>\n </div>\n </Card>\n <Card title=\"Button Sizes\">\n <div className=\"row\">\n <Button size=\"sm\">Small</Button>\n <Button size=\"md\">Medium</Button>\n <Button size=\"lg\">Large</Button>\n </div>\n </Card>\n <Card title=\"Your Component\">\n <p style={{ color: '#64748b' }}>Create your own component in /components!</p>\n </Card>\n </div>\n </div>\n );\n}\n`,\n '/components/Button.tsx': `import React from 'react';\n\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n variant?: 'primary' | 'secondary' | 'outline';\n size?: 'sm' | 'md' | 'lg';\n}\n\nconst styles: Record<string, React.CSSProperties> = {\n primary: { background: '#3b82f6', color: 'white', border: 'none' },\n secondary: { background: '#e2e8f0', color: '#334155', border: 'none' },\n outline: { background: 'transparent', color: '#3b82f6', border: '1.5px solid #3b82f6' },\n sm: { padding: '6px 12px', fontSize: 12 },\n md: { padding: '8px 16px', fontSize: 14 },\n lg: { padding: '12px 24px', fontSize: 16 },\n};\n\nexport const Button: React.FC<ButtonProps> = ({\n children,\n variant = 'primary',\n size = 'md',\n style,\n ...props\n}) => (\n <button\n style={{\n borderRadius: 8,\n fontWeight: 500,\n cursor: 'pointer',\n transition: 'opacity 0.15s',\n ...styles[variant],\n ...styles[size],\n ...style,\n }}\n {...props}\n >\n {children}\n </button>\n);\n`,\n '/components/Card.tsx': `import React from 'react';\n\ninterface CardProps {\n title: string;\n children: React.ReactNode;\n}\n\nexport const Card: React.FC<CardProps> = ({ title, children }) => (\n <div style={{\n background: 'white',\n border: '1px solid #e2e8f0',\n borderRadius: 12,\n padding: 20,\n }}>\n <h3 style={{ fontSize: 16, marginBottom: 12, color: '#334155' }}>{title}</h3>\n {children}\n </div>\n);\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 800px; margin: 24px auto; padding: 20px; }\nh1 { font-size: 22px; margin-bottom: 20px; }\n.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }\n@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }\n.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n`,\n },\n },\n {\n id: 'hooks',\n label: 'Hooks Playground',\n description: 'Practice React Hooks patterns',\n icon: '\\uD83E\\uDE9D',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React from 'react';\nimport { Counter } from './examples/Counter';\nimport { TodoList } from './examples/TodoList';\nimport { FetchData } from './examples/FetchData';\nimport './styles.css';\n\nexport default function App() {\n return (\n <div className=\"app\">\n <h1>React Hooks Playground</h1>\n <section>\n <h2>useState + useCallback</h2>\n <Counter />\n </section>\n <section>\n <h2>useState + useRef</h2>\n <TodoList />\n </section>\n <section>\n <h2>useEffect + fetch</h2>\n <FetchData />\n </section>\n </div>\n );\n}\n`,\n '/examples/Counter.tsx': `import React, { useState, useCallback } from 'react';\n\nexport const Counter = () => {\n const [count, setCount] = useState(0);\n\n const increment = useCallback(() => setCount(c => c + 1), []);\n const decrement = useCallback(() => setCount(c => c - 1), []);\n const reset = useCallback(() => setCount(0), []);\n\n return (\n <div className=\"card\">\n <p className=\"count\">{count}</p>\n <div className=\"row center\">\n <button onClick={decrement}>-</button>\n <button onClick={reset}>Reset</button>\n <button onClick={increment}>+</button>\n </div>\n </div>\n );\n};\n`,\n '/examples/TodoList.tsx': `import React, { useState, useRef } from 'react';\n\ninterface Todo {\n id: number;\n text: string;\n done: boolean;\n}\n\nexport const TodoList = () => {\n const [todos, setTodos] = useState<Todo[]>([]);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const addTodo = () => {\n const text = inputRef.current?.value.trim();\n if (!text) return;\n setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);\n inputRef.current!.value = '';\n inputRef.current!.focus();\n };\n\n const toggle = (id: number) =>\n setTodos(prev =>\n prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))\n );\n\n return (\n <div className=\"card\">\n <div className=\"row\" style={{ marginBottom: 12 }}>\n <input\n ref={inputRef}\n placeholder=\"Add todo...\"\n onKeyDown={e => e.key === 'Enter' && addTodo()}\n style={{\n flex: 1, padding: '8px 12px', border: '1px solid #e2e8f0',\n borderRadius: 6, outline: 'none', fontSize: 14,\n }}\n />\n <button onClick={addTodo}>Add</button>\n </div>\n {todos.map(t => (\n <div\n key={t.id}\n onClick={() => toggle(t.id)}\n style={{\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\n cursor: 'pointer', textDecoration: t.done ? 'line-through' : 'none',\n color: t.done ? '#94a3b8' : 'inherit',\n }}\n >\n {t.text}\n </div>\n ))}\n {todos.length === 0 && (\n <p style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center', padding: 16 }}>\n No todos yet. Add one above!\n </p>\n )}\n </div>\n );\n};\n`,\n '/examples/FetchData.tsx': `import React, { useState, useEffect } from 'react';\n\ninterface User {\n id: number;\n name: string;\n email: string;\n}\n\nexport const FetchData = () => {\n const [users, setUsers] = useState<User[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n fetch('https://jsonplaceholder.typicode.com/users')\n .then(res => {\n if (!res.ok) throw new Error('Failed to fetch');\n return res.json();\n })\n .then(data => {\n setUsers(data.slice(0, 5));\n setLoading(false);\n })\n .catch(err => {\n setError(err.message);\n setLoading(false);\n });\n }, []);\n\n if (loading) return <div className=\"card\">Loading...</div>;\n if (error) return <div className=\"card\" style={{ color: '#ef4444' }}>Error: {error}</div>;\n\n return (\n <div className=\"card\">\n {users.map(u => (\n <div key={u.id} style={{\n display: 'flex', justifyContent: 'space-between', alignItems: 'center',\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\n }}>\n <strong style={{ fontSize: 14 }}>{u.name}</strong>\n <span style={{ fontSize: 13, color: '#64748b' }}>{u.email}</span>\n </div>\n ))}\n </div>\n );\n};\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 680px; margin: 20px auto; padding: 20px; }\nh1 { font-size: 22px; margin-bottom: 20px; }\nh2 { font-size: 12px; color: #64748b; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }\nsection { margin-bottom: 24px; }\n.card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }\n.count { font-size: 48px; font-weight: 700; text-align: center; color: #3b82f6; }\n.row { display: flex; gap: 8px; align-items: center; }\n.center { justify-content: center; margin-top: 12px; }\nbutton { padding: 8px 16px; border-radius: 6px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 14px; transition: all 0.15s; }\nbutton:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'tailwind',\n label: 'React + Tailwind',\n description: 'React with Tailwind CSS via CDN',\n icon: '\\uD83C\\uDFA8',\n template: 'react',\n files: {\n '/public/index.html': `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>React + Tailwind</title>\n <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body>\n <div id=\"root\"></div>\n</body>\n</html>`,\n '/App.js': `import React, { useState } from 'react';\n\nexport default function App() {\n const [count, setCount] = useState(0);\n\n return (\n <div className=\"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4\">\n <div className=\"bg-white rounded-2xl shadow-xl p-8 w-full max-w-md text-center\">\n <h1 className=\"text-2xl font-bold text-slate-800 mb-2\">\n React + Tailwind\n </h1>\n <p className=\"text-slate-500 mb-6\">Edit App.js to get started</p>\n\n <div className=\"text-6xl font-bold text-blue-500 mb-6\">{count}</div>\n\n <div className=\"flex gap-3 justify-center\">\n <button\n onClick={() => setCount(c => c - 1)}\n className=\"w-12 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-lg font-medium transition-colors\"\n >\n -\n </button>\n <button\n onClick={() => setCount(0)}\n className=\"px-6 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-sm font-medium transition-colors\"\n >\n Reset\n </button>\n <button\n onClick={() => setCount(c => c + 1)}\n className=\"w-12 h-12 rounded-xl bg-blue-500 hover:bg-blue-600 text-white text-lg font-medium transition-colors\"\n >\n +\n </button>\n </div>\n </div>\n </div>\n );\n}\n`,\n '/index.js': `import React, { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport App from \"./App\";\n\nconst root = createRoot(document.getElementById(\"root\"));\nroot.render(\n <StrictMode>\n <App />\n </StrictMode>\n);\n`,\n },\n },\n];\n"
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
},
|
|
217
244
|
"collapsible": {
|
|
218
245
|
"name": "collapsible",
|
|
219
246
|
"dependencies": [
|
|
@@ -322,6 +349,19 @@
|
|
|
322
349
|
}
|
|
323
350
|
]
|
|
324
351
|
},
|
|
352
|
+
"empty": {
|
|
353
|
+
"name": "empty",
|
|
354
|
+
"dependencies": [
|
|
355
|
+
"lucide-react"
|
|
356
|
+
],
|
|
357
|
+
"internalDependencies": [],
|
|
358
|
+
"files": [
|
|
359
|
+
{
|
|
360
|
+
"path": "src/components/ui/empty/Empty.tsx",
|
|
361
|
+
"content": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\nimport {\n Users,\n FileText,\n Bell,\n SearchX,\n Database,\n FolderOpen,\n ShoppingCart,\n MessageSquare,\n ImageIcon,\n Inbox,\n Package,\n BarChart3,\n WifiOff,\n} from 'lucide-react';\n\n// ─── Preset definitions ───────────────────────────────────────────────────────\n\nexport type EmptyPreset =\n | 'users'\n | 'documents'\n | 'notifications'\n | 'search'\n | 'data'\n | 'inbox'\n | 'orders'\n | 'messages'\n | 'images'\n | 'folders'\n | 'stats'\n | 'offline'\n | 'general';\n\ninterface PresetConfig {\n icon: React.ElementType;\n title: string;\n description: string;\n /** Tailwind gradient classes for the icon circle background */\n bg: string;\n /** Tailwind bg class for decorative dot accents */\n dot: string;\n /** Tailwind text-color class for the icon */\n iconColor: string;\n}\n\nconst PRESETS: Record<EmptyPreset, PresetConfig> = {\n users: {\n icon: Users,\n title: 'Chưa có người dùng',\n description: 'Bắt đầu bằng cách mời thành viên đầu tiên vào hệ thống.',\n bg: 'from-blue-100 to-indigo-200 dark:from-blue-900/50 dark:to-indigo-800/50',\n dot: 'bg-indigo-400/50',\n iconColor: 'text-indigo-600 dark:text-indigo-300',\n },\n documents: {\n icon: FileText,\n title: 'Chưa có tài liệu',\n description: 'Tạo tài liệu đầu tiên để bắt đầu quản lý nội dung.',\n bg: 'from-amber-100 to-orange-200 dark:from-amber-900/50 dark:to-orange-800/50',\n dot: 'bg-orange-400/50',\n iconColor: 'text-orange-600 dark:text-orange-300',\n },\n notifications: {\n icon: Bell,\n title: 'Không có thông báo',\n description: 'Bạn đã đọc hết tất cả thông báo. Tuyệt vời!',\n bg: 'from-violet-100 to-purple-200 dark:from-violet-900/50 dark:to-purple-800/50',\n dot: 'bg-purple-400/50',\n iconColor: 'text-purple-600 dark:text-purple-300',\n },\n search: {\n icon: SearchX,\n title: 'Không tìm thấy kết quả',\n description: 'Thử thay đổi từ khóa hoặc điều chỉnh bộ lọc của bạn.',\n bg: 'from-slate-100 to-gray-200 dark:from-slate-800/60 dark:to-gray-700/50',\n dot: 'bg-slate-400/50',\n iconColor: 'text-slate-500 dark:text-slate-400',\n },\n data: {\n icon: Database,\n title: 'Chưa có dữ liệu',\n description: 'Dữ liệu sẽ hiển thị tại đây sau khi bạn thêm thông tin.',\n bg: 'from-teal-100 to-emerald-200 dark:from-teal-900/50 dark:to-emerald-800/50',\n dot: 'bg-emerald-400/50',\n iconColor: 'text-emerald-600 dark:text-emerald-300',\n },\n inbox: {\n icon: Inbox,\n title: 'Hộp thư trống',\n description: 'Không có tin nhắn nào. Thưởng thức khoảnh khắc yên bình!',\n bg: 'from-pink-100 to-rose-200 dark:from-pink-900/50 dark:to-rose-800/50',\n dot: 'bg-rose-400/50',\n iconColor: 'text-rose-600 dark:text-rose-300',\n },\n orders: {\n icon: ShoppingCart,\n title: 'Chưa có đơn hàng',\n description: 'Các đơn hàng sẽ xuất hiện ở đây sau khi được tạo.',\n bg: 'from-emerald-100 to-green-200 dark:from-emerald-900/50 dark:to-green-800/50',\n dot: 'bg-green-400/50',\n iconColor: 'text-green-700 dark:text-green-300',\n },\n messages: {\n icon: MessageSquare,\n title: 'Chưa có tin nhắn',\n description: 'Bắt đầu cuộc trò chuyện mới để kết nối với mọi người.',\n bg: 'from-cyan-100 to-sky-200 dark:from-cyan-900/50 dark:to-sky-800/50',\n dot: 'bg-sky-400/50',\n iconColor: 'text-sky-600 dark:text-sky-300',\n },\n images: {\n icon: ImageIcon,\n title: 'Chưa có hình ảnh',\n description: 'Tải lên hình ảnh đầu tiên để xây dựng thư viện media.',\n bg: 'from-fuchsia-100 to-pink-200 dark:from-fuchsia-900/50 dark:to-pink-800/50',\n dot: 'bg-fuchsia-400/50',\n iconColor: 'text-fuchsia-600 dark:text-fuchsia-300',\n },\n folders: {\n icon: FolderOpen,\n title: 'Thư mục trống',\n description: 'Chưa có file nào. Kéo thả hoặc nhấn để thêm mới.',\n bg: 'from-yellow-100 to-amber-200 dark:from-yellow-900/50 dark:to-amber-800/50',\n dot: 'bg-amber-400/50',\n iconColor: 'text-amber-600 dark:text-amber-300',\n },\n stats: {\n icon: BarChart3,\n title: 'Chưa có thống kê',\n description: 'Dữ liệu phân tích sẽ hiển thị khi có hoạt động.',\n bg: 'from-blue-100 to-violet-200 dark:from-blue-900/50 dark:to-violet-800/50',\n dot: 'bg-violet-400/50',\n iconColor: 'text-blue-600 dark:text-blue-300',\n },\n offline: {\n icon: WifiOff,\n title: 'Mất kết nối',\n description: 'Không thể tải dữ liệu. Kiểm tra kết nối mạng và thử lại.',\n bg: 'from-red-100 to-rose-200 dark:from-red-900/50 dark:to-rose-800/50',\n dot: 'bg-red-400/50',\n iconColor: 'text-red-600 dark:text-red-300',\n },\n general: {\n icon: Package,\n title: 'Không có gì ở đây',\n description: 'Nội dung sẽ xuất hiện sau khi bạn thêm dữ liệu mới.',\n bg: 'from-slate-100 to-gray-200 dark:from-slate-800/60 dark:to-gray-700/50',\n dot: 'bg-slate-400/50',\n iconColor: 'text-slate-500 dark:text-slate-400',\n },\n};\n\n// ─── Size config ──────────────────────────────────────────────────────────────\n\nexport type EmptyStateSize = 'sm' | 'md' | 'lg';\n\nconst SIZE = {\n sm: {\n root: 'py-8 px-6 gap-3',\n iconBg: 'w-16 h-16',\n outerRing: 'w-28 h-28',\n iconSize: 'w-7 h-7',\n dot: ['w-2 h-2', 'w-2.5 h-2.5', 'w-1.5 h-1.5'],\n title: 'text-base font-semibold',\n desc: 'text-xs max-w-xs',\n },\n md: {\n root: 'py-12 px-8 gap-4',\n iconBg: 'w-24 h-24',\n outerRing: 'w-40 h-40',\n iconSize: 'w-10 h-10',\n dot: ['w-3 h-3', 'w-3.5 h-3.5', 'w-2 h-2'],\n title: 'text-xl font-semibold',\n desc: 'text-sm max-w-sm',\n },\n lg: {\n root: 'py-20 px-12 gap-5',\n iconBg: 'w-32 h-32',\n outerRing: 'w-52 h-52',\n iconSize: 'w-14 h-14',\n dot: ['w-4 h-4', 'w-5 h-5', 'w-3 h-3'],\n title: 'text-2xl font-bold',\n desc: 'text-base max-w-md',\n },\n} satisfies Record<EmptyStateSize, object>;\n\n// ─── Component ────────────────────────────────────────────────────────────────\n\nexport interface EmptyStateProps {\n /** Use a predefined empty state design */\n preset?: EmptyPreset;\n /** Override the icon (pass a lucide-react component) */\n icon?: React.ElementType;\n /** Override the title */\n title?: string;\n /** Override the description */\n description?: string;\n /** Action element(s) rendered below the description */\n action?: React.ReactNode;\n size?: EmptyStateSize;\n className?: string;\n /** Extra content rendered after the action */\n children?: React.ReactNode;\n}\n\nexport const EmptyState: React.FC<EmptyStateProps> = ({\n preset = 'general',\n icon: CustomIcon,\n title: customTitle,\n description: customDescription,\n action,\n size = 'md',\n className,\n children,\n}) => {\n const cfg = PRESETS[preset];\n const s = SIZE[size];\n const Icon = CustomIcon ?? cfg.icon;\n const title = customTitle ?? cfg.title;\n const description = customDescription ?? cfg.description;\n\n return (\n <div\n className={cn(\n 'flex flex-col items-center justify-center text-center',\n 'animate-in fade-in slide-in-from-bottom-4 duration-500',\n s.root,\n className,\n )}\n >\n {/* ── Icon area ──────────────────────────────────────────────────── */}\n <div className=\"relative inline-flex items-center justify-center\">\n {/* Outer glow halo */}\n <div\n className={cn(\n 'absolute rounded-full opacity-20 bg-gradient-to-br pointer-events-none',\n cfg.bg,\n s.outerRing,\n )}\n />\n\n {/* Icon circle */}\n <div\n className={cn(\n 'relative rounded-full flex items-center justify-center shadow-lg overflow-hidden',\n 'bg-gradient-to-br',\n cfg.bg,\n s.iconBg,\n )}\n >\n {/* Gloss highlight */}\n <div className=\"absolute inset-0 bg-gradient-to-b from-white/30 to-transparent pointer-events-none\" />\n <Icon className={cn(cfg.iconColor, s.iconSize)} strokeWidth={1.5} />\n </div>\n\n {/* Decorative dot — top-right */}\n <span\n className={cn(\n 'absolute rounded-full animate-pulse',\n cfg.dot,\n s.dot[0],\n 'top-0.5 right-0.5',\n )}\n />\n {/* Decorative dot — bottom-left */}\n <span\n className={cn(\n 'absolute rounded-full animate-pulse [animation-delay:450ms]',\n cfg.dot,\n s.dot[1],\n 'bottom-1 left-0.5',\n )}\n />\n {/* Decorative dot — left-middle */}\n <span\n className={cn(\n 'absolute rounded-full animate-pulse [animation-delay:900ms]',\n cfg.dot,\n s.dot[2],\n 'top-1/3 -left-1',\n )}\n />\n </div>\n\n {/* ── Text ───────────────────────────────────────────────────────── */}\n <div className=\"space-y-1.5\">\n <h3 className={cn('text-foreground', s.title)}>{title}</h3>\n <p className={cn('text-muted-foreground leading-relaxed mx-auto', s.desc)}>\n {description}\n </p>\n </div>\n\n {/* ── Actions ────────────────────────────────────────────────────── */}\n {action && (\n <div className=\"flex flex-wrap gap-3 justify-center\">{action}</div>\n )}\n\n {children}\n </div>\n );\n};\n\nEmptyState.displayName = 'EmptyState';\n"
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
},
|
|
325
365
|
"form": {
|
|
326
366
|
"name": "form",
|
|
327
367
|
"dependencies": [
|
|
@@ -351,6 +391,19 @@
|
|
|
351
391
|
}
|
|
352
392
|
]
|
|
353
393
|
},
|
|
394
|
+
"input-otp": {
|
|
395
|
+
"name": "input-otp",
|
|
396
|
+
"dependencies": [
|
|
397
|
+
"tailwind-variants"
|
|
398
|
+
],
|
|
399
|
+
"internalDependencies": [],
|
|
400
|
+
"files": [
|
|
401
|
+
{
|
|
402
|
+
"path": "src/components/ui/input-otp/InputOtp.tsx",
|
|
403
|
+
"content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst inputOTPVariants = tv({\n slots: {\n root: 'flex items-center gap-2',\n slot: [\n 'relative flex items-center justify-center',\n 'border border-border bg-background text-foreground font-semibold',\n 'transition-all duration-200 outline-none',\n 'select-none',\n ].join(' '),\n separator: 'flex items-center justify-center text-muted-foreground shrink-0',\n caret: 'absolute inset-0 flex items-center justify-center pointer-events-none',\n caretBlink: 'w-px bg-foreground animate-blink',\n },\n variants: {\n variant: {\n outline: {\n slot: 'rounded-md focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n },\n filled: {\n slot: 'rounded-md bg-muted border-transparent focus-within:bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n },\n underline: {\n slot: 'border-0 border-b-2 border-b-border rounded-none bg-transparent focus-within:border-b-primary',\n },\n glass: {\n slot: 'rounded-xl bg-white/10 dark:bg-white/5 backdrop-blur-md border-white/20 dark:border-white/10 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 shadow-sm',\n },\n },\n size: {\n sm: {\n slot: 'h-9 w-9 text-sm',\n separator: 'text-lg px-0.5',\n caretBlink: 'h-4',\n },\n md: {\n slot: 'h-12 w-12 text-lg',\n separator: 'text-xl px-1',\n caretBlink: 'h-5',\n },\n lg: {\n slot: 'h-14 w-14 text-2xl',\n separator: 'text-2xl px-1.5',\n caretBlink: 'h-6',\n },\n },\n shape: {\n square: { slot: '' },\n rounded: { slot: '' },\n circle: { slot: '' },\n },\n },\n compoundVariants: [\n { shape: 'rounded', variant: 'outline', className: { slot: 'rounded-xl' } },\n { shape: 'rounded', variant: 'filled', className: { slot: 'rounded-xl' } },\n { shape: 'rounded', variant: 'glass', className: { slot: 'rounded-2xl' } },\n { shape: 'circle', variant: 'outline', className: { slot: 'rounded-full' } },\n { shape: 'circle', variant: 'filled', className: { slot: 'rounded-full' } },\n { shape: 'circle', variant: 'glass', className: { slot: 'rounded-full' } },\n ],\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n shape: 'square',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\ntype SeparatorType = 'dash' | 'dot' | 'space' | React.ReactNode;\n\nexport interface InputOTPProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'inputMode'>,\n VariantProps<typeof inputOTPVariants> {\n /** Total number of OTP slots */\n length?: number;\n /** Current value (controlled) */\n value?: string;\n /** Default value (uncontrolled) */\n defaultValue?: string;\n /** Fired on every value change */\n onChange?: (value: string) => void;\n /** Fired when all slots are filled */\n onComplete?: (value: string) => void;\n /** What characters are allowed */\n inputMode?: InputMode;\n /** Custom regex pattern when inputMode='custom' */\n pattern?: RegExp;\n /** Mask character for entered values (e.g. '*' for password-style) */\n mask?: string | boolean;\n /** Disable the entire input */\n disabled?: boolean;\n /** Show error state */\n error?: boolean;\n /** Error message */\n errorMessage?: string;\n /** Label */\n label?: string;\n /** Description */\n description?: string;\n /** Auto focus on mount */\n autoFocus?: boolean;\n /** Separator config: insert separator after these indices, or every N slots */\n separatorAfter?: number[] | number;\n /** Separator visual */\n separator?: SeparatorType;\n /** Auto-submit on complete */\n autoSubmit?: boolean;\n /** Slot className override */\n slotClassName?: string;\n /** Render filled slots with success style */\n successOnComplete?: boolean;\n /** Placeholder per slot */\n placeholder?: string;\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nconst INPUT_PATTERNS: Record<Exclude<InputMode, 'custom'>, RegExp> = {\n numeric: /^[0-9]$/,\n alphanumeric: /^[a-zA-Z0-9]$/,\n alpha: /^[a-zA-Z]$/,\n};\n\nfunction getPattern(mode: InputMode, custom?: RegExp): RegExp {\n if (mode === 'custom' && custom) return custom;\n return INPUT_PATTERNS[mode as Exclude<InputMode, 'custom'>] ?? INPUT_PATTERNS.numeric;\n}\n\nfunction getSeparatorPositions(config: number[] | number | undefined, length: number): Set<number> {\n if (!config) return new Set();\n if (Array.isArray(config)) return new Set(config);\n // every N slots\n const positions = new Set<number>();\n for (let i = config - 1; i < length - 1; i += config) {\n positions.add(i);\n }\n return positions;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(\n (\n {\n className,\n variant,\n size,\n shape,\n length = 6,\n value: controlledValue,\n defaultValue = '',\n onChange,\n onComplete,\n inputMode = 'numeric',\n pattern: customPattern,\n mask,\n disabled = false,\n error = false,\n errorMessage,\n label,\n description,\n autoFocus = false,\n separatorAfter,\n separator = 'dash',\n autoSubmit = false,\n slotClassName,\n successOnComplete = false,\n placeholder,\n ...props\n },\n ref,\n ) => {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const currentValue = isControlled ? controlledValue : internalValue;\n\n const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\n const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);\n const rootId = React.useId();\n\n const pat = getPattern(inputMode, customPattern);\n const separatorPositions = getSeparatorPositions(separatorAfter, length);\n const slots = inputOTPVariants({ variant, size, shape });\n\n const chars = React.useMemo(() => {\n const arr = currentValue.split('');\n while (arr.length < length) arr.push('');\n return arr.slice(0, length);\n }, [currentValue, length]);\n\n const isComplete = chars.every((c) => c !== '');\n\n // ─── Value helpers ─────────────────────────────────────────────────\n\n const updateValue = React.useCallback(\n (newChars: string[]) => {\n const val = newChars.join('');\n if (!isControlled) setInternalValue(val);\n onChange?.(val);\n if (val.length === length && newChars.every((c) => c !== '')) {\n onComplete?.(val);\n }\n },\n [isControlled, onChange, onComplete, length],\n );\n\n const focusSlot = React.useCallback((index: number) => {\n const clamped = Math.max(0, Math.min(index, length - 1));\n inputRefs.current[clamped]?.focus();\n }, [length]);\n\n // ─── Handlers ──────────────────────────────────────────────────────\n\n const handleInput = React.useCallback(\n (index: number, char: string) => {\n if (!pat.test(char)) return;\n const next = [...chars];\n next[index] = char;\n updateValue(next);\n if (index < length - 1) {\n focusSlot(index + 1);\n }\n },\n [chars, pat, updateValue, length, focusSlot],\n );\n\n const handleKeyDown = React.useCallback(\n (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {\n switch (e.key) {\n case 'Backspace': {\n e.preventDefault();\n const next = [...chars];\n if (chars[index] !== '') {\n next[index] = '';\n updateValue(next);\n } else if (index > 0) {\n next[index - 1] = '';\n updateValue(next);\n focusSlot(index - 1);\n }\n break;\n }\n case 'Delete': {\n e.preventDefault();\n const next = [...chars];\n next[index] = '';\n updateValue(next);\n break;\n }\n case 'ArrowLeft':\n e.preventDefault();\n if (index > 0) focusSlot(index - 1);\n break;\n case 'ArrowRight':\n e.preventDefault();\n if (index < length - 1) focusSlot(index + 1);\n break;\n case 'Home':\n e.preventDefault();\n focusSlot(0);\n break;\n case 'End':\n e.preventDefault();\n focusSlot(length - 1);\n break;\n }\n },\n [chars, updateValue, focusSlot, length],\n );\n\n const handlePaste = React.useCallback(\n (e: React.ClipboardEvent<HTMLInputElement>) => {\n e.preventDefault();\n const pasted = e.clipboardData.getData('text/plain').trim();\n const next = [...chars];\n let cursor = focusedIndex ?? 0;\n for (const ch of pasted) {\n if (cursor >= length) break;\n if (pat.test(ch)) {\n next[cursor] = ch;\n cursor++;\n }\n }\n updateValue(next);\n focusSlot(Math.min(cursor, length - 1));\n },\n [chars, focusedIndex, length, pat, updateValue, focusSlot],\n );\n\n // ─── Auto focus ────────────────────────────────────────────────────\n\n React.useEffect(() => {\n if (autoFocus) {\n const firstEmpty = chars.findIndex((c) => c === '');\n focusSlot(firstEmpty === -1 ? 0 : firstEmpty);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // ─── Auto submit ───────────────────────────────────────────────────\n\n React.useEffect(() => {\n if (autoSubmit && isComplete) {\n const form = inputRefs.current[0]?.closest('form');\n if (form) {\n form.requestSubmit();\n }\n }\n }, [autoSubmit, isComplete]);\n\n // ─── Render separator ──────────────────────────────────────────────\n\n const renderSeparator = () => {\n if (separator === 'dash') return <span>–</span>;\n if (separator === 'dot') return <span>•</span>;\n if (separator === 'space') return <span> </span>;\n return separator;\n };\n\n // ─── Render display char ───────────────────────────────────────────\n\n const getDisplayChar = (char: string, index: number) => {\n if (char === '') {\n if (placeholder && placeholder[index]) {\n return <span className=\"text-muted-foreground/50 font-normal\">{placeholder[index]}</span>;\n }\n return null;\n }\n if (mask) {\n const maskChar = typeof mask === 'string' ? mask : '\\u2022';\n return <span>{maskChar}</span>;\n }\n return <span>{char}</span>;\n };\n\n // ─── Render ────────────────────────────────────────────────────────\n\n return (\n <div ref={ref} className=\"flex flex-col gap-1.5\" {...props}>\n {label && (\n <label htmlFor={`${rootId}-0`} className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n <div\n className={cn(slots.root(), className)}\n role=\"group\"\n aria-label={label || 'OTP Input'}\n aria-describedby={description ? `${rootId}-desc` : errorMessage ? `${rootId}-err` : undefined}\n >\n {Array.from({ length }).map((_, i) => {\n const isFocused = focusedIndex === i;\n const isFilled = chars[i] !== '';\n const showSuccess = successOnComplete && isComplete;\n const showError = error && !showSuccess;\n\n return (\n <React.Fragment key={i}>\n <div\n className={cn(\n slots.slot(),\n isFocused && !showError && 'border-primary ring-2 ring-primary/20',\n showError && 'border-danger',\n showSuccess && 'border-success text-success',\n disabled && 'opacity-50 cursor-not-allowed',\n slotClassName,\n )}\n >\n <input\n ref={(el) => { inputRefs.current[i] = el; }}\n id={i === 0 ? `${rootId}-0` : undefined}\n type=\"text\"\n inputMode={inputMode === 'numeric' ? 'numeric' : 'text'}\n autoComplete={i === 0 ? 'one-time-code' : 'off'}\n aria-label={`Digit ${i + 1} of ${length}`}\n aria-invalid={error || undefined}\n maxLength={1}\n value=\"\"\n disabled={disabled}\n className=\"sr-only\"\n onFocus={() => setFocusedIndex(i)}\n onBlur={() => setFocusedIndex(null)}\n onChange={(e) => {\n const char = e.target.value;\n if (char) handleInput(i, char);\n }}\n onKeyDown={(e) => handleKeyDown(i, e)}\n onPaste={handlePaste}\n />\n {/* Display */}\n <div\n className=\"flex items-center justify-center w-full h-full cursor-text\"\n onClick={() => !disabled && focusSlot(i)}\n >\n {getDisplayChar(chars[i], i)}\n </div>\n {/* Caret */}\n {isFocused && !isFilled && !disabled && (\n <div className={slots.caret()}>\n <div className={slots.caretBlink()} />\n </div>\n )}\n </div>\n\n {/* Separator */}\n {separatorPositions.has(i) && (\n <div className={slots.separator()} aria-hidden=\"true\">\n {renderSeparator()}\n </div>\n )}\n </React.Fragment>\n );\n })}\n </div>\n\n {description && !error && !errorMessage && (\n <p id={`${rootId}-desc`} className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {(error || errorMessage) && (\n <p id={`${rootId}-err`} className=\"text-[0.8rem] font-medium text-danger\">{errorMessage || 'Invalid code'}</p>\n )}\n </div>\n );\n },\n);\n\nInputOTP.displayName = 'InputOTP';\n\nexport { InputOTP, inputOTPVariants };\n"
|
|
404
|
+
}
|
|
405
|
+
]
|
|
406
|
+
},
|
|
354
407
|
"menu-bar": {
|
|
355
408
|
"name": "menu-bar",
|
|
356
409
|
"dependencies": [
|
|
@@ -395,6 +448,23 @@
|
|
|
395
448
|
}
|
|
396
449
|
]
|
|
397
450
|
},
|
|
451
|
+
"pretty-code": {
|
|
452
|
+
"name": "pretty-code",
|
|
453
|
+
"dependencies": [
|
|
454
|
+
"shiki",
|
|
455
|
+
"unified",
|
|
456
|
+
"rehype-parse",
|
|
457
|
+
"rehype-react",
|
|
458
|
+
"lucide-react"
|
|
459
|
+
],
|
|
460
|
+
"internalDependencies": [],
|
|
461
|
+
"files": [
|
|
462
|
+
{
|
|
463
|
+
"path": "src/components/ui/pretty-code/PrettyCode.tsx",
|
|
464
|
+
"content": "import React, { useState, useEffect, useCallback } from 'react';\r\nimport { createHighlighter, type Highlighter } from 'shiki';\r\nimport { unified } from 'unified';\r\nimport rehypeParse from 'rehype-parse';\r\nimport rehypeReact from 'rehype-react';\r\nimport * as prod from 'react/jsx-runtime';\r\nimport { Copy, Check } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Singleton highlighter ────────────────────────────────────────────────────\r\nlet globalHighlighter: Highlighter | null = null;\r\n\r\nconst getHighlighter = async () => {\r\n if (globalHighlighter) return globalHighlighter;\r\n globalHighlighter = await createHighlighter({\r\n themes: ['nord'],\r\n langs: ['tsx', 'typescript', 'javascript', 'bash', 'json'],\r\n });\r\n return globalHighlighter;\r\n};\r\n\r\n// ─── Helpers ──────────────────────────────────────────────────────────────────\r\nconst LANG_LABELS: Record<string, string> = {\r\n tsx: 'TSX',\r\n typescript: 'TypeScript',\r\n javascript: 'JavaScript',\r\n bash: 'Bash',\r\n json: 'JSON',\r\n};\r\n\r\n// Fixed widths for loading skeleton rows\r\nconst SKELETON_WIDTHS = ['68%', '82%', '54%', '76%', '45%', '60%'];\r\n\r\n// ─── Types ────────────────────────────────────────────────────────────────────\r\ninterface PrettyCodeProps {\r\n code: string;\r\n lang?: string;\r\n /** Optional filename shown in the header bar */\r\n filename?: string;\r\n className?: string;\r\n}\r\n\r\n// ─── Component ────────────────────────────────────────────────────────────────\r\nexport const PrettyCode: React.FC<PrettyCodeProps> = ({\r\n code,\r\n lang = 'tsx',\r\n filename,\r\n className,\r\n}) => {\r\n const [nodes, setNodes] = useState<React.ReactNode>(null);\r\n const [loading, setLoading] = useState(true);\r\n const [copied, setCopied] = useState(false);\r\n\r\n useEffect(() => {\r\n let isMounted = true;\r\n setLoading(true);\r\n setNodes(null);\r\n\r\n const highlight = async () => {\r\n try {\r\n const highlighter = await getHighlighter();\r\n const html = highlighter.codeToHtml(code, { lang, theme: 'nord' });\r\n\r\n const file = await unified()\r\n .use(rehypeParse, { fragment: true })\r\n .use(rehypeReact, { ...prod })\r\n .process(html);\r\n\r\n if (isMounted) {\r\n setNodes(file.result as React.ReactNode);\r\n setLoading(false);\r\n }\r\n } catch (err) {\r\n console.error('Failed to highlight code:', err);\r\n if (isMounted) setLoading(false);\r\n }\r\n };\r\n\r\n highlight();\r\n return () => { isMounted = false; };\r\n }, [code, lang]);\r\n\r\n const handleCopy = useCallback(async () => {\r\n try {\r\n await navigator.clipboard.writeText(code);\r\n setCopied(true);\r\n setTimeout(() => setCopied(false), 2000);\r\n } catch {\r\n // clipboard not available\r\n }\r\n }, [code]);\r\n\r\n const langLabel = LANG_LABELS[lang] ?? lang.toUpperCase();\r\n\r\n return (\r\n <div className={cn('rounded-xl overflow-hidden border border-white/[0.07] bg-[#2e3440] shadow-2xl', className)}>\r\n\r\n {/* ── Header bar ── */}\r\n <div className=\"flex items-center justify-between px-4 py-2.5 bg-[#252b37] border-b border-white/[0.07] select-none\">\r\n\r\n {/* Left: window dots + filename */}\r\n <div className=\"flex items-center gap-3 min-w-0\">\r\n <div className=\"flex items-center gap-1.5 shrink-0\">\r\n <span className=\"w-3 h-3 rounded-full bg-[#ff5f57]\" />\r\n <span className=\"w-3 h-3 rounded-full bg-[#febc2e]\" />\r\n <span className=\"w-3 h-3 rounded-full bg-[#28c840]\" />\r\n </div>\r\n {filename && (\r\n <span className=\"text-[11px] text-zinc-400 font-mono truncate leading-none\">\r\n {filename}\r\n </span>\r\n )}\r\n </div>\r\n\r\n {/* Right: language badge + copy button */}\r\n <div className=\"flex items-center gap-2 shrink-0 ml-4\">\r\n <span className=\"hidden sm:block text-[10px] font-bold tracking-widest text-zinc-600 uppercase\">\r\n {langLabel}\r\n </span>\r\n\r\n <button\r\n onClick={handleCopy}\r\n aria-label=\"Copy code\"\r\n className={cn(\r\n 'flex items-center gap-1.5 px-2 py-1 rounded-md',\r\n 'text-xs font-medium transition-all duration-150 active:scale-95',\r\n copied\r\n ? 'text-emerald-400 bg-emerald-400/10'\r\n : 'text-zinc-400 hover:text-zinc-100 hover:bg-white/10',\r\n )}\r\n >\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 shrink-0\" />\r\n : <Copy className=\"w-3.5 h-3.5 shrink-0\" />\r\n }\r\n <span className=\"hidden sm:inline w-[42px]\">\r\n {copied ? 'Copied!' : 'Copy'}\r\n </span>\r\n </button>\r\n </div>\r\n </div>\r\n\r\n {/* ── Code area ── */}\r\n {loading ? (\r\n <div className=\"p-5 space-y-3 animate-pulse\" aria-hidden>\r\n {SKELETON_WIDTHS.map((w, i) => (\r\n <div\r\n key={i}\r\n className=\"h-3.5 rounded-full bg-white/[0.08]\"\r\n style={{ width: w }}\r\n />\r\n ))}\r\n </div>\r\n ) : (\r\n <div\r\n className=\"overflow-x-auto\r\n [&_pre]:!bg-transparent [&_pre]:m-0\r\n [&_pre]:px-5 [&_pre]:py-4\r\n [&_pre]:text-[13px] [&_pre]:leading-[1.7]\r\n [&_code]:font-mono [&_code]:text-[13px]\"\r\n >\r\n {nodes}\r\n </div>\r\n )}\r\n </div>\r\n );\r\n};\r\n"
|
|
465
|
+
}
|
|
466
|
+
]
|
|
467
|
+
},
|
|
398
468
|
"preview-card": {
|
|
399
469
|
"name": "preview-card",
|
|
400
470
|
"dependencies": [
|
|
@@ -435,7 +505,7 @@
|
|
|
435
505
|
"files": [
|
|
436
506
|
{
|
|
437
507
|
"path": "src/components/ui/radio/Radio.tsx",
|
|
438
|
-
"content": "import * as React from 'react';\
|
|
508
|
+
"content": "import * as React from 'react';\nimport { Radio as BaseRadio } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst radioVariants = tv({\n slots: {\n root: 'group flex shrink-0 items-center justify-center rounded-full border border-border bg-background transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:border-primary data-checked:border-primary',\n indicator: 'flex items-center justify-center',\n dot: 'rounded-full bg-primary',\n card: 'group/card relative flex flex-row items-start gap-4 cursor-pointer rounded-xl border border-border bg-card p-4 w-full shadow-sm outline-none transition-all hover:bg-accent/50 hover:text-accent-foreground data-[checked]:border-primary data-[checked]:bg-primary/5 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 overflow-hidden',\n cardCircle: 'flex shrink-0 items-center justify-center rounded-full border border-border bg-background transition-all group-data-[checked]/card:border-primary group-data-[checked]/card:text-primary mt-0.5',\n },\n variants: {\n size: {\n sm: { root: 'h-4 w-4', cardCircle: 'h-4 w-4', dot: 'h-1.5 w-1.5' },\n md: { root: 'h-5 w-5', cardCircle: 'h-5 w-5', dot: 'h-2 w-2' },\n lg: { root: 'h-6 w-6', cardCircle: 'h-6 w-6', dot: 'h-2.5 w-2.5' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\nexport interface RadioProps\n extends Omit<BaseRadio.Root.Props, 'className'>,\n VariantProps<typeof radioVariants> {\n variant?: 'default' | 'card';\n label?: string;\n className?: string;\n /** Hiện/ẩn indicator (circle). Card variant mặc định ẩn. */\n showIndicator?: boolean;\n}\n\nconst Radio = React.forwardRef<React.ElementRef<typeof BaseRadio.Root>, RadioProps>(\n ({ variant = 'default', className, size, label, id, children, showIndicator, ...props }, ref) => {\n const defaultId = React.useId();\n const radioId = id || defaultId;\n\n const { root, indicator, dot, card, cardCircle } = radioVariants({ size });\n\n if (variant === 'card') {\n return (\n <BaseRadio.Root\n ref={ref}\n id={radioId}\n className={card({ className })}\n {...props}\n >\n {showIndicator && (\n <div className={cardCircle()}>\n <BaseRadio.Indicator className={indicator()}>\n <div className={dot()} />\n </BaseRadio.Indicator>\n </div>\n )}\n {children}\n </BaseRadio.Root>\n );\n }\n\n return (\n <div className=\"flex items-center gap-2 w-fit\">\n <BaseRadio.Root\n ref={ref}\n id={radioId}\n className={root({ className })}\n {...props}\n >\n <BaseRadio.Indicator className={indicator()}>\n <div className={dot()} />\n </BaseRadio.Indicator>\n </BaseRadio.Root>\n {children}\n {label && (\n <label\n htmlFor={radioId}\n className=\"text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n >\n {label}\n </label>\n )}\n </div>\n );\n }\n);\n\nRadio.displayName = 'Radio';\n\nexport { Radio };\n"
|
|
439
509
|
}
|
|
440
510
|
]
|
|
441
511
|
},
|
|
@@ -467,6 +537,20 @@
|
|
|
467
537
|
}
|
|
468
538
|
]
|
|
469
539
|
},
|
|
540
|
+
"resizable": {
|
|
541
|
+
"name": "resizable",
|
|
542
|
+
"dependencies": [
|
|
543
|
+
"react-resizable-panels",
|
|
544
|
+
"tailwind-variants"
|
|
545
|
+
],
|
|
546
|
+
"internalDependencies": [],
|
|
547
|
+
"files": [
|
|
548
|
+
{
|
|
549
|
+
"path": "src/components/ui/resizable/Resizable.tsx",
|
|
550
|
+
"content": "import * as React from 'react';\nimport { Group, Panel, Separator } from 'react-resizable-panels';\nimport type { GroupProps, PanelProps, SeparatorProps } from 'react-resizable-panels';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Direction Context ────────────────────────────────────────\n\ninterface ResizableContextValue {\n direction: 'horizontal' | 'vertical';\n}\n\nconst ResizableContext = React.createContext<ResizableContextValue>({\n direction: 'horizontal',\n});\n\n// ─── Grip Dots ────────────────────────────────────────────────\n\nfunction GripDots({ isVerticalHandle, className }: { isVerticalHandle: boolean; className?: string }) {\n if (isVerticalHandle) {\n return (\n <div className={cn('flex flex-col gap-[3px]', className)}>\n {[0, 1, 2].map((r) => (\n <div key={r} className=\"flex gap-[3px]\">\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n </div>\n ))}\n </div>\n );\n }\n return (\n <div className={cn('flex gap-[3px]', className)}>\n {[0, 1, 2].map((c) => (\n <div key={c} className=\"flex flex-col gap-[3px]\">\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n <div className=\"w-[3px] h-[3px] rounded-full bg-current\" />\n </div>\n ))}\n </div>\n );\n}\n\n// ─── Handle Variants ─────────────────────────────────────────\n\nconst handleVariants = tv({\n base: [\n 'relative flex items-center justify-center shrink-0',\n 'select-none touch-none outline-none',\n 'group/handle',\n ].join(' '),\n variants: {\n variant: {\n line: '',\n bar: '',\n handle: '',\n ghost: '',\n },\n },\n defaultVariants: { variant: 'line' },\n});\n\n// ─── Types ────────────────────────────────────────────────────\n\nexport type ResizablePanelGroupProps = Omit<GroupProps, 'orientation'> & {\n direction?: 'horizontal' | 'vertical';\n};\n\nexport type ResizablePanelProps = PanelProps;\n\nexport type ResizableHandleProps = Omit<SeparatorProps, 'className'> &\n VariantProps<typeof handleVariants> & {\n withGrip?: boolean;\n className?: string;\n };\n\n// ─── ResizablePanelGroup ──────────────────────────────────────\n\nfunction ResizablePanelGroup({\n direction = 'horizontal',\n className,\n children,\n ...props\n}: ResizablePanelGroupProps) {\n return (\n <ResizableContext.Provider value={{ direction }}>\n <Group\n orientation={direction}\n className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}\n {...props}\n >\n {children}\n </Group>\n </ResizableContext.Provider>\n );\n}\nResizablePanelGroup.displayName = 'ResizablePanelGroup';\n\n// ─── ResizablePanel ───────────────────────────────────────────\n\nfunction ResizablePanel({ className, ...props }: ResizablePanelProps) {\n return <Panel className={cn('overflow-auto', className)} {...props} />;\n}\nResizablePanel.displayName = 'ResizablePanel';\n\n// ─── ResizableHandle ─────────────────────────────────────────\n\nfunction ResizableHandle({\n variant = 'line',\n withGrip = false,\n className,\n disabled,\n ...props\n}: ResizableHandleProps) {\n const { direction } = React.useContext(ResizableContext);\n const [isDragging, setIsDragging] = React.useState(false);\n\n const isVerticalHandle = direction === 'horizontal';\n\n // Shared thin-line indicator position styles\n const linePos = isVerticalHandle\n ? 'absolute inset-y-0 left-1/2 -translate-x-1/2 w-px'\n : 'absolute inset-x-0 top-1/2 -translate-y-1/2 h-px';\n\n return (\n <Separator\n disabled={disabled}\n onPointerDown={() => setIsDragging(true)}\n onPointerUp={() => setIsDragging(false)}\n onPointerLeave={() => setIsDragging(false)}\n className={cn(\n handleVariants({ variant }),\n\n // ── orientation & cursor ──────────────────────────────\n isVerticalHandle\n ? 'h-full cursor-col-resize'\n : 'w-full cursor-row-resize',\n\n // ── per-variant zone width ────────────────────────────\n // line: 4 px transparent zone\n variant === 'line' && (isVerticalHandle ? 'w-1' : 'h-1'),\n\n // bar: 8 px transparent zone — easy to grab anywhere along sidebar edge\n variant === 'bar' && (isVerticalHandle ? 'w-2' : 'h-2'),\n\n // handle: 12 px zone with visible pill on hover\n variant === 'handle' && (isVerticalHandle ? 'w-3' : 'h-3'),\n\n // ghost: 8 px invisible zone\n variant === 'ghost' && (isVerticalHandle ? 'w-2' : 'h-2'),\n\n // all non-ghost: transparent background\n variant !== 'ghost' && 'bg-transparent',\n\n // drag shadow — secondary tint\n isDragging && variant !== 'ghost' && 'shadow-[0_0_0_1px] shadow-secondary/30',\n\n className,\n )}\n {...props}\n >\n {/* ── LINE: thin border-colored line ────────────────── */}\n {variant === 'line' && (\n <div\n className={cn(\n linePos,\n 'transition-colors duration-150 rounded-full',\n isDragging\n ? 'bg-secondary/70'\n : 'bg-border group-hover/handle:bg-secondary/50',\n )}\n />\n )}\n\n {/* ── BAR: full-height thin line + centered grip pill ── */}\n {variant === 'bar' && (\n <>\n {/* full-height thin line */}\n <div\n className={cn(\n linePos,\n 'transition-colors duration-150',\n isDragging\n ? 'bg-secondary/60'\n : 'bg-border/70 group-hover/handle:bg-secondary/40',\n )}\n />\n {/* centered grip pill */}\n <div\n className={cn(\n 'relative z-10 rounded-full transition-colors duration-150',\n isVerticalHandle ? 'w-[3px] h-6' : 'h-[3px] w-6',\n isDragging\n ? 'bg-secondary/70'\n : 'bg-muted-foreground/25 group-hover/handle:bg-secondary/50',\n )}\n />\n </>\n )}\n\n {/* ── HANDLE: bordered pill on hover ────────────────── */}\n {variant === 'handle' && (\n <div\n className={cn(\n 'z-10 flex items-center justify-center rounded-sm border border-border bg-background',\n 'opacity-0 group-hover/handle:opacity-100 transition-opacity duration-150',\n isDragging && 'opacity-100 border-secondary/60',\n 'shadow-sm',\n isVerticalHandle ? 'w-5 h-8' : 'h-5 w-8',\n )}\n >\n <GripDots\n isVerticalHandle={isVerticalHandle}\n className={isDragging ? 'text-secondary/70' : 'text-muted-foreground/70'}\n />\n </div>\n )}\n\n {/* ── withGrip: overlay dots on line / ghost ─────────── */}\n {withGrip && variant !== 'handle' && variant !== 'bar' && (\n <div className=\"z-10 opacity-0 group-hover/handle:opacity-100 transition-opacity duration-150\">\n <GripDots\n isVerticalHandle={isVerticalHandle}\n className=\"text-muted-foreground/50 group-hover/handle:text-muted-foreground/70 transition-colors\"\n />\n </div>\n )}\n </Separator>\n );\n}\nResizableHandle.displayName = 'ResizableHandle';\n\n// ─── Exports ─────────────────────────────────────────────────\n\nexport {\n ResizablePanelGroup,\n ResizablePanel,\n ResizableHandle,\n handleVariants,\n};\n"
|
|
551
|
+
}
|
|
552
|
+
]
|
|
553
|
+
},
|
|
470
554
|
"scroll-area": {
|
|
471
555
|
"name": "scroll-area",
|
|
472
556
|
"dependencies": [
|
|
@@ -491,7 +575,7 @@
|
|
|
491
575
|
"files": [
|
|
492
576
|
{
|
|
493
577
|
"path": "src/components/ui/select/Select.tsx",
|
|
494
|
-
"content": "import * as React from 'react';\r\nimport { Select as BaseSelect } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronDown, Check, X } from 'lucide-react';\r\n\r\nconst selectVariants = tv({\r\n slots: {\r\n trigger: 'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer',\r\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-md data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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',\r\n viewport: 'p-1',\r\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\r\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\r\n }\r\n});\r\n\r\nconst { trigger, content, viewport, item, icon } = selectVariants();\r\n\r\n/** Props for the Select component */\r\nexport interface SelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\r\n /** Label text displayed above the select trigger */\r\n label?: string;\r\n /** Helper text displayed below the select (hidden when error is present) */\r\n description?: string;\r\n /** Error message displayed below the select; also applies danger styling */\r\n error?: string;\r\n /** Placeholder shown when no option is selected */\r\n placeholder?: string;\r\n /** Array of selectable options */\r\n options: { label: string; value: string }[];\r\n id?: string;\r\n className?: string;\r\n /** Controlled selected value */\r\n value?: string;\r\n /** Initial selected value for uncontrolled usage */\r\n defaultValue?: string;\r\n /** Whether a clear button is shown when a value is selected */\r\n clearable?: boolean;\r\n /** Callback fired when the selected value changes */\r\n onChange?: (value: string) => void;\r\n /** Text shown when the options array is empty */\r\n emptyText?: string;\r\n /** Accessible label for the clear button */\r\n clearLabel?: string;\r\n}\r\n\r\nconst Select = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, SelectProps>(\r\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', ...props }, ref) => {\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const [selectedValue, setSelectedValue] = React.useState<string>(value ?? defaultValue ?? '');\r\n\r\n React.useEffect(() => {\r\n if (value !== undefined) setSelectedValue(value);\r\n }, [value]);\r\n\r\n const handleValueChange = (val: string) => {\r\n setSelectedValue(val);\r\n onChange?.(val);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setSelectedValue('');\r\n onChange?.('');\r\n };\r\n\r\n const selectedLabel = options.find((o) => o.value === selectedValue)?.label;\r\n\r\n return (\r\n <div className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n {/*\r\n * Wrapper relative: nút X nằm NGOÀI BaseSelect.Trigger (absolute)\r\n * → click X không bao giờ bubble lên Trigger → popup không mở\r\n */}\r\n <div className=\"relative w-full\">\r\n <BaseSelect.Root\r\n value={value}\r\n defaultValue={defaultValue}\r\n onValueChange={handleValueChange as (value: unknown) => void}\r\n {...props}\r\n >\r\n <BaseSelect.Trigger\r\n ref={(node) => {\r\n triggerRef.current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;\r\n }}\r\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\r\n id={id}\r\n >\r\n <span className={selectedLabel ? 'text-foreground' : 'text-muted-foreground'}>\r\n {selectedLabel ?? placeholder}\r\n </span>\r\n <BaseSelect.Icon>\r\n
|
|
578
|
+
"content": "import * as React from 'react';\r\nimport { Select as BaseSelect } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronDown, Check, X } from 'lucide-react';\r\n\r\nconst selectVariants = tv({\r\n slots: {\r\n trigger: 'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer',\r\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-md data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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',\r\n viewport: 'p-1',\r\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\r\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\r\n }\r\n});\r\n\r\nconst { trigger, content, viewport, item, icon } = selectVariants();\r\n\r\n/** Props for the Select component */\r\nexport interface SelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\r\n /** Label text displayed above the select trigger */\r\n label?: string;\r\n /** Helper text displayed below the select (hidden when error is present) */\r\n description?: string;\r\n /** Error message displayed below the select; also applies danger styling */\r\n error?: string;\r\n /** Placeholder shown when no option is selected */\r\n placeholder?: string;\r\n /** Array of selectable options */\r\n options: { label: string; value: string }[];\r\n id?: string;\r\n className?: string;\r\n /** Controlled selected value */\r\n value?: string;\r\n /** Initial selected value for uncontrolled usage */\r\n defaultValue?: string;\r\n /** Whether a clear button is shown when a value is selected */\r\n clearable?: boolean;\r\n /** Callback fired when the selected value changes */\r\n onChange?: (value: string) => void;\r\n /** Text shown when the options array is empty */\r\n emptyText?: string;\r\n /** Accessible label for the clear button */\r\n clearLabel?: string;\r\n}\r\n\r\nconst Select = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, SelectProps>(\r\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', ...props }, ref) => {\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const [selectedValue, setSelectedValue] = React.useState<string>(value ?? defaultValue ?? '');\r\n\r\n React.useEffect(() => {\r\n if (value !== undefined) setSelectedValue(value);\r\n }, [value]);\r\n\r\n const handleValueChange = (val: string) => {\r\n setSelectedValue(val);\r\n onChange?.(val);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setSelectedValue('');\r\n onChange?.('');\r\n };\r\n\r\n const selectedLabel = options.find((o) => o.value === selectedValue)?.label;\r\n\r\n return (\r\n <div className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n {/*\r\n * Wrapper relative: nút X nằm NGOÀI BaseSelect.Trigger (absolute)\r\n * → click X không bao giờ bubble lên Trigger → popup không mở\r\n */}\r\n <div className=\"relative w-full\">\r\n <BaseSelect.Root\r\n value={value}\r\n defaultValue={defaultValue}\r\n onValueChange={handleValueChange as (value: unknown) => void}\r\n {...props}\r\n >\r\n <BaseSelect.Trigger\r\n ref={(node) => {\r\n triggerRef.current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;\r\n }}\r\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\r\n id={id}\r\n >\r\n <span className={selectedLabel ? 'text-foreground' : 'text-muted-foreground'}>\r\n {selectedLabel ?? placeholder}\r\n </span>\r\n <BaseSelect.Icon>\r\n {!selectedValue && <ChevronDown className={icon()} />}\r\n </BaseSelect.Icon>\r\n </BaseSelect.Trigger>\r\n <BaseSelect.Portal>\r\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\r\n <BaseSelect.Popup className={content()}>\r\n <div className={viewport()}>\r\n {options.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\r\n {emptyText}\r\n </div>\r\n ) : (\r\n options.map((option) => (\r\n <BaseSelect.Item key={option.value} value={option.value} className={item()}>\r\n <BaseSelect.ItemIndicator className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\r\n <Check className=\"h-4 w-4\" />\r\n </BaseSelect.ItemIndicator>\r\n <BaseSelect.ItemText>{option.label}</BaseSelect.ItemText>\r\n </BaseSelect.Item>\r\n ))\r\n )}\r\n </div>\r\n </BaseSelect.Popup>\r\n </BaseSelect.Positioner>\r\n </BaseSelect.Portal>\r\n </BaseSelect.Root>\r\n\r\n {/* Nút X đặt NGOÀI Trigger, absolute position — click không bubble lên Trigger */}\r\n {clearable && selectedValue && (\r\n <button\r\n type=\"button\"\r\n aria-label={clearLabel}\r\n onMouseDown={handleClear}\r\n className=\"cursor-pointer absolute right-3 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-red-50 hover:text-red-500 transition-colors z-10\"\r\n >\r\n <X className=\"h-3 w-3\" />\r\n </button>\r\n )}\r\n </div>\r\n\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSelect.displayName = 'Select';\r\n\r\nexport { Select };\r\n"
|
|
495
579
|
}
|
|
496
580
|
]
|
|
497
581
|
},
|
|
@@ -543,7 +627,7 @@
|
|
|
543
627
|
},
|
|
544
628
|
{
|
|
545
629
|
"path": "src/components/ui/sidebar/SidebarLayout.tsx",
|
|
546
|
-
"content": "import * as React from 'react';\r\nimport { PanelLeft } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar, SIDEBAR_WIDTH_DEFAULT
|
|
630
|
+
"content": "import * as React from 'react';\r\nimport { PanelLeft } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar, SIDEBAR_WIDTH_DEFAULT } from './SidebarContext';\r\n\r\n// ─── SidebarTrigger ───────────────────────────────────────────────────────────\r\n\r\nexport const SidebarTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ButtonHTMLAttributes<HTMLButtonElement>\r\n>(({ className, onClick, ...props }, ref) => {\r\n const { toggleSidebar } = useSidebar();\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"trigger\"\r\n onClick={(e) => {\r\n toggleSidebar();\r\n onClick?.(e);\r\n }}\r\n className={cn('inline-flex items-center justify-center h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', className)}\r\n title=\"Toggle Sidebar (⌘B)\"\r\n {...props}\r\n >\r\n <PanelLeft className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Toggle Sidebar</span>\r\n </button>\r\n );\r\n});\r\nSidebarTrigger.displayName = 'SidebarTrigger';\r\n\r\n// ─── Sidebar ──────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the Sidebar panel */\r\nexport interface SidebarProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Which side of the viewport the sidebar attaches to */\r\n side?: 'left' | 'right';\r\n /** Visual variant: default border, floating card, or inset with background */\r\n variant?: 'sidebar' | 'floating' | 'inset';\r\n /** Collapse behavior: slide offcanvas, shrink to icons, or non-collapsible */\r\n collapsible?: 'offcanvas' | 'icon' | 'none';\r\n}\r\n\r\nexport const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(\r\n ({ side = 'left', variant = 'sidebar', collapsible = 'icon', className, children, ...props }, ref) => {\r\n const { state, isMobile, openMobile, setOpenMobile, sidebarWidth } = useSidebar();\r\n\r\n if (collapsible === 'none') {\r\n return (\r\n <aside\r\n ref={ref}\r\n className={cn('flex h-screen flex-col bg-sidebar border-r border-sidebar-border', className)}\r\n style={{ width: SIDEBAR_WIDTH_DEFAULT }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n\r\n if (isMobile) {\r\n return (\r\n <>\r\n {openMobile && (\r\n <div\r\n className=\"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm motion-safe:transition-opacity\"\r\n onClick={() => setOpenMobile(false)}\r\n />\r\n )}\r\n <aside\r\n ref={ref}\r\n className={cn(\r\n 'fixed inset-y-0 z-50 flex flex-col bg-sidebar border-r border-sidebar-border shadow-xl',\r\n 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-in-out',\r\n side === 'left' ? 'left-0' : 'right-0',\r\n openMobile\r\n ? 'translate-x-0'\r\n : side === 'left'\r\n ? '-translate-x-full'\r\n : 'translate-x-full',\r\n className\r\n )}\r\n style={{ width: sidebarWidth }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n </>\r\n );\r\n }\r\n\r\n return (\r\n <aside\r\n ref={ref}\r\n data-state={state}\r\n data-collapsible={state === 'collapsed' ? collapsible : ''}\r\n data-variant={variant}\r\n data-side={side}\r\n className={cn(\r\n 'group relative flex h-full w-full flex-col bg-sidebar text-sidebar-foreground',\r\n 'overflow-hidden shrink-0',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n);\r\nSidebar.displayName = 'Sidebar';\r\n\r\n// ─── SidebarRail — kept for API compatibility, resize is handled by ResizableHandle ──\r\n\r\nexport const SidebarRail = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n (_props, _ref) => null,\r\n);\r\nSidebarRail.displayName = 'SidebarRail';\r\n\r\n// ─── SidebarInset ─────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('relative flex flex-1 flex-col overflow-hidden min-w-0 bg-background', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarInset.displayName = 'SidebarInset';\r\n\r\n// ─── Layout: Header / Content / Footer ───────────────────────────────────────\r\n\r\nexport const SidebarHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"header\"\r\n className={cn('flex flex-col gap-2 p-2 shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarHeader.displayName = 'SidebarHeader';\r\n\r\nexport const SidebarFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { isMobile } = useSidebar();\r\n if (isMobile) {\r\n return null;\r\n }\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"footer\"\r\n className={cn('flex flex-col gap-2 p-2 mt-auto shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n}\r\n);\r\nSidebarFooter.displayName = 'SidebarFooter';\r\n\r\nexport const SidebarContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"content\"\r\n className={cn('flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden py-2', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarContent.displayName = 'SidebarContent';\r\n\r\nexport const SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"separator\"\r\n className={cn('mx-2 h-px border-t border-sidebar-border', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarSeparator.displayName = 'SidebarSeparator';\r\n"
|
|
547
631
|
},
|
|
548
632
|
{
|
|
549
633
|
"path": "src/components/ui/sidebar/SidebarMenu.tsx",
|
|
@@ -5,7 +5,7 @@ const COMPONENTS_DIR = './src/components/ui';
|
|
|
5
5
|
const OUTPUT_FILE = './registry.json';
|
|
6
6
|
|
|
7
7
|
// Directories to exclude from registry (not reusable components)
|
|
8
|
-
const EXCLUDE_DIRS = new Set(['icons', 'layout', 'vs-code', '
|
|
8
|
+
const EXCLUDE_DIRS = new Set(['icons', 'layout', 'vs-code', 'Showcase.tsx']);
|
|
9
9
|
|
|
10
10
|
interface RegistryFile {
|
|
11
11
|
path: string;
|