@dyrected/admin 1.0.1

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 (95) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE.md +50 -0
  3. package/README.md +73 -0
  4. package/components.json +17 -0
  5. package/eslint.config.js +22 -0
  6. package/index.html +13 -0
  7. package/package.json +99 -0
  8. package/postcss.config.js +6 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/icons.svg +24 -0
  11. package/src/App.css +184 -0
  12. package/src/App.tsx +25 -0
  13. package/src/assets/dyrected.svg +155 -0
  14. package/src/assets/hero.png +0 -0
  15. package/src/assets/react.svg +1 -0
  16. package/src/assets/vite.svg +1 -0
  17. package/src/components/auth/auth-gate.tsx +64 -0
  18. package/src/components/error-boundary.tsx +45 -0
  19. package/src/components/forms/field-renderer.tsx +111 -0
  20. package/src/components/forms/fields/block-builder.tsx +213 -0
  21. package/src/components/forms/fields/date-picker.tsx +60 -0
  22. package/src/components/forms/fields/json-editor.tsx +62 -0
  23. package/src/components/forms/fields/media-picker.tsx +286 -0
  24. package/src/components/forms/fields/multi-select.tsx +145 -0
  25. package/src/components/forms/fields/radio-field.tsx +51 -0
  26. package/src/components/forms/fields/relationship-picker.tsx +143 -0
  27. package/src/components/forms/fields/rich-text-editor.tsx +224 -0
  28. package/src/components/forms/fields/select-field.tsx +35 -0
  29. package/src/components/forms/fields/switch-field.tsx +16 -0
  30. package/src/components/forms/fields/text-area-field.tsx +15 -0
  31. package/src/components/forms/fields/text-field.tsx +24 -0
  32. package/src/components/forms/form-engine.tsx +87 -0
  33. package/src/components/forms/form-field-renderer.tsx +269 -0
  34. package/src/components/forms/utils.ts +97 -0
  35. package/src/components/layout/admin-shell.tsx +479 -0
  36. package/src/components/layout/branding-provider.tsx +112 -0
  37. package/src/components/live-preview/LivePreviewPane.tsx +128 -0
  38. package/src/components/media/focal-point-picker.tsx +66 -0
  39. package/src/components/media/media-card.tsx +44 -0
  40. package/src/components/media/media-grid.tsx +32 -0
  41. package/src/components/media/media-library-dialog.tsx +465 -0
  42. package/src/components/ui/aspect-ratio.tsx +7 -0
  43. package/src/components/ui/badge.tsx +36 -0
  44. package/src/components/ui/button.tsx +56 -0
  45. package/src/components/ui/calendar.tsx +214 -0
  46. package/src/components/ui/card.tsx +79 -0
  47. package/src/components/ui/checkbox.tsx +28 -0
  48. package/src/components/ui/command.tsx +151 -0
  49. package/src/components/ui/data-table.tsx +219 -0
  50. package/src/components/ui/dialog.tsx +122 -0
  51. package/src/components/ui/dropdown-menu.tsx +200 -0
  52. package/src/components/ui/form.tsx +178 -0
  53. package/src/components/ui/input.tsx +24 -0
  54. package/src/components/ui/label.tsx +24 -0
  55. package/src/components/ui/page-header.tsx +30 -0
  56. package/src/components/ui/pagination.tsx +57 -0
  57. package/src/components/ui/popover.tsx +29 -0
  58. package/src/components/ui/progress.tsx +26 -0
  59. package/src/components/ui/radio-group.tsx +42 -0
  60. package/src/components/ui/render-cell.tsx +110 -0
  61. package/src/components/ui/scroll-area.tsx +46 -0
  62. package/src/components/ui/select.tsx +160 -0
  63. package/src/components/ui/separator.tsx +29 -0
  64. package/src/components/ui/sheet.tsx +140 -0
  65. package/src/components/ui/sidebar.tsx +771 -0
  66. package/src/components/ui/skeleton.tsx +15 -0
  67. package/src/components/ui/sonner.tsx +27 -0
  68. package/src/components/ui/switch.tsx +27 -0
  69. package/src/components/ui/table.tsx +117 -0
  70. package/src/components/ui/tabs.tsx +53 -0
  71. package/src/components/ui/textarea.tsx +22 -0
  72. package/src/components/ui/toggle.tsx +43 -0
  73. package/src/components/ui/tooltip.tsx +28 -0
  74. package/src/hooks/use-mobile.tsx +19 -0
  75. package/src/hooks/use-preferences.ts +56 -0
  76. package/src/index.css +111 -0
  77. package/src/index.tsx +198 -0
  78. package/src/lib/utils.ts +32 -0
  79. package/src/main.tsx +10 -0
  80. package/src/pages/auth/first-user-page.tsx +115 -0
  81. package/src/pages/auth/login-page.tsx +91 -0
  82. package/src/pages/collections/edit-page.tsx +280 -0
  83. package/src/pages/collections/list-page.tsx +343 -0
  84. package/src/pages/dashboard/dashboard.tsx +150 -0
  85. package/src/pages/globals/editor-page.tsx +122 -0
  86. package/src/pages/media/media-page.tsx +564 -0
  87. package/src/pages/setup/setup-prompt.tsx +152 -0
  88. package/src/providers/dyrected-provider.tsx +122 -0
  89. package/src/providers/query-provider.tsx +19 -0
  90. package/src/types/jexl.d.ts +11 -0
  91. package/tailwind.config.ts +102 -0
  92. package/tsconfig.app.json +29 -0
  93. package/tsconfig.json +12 -0
  94. package/tsconfig.node.json +27 -0
  95. package/vite.config.ts +36 -0
