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.
Files changed (110) hide show
  1. package/README.md +73 -0
  2. package/dist/auth/index.js +40 -0
  3. package/dist/cli/commands.js +389 -0
  4. package/dist/cli/index.js +403 -0
  5. package/dist/cli/mcp.js +209 -0
  6. package/dist/db/index.js +164 -0
  7. package/dist/index.js +17626 -0
  8. package/package.json +105 -0
  9. package/scripts/fix-api-paths.mjs +32 -0
  10. package/scripts/fix-imports.mjs +38 -0
  11. package/scripts/prefix-css.mjs +45 -0
  12. package/src/api/lib/cache.ts +45 -0
  13. package/src/api/lib/response.ts +40 -0
  14. package/src/api/middlewares/auth.ts +186 -0
  15. package/src/api/middlewares/cors.ts +10 -0
  16. package/src/api/middlewares/rbac.ts +85 -0
  17. package/src/api/routes/auth.ts +377 -0
  18. package/src/api/routes/collections.ts +205 -0
  19. package/src/api/routes/content.ts +175 -0
  20. package/src/api/routes/device.ts +160 -0
  21. package/src/api/routes/magic.ts +150 -0
  22. package/src/api/routes/mcp.ts +273 -0
  23. package/src/api/routes/oauth.ts +160 -0
  24. package/src/api/routes/settings.ts +43 -0
  25. package/src/api/routes/setup.ts +307 -0
  26. package/src/api/routes/tokens.ts +80 -0
  27. package/src/api/schemas/auth.ts +15 -0
  28. package/src/api/schemas/index.ts +51 -0
  29. package/src/api/schemas/tokens.ts +24 -0
  30. package/src/auth/index.ts +28 -0
  31. package/src/cli/commands.ts +217 -0
  32. package/src/cli/index.ts +21 -0
  33. package/src/cli/mcp.ts +210 -0
  34. package/src/cli/tests/cli.test.ts +40 -0
  35. package/src/cli/tests/create.test.ts +87 -0
  36. package/src/client/FlareAdminRouter.tsx +47 -0
  37. package/src/client/app.tsx +175 -0
  38. package/src/client/components/app-sidebar.tsx +227 -0
  39. package/src/client/components/collection-modal.tsx +215 -0
  40. package/src/client/components/content-list.tsx +247 -0
  41. package/src/client/components/dynamic-form.tsx +190 -0
  42. package/src/client/components/field-modal.tsx +221 -0
  43. package/src/client/components/settings/api-token-section.tsx +400 -0
  44. package/src/client/components/settings/general-section.tsx +224 -0
  45. package/src/client/components/settings/security-section.tsx +154 -0
  46. package/src/client/components/settings/seo-section.tsx +200 -0
  47. package/src/client/components/settings/signup-section.tsx +257 -0
  48. package/src/client/components/ui/accordion.tsx +78 -0
  49. package/src/client/components/ui/avatar.tsx +107 -0
  50. package/src/client/components/ui/badge.tsx +52 -0
  51. package/src/client/components/ui/button.tsx +60 -0
  52. package/src/client/components/ui/card.tsx +103 -0
  53. package/src/client/components/ui/checkbox.tsx +27 -0
  54. package/src/client/components/ui/collapsible.tsx +19 -0
  55. package/src/client/components/ui/dialog.tsx +162 -0
  56. package/src/client/components/ui/icon-picker.tsx +485 -0
  57. package/src/client/components/ui/icons-data.ts +8476 -0
  58. package/src/client/components/ui/input.tsx +20 -0
  59. package/src/client/components/ui/label.tsx +20 -0
  60. package/src/client/components/ui/popover.tsx +91 -0
  61. package/src/client/components/ui/select.tsx +204 -0
  62. package/src/client/components/ui/separator.tsx +23 -0
  63. package/src/client/components/ui/sheet.tsx +141 -0
  64. package/src/client/components/ui/sidebar.tsx +722 -0
  65. package/src/client/components/ui/skeleton.tsx +13 -0
  66. package/src/client/components/ui/sonner.tsx +47 -0
  67. package/src/client/components/ui/switch.tsx +30 -0
  68. package/src/client/components/ui/table.tsx +116 -0
  69. package/src/client/components/ui/tabs.tsx +80 -0
  70. package/src/client/components/ui/textarea.tsx +18 -0
  71. package/src/client/components/ui/tooltip.tsx +68 -0
  72. package/src/client/hooks/use-mobile.ts +19 -0
  73. package/src/client/index.css +149 -0
  74. package/src/client/index.ts +7 -0
  75. package/src/client/layouts/admin-layout.tsx +93 -0
  76. package/src/client/layouts/settings-layout.tsx +104 -0
  77. package/src/client/lib/api.ts +72 -0
  78. package/src/client/lib/utils.ts +6 -0
  79. package/src/client/main.tsx +10 -0
  80. package/src/client/pages/collection-detail.tsx +634 -0
  81. package/src/client/pages/collections.tsx +180 -0
  82. package/src/client/pages/dashboard.tsx +133 -0
  83. package/src/client/pages/device.tsx +66 -0
  84. package/src/client/pages/document-detail-page.tsx +139 -0
  85. package/src/client/pages/documents-page.tsx +103 -0
  86. package/src/client/pages/login.tsx +345 -0
  87. package/src/client/pages/settings.tsx +65 -0
  88. package/src/client/pages/setup.tsx +129 -0
  89. package/src/client/pages/signup.tsx +188 -0
  90. package/src/client/store/auth.ts +30 -0
  91. package/src/client/store/collections.ts +13 -0
  92. package/src/client/store/config.ts +12 -0
  93. package/src/client/store/fetcher.ts +30 -0
  94. package/src/client/store/router.ts +95 -0
  95. package/src/client/store/schema.ts +39 -0
  96. package/src/client/store/settings.ts +31 -0
  97. package/src/client/types.ts +34 -0
  98. package/src/db/dynamic.ts +70 -0
  99. package/src/db/index.ts +16 -0
  100. package/src/db/migrations/001_initial_schema.ts +57 -0
  101. package/src/db/migrations/002_auth_tables.ts +84 -0
  102. package/src/db/migrator.ts +61 -0
  103. package/src/db/schema.ts +142 -0
  104. package/src/index.ts +12 -0
  105. package/src/server/index.ts +66 -0
  106. package/src/types.ts +20 -0
  107. package/style.css.d.ts +8 -0
  108. package/tests/css.test.ts +21 -0
  109. package/tests/modular.test.ts +29 -0
  110. 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
+ }