flarecms 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.
- package/README.md +73 -0
- package/dist/auth/index.js +40 -0
- package/dist/cli/commands.js +389 -0
- package/dist/cli/index.js +403 -0
- package/dist/cli/mcp.js +209 -0
- package/dist/db/index.js +164 -0
- package/dist/index.js +17626 -0
- package/package.json +105 -0
- package/scripts/fix-api-paths.mjs +32 -0
- package/scripts/fix-imports.mjs +38 -0
- package/scripts/prefix-css.mjs +45 -0
- package/src/api/lib/cache.ts +45 -0
- package/src/api/lib/response.ts +40 -0
- package/src/api/middlewares/auth.ts +186 -0
- package/src/api/middlewares/cors.ts +10 -0
- package/src/api/middlewares/rbac.ts +85 -0
- package/src/api/routes/auth.ts +377 -0
- package/src/api/routes/collections.ts +205 -0
- package/src/api/routes/content.ts +175 -0
- package/src/api/routes/device.ts +160 -0
- package/src/api/routes/magic.ts +150 -0
- package/src/api/routes/mcp.ts +273 -0
- package/src/api/routes/oauth.ts +160 -0
- package/src/api/routes/settings.ts +43 -0
- package/src/api/routes/setup.ts +307 -0
- package/src/api/routes/tokens.ts +80 -0
- package/src/api/schemas/auth.ts +15 -0
- package/src/api/schemas/index.ts +51 -0
- package/src/api/schemas/tokens.ts +24 -0
- package/src/auth/index.ts +28 -0
- package/src/cli/commands.ts +217 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/mcp.ts +210 -0
- package/src/cli/tests/cli.test.ts +40 -0
- package/src/cli/tests/create.test.ts +87 -0
- package/src/client/FlareAdminRouter.tsx +47 -0
- package/src/client/app.tsx +175 -0
- package/src/client/components/app-sidebar.tsx +227 -0
- package/src/client/components/collection-modal.tsx +215 -0
- package/src/client/components/content-list.tsx +247 -0
- package/src/client/components/dynamic-form.tsx +190 -0
- package/src/client/components/field-modal.tsx +221 -0
- package/src/client/components/settings/api-token-section.tsx +400 -0
- package/src/client/components/settings/general-section.tsx +224 -0
- package/src/client/components/settings/security-section.tsx +154 -0
- package/src/client/components/settings/seo-section.tsx +200 -0
- package/src/client/components/settings/signup-section.tsx +257 -0
- package/src/client/components/ui/accordion.tsx +78 -0
- package/src/client/components/ui/avatar.tsx +107 -0
- package/src/client/components/ui/badge.tsx +52 -0
- package/src/client/components/ui/button.tsx +60 -0
- package/src/client/components/ui/card.tsx +103 -0
- package/src/client/components/ui/checkbox.tsx +27 -0
- package/src/client/components/ui/collapsible.tsx +19 -0
- package/src/client/components/ui/dialog.tsx +162 -0
- package/src/client/components/ui/icon-picker.tsx +485 -0
- package/src/client/components/ui/icons-data.ts +8476 -0
- package/src/client/components/ui/input.tsx +20 -0
- package/src/client/components/ui/label.tsx +20 -0
- package/src/client/components/ui/popover.tsx +91 -0
- package/src/client/components/ui/select.tsx +204 -0
- package/src/client/components/ui/separator.tsx +23 -0
- package/src/client/components/ui/sheet.tsx +141 -0
- package/src/client/components/ui/sidebar.tsx +722 -0
- package/src/client/components/ui/skeleton.tsx +13 -0
- package/src/client/components/ui/sonner.tsx +47 -0
- package/src/client/components/ui/switch.tsx +30 -0
- package/src/client/components/ui/table.tsx +116 -0
- package/src/client/components/ui/tabs.tsx +80 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/components/ui/tooltip.tsx +68 -0
- package/src/client/hooks/use-mobile.ts +19 -0
- package/src/client/index.css +149 -0
- package/src/client/index.ts +7 -0
- package/src/client/layouts/admin-layout.tsx +93 -0
- package/src/client/layouts/settings-layout.tsx +104 -0
- package/src/client/lib/api.ts +72 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +10 -0
- package/src/client/pages/collection-detail.tsx +634 -0
- package/src/client/pages/collections.tsx +180 -0
- package/src/client/pages/dashboard.tsx +133 -0
- package/src/client/pages/device.tsx +66 -0
- package/src/client/pages/document-detail-page.tsx +139 -0
- package/src/client/pages/documents-page.tsx +103 -0
- package/src/client/pages/login.tsx +345 -0
- package/src/client/pages/settings.tsx +65 -0
- package/src/client/pages/setup.tsx +129 -0
- package/src/client/pages/signup.tsx +188 -0
- package/src/client/store/auth.ts +30 -0
- package/src/client/store/collections.ts +13 -0
- package/src/client/store/config.ts +12 -0
- package/src/client/store/fetcher.ts +30 -0
- package/src/client/store/router.ts +95 -0
- package/src/client/store/schema.ts +39 -0
- package/src/client/store/settings.ts +31 -0
- package/src/client/types.ts +34 -0
- package/src/db/dynamic.ts +70 -0
- package/src/db/index.ts +16 -0
- package/src/db/migrations/001_initial_schema.ts +57 -0
- package/src/db/migrations/002_auth_tables.ts +84 -0
- package/src/db/migrator.ts +61 -0
- package/src/db/schema.ts +142 -0
- package/src/index.ts +12 -0
- package/src/server/index.ts +66 -0
- package/src/types.ts +20 -0
- package/style.css.d.ts +8 -0
- package/tests/css.test.ts +21 -0
- package/tests/modular.test.ts +29 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { apiFetch } from '../lib/api';
|
|
3
|
+
import { useStore } from '@nanostores/react';
|
|
4
|
+
import { $schema } from '../store/schema';
|
|
5
|
+
import { $router, navigate } from '../store/router';
|
|
6
|
+
import {
|
|
7
|
+
FileEdit as FileEditIcon,
|
|
8
|
+
Trash2 as Trash2Icon,
|
|
9
|
+
Loader2 as Loader2Icon,
|
|
10
|
+
Search as SearchIcon,
|
|
11
|
+
ChevronDown as ChevronDownIcon,
|
|
12
|
+
Filter as FilterIcon,
|
|
13
|
+
LayoutGrid as LayoutGridIcon,
|
|
14
|
+
List as ListIcon,
|
|
15
|
+
History as HistoryIcon,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
Table,
|
|
20
|
+
TableBody,
|
|
21
|
+
TableCell,
|
|
22
|
+
TableHead,
|
|
23
|
+
TableHeader,
|
|
24
|
+
TableRow,
|
|
25
|
+
} from './ui/table';
|
|
26
|
+
import { Button } from './ui/button';
|
|
27
|
+
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
|
28
|
+
import { Input } from './ui/input';
|
|
29
|
+
|
|
30
|
+
export function ContentList({ slug }: { slug: string }) {
|
|
31
|
+
const [data, setData] = useState<any[]>([]);
|
|
32
|
+
const [meta, setMeta] = useState<any>(null);
|
|
33
|
+
const [page, setPage] = useState(1);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const { data: schema } = useStore($schema);
|
|
36
|
+
|
|
37
|
+
const fetchContent = async () => {
|
|
38
|
+
setLoading(true);
|
|
39
|
+
try {
|
|
40
|
+
const response = await apiFetch(`/content/${slug}?page=${page}&limit=20`);
|
|
41
|
+
const json = await response.json();
|
|
42
|
+
setData(json.data || []);
|
|
43
|
+
setMeta(json.meta || null);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(err);
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
fetchContent();
|
|
53
|
+
}, [slug, page]);
|
|
54
|
+
|
|
55
|
+
const handleDelete = async (id: string | number) => {
|
|
56
|
+
if (!confirm('Are you certain? This action is irreversible.')) return;
|
|
57
|
+
try {
|
|
58
|
+
await apiFetch(`/content/${slug}/${id}`, { method: 'DELETE' });
|
|
59
|
+
fetchContent();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(err);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (loading) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex flex-col items-center justify-center p-20 text-muted-foreground gap-6 bg-background border rounded-lg shadow-sm">
|
|
68
|
+
<Loader2Icon className="size-8 animate-spin text-primary" />
|
|
69
|
+
<p className="font-semibold text-[10px] uppercase tracking-[0.2em]">
|
|
70
|
+
Synchronizing Data...
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="space-y-6">
|
|
78
|
+
{/* Table Actions Bar */}
|
|
79
|
+
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
|
80
|
+
<div className="flex items-center gap-2 w-full md:w-auto">
|
|
81
|
+
<div className="relative w-full md:w-64 group">
|
|
82
|
+
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground transition-colors" />
|
|
83
|
+
<Input
|
|
84
|
+
placeholder="Search entries..."
|
|
85
|
+
className="pl-9 h-9 text-xs"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
<Button
|
|
89
|
+
variant="outline"
|
|
90
|
+
size="sm"
|
|
91
|
+
className="h-9 gap-2 text-xs font-semibold"
|
|
92
|
+
>
|
|
93
|
+
<FilterIcon className="size-3.5" />
|
|
94
|
+
Filter
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="flex items-center gap-1 border bg-muted/50 rounded-md p-0.5 border-border shrink-0">
|
|
99
|
+
<Button
|
|
100
|
+
variant="ghost"
|
|
101
|
+
size="icon"
|
|
102
|
+
className="h-7 w-7 bg-background shadow-sm text-primary"
|
|
103
|
+
>
|
|
104
|
+
<ListIcon className="size-3.5" />
|
|
105
|
+
</Button>
|
|
106
|
+
<Button
|
|
107
|
+
variant="ghost"
|
|
108
|
+
size="icon"
|
|
109
|
+
className="h-7 w-7 text-muted-foreground/50 hover:text-foreground"
|
|
110
|
+
>
|
|
111
|
+
<LayoutGridIcon className="size-3.5" />
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<Card className="py-0 shadow-none border-border overflow-hidden">
|
|
117
|
+
<CardHeader className="bg-muted/30 border-b py-3 px-6">
|
|
118
|
+
<div className="flex items-center justify-between">
|
|
119
|
+
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider leading-none">
|
|
120
|
+
<HistoryIcon className="size-3.5 opacity-50" />
|
|
121
|
+
Synchronized Ledger
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex items-center gap-2 text-[10px] text-muted-foreground font-semibold uppercase tracking-wider">
|
|
124
|
+
<span className="size-1.5 bg-primary rounded-full" />
|
|
125
|
+
<span>{meta?.total || data.length} Entries</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</CardHeader>
|
|
129
|
+
<CardContent className="p-0">
|
|
130
|
+
<Table>
|
|
131
|
+
<TableHeader>
|
|
132
|
+
<TableRow className="hover:bg-transparent text-muted-foreground uppercase text-[10px] font-bold tracking-wider bg-muted/20">
|
|
133
|
+
<TableHead className="pl-6 py-3">
|
|
134
|
+
Document Identification
|
|
135
|
+
</TableHead>
|
|
136
|
+
{schema?.fields?.slice(0, 2).map((field: any) => (
|
|
137
|
+
<TableHead key={field.id}>{field.label}</TableHead>
|
|
138
|
+
))}
|
|
139
|
+
<TableHead>Created</TableHead>
|
|
140
|
+
<TableHead className="text-right pr-6">Actions</TableHead>
|
|
141
|
+
</TableRow>
|
|
142
|
+
</TableHeader>
|
|
143
|
+
<TableBody>
|
|
144
|
+
{data.map((item) => (
|
|
145
|
+
<TableRow
|
|
146
|
+
key={item.id}
|
|
147
|
+
className="group transition-colors border-border/50"
|
|
148
|
+
>
|
|
149
|
+
<TableCell className="pl-6 py-4">
|
|
150
|
+
<div className="flex flex-col gap-0.5">
|
|
151
|
+
<span
|
|
152
|
+
className="font-semibold text-primary hover:underline cursor-pointer text-sm leading-tight"
|
|
153
|
+
onClick={() => navigate('document_edit', { slug, id: item.id })}
|
|
154
|
+
>
|
|
155
|
+
{item.slug || `Entry #${item.id}`}
|
|
156
|
+
</span>
|
|
157
|
+
<span className="text-[10px] text-muted-foreground font-medium font-mono uppercase tracking-tighter opacity-70">
|
|
158
|
+
ID: {item.id}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
</TableCell>
|
|
162
|
+
|
|
163
|
+
{schema?.fields?.slice(0, 2).map((field: any) => (
|
|
164
|
+
<TableCell
|
|
165
|
+
key={field.id}
|
|
166
|
+
className="text-xs font-medium text-foreground/80"
|
|
167
|
+
>
|
|
168
|
+
{String(item[field.slug] || '—')}
|
|
169
|
+
</TableCell>
|
|
170
|
+
))}
|
|
171
|
+
|
|
172
|
+
<TableCell className="text-[11px] text-muted-foreground font-medium">
|
|
173
|
+
{item.created_at
|
|
174
|
+
? new Date(item.created_at).toLocaleDateString()
|
|
175
|
+
: '—'}
|
|
176
|
+
</TableCell>
|
|
177
|
+
|
|
178
|
+
<TableCell className="text-right pr-6">
|
|
179
|
+
<div className="flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
180
|
+
<Button
|
|
181
|
+
variant="ghost"
|
|
182
|
+
size="icon"
|
|
183
|
+
className="size-7 text-muted-foreground hover:text-primary"
|
|
184
|
+
onClick={() => navigate('document_edit', { slug, id: item.id })}
|
|
185
|
+
>
|
|
186
|
+
<FileEditIcon className="size-3.5" />
|
|
187
|
+
</Button>
|
|
188
|
+
<Button
|
|
189
|
+
variant="ghost"
|
|
190
|
+
size="icon"
|
|
191
|
+
className="size-7 text-muted-foreground hover:text-destructive"
|
|
192
|
+
onClick={() => handleDelete(item.id)}
|
|
193
|
+
>
|
|
194
|
+
<Trash2Icon className="size-3.5" />
|
|
195
|
+
</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</TableCell>
|
|
198
|
+
</TableRow>
|
|
199
|
+
))}
|
|
200
|
+
{data.length === 0 && (
|
|
201
|
+
<TableRow>
|
|
202
|
+
<TableCell
|
|
203
|
+
colSpan={10}
|
|
204
|
+
className="h-64 text-center text-muted-foreground bg-muted/5"
|
|
205
|
+
>
|
|
206
|
+
<div className="flex flex-col items-center justify-center gap-4">
|
|
207
|
+
<HistoryIcon className="size-12 opacity-10" />
|
|
208
|
+
<p className="text-sm font-semibold text-foreground/50">
|
|
209
|
+
No Data Available
|
|
210
|
+
</p>
|
|
211
|
+
<p className="text-[10px] uppercase font-semibold tracking-wider opacity-40">
|
|
212
|
+
Add your first entry to begin
|
|
213
|
+
</p>
|
|
214
|
+
</div>
|
|
215
|
+
</TableCell>
|
|
216
|
+
</TableRow>
|
|
217
|
+
)}
|
|
218
|
+
</TableBody>
|
|
219
|
+
</Table>
|
|
220
|
+
</CardContent>
|
|
221
|
+
</Card>
|
|
222
|
+
|
|
223
|
+
{/* Footer Info */}
|
|
224
|
+
<div className="px-1 flex justify-between items-center text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
|
225
|
+
<p>
|
|
226
|
+
Showing {meta ? (meta.page - 1) * meta.limit + 1 : 1} — {meta ? Math.min(meta.page * meta.limit, meta.total) : data.length} of {meta?.total || data.length}
|
|
227
|
+
</p>
|
|
228
|
+
<div className="flex gap-4">
|
|
229
|
+
<button
|
|
230
|
+
disabled={!meta?.hasPrevPage}
|
|
231
|
+
onClick={() => setPage(p => p - 1)}
|
|
232
|
+
className="hover:text-primary disabled:opacity-30 disabled:hover:text-muted-foreground cursor-pointer transition-colors"
|
|
233
|
+
>
|
|
234
|
+
Previous
|
|
235
|
+
</button>
|
|
236
|
+
<button
|
|
237
|
+
disabled={!meta?.hasNextPage}
|
|
238
|
+
onClick={() => setPage(p => p + 1)}
|
|
239
|
+
className="hover:text-primary disabled:opacity-30 disabled:hover:text-muted-foreground cursor-pointer transition-colors"
|
|
240
|
+
>
|
|
241
|
+
Next
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useStore } from '@nanostores/react';
|
|
3
|
+
import { $schema } from '../store/schema';
|
|
4
|
+
import { Loader2Icon, CheckIcon, TypeIcon } from 'lucide-react';
|
|
5
|
+
import type { Field } from '../types';
|
|
6
|
+
import { Button } from './ui/button';
|
|
7
|
+
|
|
8
|
+
interface DynamicFormProps {
|
|
9
|
+
slug: string;
|
|
10
|
+
onSubmit: (data: Record<string, any>) => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
initialData?: Record<string, any> | null;
|
|
13
|
+
isSubmitting?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function DynamicForm({
|
|
17
|
+
slug,
|
|
18
|
+
onSubmit,
|
|
19
|
+
onCancel,
|
|
20
|
+
initialData,
|
|
21
|
+
isSubmitting,
|
|
22
|
+
}: DynamicFormProps) {
|
|
23
|
+
const { data: schema, loading } = useStore($schema);
|
|
24
|
+
const [formData, setFormData] = useState<Record<string, any>>(
|
|
25
|
+
initialData || {},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (loading)
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex flex-col items-center gap-4 py-20 justify-center text-muted-foreground">
|
|
31
|
+
<Loader2Icon className="size-8 animate-spin text-primary/40" />
|
|
32
|
+
<p className="text-[10px] font-bold uppercase tracking-widest">
|
|
33
|
+
Acquiring Structure...
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
if (isSubmitting) return;
|
|
41
|
+
onSubmit(formData);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const renderField = (field: Field) => {
|
|
45
|
+
const value = formData[field.slug] ?? '';
|
|
46
|
+
const onChange = (val: any) =>
|
|
47
|
+
setFormData({ ...formData, [field.slug]: val });
|
|
48
|
+
|
|
49
|
+
switch (field.type) {
|
|
50
|
+
case 'boolean':
|
|
51
|
+
return (
|
|
52
|
+
<label className="flex items-center gap-4 cursor-pointer p-5 bg-muted/20 rounded-md border border-border/50 hover:bg-muted/30 transition-all border-dashed group opacity-100 disabled:opacity-50">
|
|
53
|
+
<div className="relative flex items-center">
|
|
54
|
+
<input
|
|
55
|
+
type="checkbox"
|
|
56
|
+
className="peer sr-only"
|
|
57
|
+
checked={!!value}
|
|
58
|
+
disabled={isSubmitting}
|
|
59
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
60
|
+
/>
|
|
61
|
+
<div className="w-10 h-5 bg-muted-foreground/20 rounded-full peer peer-checked:bg-primary transition-colors after:content-[''] after:absolute after:top-1 after:left-1 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-transform peer-checked:after:translate-x-5" />
|
|
62
|
+
</div>
|
|
63
|
+
<div className="flex flex-col">
|
|
64
|
+
<span className="text-xs font-bold text-foreground group-hover:text-primary transition-colors">
|
|
65
|
+
{field.label}
|
|
66
|
+
</span>
|
|
67
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider opacity-60">
|
|
68
|
+
Binary State Toggle
|
|
69
|
+
</span>
|
|
70
|
+
</div>
|
|
71
|
+
</label>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
case 'richtext':
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
78
|
+
{field.label}
|
|
79
|
+
</label>
|
|
80
|
+
<textarea
|
|
81
|
+
className="w-full bg-muted/20 border border-border rounded-md px-4 py-3 text-sm focus:bg-background transition-all min-h-[180px] outline-none focus:ring-1 focus:ring-primary/20 disabled:opacity-50"
|
|
82
|
+
value={value}
|
|
83
|
+
disabled={isSubmitting}
|
|
84
|
+
onChange={(e) => onChange(e.target.value)}
|
|
85
|
+
placeholder={`Enter content for ${field.label.toLowerCase()}...`}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
case 'number':
|
|
91
|
+
return (
|
|
92
|
+
<div className="space-y-2">
|
|
93
|
+
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
94
|
+
{field.label}
|
|
95
|
+
</label>
|
|
96
|
+
<input
|
|
97
|
+
type="number"
|
|
98
|
+
className="w-full h-10 bg-muted/20 border border-border rounded-md px-4 text-sm focus:bg-background transition-all outline-none focus:ring-1 focus:ring-primary/20 disabled:opacity-50"
|
|
99
|
+
value={value}
|
|
100
|
+
disabled={isSubmitting}
|
|
101
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
case 'date':
|
|
107
|
+
return (
|
|
108
|
+
<div className="space-y-2">
|
|
109
|
+
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
110
|
+
{field.label}
|
|
111
|
+
</label>
|
|
112
|
+
<input
|
|
113
|
+
type="date"
|
|
114
|
+
className="w-full h-10 bg-muted/20 border border-border rounded-md px-4 text-sm focus:bg-background transition-all outline-none focus:ring-1 focus:ring-primary/20 disabled:opacity-50"
|
|
115
|
+
value={value}
|
|
116
|
+
disabled={isSubmitting}
|
|
117
|
+
onChange={(e) => onChange(e.target.value)}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
default:
|
|
123
|
+
return (
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
126
|
+
{field.label}
|
|
127
|
+
</label>
|
|
128
|
+
<input
|
|
129
|
+
type="text"
|
|
130
|
+
className="w-full h-10 bg-muted/20 border border-border rounded-md px-4 text-sm focus:bg-background transition-all outline-none focus:ring-1 focus:ring-primary/20 disabled:opacity-50"
|
|
131
|
+
value={value}
|
|
132
|
+
disabled={isSubmitting}
|
|
133
|
+
onChange={(e) => onChange(e.target.value)}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<form onSubmit={handleSubmit} className="space-y-10">
|
|
142
|
+
<fieldset disabled={isSubmitting} className="space-y-8 contents">
|
|
143
|
+
{schema?.fields?.map((field: Field) => (
|
|
144
|
+
<div key={field.id}>{renderField(field)}</div>
|
|
145
|
+
))}
|
|
146
|
+
{(!schema?.fields || schema.fields.length === 0) && (
|
|
147
|
+
<div className="p-12 text-center bg-muted/10 rounded-lg border border-dashed flex flex-col items-center gap-4">
|
|
148
|
+
<TypeIcon className="size-8 opacity-10" />
|
|
149
|
+
<div className="space-y-1">
|
|
150
|
+
<p className="text-sm font-bold uppercase tracking-wider text-foreground/50">
|
|
151
|
+
Model Void
|
|
152
|
+
</p>
|
|
153
|
+
<p className="text-[10px] uppercase font-semibold text-muted-foreground tracking-widest opacity-40">
|
|
154
|
+
Define fields in the structure engine to begin entry
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</fieldset>
|
|
160
|
+
|
|
161
|
+
<div className="flex justify-end gap-3 pt-8 border-t border-dashed">
|
|
162
|
+
<Button
|
|
163
|
+
type="button"
|
|
164
|
+
variant="ghost"
|
|
165
|
+
disabled={isSubmitting}
|
|
166
|
+
onClick={onCancel}
|
|
167
|
+
className="text-xs font-semibold h-10 px-6 text-muted-foreground hover:text-foreground"
|
|
168
|
+
>
|
|
169
|
+
Discard Changes
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
type="submit"
|
|
173
|
+
disabled={isSubmitting}
|
|
174
|
+
className="font-bold h-10 px-8 text-xs tracking-tight min-w-32"
|
|
175
|
+
>
|
|
176
|
+
{isSubmitting ? (
|
|
177
|
+
<Loader2Icon className="size-4 mr-2 animate-spin" />
|
|
178
|
+
) : (
|
|
179
|
+
<CheckIcon className="size-4 mr-2" />
|
|
180
|
+
)}
|
|
181
|
+
{isSubmitting
|
|
182
|
+
? 'Processing...'
|
|
183
|
+
: initialData?.id
|
|
184
|
+
? 'Update Document'
|
|
185
|
+
: 'Create Document'}
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
</form>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useStore } from '@nanostores/react';
|
|
3
|
+
import {
|
|
4
|
+
PlusIcon,
|
|
5
|
+
Loader2Icon,
|
|
6
|
+
TypeIcon,
|
|
7
|
+
HashIcon,
|
|
8
|
+
CheckSquareIcon,
|
|
9
|
+
CalendarIcon,
|
|
10
|
+
AlignLeftIcon,
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import { $addField } from '../store/schema';
|
|
13
|
+
import type { Field } from '../types';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogDescription,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
DialogTrigger,
|
|
23
|
+
} from './ui/dialog';
|
|
24
|
+
import { Button } from './ui/button';
|
|
25
|
+
import { Input } from './ui/input';
|
|
26
|
+
import { Label } from './ui/label';
|
|
27
|
+
|
|
28
|
+
interface FieldModalProps {
|
|
29
|
+
children: React.ReactElement;
|
|
30
|
+
collectionId: string;
|
|
31
|
+
collectionSlug: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const FIELD_TYPES = [
|
|
35
|
+
{ id: 'text', label: 'Plain Text', icon: <TypeIcon className="w-4 h-4" /> },
|
|
36
|
+
{
|
|
37
|
+
id: 'richtext',
|
|
38
|
+
label: 'Rich Text',
|
|
39
|
+
icon: <AlignLeftIcon className="w-4 h-4" />,
|
|
40
|
+
},
|
|
41
|
+
{ id: 'number', label: 'Number', icon: <HashIcon className="w-4 h-4" /> },
|
|
42
|
+
{
|
|
43
|
+
id: 'boolean',
|
|
44
|
+
label: 'Boolean',
|
|
45
|
+
icon: <CheckSquareIcon className="w-4 h-4" />,
|
|
46
|
+
},
|
|
47
|
+
{ id: 'date', label: 'Date', icon: <CalendarIcon className="w-4 h-4" /> },
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export function FieldModal({
|
|
51
|
+
children,
|
|
52
|
+
collectionId,
|
|
53
|
+
collectionSlug,
|
|
54
|
+
}: FieldModalProps) {
|
|
55
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
56
|
+
const [formData, setFormData] = useState<Partial<Field>>({
|
|
57
|
+
label: '',
|
|
58
|
+
slug: '',
|
|
59
|
+
type: 'text',
|
|
60
|
+
required: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const { mutate, loading } = useStore($addField);
|
|
64
|
+
|
|
65
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
try {
|
|
68
|
+
await mutate({
|
|
69
|
+
...formData,
|
|
70
|
+
collectionId,
|
|
71
|
+
collectionSlug,
|
|
72
|
+
});
|
|
73
|
+
setIsOpen(false);
|
|
74
|
+
setFormData({ label: '', slug: '', type: 'text', required: false });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(err);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
82
|
+
<DialogTrigger render={children} />
|
|
83
|
+
<DialogContent className="sm:max-w-[540px] p-0 overflow-hidden border-border bg-background rounded-md shadow-lg">
|
|
84
|
+
<DialogHeader className="px-8 py-8 border-b bg-muted/30">
|
|
85
|
+
<div className="flex items-center gap-4">
|
|
86
|
+
<div className="p-2.5 bg-background border rounded-md shadow-sm">
|
|
87
|
+
<PlusIcon className="size-5 text-primary" />
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<DialogTitle className="text-lg font-bold tracking-tight">
|
|
91
|
+
New Structure Field
|
|
92
|
+
</DialogTitle>
|
|
93
|
+
<DialogDescription className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mt-0.5">
|
|
94
|
+
Define Attribute Parameter
|
|
95
|
+
</DialogDescription>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</DialogHeader>
|
|
99
|
+
|
|
100
|
+
<form onSubmit={handleSubmit}>
|
|
101
|
+
<div className="p-8 space-y-8">
|
|
102
|
+
<div className="grid grid-cols-2 gap-6">
|
|
103
|
+
<div className="space-y-2">
|
|
104
|
+
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
105
|
+
Field Label
|
|
106
|
+
</Label>
|
|
107
|
+
<Input
|
|
108
|
+
required
|
|
109
|
+
placeholder="e.g. Featured Toggle"
|
|
110
|
+
className="h-10 bg-muted/20 focus:bg-background transition-all border-border rounded-md px-4 text-sm font-medium"
|
|
111
|
+
value={formData.label}
|
|
112
|
+
onChange={(e) => {
|
|
113
|
+
const label = e.target.value;
|
|
114
|
+
const slug = label
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.replace(/\s+/g, '_')
|
|
117
|
+
.replace(/[^a-z0-9_]/g, '');
|
|
118
|
+
setFormData({ ...formData, label, slug });
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="space-y-2">
|
|
123
|
+
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
124
|
+
Database Key
|
|
125
|
+
</Label>
|
|
126
|
+
<Input
|
|
127
|
+
required
|
|
128
|
+
className="h-10 bg-muted/20 focus:bg-background transition-all border-border rounded-md font-mono text-[11px] font-semibold text-muted-foreground"
|
|
129
|
+
value={formData.slug}
|
|
130
|
+
onChange={(e) =>
|
|
131
|
+
setFormData({ ...formData, slug: e.target.value })
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="space-y-4">
|
|
138
|
+
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 pl-1">
|
|
139
|
+
Field Type
|
|
140
|
+
</Label>
|
|
141
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
142
|
+
{FIELD_TYPES.map((type) => (
|
|
143
|
+
<button
|
|
144
|
+
key={type.id}
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() =>
|
|
147
|
+
setFormData({ ...formData, type: type.id as any })
|
|
148
|
+
}
|
|
149
|
+
className={`flex items-center gap-3 p-3.5 rounded-md border transition-all text-left group ${
|
|
150
|
+
formData.type === type.id
|
|
151
|
+
? 'bg-primary/5 border-primary text-primary font-bold shadow-sm'
|
|
152
|
+
: 'bg-background border-border text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground'
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
<div
|
|
156
|
+
className={
|
|
157
|
+
formData.type === type.id
|
|
158
|
+
? 'text-primary'
|
|
159
|
+
: 'text-muted-foreground/40 group-hover:text-muted-foreground/60 transition-colors'
|
|
160
|
+
}
|
|
161
|
+
>
|
|
162
|
+
{type.icon}
|
|
163
|
+
</div>
|
|
164
|
+
<span className="text-[11px] uppercase tracking-tight leading-none font-semibold">
|
|
165
|
+
{type.label}
|
|
166
|
+
</span>
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<label className="flex items-center gap-4 cursor-pointer group p-5 bg-muted/20 border border-border/50 rounded-md hover:bg-muted/30 transition-all border-dashed">
|
|
173
|
+
<div className="relative flex items-center">
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
className="peer sr-only"
|
|
177
|
+
checked={formData.required}
|
|
178
|
+
onChange={(e) =>
|
|
179
|
+
setFormData({ ...formData, required: e.target.checked })
|
|
180
|
+
}
|
|
181
|
+
/>
|
|
182
|
+
<div className="w-10 h-5 bg-muted-foreground/20 rounded-full peer peer-checked:bg-primary transition-colors after:content-[''] after:absolute after:top-1 after:left-1 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-transform peer-checked:after:translate-x-5" />
|
|
183
|
+
</div>
|
|
184
|
+
<div className="flex-1">
|
|
185
|
+
<p className="text-xs font-bold text-foreground">
|
|
186
|
+
Required Property
|
|
187
|
+
</p>
|
|
188
|
+
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-widest opacity-60">
|
|
189
|
+
Enforce data integrity
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
</label>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<DialogFooter className="bg-muted/10 border-t p-6 px-8 gap-3 sm:gap-2">
|
|
196
|
+
<Button
|
|
197
|
+
type="button"
|
|
198
|
+
variant="ghost"
|
|
199
|
+
onClick={() => setIsOpen(false)}
|
|
200
|
+
className="text-xs font-semibold text-muted-foreground hover:text-foreground h-10 px-6"
|
|
201
|
+
>
|
|
202
|
+
Discard
|
|
203
|
+
</Button>
|
|
204
|
+
<Button
|
|
205
|
+
type="submit"
|
|
206
|
+
disabled={loading}
|
|
207
|
+
className="font-bold px-8 rounded-md h-10 text-xs tracking-tight"
|
|
208
|
+
>
|
|
209
|
+
{loading ? (
|
|
210
|
+
<Loader2Icon className="size-4 animate-spin mr-2.5" />
|
|
211
|
+
) : (
|
|
212
|
+
<PlusIcon className="size-4 mr-2.5" />
|
|
213
|
+
)}
|
|
214
|
+
Add Property
|
|
215
|
+
</Button>
|
|
216
|
+
</DialogFooter>
|
|
217
|
+
</form>
|
|
218
|
+
</DialogContent>
|
|
219
|
+
</Dialog>
|
|
220
|
+
);
|
|
221
|
+
}
|