@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,128 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Button } from '../ui/button';
|
|
3
|
+
import { ExternalLink, Smartphone, Monitor, RotateCcw } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface LivePreviewPaneProps {
|
|
6
|
+
previewUrl: string;
|
|
7
|
+
data: any;
|
|
8
|
+
mode?: 'postMessage' | 'token';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function LivePreviewPane({ previewUrl, data, mode = 'postMessage' }: LivePreviewPaneProps) {
|
|
12
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
13
|
+
const [isReady, setIsReady] = useState(false);
|
|
14
|
+
const [viewMode, setViewMode] = useState<'desktop' | 'mobile'>('desktop');
|
|
15
|
+
const [zoom, setZoom] = useState(0.50); // 85% zoom by default for desktop
|
|
16
|
+
|
|
17
|
+
// Handle postMessage communication
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (mode !== 'postMessage') return;
|
|
20
|
+
|
|
21
|
+
const handleMessage = (event: MessageEvent) => {
|
|
22
|
+
if (event.data?.type === 'dyrected-live-preview-ready') {
|
|
23
|
+
setIsReady(true);
|
|
24
|
+
// Send initial data once ready
|
|
25
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
26
|
+
{ type: 'dyrected-live-preview', data },
|
|
27
|
+
'*'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
window.addEventListener('message', handleMessage);
|
|
33
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
34
|
+
}, [mode, data]);
|
|
35
|
+
|
|
36
|
+
// Sync data whenever it changes
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (mode === 'postMessage' && isReady) {
|
|
39
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
40
|
+
{ type: 'dyrected-live-preview', data },
|
|
41
|
+
'*'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}, [data, isReady, mode]);
|
|
45
|
+
|
|
46
|
+
const reload = () => {
|
|
47
|
+
if (iframeRef.current) {
|
|
48
|
+
iframeRef.current.src = previewUrl;
|
|
49
|
+
setIsReady(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex flex-col h-full bg-muted/20 border-l border-border/60">
|
|
55
|
+
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-border/40">
|
|
56
|
+
<div className="flex items-center gap-2">
|
|
57
|
+
<Button
|
|
58
|
+
variant="ghost"
|
|
59
|
+
size="icon"
|
|
60
|
+
className={`h-8 w-8 ${viewMode === 'desktop' ? 'bg-muted' : ''}`}
|
|
61
|
+
onClick={() => setViewMode('desktop')}
|
|
62
|
+
>
|
|
63
|
+
<Monitor className="h-4 w-4" />
|
|
64
|
+
</Button>
|
|
65
|
+
<Button
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="icon"
|
|
68
|
+
className={`h-8 w-8 ${viewMode === 'mobile' ? 'bg-muted' : ''}`}
|
|
69
|
+
onClick={() => setViewMode('mobile')}
|
|
70
|
+
>
|
|
71
|
+
<Smartphone className="h-4 w-4" />
|
|
72
|
+
</Button>
|
|
73
|
+
|
|
74
|
+
{viewMode === 'desktop' && (
|
|
75
|
+
<div className="flex items-center gap-1 ml-2 pl-2 border-l border-border/40">
|
|
76
|
+
<span className="text-[10px] font-bold text-muted-foreground/50 uppercase tracking-wider mr-1">Zoom</span>
|
|
77
|
+
{[0.50, 0.75, 1.0].map((z) => (
|
|
78
|
+
<Button
|
|
79
|
+
key={z}
|
|
80
|
+
variant="ghost"
|
|
81
|
+
size="sm"
|
|
82
|
+
className={`h-7 px-2 text-[10px] font-medium ${zoom === z ? 'bg-primary/10 text-primary' : 'text-muted-foreground/60'}`}
|
|
83
|
+
onClick={() => setZoom(z)}
|
|
84
|
+
>
|
|
85
|
+
{Math.round(z * 100)}%
|
|
86
|
+
</Button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="flex items-center gap-2">
|
|
93
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={reload}>
|
|
94
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
95
|
+
</Button>
|
|
96
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
|
97
|
+
<a href={previewUrl} target="_blank" rel="noreferrer">
|
|
98
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
99
|
+
</a>
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="flex-1 flex items-center justify-center p-0 overflow-hidden bg-muted/5">
|
|
105
|
+
<div
|
|
106
|
+
className={`bg-white shadow-[0_20px_50px_rgba(0,0,0,0.1)] transition-all duration-500 overflow-hidden border border-border/40 ${viewMode === 'mobile' ? 'w-[375px] h-[667px]' : 'w-full h-full'
|
|
107
|
+
}`}
|
|
108
|
+
>
|
|
109
|
+
<iframe
|
|
110
|
+
ref={iframeRef}
|
|
111
|
+
src={previewUrl}
|
|
112
|
+
className="border-none transition-transform duration-300"
|
|
113
|
+
style={viewMode === 'desktop' ? {
|
|
114
|
+
width: `${100 / zoom}%`,
|
|
115
|
+
height: `${100 / zoom}%`,
|
|
116
|
+
transform: `scale(${zoom})`,
|
|
117
|
+
transformOrigin: 'top left',
|
|
118
|
+
} : {
|
|
119
|
+
width: '100%',
|
|
120
|
+
height: '100%',
|
|
121
|
+
}}
|
|
122
|
+
title="Live Preview"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../lib/utils"
|
|
3
|
+
|
|
4
|
+
interface FocalPoint {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface FocalPointPickerProps {
|
|
10
|
+
url: string;
|
|
11
|
+
value?: FocalPoint;
|
|
12
|
+
onChange: (value: FocalPoint) => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FocalPointPicker({ url, value, onChange, className }: FocalPointPickerProps) {
|
|
17
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
18
|
+
|
|
19
|
+
const focalPoint = value || { x: 50, y: 50 };
|
|
20
|
+
|
|
21
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
22
|
+
if (!containerRef.current) return;
|
|
23
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
24
|
+
const x = Math.round(((e.clientX - rect.left) / rect.width) * 100);
|
|
25
|
+
const y = Math.round(((e.clientY - rect.top) / rect.height) * 100);
|
|
26
|
+
|
|
27
|
+
// Clamp values between 0 and 100
|
|
28
|
+
const clampedX = Math.max(0, Math.min(100, x));
|
|
29
|
+
const clampedY = Math.max(0, Math.min(100, y));
|
|
30
|
+
|
|
31
|
+
onChange({ x: clampedX, y: clampedY });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-2">
|
|
36
|
+
<div
|
|
37
|
+
ref={containerRef}
|
|
38
|
+
className={cn("relative cursor-crosshair overflow-hidden rounded-xl border border-border/40 bg-muted/20 group", className)}
|
|
39
|
+
onClick={handleClick}
|
|
40
|
+
>
|
|
41
|
+
<img
|
|
42
|
+
src={url}
|
|
43
|
+
alt="Focal point picker"
|
|
44
|
+
className="w-full h-auto pointer-events-none select-none max-h-[400px] object-contain bg-checkered"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
{/* Focal point indicator */}
|
|
48
|
+
<div
|
|
49
|
+
className="absolute w-8 h-8 -ml-4 -mt-4 border-2 border-white rounded-full shadow-2xl flex items-center justify-center pointer-events-none transition-all duration-200 ease-out"
|
|
50
|
+
style={{ left: `${focalPoint.x}%`, top: `${focalPoint.y}%` }}
|
|
51
|
+
>
|
|
52
|
+
<div className="w-1.5 h-1.5 bg-white rounded-full shadow-sm" />
|
|
53
|
+
<div className="absolute w-full h-px bg-white/40" />
|
|
54
|
+
<div className="absolute h-full w-px bg-white/40" />
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className="absolute bottom-3 left-3 bg-black/60 backdrop-blur-md px-2.5 py-1 rounded-lg text-[10px] text-white font-bold tracking-widest border border-white/10 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
58
|
+
X: {focalPoint.x}% / Y: {focalPoint.y}%
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<p className="text-[10px] text-muted-foreground font-medium px-1">
|
|
62
|
+
Click on the image to set the focal point for smart cropping.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Link } from "react-router-dom"
|
|
2
|
+
import { Pencil, Trash2 } from "lucide-react"
|
|
3
|
+
import { Button } from "../ui/button"
|
|
4
|
+
import { getMediaUrl } from "../../lib/utils"
|
|
5
|
+
|
|
6
|
+
interface MediaCardProps {
|
|
7
|
+
item: any
|
|
8
|
+
baseUrl: string
|
|
9
|
+
onDelete: (id: string) => void
|
|
10
|
+
editPath: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function MediaCard({ item, baseUrl, onDelete, editPath }: MediaCardProps) {
|
|
14
|
+
const url = getMediaUrl(item, baseUrl)
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="group relative aspect-square rounded-2xl overflow-hidden bg-white border border-border/40 shadow-sm hover:shadow-xl hover:border-primary/20 transition-all duration-300">
|
|
18
|
+
<img
|
|
19
|
+
src={url}
|
|
20
|
+
alt={item.filename}
|
|
21
|
+
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
22
|
+
/>
|
|
23
|
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3 backdrop-blur-[2px]">
|
|
24
|
+
<Link to={editPath}>
|
|
25
|
+
<Button size="icon" variant="secondary" className="h-9 w-9 rounded-full bg-white/90 hover:bg-white text-foreground shadow-lg transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
|
|
26
|
+
<Pencil className="h-4 w-4" />
|
|
27
|
+
</Button>
|
|
28
|
+
</Link>
|
|
29
|
+
<Button
|
|
30
|
+
size="icon"
|
|
31
|
+
variant="destructive"
|
|
32
|
+
className="h-9 w-9 rounded-full bg-destructive/90 hover:bg-destructive shadow-lg transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300 delay-75"
|
|
33
|
+
onClick={() => onDelete(item.id)}
|
|
34
|
+
>
|
|
35
|
+
<Trash2 className="h-4 w-4" />
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
39
|
+
<p className="text-[10px] text-white truncate font-medium">{item.filename}</p>
|
|
40
|
+
<p className="text-[8px] text-white/60 uppercase tracking-wider mt-0.5">{item.mimeType}</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MediaCard } from "./media-card"
|
|
2
|
+
|
|
3
|
+
interface MediaGridProps {
|
|
4
|
+
items: any[]
|
|
5
|
+
baseUrl: string
|
|
6
|
+
onDelete: (id: string) => void
|
|
7
|
+
slug: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function MediaGrid({ items, baseUrl, onDelete, slug }: MediaGridProps) {
|
|
11
|
+
if (!items || items.length === 0) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col items-center justify-center h-[300px] border-2 border-dashed border-border/60 rounded-3xl bg-muted/5">
|
|
14
|
+
<p className="text-sm text-muted-foreground font-medium">No media assets found</p>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
|
21
|
+
{items.map((item) => (
|
|
22
|
+
<MediaCard
|
|
23
|
+
key={item.id}
|
|
24
|
+
item={item}
|
|
25
|
+
baseUrl={baseUrl}
|
|
26
|
+
onDelete={onDelete}
|
|
27
|
+
editPath={`/collections/${slug}/edit/${item.id}`}
|
|
28
|
+
/>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|