@devbycrux/editor 0.1.0

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.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. package/src/ui/utils.ts +7 -0
package/src/types.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * editor-core / types — the host-agnostic boundary for Montaj's carousel
3
+ * editor.
4
+ *
5
+ * The editor module knows nothing about *where* a project lives or *how* it is
6
+ * transported. The host application (Montaj's own UI, or a Next.js client app
7
+ * like mission-control) supplies an `EditorAdapter` that implements load / save
8
+ * / subscribe / render / image-resolution against whatever transport it owns.
9
+ *
10
+ * The canonical project/slide/element shapes live in `./schema` (the package's
11
+ * own editor-facing schema). Internally we alias `EditorProject` to `Project`
12
+ * so the ported reducer/hook/tests keep their original naming. These names are
13
+ * re-exported from this module so the package's internal modules can import
14
+ * them from `../types`; the public barrel (index.ts) sources the schema types
15
+ * from `./schema` directly to avoid duplicate-export conflicts.
16
+ */
17
+ import type { ReactElement, ReactNode } from 'react'
18
+ import type {
19
+ EditorProject as Project,
20
+ Slide,
21
+ CarouselElement,
22
+ ImageElement,
23
+ OverlayElement,
24
+ } from './schema'
25
+
26
+ // ── Overlay compiler ─────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * A compiled overlay factory: given the current frame/fps/durationFrames and
30
+ * the overlay's runtime props, returns a React element (or null on error).
31
+ * Matches the signature produced by `lib/overlay-eval`'s `compileOverlay`.
32
+ */
33
+ export type OverlayFactory = (
34
+ frame: number,
35
+ fps: number,
36
+ durationFrames: number,
37
+ props: Record<string, unknown>,
38
+ ) => ReactElement | null
39
+
40
+ // ── Re-exported canonical carousel types ─────────────────────────────────────
41
+ // schema.ts is the single source of truth. Consumers of editor-core import
42
+ // these from here so the module presents one coherent surface.
43
+ export type { Project, Slide, CarouselElement, ImageElement, OverlayElement }
44
+
45
+ // ── Render ───────────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * A single frame of render progress. Discriminated on `type`:
49
+ * - 'log' — a human-readable progress line.
50
+ * - 'done' — terminal success; `outputPath` is the rendered artifact location
51
+ * (a host-resolvable path/URL — Montaj returns a workspace path,
52
+ * a Hub client may return a media URL).
53
+ * - 'error' — terminal failure; `message` describes what went wrong.
54
+ */
55
+ export type RenderEvent =
56
+ | { type: 'log'; message: string }
57
+ | { type: 'done'; outputPath: string }
58
+ | { type: 'error'; message: string }
59
+
60
+ /**
61
+ * Options for a render request. Kept intentionally minimal — Montaj's render
62
+ * endpoint (`POST /api/projects/:id/render`) takes no body today, so `scale`
63
+ * is the only forward-looking knob and is optional. Hosts ignore fields they
64
+ * don't support.
65
+ */
66
+ export interface RenderOptions {
67
+ /** Output scale multiplier (1 = native resolution). */
68
+ scale?: number
69
+ }
70
+
71
+ // ── Overlay library types ─────────────────────────────────────────────────────
72
+ // Copied verbatim from Montaj's `ui/src/lib/api.ts` so the package owns the
73
+ // shape the editor consumes. A host's overlay-listing endpoints return these;
74
+ // the adapter wraps whatever transport produces them.
75
+
76
+ /**
77
+ * A single declared prop on an overlay template. `type` drives the input
78
+ * control the editor renders; `default` seeds an unset value.
79
+ */
80
+ export interface GlobalOverlayProp {
81
+ name: string
82
+ type: 'string' | 'int' | 'float' | 'bool' | 'color'
83
+ default?: unknown
84
+ description?: string
85
+ }
86
+
87
+ /**
88
+ * A reusable overlay template the host exposes (global, system, or
89
+ * profile-scoped). `jsxPath` is the host-resolvable path to the JSX template
90
+ * the adapter feeds to `compileOverlay`; `group` is an optional UI grouping;
91
+ * `empty` flags a placeholder group with no concrete overlay yet.
92
+ */
93
+ export interface GlobalOverlay {
94
+ name: string
95
+ description: string
96
+ props: GlobalOverlayProp[]
97
+ jsxPath: string
98
+ group?: string
99
+ empty?: boolean
100
+ }
101
+
102
+ // ── Media (optional capability) ───────────────────────────────────────────────
103
+
104
+ /**
105
+ * Scope for a media-library query. Grounded in mission-control's
106
+ * `UseMediaListScope`: media is either project-scoped or drawn from the host's
107
+ * universal/global library.
108
+ */
109
+ export type MediaScope =
110
+ | { kind: 'universal' }
111
+ | { kind: 'project'; projectId: string }
112
+
113
+ /**
114
+ * A minimal media-library item. Hosts may carry more fields, but the editor
115
+ * only relies on these: an id to reference, a resolvable URL to display, a
116
+ * MIME content type, and an optional display name.
117
+ */
118
+ export interface MediaItem {
119
+ id: string
120
+ /** A directly displayable URL (presigned, workspace, or otherwise host-resolved). */
121
+ url: string
122
+ contentType: string
123
+ name?: string
124
+ }
125
+
126
+ // ── Adapter ────────────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * The contract a host implements to drive the editor. All transport,
130
+ * authentication, and URL-shape concerns live behind this interface; the
131
+ * editor calls only these methods.
132
+ *
133
+ * Generic over the host's concrete project type `P` (constrained to the
134
+ * editor-facing `Project` = EditorProject). Montaj instantiates it with its
135
+ * full `Project`; a host with no extra fields gets the default `Project`. This
136
+ * lets the host's pipeline fields survive load→edit→save round-trips at the
137
+ * type level without casts.
138
+ */
139
+ export interface EditorAdapter<P extends Project = Project> {
140
+ /** Fetch the full project by id. */
141
+ loadProject(id: string): Promise<P>
142
+
143
+ /** Persist the full project. Mirrors Montaj's `PUT /api/projects/:id`. */
144
+ saveProject(id: string, project: P): Promise<void>
145
+
146
+ /**
147
+ * Subscribe to live project frames (e.g. an SSE stream). `onFrame` is invoked
148
+ * with each fresh project snapshot. Returns an unsubscribe function the
149
+ * editor calls on teardown.
150
+ */
151
+ subscribe(id: string, onFrame: (project: P) => void): () => void
152
+
153
+ /**
154
+ * Start a render and stream progress as an async iterable of `RenderEvent`s.
155
+ * The iterable completes after a terminal 'done' or 'error' event.
156
+ */
157
+ render(id: string, opts?: RenderOptions): AsyncIterable<RenderEvent>
158
+
159
+ /**
160
+ * Resolve an `ImageElement` to a directly displayable URL. This is the host's
161
+ * job because the resolution rule differs per host:
162
+ * - Montaj returns a workspace/files URL (e.g. `/api/files?path=...`).
163
+ * - Hub clients resolve a `mediaId` → presigned URL.
164
+ * The editor never assumes a URL shape — it always routes through here.
165
+ */
166
+ resolveImageSrc(element: ImageElement): string
167
+
168
+ /**
169
+ * Optional: list media available to the editor in the given scope. Hosts
170
+ * without a media library omit this; the editor must feature-detect it.
171
+ */
172
+ listMedia?(scope: MediaScope): Promise<MediaItem[]>
173
+
174
+ /**
175
+ * Compile a JSX overlay template file into an `OverlayFactory`.
176
+ * The host supplies this because the compilation pipeline (Babel, fetch
177
+ * strategy, caching) is host-specific. The editor-core preview component
178
+ * receives it as a prop; nothing inside editor-core imports the host's
179
+ * compiler directly.
180
+ */
181
+ compileOverlay(template: string): Promise<OverlayFactory>
182
+
183
+ /**
184
+ * List the host's global (workspace-wide) overlay templates. The assembled
185
+ * editor's overlay picker reads these. Maps to Montaj's `GET /api/overlays`.
186
+ */
187
+ listGlobalOverlays(): Promise<GlobalOverlay[]>
188
+
189
+ /**
190
+ * List the host's built-in/system overlay templates. Maps to Montaj's
191
+ * `GET /api/overlays/system`.
192
+ */
193
+ listSystemOverlays(): Promise<GlobalOverlay[]>
194
+
195
+ /**
196
+ * Upload a file and return a host-resolvable path/ref. When `projectId` is
197
+ * given, the host should store it inside the project (so it stays
198
+ * self-contained); otherwise a shared/upload location is used. Maps to
199
+ * Montaj's `POST /api/projects/:id/upload-asset` (or `POST /api/upload`).
200
+ */
201
+ uploadFile(file: File, projectId?: string): Promise<string>
202
+
203
+ /**
204
+ * Map a host path to a directly fetchable URL. Synchronous because hosts
205
+ * derive it by string transform (Montaj: `/api/files?path=...`). Distinct
206
+ * from `resolveImageSrc`, which takes an `ImageElement` and applies element
207
+ * resolution rules; `fileUrl` is the raw path→URL primitive.
208
+ */
209
+ fileUrl(path: string): string
210
+
211
+ /**
212
+ * Optional: list overlay templates scoped to a named profile. Hosts without
213
+ * profile-scoped overlays omit this. Maps to Montaj's
214
+ * `GET /api/profiles/:name/overlays`.
215
+ */
216
+ listProfileOverlays?(profileName: string): Promise<GlobalOverlay[]>
217
+
218
+ /**
219
+ * Optional: host environment info the editor may surface (e.g. the root
220
+ * skill path for authoring overlays). Maps to Montaj's `GET /api/info`.
221
+ */
222
+ getInfo?(): Promise<{ root_skill_path?: string }>
223
+
224
+ /**
225
+ * Optional: generate an image from a prompt and return its host path. Hosts
226
+ * without AI image generation omit this; the editor feature-detects it.
227
+ */
228
+ generateImage?(prompt: string, projectId: string): Promise<{ path: string }>
229
+
230
+ /**
231
+ * Optional: watch a host file path for changes, invoking `onChange` whenever
232
+ * the file is rewritten. Returns an unsubscribe function. The editor uses this
233
+ * to auto-recover an overlay preview when its source is edited on disk. Hosts
234
+ * without a file-watch transport omit this; the editor simply doesn't watch
235
+ * (no fallback EventSource). Montaj wires this to its `/api/files/stream` SSE.
236
+ */
237
+ watchFile?(path: string, onChange: () => void): () => void
238
+
239
+ /**
240
+ * Optional: resolve the host's default "static text" overlay template — the
241
+ * one the editor's "+ Text" button seeds. Returns null when the host has no
242
+ * such template (the editor then hides "+ Text"). Hosts without any system
243
+ * text overlay omit this entirely. Montaj implements it over
244
+ * `listSystemOverlays()` + its `static-text` matcher.
245
+ */
246
+ getDefaultTextOverlay?(): Promise<GlobalOverlay | null>
247
+ }
248
+
249
+ // ── Theme ────────────────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * A flat token record describing the editor's visual language. The host passes
253
+ * one of these (or relies on the Montaj default). `applyTheme` (in theme.ts)
254
+ * writes these tokens as CSS custom properties so styling stays declarative and
255
+ * host-overridable.
256
+ */
257
+ export interface EditorTheme {
258
+ colors: {
259
+ /** Outermost canvas/page background. */
260
+ background: string
261
+ /** Raised panels, toolbars, inspectors. */
262
+ surface: string
263
+ /** Primary interactive/brand accent. */
264
+ accent: string
265
+ /** Default text color. */
266
+ text: string
267
+ /** Hairline/divider color. */
268
+ border: string
269
+ /** Selection outline / active-element highlight. */
270
+ selection: string
271
+ }
272
+ fonts: {
273
+ sans: string
274
+ serif?: string
275
+ display?: string
276
+ }
277
+ /** Border-radius scale, smallest → largest. */
278
+ radii: {
279
+ sm: string
280
+ md: string
281
+ lg: string
282
+ }
283
+ /**
284
+ * Spacing scale keyed by step. Indices follow a 4px-base rhythm (matching
285
+ * Tailwind's `1`=4px, `2`=8px, …). Values are CSS lengths.
286
+ */
287
+ spacing: Record<number, string>
288
+ }
289
+
290
+ // ── Host-injected UI ──────────────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Optional UI the host injects into editor slots — e.g. a "Publish to Hub"
294
+ * button in the toolbar, or app-specific export controls.
295
+ */
296
+ export interface EditorSlots {
297
+ /** Rendered into the editor toolbar's action area. */
298
+ toolbarActions?: ReactNode
299
+ /** Rendered into the editor's export/render action area. */
300
+ exportActions?: ReactNode
301
+ /** Rendered into the editor's assets/media panel area. */
302
+ assetsPanel?: ReactNode
303
+ /**
304
+ * Rendered in the pending/empty view in place of the default
305
+ * "Message your agent to start" copy. Hosts use this to surface live agent
306
+ * progress (Montaj feeds its SSE log line here); absent → default copy shows.
307
+ */
308
+ pendingStatus?: ReactNode
309
+ }
310
+
311
+ // ── Top-level component props ──────────────────────────────────────────────────
312
+
313
+ /**
314
+ * Props for the carousel editor component. Controlled shape: the host owns the
315
+ * `project` and is notified of edits via `onProjectChange`. The adapter drives
316
+ * transport; theme and slots are optional, and `readOnly` disables mutation.
317
+ */
318
+ export interface CarouselEditorProps<P extends Project = Project> {
319
+ project: P
320
+ adapter: EditorAdapter<P>
321
+ onProjectChange?: (p: P) => void
322
+ theme?: EditorTheme
323
+ slots?: EditorSlots
324
+ readOnly?: boolean
325
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Button } from '../button'
4
+
5
+ describe('vendored Button', () => {
6
+ it('renders its children', () => {
7
+ render(<Button>Click me</Button>)
8
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
9
+ })
10
+
11
+ it('fires onClick when clicked', () => {
12
+ const onClick = vi.fn()
13
+ render(<Button onClick={onClick}>Go</Button>)
14
+ fireEvent.click(screen.getByRole('button', { name: 'Go' }))
15
+ expect(onClick).toHaveBeenCalledTimes(1)
16
+ })
17
+ })
@@ -0,0 +1,32 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { cn } from './utils'
3
+
4
+ const badgeVariants = cva(
5
+ 'inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold',
6
+ {
7
+ variants: {
8
+ variant: {
9
+ pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
10
+ draft: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
11
+ final: 'bg-emerald-100 text-emerald-700 dark:bg-green-900/50 dark:text-green-300',
12
+ default: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
13
+ },
14
+ },
15
+ defaultVariants: { variant: 'default' },
16
+ },
17
+ )
18
+
19
+ export interface BadgeProps
20
+ extends React.HTMLAttributes<HTMLSpanElement>,
21
+ VariantProps<typeof badgeVariants> {}
22
+
23
+ export function Badge({ className, variant, ...props }: BadgeProps) {
24
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />
25
+ }
26
+
27
+ export function StatusBadge({ status }: { status: string }) {
28
+ const variant = ['pending', 'draft', 'final'].includes(status)
29
+ ? (status as 'pending' | 'draft' | 'final')
30
+ : 'default'
31
+ return <Badge variant={variant}>{status}</Badge>
32
+ }
@@ -0,0 +1,32 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { cn } from './utils'
3
+
4
+ const buttonVariants = cva(
5
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:pointer-events-none disabled:opacity-50',
6
+ {
7
+ variants: {
8
+ variant: {
9
+ default: 'bg-blue-600 text-white hover:bg-blue-700',
10
+ secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
11
+ ghost: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100',
12
+ danger: 'bg-red-600 text-white hover:bg-red-700',
13
+ outline: 'border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
14
+ },
15
+ size: {
16
+ default: 'h-9 px-4 py-2',
17
+ sm: 'h-7 px-3 text-xs',
18
+ lg: 'h-11 px-6',
19
+ icon: 'h-9 w-9',
20
+ },
21
+ },
22
+ defaultVariants: { variant: 'default', size: 'default' },
23
+ },
24
+ )
25
+
26
+ export interface ButtonProps
27
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
28
+ VariantProps<typeof buttonVariants> {}
29
+
30
+ export function Button({ className, variant, size, ...props }: ButtonProps) {
31
+ return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
32
+ }
@@ -0,0 +1,16 @@
1
+ // Vendored UI primitives — self-contained copies of Montaj's shadcn-style
2
+ // components so the package has no `@/` host dependency. Tailwind classes are
3
+ // preserved verbatim; the host supplies the matching Tailwind theme.
4
+ export { cn } from './utils'
5
+ export { Button } from './button'
6
+ export type { ButtonProps } from './button'
7
+ export { Input } from './input'
8
+ export type { InputProps } from './input'
9
+ export { Label } from './label'
10
+ export { Select } from './select'
11
+ export type { SelectProps } from './select'
12
+ export { Switch } from './switch'
13
+ export { Textarea } from './textarea'
14
+ export type { TextareaProps } from './textarea'
15
+ export { Badge, StatusBadge } from './badge'
16
+ export type { BadgeProps } from './badge'
@@ -0,0 +1,15 @@
1
+ import { cn } from './utils'
2
+
3
+ export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
4
+
5
+ export function Input({ className, ...props }: InputProps) {
6
+ return (
7
+ <input
8
+ className={cn(
9
+ 'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500',
10
+ className,
11
+ )}
12
+ {...props}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,10 @@
1
+ import { cn } from './utils'
2
+
3
+ export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
4
+ return (
5
+ <label
6
+ className={cn('text-xs font-medium text-gray-400 leading-none', className)}
7
+ {...props}
8
+ />
9
+ )
10
+ }
@@ -0,0 +1,23 @@
1
+ import { cn } from './utils'
2
+
3
+ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
4
+ options: Array<{ value: string; label: string }>
5
+ }
6
+
7
+ export function Select({ className, options, ...props }: SelectProps) {
8
+ return (
9
+ <select
10
+ className={cn(
11
+ 'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100',
12
+ className,
13
+ )}
14
+ {...props}
15
+ >
16
+ {options.map((o) => (
17
+ <option key={o.value} value={o.value}>
18
+ {o.label}
19
+ </option>
20
+ ))}
21
+ </select>
22
+ )
23
+ }
@@ -0,0 +1,31 @@
1
+ import { cn } from './utils'
2
+
3
+ interface SwitchProps {
4
+ checked: boolean
5
+ onCheckedChange: (v: boolean) => void
6
+ className?: string
7
+ disabled?: boolean
8
+ }
9
+
10
+ export function Switch({ checked, onCheckedChange, className, disabled }: SwitchProps) {
11
+ return (
12
+ <button
13
+ role="switch"
14
+ aria-checked={checked}
15
+ disabled={disabled}
16
+ onClick={() => onCheckedChange(!checked)}
17
+ className={cn(
18
+ 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50',
19
+ checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-700',
20
+ className,
21
+ )}
22
+ >
23
+ <span
24
+ className={cn(
25
+ 'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
26
+ checked ? 'translate-x-4' : 'translate-x-1',
27
+ )}
28
+ />
29
+ </button>
30
+ )
31
+ }
@@ -0,0 +1,15 @@
1
+ import { cn } from './utils'
2
+
3
+ export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
4
+
5
+ export function Textarea({ className, ...props }: TextareaProps) {
6
+ return (
7
+ <textarea
8
+ className={cn(
9
+ 'flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500',
10
+ className,
11
+ )}
12
+ {...props}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,7 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ /** Merge conditional class names, de-duplicating Tailwind conflicts. */
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }