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,188 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { $router, navigate } from '../store/router';
|
|
3
|
+
import { $auth } from '../store/auth';
|
|
4
|
+
import { apiFetch } from '../lib/api';
|
|
5
|
+
import { LockIcon, MailIcon, Loader2Icon, UserIcon } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { Button } from '../components/ui/button';
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
CardDescription,
|
|
13
|
+
CardContent,
|
|
14
|
+
CardFooter,
|
|
15
|
+
} from '../components/ui/card';
|
|
16
|
+
import { Input } from '../components/ui/input';
|
|
17
|
+
import { Label } from '../components/ui/label';
|
|
18
|
+
|
|
19
|
+
export function SignupPage() {
|
|
20
|
+
const [email, setEmail] = useState('');
|
|
21
|
+
const [password, setPassword] = useState('');
|
|
22
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [error, setError] = useState('');
|
|
25
|
+
const [signupEnabled, setSignupEnabled] = useState(false);
|
|
26
|
+
const [checking, setChecking] = useState(true);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
async function checkAvailability() {
|
|
30
|
+
try {
|
|
31
|
+
const res = await apiFetch('/auth/registration-settings');
|
|
32
|
+
const { data } = await res.json();
|
|
33
|
+
setSignupEnabled(data.enabled === 'true');
|
|
34
|
+
} catch (err) {
|
|
35
|
+
setSignupEnabled(false);
|
|
36
|
+
} finally {
|
|
37
|
+
setChecking(false);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
checkAvailability();
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const handleSignup = async (e: React.FormEvent) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
if (password !== confirmPassword) {
|
|
46
|
+
return setError('Passwords do not match');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setLoading(true);
|
|
50
|
+
setError('');
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const response = await apiFetch('/auth/signup', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ email, password }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const data = await response.json();
|
|
60
|
+
|
|
61
|
+
if (response.ok) {
|
|
62
|
+
$auth.set({
|
|
63
|
+
token: data.data.token || 'cookie',
|
|
64
|
+
user: data.data.user || { email, role: 'editor' },
|
|
65
|
+
});
|
|
66
|
+
navigate('home');
|
|
67
|
+
} else {
|
|
68
|
+
setError(data.error || 'Registration failed');
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
setError('Connection failed. Is the API running?');
|
|
72
|
+
} finally {
|
|
73
|
+
setLoading(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (checking) return null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-6 antialiased">
|
|
81
|
+
<div className="w-full max-w-[360px] space-y-8">
|
|
82
|
+
<Card className="py-0 shadow-sm border-border">
|
|
83
|
+
<CardHeader className="space-y-1 pb-4 pt-8 px-8">
|
|
84
|
+
<CardTitle className="text-xl font-bold text-center tracking-tight">
|
|
85
|
+
Create Account
|
|
86
|
+
</CardTitle>
|
|
87
|
+
<CardDescription className="text-center text-[10px] text-muted-foreground uppercase font-semibold tracking-wider">
|
|
88
|
+
Join the high-speed CMS network
|
|
89
|
+
</CardDescription>
|
|
90
|
+
</CardHeader>
|
|
91
|
+
<form onSubmit={handleSignup}>
|
|
92
|
+
<CardContent className="space-y-4 px-8 pb-8 pt-2">
|
|
93
|
+
<div className="space-y-2">
|
|
94
|
+
<Label
|
|
95
|
+
htmlFor="email"
|
|
96
|
+
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
97
|
+
>
|
|
98
|
+
Email Address
|
|
99
|
+
</Label>
|
|
100
|
+
<div className="relative">
|
|
101
|
+
<MailIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/50" />
|
|
102
|
+
<Input
|
|
103
|
+
id="email"
|
|
104
|
+
type="email"
|
|
105
|
+
placeholder="name@company.com"
|
|
106
|
+
value={email}
|
|
107
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
108
|
+
className="pl-10 h-10 text-sm"
|
|
109
|
+
required
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="space-y-2">
|
|
115
|
+
<Label
|
|
116
|
+
htmlFor="password"
|
|
117
|
+
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
118
|
+
>
|
|
119
|
+
Password
|
|
120
|
+
</Label>
|
|
121
|
+
<div className="relative">
|
|
122
|
+
<LockIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/50" />
|
|
123
|
+
<Input
|
|
124
|
+
id="password"
|
|
125
|
+
type="password"
|
|
126
|
+
placeholder="Min. 6 characters"
|
|
127
|
+
value={password}
|
|
128
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
129
|
+
className="pl-10 h-10 text-sm"
|
|
130
|
+
required
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className="space-y-2">
|
|
136
|
+
<Label
|
|
137
|
+
htmlFor="confirm"
|
|
138
|
+
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
139
|
+
>
|
|
140
|
+
Confirm Password
|
|
141
|
+
</Label>
|
|
142
|
+
<div className="relative">
|
|
143
|
+
<LockIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/50" />
|
|
144
|
+
<Input
|
|
145
|
+
id="confirm"
|
|
146
|
+
type="password"
|
|
147
|
+
placeholder="********"
|
|
148
|
+
value={confirmPassword}
|
|
149
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
150
|
+
className="pl-10 h-10 text-sm"
|
|
151
|
+
required
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{error && (
|
|
157
|
+
<div className="p-3 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-[11px] font-medium text-center">
|
|
158
|
+
{error}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</CardContent>
|
|
162
|
+
<CardFooter className="flex flex-col gap-3 px-8 pb-8">
|
|
163
|
+
<Button
|
|
164
|
+
type="submit"
|
|
165
|
+
className="w-full h-10 font-bold text-xs uppercase tracking-widest active:scale-95 transition-transform"
|
|
166
|
+
disabled={loading}
|
|
167
|
+
>
|
|
168
|
+
{loading ? (
|
|
169
|
+
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
|
170
|
+
) : (
|
|
171
|
+
'Deploy Account'
|
|
172
|
+
)}
|
|
173
|
+
</Button>
|
|
174
|
+
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={() => navigate('login')}
|
|
178
|
+
className="text-[10px] text-muted-foreground font-semibold uppercase tracking-widest hover:text-foreground transition-colors mt-2"
|
|
179
|
+
>
|
|
180
|
+
Already have an account? Sign In
|
|
181
|
+
</button>
|
|
182
|
+
</CardFooter>
|
|
183
|
+
</form>
|
|
184
|
+
</Card>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
import { persistentAtom } from '@nanostores/persistent';
|
|
3
|
+
|
|
4
|
+
export interface AuthState {
|
|
5
|
+
token: string | null;
|
|
6
|
+
user: {
|
|
7
|
+
email: string;
|
|
8
|
+
role: string;
|
|
9
|
+
} | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Persist token in localStorage
|
|
13
|
+
export const $auth = persistentAtom<AuthState>('flare:auth', {
|
|
14
|
+
token: null,
|
|
15
|
+
user: null
|
|
16
|
+
}, {
|
|
17
|
+
encode: JSON.stringify,
|
|
18
|
+
decode: JSON.parse
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const login = (token: string, email: string) => {
|
|
22
|
+
$auth.set({
|
|
23
|
+
token,
|
|
24
|
+
user: { email, role: 'admin' }
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const logout = () => {
|
|
29
|
+
$auth.set({ token: null, user: null });
|
|
30
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createFetcherStore, createMutatorStore } from './fetcher';
|
|
2
|
+
import { api } from '../lib/api';
|
|
3
|
+
import type { Collection } from '../types';
|
|
4
|
+
|
|
5
|
+
export const $collections = createFetcherStore<Collection[]>(['/api/collections']);
|
|
6
|
+
|
|
7
|
+
export const $createCollection = createMutatorStore<Partial<Collection>, Collection>(
|
|
8
|
+
async ({ data, invalidate }) => {
|
|
9
|
+
const json = await api.post('api/collections', { json: data }).json<Collection>();
|
|
10
|
+
invalidate('/api/collections');
|
|
11
|
+
return json;
|
|
12
|
+
}
|
|
13
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
|
|
3
|
+
export const $basePath = atom('/admin');
|
|
4
|
+
export const $apiBaseUrl = atom('/api');
|
|
5
|
+
|
|
6
|
+
export function setBase(base: string) {
|
|
7
|
+
$basePath.set(base === '/' ? '' : base);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setApiBaseUrl(url: string) {
|
|
11
|
+
$apiBaseUrl.set(url);
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { nanoquery } from '@nanostores/query';
|
|
2
|
+
import { $auth } from './auth';
|
|
3
|
+
import { api } from '../lib/api';
|
|
4
|
+
import { $apiBaseUrl } from './config';
|
|
5
|
+
|
|
6
|
+
export const [createFetcherStore, createMutatorStore] = nanoquery({
|
|
7
|
+
fetcher: (...keys) => {
|
|
8
|
+
const apiBase = $apiBaseUrl.get();
|
|
9
|
+
let path = keys.join('');
|
|
10
|
+
|
|
11
|
+
// Ensure path doesn't duplicate prefix if keys already contain it
|
|
12
|
+
if (path.startsWith(apiBase)) {
|
|
13
|
+
path = path.replace(apiBase, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return api.get(path).json().then((json: any) => {
|
|
17
|
+
// Automatically unwrap the .data property from our standardized API response
|
|
18
|
+
if (json && typeof json === 'object' && 'data' in json && !('error' in json)) {
|
|
19
|
+
return json.data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// If the API returned an error object but 2xx (rare), treat as error to prevent crashes
|
|
23
|
+
if (json && typeof json === 'object' && 'error' in json) {
|
|
24
|
+
throw new Error(json.error);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return json;
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createRouter, openPage, redirectPage, type Page, type Router } from '@nanostores/router';
|
|
2
|
+
import { map } from 'nanostores';
|
|
3
|
+
|
|
4
|
+
const ROUTE_PATTERNS = {
|
|
5
|
+
home: '',
|
|
6
|
+
setup: '/setup',
|
|
7
|
+
login: '/login',
|
|
8
|
+
signup: '/signup',
|
|
9
|
+
collections: '/collections',
|
|
10
|
+
users: '/users',
|
|
11
|
+
settings: '/settings',
|
|
12
|
+
settings_general: '/settings/general',
|
|
13
|
+
settings_seo: '/settings/seo',
|
|
14
|
+
settings_security: '/settings/security',
|
|
15
|
+
settings_signup: '/settings/signup',
|
|
16
|
+
settings_api: '/settings/api',
|
|
17
|
+
device: '/device',
|
|
18
|
+
collection: '/collection/:id/:slug',
|
|
19
|
+
document_list: '/:slug',
|
|
20
|
+
document_edit: '/:slug/:id',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
type RoutePatterns = typeof ROUTE_PATTERNS;
|
|
24
|
+
type RouteName = keyof RoutePatterns;
|
|
25
|
+
|
|
26
|
+
// Global reference to the internal nanostores-router instance
|
|
27
|
+
let internalRouter: Router<RoutePatterns> | null = null;
|
|
28
|
+
|
|
29
|
+
// The public store that components consume.
|
|
30
|
+
// It mirrors the state of the active internal router.
|
|
31
|
+
// We initialize it with a safe default state.
|
|
32
|
+
export const $router = map<Page<RoutePatterns>>({
|
|
33
|
+
route: 'home',
|
|
34
|
+
path: '/',
|
|
35
|
+
params: {} as any,
|
|
36
|
+
search: {},
|
|
37
|
+
hash: '',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Initializes the router with a specific base path.
|
|
42
|
+
*/
|
|
43
|
+
export function initRouter(base: string) {
|
|
44
|
+
const prefix = base === '/' ? '' : base;
|
|
45
|
+
|
|
46
|
+
// We cast to any here only because we are dynamically building the object keys,
|
|
47
|
+
// but the resulting structure is guaranteed to match RoutePatterns.
|
|
48
|
+
const patterns = {} as any;
|
|
49
|
+
for (const name of Object.keys(ROUTE_PATTERNS) as RouteName[]) {
|
|
50
|
+
const path = ROUTE_PATTERNS[name];
|
|
51
|
+
patterns[name] = name === 'home' ? (prefix || '/') : `${prefix}${path}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Create new underlying router
|
|
55
|
+
internalRouter = createRouter<RoutePatterns>(patterns);
|
|
56
|
+
|
|
57
|
+
// Sync internal router state to our public $router map
|
|
58
|
+
internalRouter.subscribe((state) => {
|
|
59
|
+
if (state) {
|
|
60
|
+
$router.set(state as Page<RoutePatterns>);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return internalRouter;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Navigate to a named route.
|
|
69
|
+
*/
|
|
70
|
+
export function navigate<T extends RouteName>(
|
|
71
|
+
route: T,
|
|
72
|
+
params?: any,
|
|
73
|
+
search?: Record<string, string | number>
|
|
74
|
+
) {
|
|
75
|
+
if (!internalRouter) {
|
|
76
|
+
console.warn('Router not initialized.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
openPage(internalRouter, route, params, search as any);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Redirect to a named route.
|
|
84
|
+
*/
|
|
85
|
+
export function redirect<T extends RouteName>(
|
|
86
|
+
route: T,
|
|
87
|
+
params?: any,
|
|
88
|
+
search?: Record<string, string | number>
|
|
89
|
+
) {
|
|
90
|
+
if (!internalRouter) return;
|
|
91
|
+
redirectPage(internalRouter, route, params, search as any);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Default initialization
|
|
95
|
+
initRouter('');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { atom } from 'nanostores';
|
|
2
|
+
import { createFetcherStore, createMutatorStore } from './fetcher';
|
|
3
|
+
import { apiFetch } from '../lib/api';
|
|
4
|
+
import type { CollectionSchema, Field } from '../types';
|
|
5
|
+
|
|
6
|
+
export const $activeSlug = atom<string | null>(null);
|
|
7
|
+
|
|
8
|
+
export const $schema = createFetcherStore<CollectionSchema>([
|
|
9
|
+
'/api/collections/',
|
|
10
|
+
$activeSlug,
|
|
11
|
+
'/schema'
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export const $addField = createMutatorStore<Partial<Field> & { collectionSlug: string }, Field>(
|
|
15
|
+
async ({ data, invalidate }) => {
|
|
16
|
+
const { collectionId, collectionSlug, ...fieldData } = data;
|
|
17
|
+
const response = await apiFetch(`/collections/${collectionId}/fields`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
body: JSON.stringify(fieldData),
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) throw new Error('Failed to add field');
|
|
23
|
+
|
|
24
|
+
// Invalidate the schema cache
|
|
25
|
+
invalidate(`/api/collections/${collectionSlug}/schema`);
|
|
26
|
+
const result = await response.json();
|
|
27
|
+
return result.data;
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const $reloadSchema = () => {
|
|
32
|
+
const slug = $activeSlug.get();
|
|
33
|
+
if (slug) {
|
|
34
|
+
// We can't easily invalidate from here without the invalidate function from mutate
|
|
35
|
+
// But we can trigger a refresh by setting active slug to null and back
|
|
36
|
+
$activeSlug.set(null);
|
|
37
|
+
setTimeout(() => $activeSlug.set(slug), 10);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { map } from 'nanostores';
|
|
2
|
+
import { apiFetch } from '../lib/api';
|
|
3
|
+
|
|
4
|
+
export interface Settings {
|
|
5
|
+
[key: string]: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const $settings = map<Settings>({});
|
|
9
|
+
|
|
10
|
+
export async function loadSettings() {
|
|
11
|
+
try {
|
|
12
|
+
const res = await apiFetch('/settings');
|
|
13
|
+
if (res.ok) {
|
|
14
|
+
const { data } = await res.json();
|
|
15
|
+
$settings.set(data);
|
|
16
|
+
}
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error('Failed to load settings:', err);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function updateSettingsLocally(newSettings: Settings) {
|
|
23
|
+
$settings.set({
|
|
24
|
+
...$settings.get(),
|
|
25
|
+
...newSettings
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getSetting(key: string, fallback: string = ''): string {
|
|
30
|
+
return $settings.get()[key] || fallback;
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface Collection {
|
|
2
|
+
id: string;
|
|
3
|
+
slug: string;
|
|
4
|
+
label: string;
|
|
5
|
+
label_singular: string | null;
|
|
6
|
+
description: string | null;
|
|
7
|
+
icon: string | null;
|
|
8
|
+
is_public: number;
|
|
9
|
+
features: string[];
|
|
10
|
+
url_pattern: string | null;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Field {
|
|
15
|
+
id: string;
|
|
16
|
+
collectionId: string;
|
|
17
|
+
label: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
type: 'text' | 'richtext' | 'number' | 'boolean' | 'date';
|
|
20
|
+
required: boolean;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CollectionSchema extends Collection {
|
|
25
|
+
fields: Field[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ContentEntry {
|
|
29
|
+
id: string;
|
|
30
|
+
slug: string;
|
|
31
|
+
createdAt: Date | string;
|
|
32
|
+
updatedAt: Date | string;
|
|
33
|
+
[key: string]: any; // Content entries are dynamic by nature
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import type { FlareDb } from "./index";
|
|
3
|
+
|
|
4
|
+
export const FIELD_TYPE_MAP: Record<string, string> = {
|
|
5
|
+
text: "TEXT",
|
|
6
|
+
number: "REAL",
|
|
7
|
+
integer: "INTEGER",
|
|
8
|
+
boolean: "INTEGER",
|
|
9
|
+
json: "TEXT",
|
|
10
|
+
date: "TEXT",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function createCollectionTable(db: FlareDb, slug: string) {
|
|
14
|
+
const tableName = `ec_${slug}`;
|
|
15
|
+
|
|
16
|
+
// Basic content table structure
|
|
17
|
+
// id (ULID), slug, status, created_at, updated_at are standard
|
|
18
|
+
await sql.raw(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
slug TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
23
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
25
|
+
)
|
|
26
|
+
`).execute(db);
|
|
27
|
+
|
|
28
|
+
// Add an index on slug
|
|
29
|
+
await sql.raw(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${tableName}_slug ON ${tableName} (slug)`).execute(db);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function addFieldToTable(db: FlareDb, collectionSlug: string, fieldSlug: string, type: string) {
|
|
33
|
+
const tableName = `ec_${collectionSlug}`;
|
|
34
|
+
const columnType = FIELD_TYPE_MAP[type] || "TEXT";
|
|
35
|
+
|
|
36
|
+
await sql.raw(`ALTER TABLE ${tableName} ADD COLUMN ${fieldSlug} ${columnType}`).execute(db);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function ensureUniqueSlug(
|
|
40
|
+
db: FlareDb,
|
|
41
|
+
collectionName: string,
|
|
42
|
+
baseSlug: string,
|
|
43
|
+
excludeId?: string
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
let slug = baseSlug;
|
|
46
|
+
let counter = 0;
|
|
47
|
+
let exists = true;
|
|
48
|
+
|
|
49
|
+
while (exists) {
|
|
50
|
+
const currentSlug = counter === 0 ? slug : `${slug}-${counter}`;
|
|
51
|
+
let query = db.selectFrom(`ec_${collectionName}` as any)
|
|
52
|
+
.select('id')
|
|
53
|
+
.where('slug', '=', currentSlug)
|
|
54
|
+
.where('status', '!=', 'deleted');
|
|
55
|
+
|
|
56
|
+
if (excludeId) {
|
|
57
|
+
query = query.where('id', '!=', (excludeId as any));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const collision = await query.executeTakeFirst();
|
|
61
|
+
|
|
62
|
+
if (!collision) {
|
|
63
|
+
return currentSlug;
|
|
64
|
+
}
|
|
65
|
+
counter++;
|
|
66
|
+
if (counter > 100) break; // Safety break
|
|
67
|
+
}
|
|
68
|
+
return `${slug}-${Math.random().toString(36).substring(2, 7)}`;
|
|
69
|
+
}
|
|
70
|
+
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Kysely } from "kysely";
|
|
2
|
+
import { D1Dialect } from "kysely-d1";
|
|
3
|
+
import type { Database } from "./schema";
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
|
|
6
|
+
export * from "./schema";
|
|
7
|
+
export * from "./dynamic";
|
|
8
|
+
export * from "./migrator";
|
|
9
|
+
|
|
10
|
+
export const createDb = (d1: D1Database) => {
|
|
11
|
+
return new Kysely<Database>({
|
|
12
|
+
dialect: new D1Dialect({ database: d1 }),
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FlareDb = ReturnType<typeof createDb>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Kysely, sql } from 'kysely';
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
await db.schema
|
|
5
|
+
.createTable('options')
|
|
6
|
+
.ifNotExists()
|
|
7
|
+
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
8
|
+
.addColumn('value', 'text', (col) => col.notNull())
|
|
9
|
+
.execute();
|
|
10
|
+
|
|
11
|
+
await db.schema
|
|
12
|
+
.createTable('fc_users')
|
|
13
|
+
.ifNotExists()
|
|
14
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
15
|
+
.addColumn('email', 'text', (col) => col.unique().notNull())
|
|
16
|
+
.addColumn('password', 'text')
|
|
17
|
+
.addColumn('role', 'text', (col) => col.notNull().defaultTo('admin'))
|
|
18
|
+
.addColumn('disabled', 'integer', (col) => col.defaultTo(0))
|
|
19
|
+
.addColumn('created_at', 'text', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
|
20
|
+
.addColumn('updated_at', 'text', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
|
21
|
+
.execute();
|
|
22
|
+
|
|
23
|
+
await db.schema
|
|
24
|
+
.createTable('fc_collections')
|
|
25
|
+
.ifNotExists()
|
|
26
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
27
|
+
.addColumn('slug', 'text', (col) => col.unique().notNull())
|
|
28
|
+
.addColumn('label', 'text', (col) => col.notNull())
|
|
29
|
+
.addColumn('label_singular', 'text')
|
|
30
|
+
.addColumn('description', 'text')
|
|
31
|
+
.addColumn('icon', 'text')
|
|
32
|
+
.addColumn('is_public', 'integer', (col) => col.defaultTo(0))
|
|
33
|
+
.addColumn('features', 'text') // JSON array
|
|
34
|
+
.addColumn('url_pattern', 'text')
|
|
35
|
+
.addColumn('created_at', 'text', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
|
36
|
+
.addColumn('updated_at', 'text', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
|
37
|
+
.execute();
|
|
38
|
+
|
|
39
|
+
await db.schema
|
|
40
|
+
.createTable('fc_fields')
|
|
41
|
+
.ifNotExists()
|
|
42
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
43
|
+
.addColumn('collection_id', 'text', (col) => col.notNull())
|
|
44
|
+
.addColumn('label', 'text', (col) => col.notNull())
|
|
45
|
+
.addColumn('slug', 'text', (col) => col.notNull())
|
|
46
|
+
.addColumn('type', 'text', (col) => col.notNull())
|
|
47
|
+
.addColumn('required', 'integer', (col) => col.defaultTo(0))
|
|
48
|
+
.addColumn('created_at', 'text', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
|
49
|
+
.execute();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
53
|
+
await db.schema.dropTable('options').ifExists().execute();
|
|
54
|
+
await db.schema.dropTable('fc_users').ifExists().execute();
|
|
55
|
+
await db.schema.dropTable('fc_collections').ifExists().execute();
|
|
56
|
+
await db.schema.dropTable('fc_fields').ifExists().execute();
|
|
57
|
+
}
|