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,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
+
@@ -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
+ }