@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.
- package/CHANGELOG.md +40 -0
- package/LICENSE.md +50 -0
- package/README.md +73 -0
- package/components.json +17 -0
- package/eslint.config.js +22 -0
- package/index.html +13 -0
- package/package.json +99 -0
- package/postcss.config.js +6 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +184 -0
- package/src/App.tsx +25 -0
- package/src/assets/dyrected.svg +155 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/auth/auth-gate.tsx +64 -0
- package/src/components/error-boundary.tsx +45 -0
- package/src/components/forms/field-renderer.tsx +111 -0
- package/src/components/forms/fields/block-builder.tsx +213 -0
- package/src/components/forms/fields/date-picker.tsx +60 -0
- package/src/components/forms/fields/json-editor.tsx +62 -0
- package/src/components/forms/fields/media-picker.tsx +286 -0
- package/src/components/forms/fields/multi-select.tsx +145 -0
- package/src/components/forms/fields/radio-field.tsx +51 -0
- package/src/components/forms/fields/relationship-picker.tsx +143 -0
- package/src/components/forms/fields/rich-text-editor.tsx +224 -0
- package/src/components/forms/fields/select-field.tsx +35 -0
- package/src/components/forms/fields/switch-field.tsx +16 -0
- package/src/components/forms/fields/text-area-field.tsx +15 -0
- package/src/components/forms/fields/text-field.tsx +24 -0
- package/src/components/forms/form-engine.tsx +87 -0
- package/src/components/forms/form-field-renderer.tsx +269 -0
- package/src/components/forms/utils.ts +97 -0
- package/src/components/layout/admin-shell.tsx +479 -0
- package/src/components/layout/branding-provider.tsx +112 -0
- package/src/components/live-preview/LivePreviewPane.tsx +128 -0
- package/src/components/media/focal-point-picker.tsx +66 -0
- package/src/components/media/media-card.tsx +44 -0
- package/src/components/media/media-grid.tsx +32 -0
- package/src/components/media/media-library-dialog.tsx +465 -0
- package/src/components/ui/aspect-ratio.tsx +7 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +214 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/command.tsx +151 -0
- package/src/components/ui/data-table.tsx +219 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/form.tsx +178 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/page-header.tsx +30 -0
- package/src/components/ui/pagination.tsx +57 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +26 -0
- package/src/components/ui/radio-group.tsx +42 -0
- package/src/components/ui/render-cell.tsx +110 -0
- package/src/components/ui/scroll-area.tsx +46 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +771 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +27 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-preferences.ts +56 -0
- package/src/index.css +111 -0
- package/src/index.tsx +198 -0
- package/src/lib/utils.ts +32 -0
- package/src/main.tsx +10 -0
- package/src/pages/auth/first-user-page.tsx +115 -0
- package/src/pages/auth/login-page.tsx +91 -0
- package/src/pages/collections/edit-page.tsx +280 -0
- package/src/pages/collections/list-page.tsx +343 -0
- package/src/pages/dashboard/dashboard.tsx +150 -0
- package/src/pages/globals/editor-page.tsx +122 -0
- package/src/pages/media/media-page.tsx +564 -0
- package/src/pages/setup/setup-prompt.tsx +152 -0
- package/src/providers/dyrected-provider.tsx +122 -0
- package/src/providers/query-provider.tsx +19 -0
- package/src/types/jexl.d.ts +11 -0
- package/tailwind.config.ts +102 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +12 -0
- package/tsconfig.node.json +27 -0
- 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
|
+
}
|