@@ -0,0 +1,479 @@
1
+ import * as React from "react"
2
+ import { useState, useEffect } from "react"
3
+ import { useQuery } from "@tanstack/react-query"
4
+ import { Link, useLocation } from "react-router-dom"
5
+ import {
6
+ Database,
7
+ Image as ImageIcon,
8
+ Settings,
9
+ LogOut,
10
+ Menu,
11
+ X,
12
+ ChevronRight,
13
+ ChevronDown,
14
+ PanelLeftClose,
15
+ PanelLeftOpen,
16
+ Sparkles,
17
+ Lock,
18
+ Shield,
19
+ Share2,
20
+ LayoutDashboard,
21
+ Users,
22
+ } from "lucide-react"
23
+ import { useDyrected } from "../../providers/dyrected-provider"
24
+ import { cn, getMediaUrl } from "../../lib/utils"
25
+ import { BrandingProvider } from "./branding-provider"
26
+ import logo from "@/assets/dyrected.svg"
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Single nav item
30
+ // ---------------------------------------------------------------------------
31
+ function NavItem({
32
+ to,
33
+ icon: Icon,
34
+ label,
35
+ active,
36
+ collapsed,
37
+ onClick,
38
+ }: {
39
+ to: string
40
+ icon: React.ElementType
41
+ label: React.ReactNode
42
+ active: boolean
43
+ collapsed: boolean
44
+ onClick?: () => void
45
+ }) {
46
+ return (
47
+ <Link
48
+ to={to}
49
+ onClick={onClick}
50
+ className={cn(
51
+ "group flex items-center gap-3 rounded-md px-3 py-2 text-[13px] font-medium transition-all duration-150",
52
+ collapsed ? "justify-center px-2" : "",
53
+ active
54
+ ? "bg-primary text-primary-foreground"
55
+ : "text-muted-foreground hover:bg-accent hover:text-foreground"
56
+ )}
57
+ >
58
+ <Icon
59
+ className={cn(
60
+ "shrink-0 transition-colors",
61
+ collapsed ? "h-[17px] w-[17px]" : "h-[15px] w-[15px]",
62
+ active ? "text-background" : "text-muted-foreground group-hover:text-foreground"
63
+ )}
64
+ />
65
+ {!collapsed && <span className="truncate">{label}</span>}
66
+ {!collapsed && active && (
67
+ <ChevronRight className="ml-auto h-3.5 w-3.5 opacity-50 shrink-0" />
68
+ )}
69
+ </Link>
70
+ )
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Nav Group (Collapsible)
75
+ // ---------------------------------------------------------------------------
76
+ function NavGroup({
77
+ label,
78
+ children,
79
+ collapsed,
80
+ defaultExpanded = true,
81
+ }: {
82
+ label: string
83
+ children: React.ReactNode
84
+ collapsed: boolean
85
+ defaultExpanded?: boolean
86
+ }) {
87
+ const [expanded, setExpanded] = useState(defaultExpanded)
88
+
89
+ if (collapsed) {
90
+ return (
91
+ <div className="space-y-1">
92
+ <div className="my-2 mx-3 h-px bg-border" />
93
+ {children}
94
+ </div>
95
+ )
96
+ }
97
+
98
+ return (
99
+ <div className="space-y-1">
100
+ <button
101
+ onClick={() => setExpanded(!expanded)}
102
+ className="flex w-full items-center justify-between px-3 mt-4 mb-1 group"
103
+ >
104
+ <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/40 group-hover:text-muted-foreground/60 transition-colors">
105
+ {label}
106
+ </span>
107
+ {expanded ? (
108
+ <ChevronDown className="h-3 w-3 text-muted-foreground/30 group-hover:text-muted-foreground/50" />
109
+ ) : (
110
+ <ChevronRight className="h-3 w-3 text-muted-foreground/30 group-hover:text-muted-foreground/50" />
111
+ )}
112
+ </button>
113
+ <div className={cn("space-y-0.5 overflow-hidden transition-all duration-200", expanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0")}>
114
+ {children}
115
+ </div>
116
+ </div>
117
+ )
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Sidebar inner content (shared)
122
+ // ---------------------------------------------------------------------------
123
+ function SidebarInner({
124
+ schemas,
125
+ isLoading,
126
+ location,
127
+ logout,
128
+ isEmbedded,
129
+ collapsed,
130
+ onToggleCollapse,
131
+ onNavigate,
132
+ }: {
133
+ schemas: any
134
+ isLoading: boolean
135
+ location: ReturnType<typeof useLocation>
136
+ logout: () => void
137
+ isEmbedded: boolean
138
+ collapsed: boolean
139
+ onToggleCollapse?: () => void
140
+ onNavigate?: () => void
141
+ }) {
142
+ const { client } = useDyrected()
143
+ const collections = schemas?.collections?.filter((c: any) => !c?.admin?.hidden && !c?.slug.startsWith('platform_')) ?? []
144
+ const globals = schemas?.globals?.filter((g: any) => !g?.admin?.hidden && !g?.slug.startsWith('platform_')) ?? []
145
+ const uploadCollections = collections.filter((c: any) => c.upload)
146
+
147
+ const groupLabel = (text: string) =>
148
+ !collapsed ? (
149
+ <p className="px-3 mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
150
+ {text}
151
+ </p>
152
+ ) : (
153
+ <div className="my-2 mx-3 h-px bg-border" />
154
+ )
155
+
156
+ const branding = schemas?.admin?.branding;
157
+
158
+ return (
159
+ <div className="flex flex-col min-h-screen admin-ui">
160
+ {/* Logo */}
161
+ <div
162
+ className={cn(
163
+ "flex items-center h-14 shrink-0 transition-all",
164
+ collapsed ? "justify-center px-2" : "gap-2.5 px-4"
165
+ )}
166
+ >
167
+ <div>
168
+ {!isEmbedded && (
169
+ <>
170
+ {branding?.logo || branding?.logoMark ? (
171
+ <div className="h-7 w-7 flex items-center justify-center shrink-0">
172
+ <img
173
+ src={getMediaUrl(collapsed ? (branding.logoMark || branding.logo) : (branding.logo || branding.logoMark), client?.getBaseUrl() || "")}
174
+ alt="Logo"
175
+ className="max-h-full max-w-full object-contain"
176
+ />
177
+ </div>
178
+ ) : (
179
+ <div className="h-7 w-auto flex items-center justify-center shrink-0">
180
+ <img src={logo} alt="Dyrected" className="h-8 w-auto" />
181
+ </div>
182
+ )}
183
+ {!collapsed && (
184
+ <span className="font-serif text-lg tracking-tight text-foreground flex-1 truncate">
185
+ {branding?.titleSuffix?.replace(/^- /, '') || ''}
186
+ </span>
187
+ )}
188
+ </>
189
+ )}
190
+ </div>
191
+ {/* Desktop Toggle - Only visible on desktop since mobile uses overlay */}
192
+
193
+ </div>
194
+
195
+
196
+ {/* Nav */}
197
+ <nav className="flex-1 overflow-y-auto py-4 px-2 space-y-4">
198
+ <div>
199
+ <NavItem
200
+ to="/"
201
+ icon={LayoutDashboard}
202
+ label="Dashboard"
203
+ active={location.pathname === "/" || location.pathname === ""}
204
+ collapsed={collapsed}
205
+ onClick={onNavigate}
206
+ />
207
+ </div>
208
+
209
+ {uploadCollections.length > 0 && (
210
+ <div>
211
+ {groupLabel("Media")}
212
+ {uploadCollections.map((col: any) => (
213
+ <NavItem
214
+ key={col.slug}
215
+ to={`/collections/${col.slug}`}
216
+ icon={ImageIcon}
217
+ label={col.labels?.plural ?? col.label ?? col.slug}
218
+ active={location.pathname.startsWith(`/collections/${col.slug}`)}
219
+ collapsed={collapsed}
220
+ onClick={onNavigate}
221
+ />
222
+ ))}
223
+ </div>
224
+ )}
225
+
226
+ {(isLoading || collections.filter((c: any) => !c.upload).length > 0) && (
227
+ <div>
228
+ {isLoading ? (
229
+ <div className="space-y-1 px-1">
230
+ {[1, 2, 3].map((i) => (
231
+ <div key={i} className={cn("h-8 rounded-md bg-muted/60 animate-pulse", collapsed ? "mx-1" : "mx-2")} />
232
+ ))}
233
+ </div>
234
+ ) : (() => {
235
+ const nonUpload = collections.filter((col: any) => !col.upload)
236
+ const groups = new Map<string, any[]>()
237
+ const ungrouped: any[] = []
238
+
239
+ nonUpload.forEach((col: any) => {
240
+ let g = col.admin?.group
241
+ if (!g && col.auth) g = "System"
242
+
243
+ if (g) {
244
+ if (!groups.has(g)) groups.set(g, [])
245
+ groups.get(g)!.push(col)
246
+ } else {
247
+ ungrouped.push(col)
248
+ }
249
+ })
250
+
251
+ const renderCollectionItem = (col: any) => {
252
+ const isReadOnly = col.access?.read && !col.access?.create && !col.access?.update && !col.access?.delete
253
+ const navLabel = (
254
+ <div className="flex items-center gap-1.5 min-w-0">
255
+ <span className="truncate">{col.labels?.plural ?? col.label ?? col.slug}</span>
256
+ {!collapsed && (
257
+ <div className="flex gap-1 shrink-0">
258
+ {col.auth && <Shield className="h-4 w-4 text-primary/70" />}
259
+ {col.shared && <Share2 className="h-4 w-4 text-purple-500/70" />}
260
+ {isReadOnly && <Lock className="h-4 w-4 text-muted-foreground/40" />}
261
+ </div>
262
+ )}
263
+ </div>
264
+ )
265
+
266
+ return (
267
+ <NavItem
268
+ key={col.slug}
269
+ to={`/collections/${col.slug}`}
270
+ icon={col.auth ? Users : Database}
271
+ label={navLabel}
272
+ active={location.pathname.startsWith(`/collections/${col.slug}`)}
273
+ collapsed={collapsed}
274
+ onClick={onNavigate}
275
+ />
276
+ )
277
+ }
278
+
279
+ return (
280
+ <div className="space-y-1">
281
+ {/* Grouped sections */}
282
+ {Array.from(groups.entries()).map(([groupName, cols]) => (
283
+ <NavGroup key={groupName} label={groupName} collapsed={collapsed} defaultExpanded={true}>
284
+ {cols.map(col => renderCollectionItem(col))}
285
+ </NavGroup>
286
+ ))}
287
+
288
+ {/* Ungrouped */}
289
+ {ungrouped.length > 0 && (
290
+ <NavGroup label="Collections" collapsed={collapsed} defaultExpanded={true}>
291
+ {ungrouped.map(col => renderCollectionItem(col))}
292
+ </NavGroup>
293
+ )}
294
+ </div>
295
+ )
296
+ })()}
297
+ </div>
298
+ )}
299
+
300
+
301
+ {globals.length > 0 && (
302
+ <div>
303
+ {groupLabel("Configuration")}
304
+ <div className="space-y-0.5">
305
+ {globals.map((glob: any) => (
306
+ <NavItem
307
+ key={glob.slug}
308
+ to={`/globals/${glob.slug}`}
309
+ icon={Settings}
310
+ label={glob.label ?? glob.slug}
311
+ active={location.pathname === `/globals/${glob.slug}`}
312
+ collapsed={collapsed}
313
+ onClick={onNavigate}
314
+ />
315
+ ))}
316
+ </div>
317
+ </div>
318
+ )}
319
+ </nav>
320
+
321
+ {/* Footer */}
322
+ <div className="border-t border-border px-2 py-3 shrink-0 space-y-0.5">
323
+ {/* Integration guide — always visible so embedded users can access the prompt */}
324
+ <NavItem
325
+ to="/setup"
326
+ icon={Sparkles}
327
+ label="Integration Guide"
328
+ active={location.pathname === "/setup"}
329
+ collapsed={collapsed}
330
+ onClick={onNavigate}
331
+ />
332
+
333
+ {!isEmbedded && (
334
+ <button
335
+ onClick={logout}
336
+ title={collapsed ? "Logout" : undefined}
337
+ className={cn(
338
+ "flex w-full items-center gap-3 rounded-md px-3 py-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive",
339
+ collapsed ? "justify-center px-2" : ""
340
+ )}
341
+ >
342
+ <LogOut className="h-[15px] w-[15px] shrink-0" />
343
+ {!collapsed && <span>Logout</span>}
344
+ </button>
345
+ )}
346
+ </div>
347
+
348
+ {/* Desktop Collapse Toggle at Bottom */}
349
+ {onToggleCollapse && !isEmbedded && (
350
+ <div className="mt-auto p-4 border-t border-border/40">
351
+ <button
352
+ onClick={onToggleCollapse}
353
+ className={cn(
354
+ "w-full flex items-center gap-3 p-2.5 rounded-xl text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-all group/btn",
355
+ collapsed ? "justify-center" : "px-3"
356
+ )}
357
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
358
+ >
359
+ {collapsed ? (
360
+ <PanelLeftOpen className="h-5 w-5" />
361
+ ) : (
362
+ <>
363
+ <PanelLeftClose className="h-5 w-5 group-hover/btn:-translate-x-0.5 transition-transform" />
364
+ <span className="text-sm font-medium text-[13px]">Collapse Sidebar</span>
365
+ </>
366
+ )}
367
+ </button>
368
+ </div>
369
+ )}
370
+ </div>
371
+ )
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // AdminShell
376
+ // ---------------------------------------------------------------------------
377
+ export function AdminShell({
378
+ children,
379
+ isEmbedded = false,
380
+ }: {
381
+ children: React.ReactNode
382
+ isEmbedded?: boolean
383
+ }) {
384
+ const { client, logout } = useDyrected()
385
+ const location = useLocation()
386
+
387
+ // Desktop: collapsed state (sidebar still sits in the layout)
388
+ const [collapsed, setCollapsed] = useState(false)
389
+ // Mobile: open/close overlay
390
+ const [mobileOpen, setMobileOpen] = useState(false)
391
+
392
+ // Close mobile sidebar on navigation
393
+ useEffect(() => {
394
+ setMobileOpen(false)
395
+ }, [location.pathname])
396
+
397
+ // Lock scroll on mobile when open
398
+ useEffect(() => {
399
+ document.body.style.overflow = mobileOpen ? "hidden" : ""
400
+ return () => { document.body.style.overflow = "" }
401
+ }, [mobileOpen])
402
+
403
+ const { data: schemas, isLoading } = useQuery({
404
+ queryKey: ["schemas"],
405
+ queryFn: async () => {
406
+ if (!client) return null
407
+ return client.getSchemas()
408
+ },
409
+ enabled: !!client,
410
+ })
411
+
412
+ return (
413
+ <BrandingProvider>
414
+ <div className={cn("flex w-full relative", isEmbedded ? "h-full min-h-[600px]" : "min-h-screen")}>
415
+ {/* ... existing sidebar and main content ... */}
416
+ <aside
417
+ className={cn(
418
+ "hidden md:flex flex-col shrink-0 h-full border-r border-border bg-background transition-all duration-300 overflow-hidden",
419
+ collapsed ? "w-[56px]" : "w-[220px]"
420
+ )}
421
+ >
422
+ <SidebarInner
423
+ schemas={schemas}
424
+ isLoading={isLoading}
425
+ location={location}
426
+ logout={logout}
427
+ isEmbedded={isEmbedded}
428
+ collapsed={collapsed}
429
+ onToggleCollapse={() => setCollapsed((v) => !v)}
430
+ />
431
+ </aside>
432
+
433
+ {mobileOpen && (
434
+ <div
435
+ className="fixed inset-0 z-30 bg-black/30 md:hidden"
436
+ onClick={() => setMobileOpen(false)}
437
+ />
438
+ )}
439
+ <aside
440
+ className={cn(
441
+ "fixed top-0 left-0 z-40 h-full w-[220px] flex flex-col border-r border-border bg-background transition-transform duration-300 ease-in-out md:hidden",
442
+ mobileOpen ? "translate-x-0" : "-translate-x-full"
443
+ )}
444
+ >
445
+ <button
446
+ onClick={() => setMobileOpen(false)}
447
+ className="absolute top-3.5 right-3 p-1.5 rounded-md text-muted-foreground hover:bg-muted transition-colors"
448
+ >
449
+ <X className="h-4 w-4" />
450
+ </button>
451
+ <SidebarInner
452
+ schemas={schemas}
453
+ isLoading={isLoading}
454
+ location={location}
455
+ logout={logout}
456
+ isEmbedded={isEmbedded}
457
+ collapsed={false}
458
+ onNavigate={() => setMobileOpen(false)}
459
+ />
460
+ </aside>
461
+
462
+ <main className="flex-1 min-w-0 overflow-auto flex flex-col relative bg-background/50">
463
+ {/* Mobile Floating Toggle */}
464
+ <button
465
+ onClick={() => setMobileOpen(true)}
466
+ className="md:hidden fixed bottom-8 right-8 z-50 h-14 w-14 rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-[0_8px_30px_rgb(0,0,0,0.3)] flex items-center justify-center transition-all active:scale-90 hover:scale-105 border border-white/20"
467
+ aria-label="Open menu"
468
+ >
469
+ <Menu className="h-6 w-6" />
470
+ </button>
471
+
472
+ <div className="flex-1 py-6 px-4 lg:py-10 lg:px-6">
473
+ {children}
474
+ </div>
475
+ </main>
476
+ </div>
477
+ </BrandingProvider>
478
+ )
479
+ }
@@ -0,0 +1,112 @@
1
+ import React, { useMemo } from "react";
2
+ import { useDyrected } from "../../providers/dyrected-provider";
3
+
4
+ /**
5
+ * Converts a color value (hex, named, or raw HSL string) to the raw HSL
6
+ * triplet expected by our CSS variables, e.g. "38 92% 50%".
7
+ */
8
+ function toRawHsl(color: string): string {
9
+ if (!color) return "38 92% 50%"; // Default amber
10
+
11
+ if (color.startsWith("#")) {
12
+ let r = 0, g = 0, b = 0;
13
+ if (color.length === 4) {
14
+ r = parseInt(color[1] + color[1], 16);
15
+ g = parseInt(color[2] + color[2], 16);
16
+ b = parseInt(color[3] + color[3], 16);
17
+ } else if (color.length === 7) {
18
+ r = parseInt(color.substring(1, 3), 16);
19
+ g = parseInt(color.substring(3, 5), 16);
20
+ b = parseInt(color.substring(5, 7), 16);
21
+ }
22
+
23
+ r /= 255; g /= 255; b /= 255;
24
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
25
+ let h = 0, s = 0;
26
+ const l = (max + min) / 2;
27
+
28
+ if (max !== min) {
29
+ const d = max - min;
30
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
31
+ switch (max) {
32
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
33
+ case g: h = (b - r) / d + 2; break;
34
+ case b: h = (r - g) / d + 4; break;
35
+ }
36
+ h /= 6;
37
+ }
38
+
39
+ return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
40
+ }
41
+
42
+ const named: Record<string, string> = {
43
+ amber: "38 92% 50%",
44
+ green: "142 76% 36%",
45
+ blue: "217 91% 60%",
46
+ red: "0 84% 60%",
47
+ purple: "262 83% 58%",
48
+ orange: "24 95% 53%",
49
+ };
50
+
51
+ return named[color.toLowerCase()] || color;
52
+ }
53
+
54
+ /**
55
+ * Determines the best foreground color for text rendered on top of a given
56
+ * primary background. Warm, saturated colours (yellows, ambers) are
57
+ * perceptually bright even at moderate lightness and need dark text.
58
+ */
59
+ function primaryForeground(hsl: string): string {
60
+ const parts = hsl.split(" ");
61
+ if (parts.length < 3) return "60 3% 6%";
62
+
63
+ const hue = parseFloat(parts[0]);
64
+ const saturation = parseFloat(parts[1]);
65
+ const lightness = parseFloat(parts[2]);
66
+
67
+ // Warm saturated colours (yellow → lime, hue 20–150°) at moderate lightness
68
+ // are visually bright and need the dark near-black foreground.
69
+ const isWarmBright =
70
+ saturation > 50 &&
71
+ lightness > 38 &&
72
+ hue >= 20 &&
73
+ hue <= 150;
74
+
75
+ if (isWarmBright) return "60 3% 6%"; // warm near-black
76
+
77
+ // Cool dark colours need white text; very pale tints need dark text.
78
+ return lightness < 55 ? "0 0% 100%" : "60 3% 6%";
79
+ }
80
+
81
+ export function BrandingProvider({ children }: { children: React.ReactNode }) {
82
+ const { schemas } = useDyrected();
83
+ const branding = schemas?.admin?.branding;
84
+
85
+ const styleTag = useMemo(() => {
86
+ const hsl = toRawHsl(branding?.primaryColor || "38 92% 50%");
87
+ const fg = primaryForeground(hsl);
88
+
89
+ return (
90
+ <style dangerouslySetInnerHTML={{ __html: `
91
+ .admin-ui {
92
+ --primary: ${hsl};
93
+ --primary-foreground: ${fg};
94
+ --sidebar-primary: ${hsl};
95
+ --sidebar-primary-foreground: ${fg};
96
+ --sidebar-accent-foreground: ${hsl};
97
+ --sidebar-ring: ${hsl};
98
+ --ring: ${hsl} / 0.15;
99
+ ${branding?.fontSans ? `--font-sans: ${branding.fontSans};` : ""}
100
+ ${branding?.fontSerif ? `--font-serif: ${branding.fontSerif};` : ""}
101
+ }
102
+ `}} />
103
+ );
104
+ }, [branding?.primaryColor, branding?.fontSans, branding?.fontSerif]);
105
+
106
+ return (
107
+ <>
108
+ {styleTag}
109
+ {children}
110
+ </>
111
+ );
112
+ }