@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
package/src/index.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
import "./index.css";
|
|
3
|
+
import React, { useEffect, useState, StrictMode } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import {
|
|
6
|
+
HashRouter,
|
|
7
|
+
MemoryRouter,
|
|
8
|
+
Routes,
|
|
9
|
+
Route,
|
|
10
|
+
useParams,
|
|
11
|
+
useLocation,
|
|
12
|
+
} from "react-router-dom";
|
|
13
|
+
import { useQuery } from "@tanstack/react-query";
|
|
14
|
+
import { DyrectedProvider, useDyrected } from "./providers/dyrected-provider";
|
|
15
|
+
import { QueryProvider } from "./providers/query-provider";
|
|
16
|
+
import { AdminShell } from "./components/layout/admin-shell";
|
|
17
|
+
import { Dashboard } from "./pages/dashboard/dashboard";
|
|
18
|
+
import { CollectionListPage } from "./pages/collections/list-page";
|
|
19
|
+
import { EditEntryPage } from "./pages/collections/edit-page";
|
|
20
|
+
import { MediaPage } from "./pages/media/media-page";
|
|
21
|
+
import { GlobalEditorPage } from "./pages/globals/editor-page";
|
|
22
|
+
import { SetupPromptUI } from "./pages/setup/setup-prompt";
|
|
23
|
+
import { ErrorBoundary } from "./components/error-boundary";
|
|
24
|
+
import { AuthGate } from "./components/auth/auth-gate";
|
|
25
|
+
import { Toaster } from "./components/ui/sonner";
|
|
26
|
+
|
|
27
|
+
// ─── Route that resolves collection → list or media page ─────────────────────
|
|
28
|
+
|
|
29
|
+
function CollectionRoute() {
|
|
30
|
+
const { slug } = useParams();
|
|
31
|
+
const { client } = useDyrected();
|
|
32
|
+
|
|
33
|
+
const { data: schemas } = useQuery({
|
|
34
|
+
queryKey: ["schemas"],
|
|
35
|
+
queryFn: () => client?.getSchemas() || Promise.resolve({ collections: [], globals: [] }),
|
|
36
|
+
enabled: !!client,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const schema = schemas?.collections.find((c: any) => c.slug === slug);
|
|
40
|
+
|
|
41
|
+
if (schema?.admin?.hidden) {
|
|
42
|
+
return <div>404: Not Found</div>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (schema?.upload) {
|
|
46
|
+
return <MediaPage collectionSlug={slug!} schema={schema} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return <CollectionListPage slug={slug!} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Setup page — reads config from context ───────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function SetupPage() {
|
|
55
|
+
const { config } = useDyrected();
|
|
56
|
+
return <SetupPromptUI config={config} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Navigation sync — notifies host on every internal route change ───────────
|
|
60
|
+
|
|
61
|
+
interface NavigationSyncProps {
|
|
62
|
+
onNavigate?: (path: string) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function NavigationSync({ onNavigate }: NavigationSyncProps) {
|
|
66
|
+
const location = useLocation();
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
onNavigate?.(location.pathname + location.search);
|
|
70
|
+
}, [location, onNavigate]);
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Route tree (shared between embedded and standalone) ──────────────────────
|
|
76
|
+
|
|
77
|
+
function AdminRoutes({ onNavigate, isEmbedded = false }: { onNavigate?: (path: string) => void, isEmbedded?: boolean }) {
|
|
78
|
+
return (
|
|
79
|
+
<AuthGate>
|
|
80
|
+
<AdminShell isEmbedded={isEmbedded}>
|
|
81
|
+
<NavigationSync onNavigate={onNavigate} />
|
|
82
|
+
<Routes>
|
|
83
|
+
<Route path="/" element={<Dashboard />} />
|
|
84
|
+
<Route path="/collections/:slug" element={<CollectionRoute />} />
|
|
85
|
+
<Route path="/collections/:slug/new" element={<EditEntryPage />} />
|
|
86
|
+
<Route path="/collections/:slug/edit/:id" element={<EditEntryPage />} />
|
|
87
|
+
<Route path="/globals/:slug" element={<GlobalEditorPage />} />
|
|
88
|
+
<Route path="/setup" element={<SetupPage />} />
|
|
89
|
+
</Routes>
|
|
90
|
+
</AdminShell>
|
|
91
|
+
</AuthGate>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface AdminUIProps {
|
|
98
|
+
apiKey?: string;
|
|
99
|
+
baseUrl?: string;
|
|
100
|
+
siteId?: string;
|
|
101
|
+
/**
|
|
102
|
+
* The base path where the admin is mounted in the host app.
|
|
103
|
+
* Defaults to "/admin". Used by BrowserRouter so internal links
|
|
104
|
+
* are relative to this prefix.
|
|
105
|
+
*
|
|
106
|
+
* Example — Next.js catch-all page at `app/admin/[[...path]]/page.tsx`:
|
|
107
|
+
* <AdminUI basename="/admin" ... />
|
|
108
|
+
*/
|
|
109
|
+
basename?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Called whenever the internal admin route changes.
|
|
112
|
+
* Use this to sync the host router (e.g. Next.js router.push / Nuxt navigateTo)
|
|
113
|
+
* so browser history works correctly when embedded.
|
|
114
|
+
*
|
|
115
|
+
* Example (Nuxt):
|
|
116
|
+
* <AdminUI onNavigate={(path) => navigateTo('/admin' + path)} ... />
|
|
117
|
+
*/
|
|
118
|
+
onNavigate?: (path: string) => void;
|
|
119
|
+
isEmbedded?: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Embedded component (BrowserRouter — real URL + history) ─────────────────
|
|
123
|
+
|
|
124
|
+
export function AdminUI({
|
|
125
|
+
apiKey,
|
|
126
|
+
baseUrl = "/dyrected",
|
|
127
|
+
siteId,
|
|
128
|
+
onNavigate,
|
|
129
|
+
isEmbedded
|
|
130
|
+
}: AdminUIProps) {
|
|
131
|
+
const [mounted, setMounted] = useState(false);
|
|
132
|
+
useEffect(() => setMounted(true), []);
|
|
133
|
+
|
|
134
|
+
if (!mounted) {
|
|
135
|
+
return (
|
|
136
|
+
<div className="flex-1 flex items-center justify-center p-12 bg-muted/5 animate-pulse">
|
|
137
|
+
<div className="text-muted-foreground/40 text-sm font-medium">Loading Dashboard...</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="admin-ui h-full">
|
|
144
|
+
<ErrorBoundary>
|
|
145
|
+
<DyrectedProvider apiKey={apiKey} baseUrl={baseUrl} siteId={siteId}>
|
|
146
|
+
<QueryProvider>
|
|
147
|
+
<HashRouter>
|
|
148
|
+
<AdminRoutes onNavigate={onNavigate} isEmbedded={isEmbedded} />
|
|
149
|
+
</HashRouter>
|
|
150
|
+
</QueryProvider>
|
|
151
|
+
<Toaster position="top-right" expand={true} richColors />
|
|
152
|
+
</DyrectedProvider>
|
|
153
|
+
</ErrorBoundary>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Renders the Admin UI into a DOM element.
|
|
160
|
+
* Useful for non-React frameworks like Nuxt, Svelte, or Vanilla JS.
|
|
161
|
+
*/
|
|
162
|
+
export function renderAdminUI(container: HTMLElement, props: AdminUIProps) {
|
|
163
|
+
const root = createRoot(container);
|
|
164
|
+
root.render(
|
|
165
|
+
React.createElement(StrictMode, null,
|
|
166
|
+
React.createElement(AdminUI, props)
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
return () => root.unmount();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Standalone component (MemoryRouter — for iframe / self-hosted mode) ──────
|
|
173
|
+
|
|
174
|
+
export interface AdminStandaloneProps {
|
|
175
|
+
apiKey: string;
|
|
176
|
+
baseUrl: string;
|
|
177
|
+
siteId?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function AdminStandalone({ apiKey, baseUrl, siteId }: AdminStandaloneProps) {
|
|
181
|
+
return (
|
|
182
|
+
<div className="admin-ui h-full">
|
|
183
|
+
<DyrectedProvider apiKey={apiKey} baseUrl={baseUrl} siteId={siteId}>
|
|
184
|
+
<QueryProvider>
|
|
185
|
+
<MemoryRouter>
|
|
186
|
+
<AdminRoutes />
|
|
187
|
+
</MemoryRouter>
|
|
188
|
+
</QueryProvider>
|
|
189
|
+
<Toaster position="top-right" expand={true} richColors />
|
|
190
|
+
</DyrectedProvider>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Re-exports for external use ──────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export { SetupPromptUI } from "./pages/setup/setup-prompt";
|
|
198
|
+
export type { SetupPromptProps } from "./pages/setup/setup-prompt";
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx"
|
|
2
|
+
import { twMerge } from "tailwind-merge"
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getMediaUrl(val: string | any, baseUrl: string) {
|
|
9
|
+
if (!val) return "";
|
|
10
|
+
|
|
11
|
+
// Handle object with direct URL
|
|
12
|
+
if (typeof val === 'object' && (val.url || val.filename)) {
|
|
13
|
+
const url = val.url || val.filename;
|
|
14
|
+
if (url.startsWith('http')) return url;
|
|
15
|
+
if (url.startsWith('/')) return `${baseUrl}${url}`;
|
|
16
|
+
// If it's just a filename in the object, use proxy
|
|
17
|
+
return `${baseUrl}/media/${val.filename || val.url}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const valueStr = typeof val === 'string' ? val : val.id || val.filename || val.url;
|
|
21
|
+
if (!valueStr) return "";
|
|
22
|
+
|
|
23
|
+
if (valueStr.startsWith('http')) return valueStr;
|
|
24
|
+
|
|
25
|
+
// Check if it's a relative path starting with /
|
|
26
|
+
if (valueStr.startsWith('/')) {
|
|
27
|
+
return `${baseUrl}${valueStr}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Default fallback to proxy endpoint
|
|
31
|
+
return `${baseUrl}/media/${valueStr}`;
|
|
32
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useDyrected } from "../../providers/dyrected-provider";
|
|
3
|
+
import { Button } from "../../components/ui/button";
|
|
4
|
+
import { Input } from "../../components/ui/input";
|
|
5
|
+
import { Label } from "../../components/ui/label";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
|
|
8
|
+
export function FirstUserPage({
|
|
9
|
+
collectionSlug,
|
|
10
|
+
onComplete
|
|
11
|
+
}: {
|
|
12
|
+
collectionSlug: string;
|
|
13
|
+
onComplete: (data: any) => void
|
|
14
|
+
}) {
|
|
15
|
+
const { client } = useDyrected();
|
|
16
|
+
const [email, setEmail] = useState("");
|
|
17
|
+
const [password, setPassword] = useState("");
|
|
18
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
19
|
+
const [error, setError] = useState("");
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
|
|
22
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
setError("");
|
|
25
|
+
|
|
26
|
+
if (password !== confirmPassword) {
|
|
27
|
+
setError("Passwords do not match");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setLoading(true);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const data = await client!.collection(collectionSlug).registerFirstUser({
|
|
35
|
+
email,
|
|
36
|
+
password,
|
|
37
|
+
});
|
|
38
|
+
toast.success("Admin account created successfully");
|
|
39
|
+
onComplete(data);
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
const message = err.message || "Failed to create initial user";
|
|
42
|
+
setError(message);
|
|
43
|
+
toast.error(message);
|
|
44
|
+
} finally {
|
|
45
|
+
setLoading(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
|
51
|
+
<div className="w-full max-w-sm space-y-8">
|
|
52
|
+
<div className="space-y-2 text-center">
|
|
53
|
+
<div className="mx-auto h-12 w-12 rounded-full bg-primary/5 flex items-center justify-center mb-4">
|
|
54
|
+
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-pulse" />
|
|
55
|
+
</div>
|
|
56
|
+
<h1 className="text-2xl font-semibold tracking-tight">Setup Admin Account</h1>
|
|
57
|
+
<p className="text-sm text-muted-foreground">Create the first administrative user to get started</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
61
|
+
<div className="space-y-2">
|
|
62
|
+
<Label htmlFor="email">Admin Email</Label>
|
|
63
|
+
<Input
|
|
64
|
+
id="email"
|
|
65
|
+
type="email"
|
|
66
|
+
placeholder="admin@example.com"
|
|
67
|
+
value={email}
|
|
68
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
69
|
+
required
|
|
70
|
+
className="bg-transparent"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="space-y-2">
|
|
74
|
+
<Label htmlFor="password">Password</Label>
|
|
75
|
+
<Input
|
|
76
|
+
id="password"
|
|
77
|
+
type="password"
|
|
78
|
+
value={password}
|
|
79
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
80
|
+
required
|
|
81
|
+
className="bg-transparent"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="space-y-2">
|
|
85
|
+
<Label htmlFor="confirm-password">Confirm Password</Label>
|
|
86
|
+
<Input
|
|
87
|
+
id="confirm-password"
|
|
88
|
+
type="password"
|
|
89
|
+
value={confirmPassword}
|
|
90
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
91
|
+
required
|
|
92
|
+
className="bg-transparent"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{error && (
|
|
97
|
+
<div className="text-xs text-destructive font-medium bg-destructive/10 p-3 rounded-md">
|
|
98
|
+
{error}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
103
|
+
{loading ? "Creating account..." : "Create Admin Account"}
|
|
104
|
+
</Button>
|
|
105
|
+
</form>
|
|
106
|
+
|
|
107
|
+
<div className="pt-4 border-t text-center space-y-2">
|
|
108
|
+
<p className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
|
109
|
+
Dyrected CMS · Initial Setup
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useDyrected } from "../../providers/dyrected-provider";
|
|
3
|
+
import { Button } from "../../components/ui/button";
|
|
4
|
+
import { Input } from "../../components/ui/input";
|
|
5
|
+
import { Label } from "../../components/ui/label";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
|
|
8
|
+
export function LoginPage({
|
|
9
|
+
collectionSlug,
|
|
10
|
+
onLogin
|
|
11
|
+
}: {
|
|
12
|
+
collectionSlug: string;
|
|
13
|
+
onLogin: (data: any) => void
|
|
14
|
+
}) {
|
|
15
|
+
const { client } = useDyrected();
|
|
16
|
+
const [email, setEmail] = useState("");
|
|
17
|
+
const [password, setPassword] = useState("");
|
|
18
|
+
const [error, setError] = useState("");
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
|
|
21
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setError("");
|
|
24
|
+
setLoading(true);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const data = await client!.collection(collectionSlug).login(email, password);
|
|
28
|
+
toast.success("Welcome back!");
|
|
29
|
+
onLogin(data);
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
const message = err.message || "Invalid email or password";
|
|
32
|
+
setError(message);
|
|
33
|
+
toast.error(message);
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
|
41
|
+
<div className="w-full max-w-sm space-y-8">
|
|
42
|
+
<div className="space-y-2 text-center">
|
|
43
|
+
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
|
|
44
|
+
<p className="text-sm text-muted-foreground">Enter your credentials to access the dashboard</p>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
48
|
+
<div className="space-y-2">
|
|
49
|
+
<Label htmlFor="email">Email</Label>
|
|
50
|
+
<Input
|
|
51
|
+
id="email"
|
|
52
|
+
type="email"
|
|
53
|
+
placeholder="name@example.com"
|
|
54
|
+
value={email}
|
|
55
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
56
|
+
required
|
|
57
|
+
className="bg-transparent"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="space-y-2">
|
|
61
|
+
<div className="flex items-center justify-between">
|
|
62
|
+
<Label htmlFor="password">Password</Label>
|
|
63
|
+
</div>
|
|
64
|
+
<Input
|
|
65
|
+
id="password"
|
|
66
|
+
type="password"
|
|
67
|
+
value={password}
|
|
68
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
69
|
+
required
|
|
70
|
+
className="bg-transparent"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{error && (
|
|
75
|
+
<div className="text-xs text-destructive font-medium bg-destructive/10 p-3 rounded-md">
|
|
76
|
+
{error}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
81
|
+
{loading ? "Signing in..." : "Sign In"}
|
|
82
|
+
</Button>
|
|
83
|
+
</form>
|
|
84
|
+
|
|
85
|
+
<p className="text-center text-xs text-muted-foreground uppercase tracking-widest">
|
|
86
|
+
Dyrected CMS
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|