@kyro-cms/admin 0.1.6 → 0.1.8
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 +149 -51
- package/package.json +54 -5
- package/src/collections/auth/index.ts +2 -2
- package/src/collections/portfolio/index.ts +343 -0
- package/src/components/ActionBar.tsx +153 -16
- package/src/components/Admin.tsx +137 -28
- package/src/components/ApiExplorer.tsx +325 -0
- package/src/components/ApiKeysManager.tsx +563 -0
- package/src/components/AuditLogsPage.tsx +664 -0
- package/src/components/AutoForm.tsx +2155 -770
- package/src/components/BrandingHub.tsx +267 -0
- package/src/components/BulkActionsBar.tsx +3 -3
- package/src/components/CreateView.tsx +4 -4
- package/src/components/Dashboard.tsx +393 -0
- package/src/components/DetailView.tsx +200 -58
- package/src/components/DeveloperCenter.tsx +403 -0
- package/src/components/EnhancedListView.tsx +890 -0
- package/src/components/GraphQLExplorer.tsx +675 -0
- package/src/components/GraphQLPlayground.tsx +627 -0
- package/src/components/ListView.tsx +192 -54
- package/src/components/MediaGallery.tsx +1569 -0
- package/src/components/Modal.tsx +206 -0
- package/src/components/RestPlayground.tsx +951 -0
- package/src/components/Sidebar.astro +237 -0
- package/src/components/ThemeProvider.tsx +8 -2
- package/src/components/UserManagement.tsx +204 -0
- package/src/components/VersionHistoryPanel.tsx +3 -3
- package/src/components/WebhookManager.tsx +608 -0
- package/src/components/blocks/AccordionBlock.tsx +65 -0
- package/src/components/blocks/ArrayBlock.tsx +84 -0
- package/src/components/blocks/BlockEditModal.tsx +363 -0
- package/src/components/blocks/ButtonBlock.tsx +64 -0
- package/src/components/blocks/ChildBlocksTree.tsx +551 -0
- package/src/components/blocks/CodeBlock.tsx +114 -0
- package/src/components/blocks/ColumnsBlock.tsx +93 -0
- package/src/components/blocks/DividerBlock.tsx +43 -0
- package/src/components/blocks/FileBlock.tsx +63 -0
- package/src/components/blocks/HeadingBlock.tsx +59 -0
- package/src/components/blocks/HeroBlock.tsx +99 -0
- package/src/components/blocks/ImageBlock.tsx +82 -0
- package/src/components/blocks/LinkBlock.tsx +65 -0
- package/src/components/blocks/ListBlock.tsx +60 -0
- package/src/components/blocks/ParagraphBlock.tsx +61 -0
- package/src/components/blocks/RelationshipBlock.tsx +72 -0
- package/src/components/blocks/RichTextBlock.tsx +66 -0
- package/src/components/blocks/VStackBlock.tsx +61 -0
- package/src/components/blocks/VideoBlock.tsx +65 -0
- package/src/components/blocks/index.ts +10 -0
- package/src/components/fields/AccordionField.tsx +213 -0
- package/src/components/fields/ArrayField.tsx +241 -0
- package/src/components/fields/BlocksField.tsx +323 -0
- package/src/components/fields/ButtonField.tsx +53 -0
- package/src/components/fields/CheckboxField.tsx +18 -8
- package/src/components/fields/ChildrenField.tsx +48 -0
- package/src/components/fields/CodeField.tsx +294 -0
- package/src/components/fields/ColumnsField.tsx +137 -0
- package/src/components/fields/DateField.tsx +24 -12
- package/src/components/fields/EditorClient.tsx +537 -0
- package/src/components/fields/HeadingField.tsx +31 -0
- package/src/components/fields/HeroField.tsx +101 -0
- package/src/components/fields/JSONField.tsx +341 -0
- package/src/components/fields/LinkField.tsx +81 -0
- package/src/components/fields/ListField.tsx +74 -0
- package/src/components/fields/MarkdownField.tsx +260 -0
- package/src/components/fields/NumberField.tsx +25 -13
- package/src/components/fields/PortableTextField.tsx +155 -0
- package/src/components/fields/PortableTextRenderer.tsx +68 -0
- package/src/components/fields/RelationshipBlockField.tsx +233 -0
- package/src/components/fields/RelationshipField.tsx +278 -60
- package/src/components/fields/SelectField.tsx +28 -16
- package/src/components/fields/TextField.tsx +31 -15
- package/src/components/fields/UploadField.tsx +613 -0
- package/src/components/fields/VideoField.tsx +73 -0
- package/src/components/fields/extensions/blockComponents.tsx +247 -0
- package/src/components/fields/extensions/blocksStore.ts +273 -0
- package/src/components/fields/index.ts +24 -0
- package/src/components/index.ts +1 -2
- package/src/components/layout/Header.tsx +2 -2
- package/src/components/layout/Layout.tsx +3 -3
- package/src/components/ui/Badge.tsx +9 -4
- package/src/components/ui/BlockDrawer.tsx +79 -0
- package/src/components/ui/Button.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +362 -0
- package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
- package/src/components/ui/Dropdown.tsx +1 -1
- package/src/components/ui/Modal.tsx +37 -12
- package/src/components/ui/PromptModal.tsx +94 -0
- package/src/components/ui/SlidePanel.tsx +43 -16
- package/src/components/ui/Toast.tsx +80 -14
- package/src/env.d.ts +16 -0
- package/src/env.ts +20 -0
- package/src/index.ts +0 -1
- package/src/layouts/AdminLayout.astro +164 -170
- package/src/layouts/AuthLayout.astro +23 -6
- package/src/lib/MediaService.ts +541 -0
- package/src/lib/api.ts +163 -0
- package/src/lib/auth/sqlite-adapter.ts +319 -0
- package/src/lib/config.ts +23 -7
- package/src/lib/dataStore.ts +188 -73
- package/src/lib/date-utils.ts +69 -0
- package/src/lib/db/adapter.ts +54 -0
- package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
- package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
- package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
- package/src/lib/db/index.ts +449 -0
- package/src/lib/db/mongodb-adapter.ts +207 -0
- package/src/lib/db/mongodb-auth-adapter.ts +305 -0
- package/src/lib/db/schema/mysql-auth.ts +113 -0
- package/src/lib/db/schema/mysql-content.ts +20 -0
- package/src/lib/db/schema/postgres-auth.ts +116 -0
- package/src/lib/db/schema/postgres-content.ts +35 -0
- package/src/lib/db/schema/postgres-media.ts +52 -0
- package/src/lib/db/schema/postgres-settings.ts +11 -0
- package/src/lib/db/schema/sqlite-auth.ts +112 -0
- package/src/lib/db/schema/sqlite-content.ts +20 -0
- package/src/lib/db/version-adapter.ts +248 -0
- package/src/lib/graphql/index.ts +1 -0
- package/src/lib/graphql/schema.ts +443 -0
- package/src/lib/i18n.tsx +353 -0
- package/src/lib/rate-limit.ts +267 -0
- package/src/lib/slugify.ts +15 -0
- package/src/lib/storage.ts +374 -0
- package/src/lib/store.ts +85 -0
- package/src/lib/validation.ts +250 -0
- package/src/middleware.ts +70 -11
- package/src/pages/[collection]/[id].astro +178 -122
- package/src/pages/[collection]/index.astro +24 -156
- package/src/pages/admin/api-explorer.astro +98 -0
- package/src/pages/admin/graphql-explorer.astro +40 -0
- package/src/pages/admin/graphql.astro +97 -0
- package/src/pages/admin/index.astro +200 -139
- package/src/pages/admin/keys.astro +8 -0
- package/src/pages/admin/rest-playground.astro +44 -0
- package/src/pages/admin/webhooks.astro +8 -0
- package/src/pages/api/[collection]/[id]/publish.ts +52 -0
- package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
- package/src/pages/api/[collection]/[id]/versions.ts +66 -0
- package/src/pages/api/[collection]/[id].ts +114 -159
- package/src/pages/api/[collection]/index.ts +150 -230
- package/src/pages/api/auth/[id].ts +48 -69
- package/src/pages/api/auth/audit-logs.ts +20 -43
- package/src/pages/api/auth/login.ts +159 -45
- package/src/pages/api/auth/logout.ts +42 -24
- package/src/pages/api/auth/refresh.ts +119 -0
- package/src/pages/api/auth/register.ts +110 -40
- package/src/pages/api/auth/users.ts +22 -97
- package/src/pages/api/collections.ts +59 -0
- package/src/pages/api/globals/[slug]/test.ts +172 -0
- package/src/pages/api/globals/[slug].ts +42 -0
- package/src/pages/api/graphql.ts +90 -0
- package/src/pages/api/health.ts +417 -40
- package/src/pages/api/keys/[id].ts +26 -0
- package/src/pages/api/keys/index.ts +75 -0
- package/src/pages/api/media/[id].ts +309 -0
- package/src/pages/api/media/folders.ts +609 -0
- package/src/pages/api/media/index.ts +146 -0
- package/src/pages/api/media/resize.ts +267 -0
- package/src/pages/api/search.ts +82 -0
- package/src/pages/api/slug-availability.ts +70 -0
- package/src/pages/api/storage-config.ts +20 -0
- package/src/pages/api/storage-status.ts +206 -0
- package/src/pages/api/upload.ts +334 -0
- package/src/pages/api/webhooks/index.ts +71 -0
- package/src/pages/audit/index.astro +2 -104
- package/src/pages/login.astro +11 -11
- package/src/pages/media.astro +10 -0
- package/src/pages/preview/[collection]/[id].astro +178 -0
- package/src/pages/register.astro +13 -13
- package/src/pages/roles/index.astro +21 -21
- package/src/pages/settings/[slug].astro +162 -0
- package/src/pages/settings/index.astro +9 -0
- package/src/pages/users/[id].astro +29 -21
- package/src/pages/users/index.astro +22 -17
- package/src/pages/users/new.astro +18 -17
- package/src/styles/main.css +563 -128
- package/src/components/layout/Sidebar.tsx +0 -497
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { getDatabaseConfig } from "@/lib/db";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract the Public Dev URL ID from either a full URL or just the ID.
|
|
6
|
+
* Handles formats like:
|
|
7
|
+
* - https://bucket.pub-xxx.r2.dev -> pub-xxx
|
|
8
|
+
* - pub-xxx -> pub-xxx
|
|
9
|
+
* - empty/undefined -> ""
|
|
10
|
+
*/
|
|
11
|
+
function extractPublicDevUrlId(url?: string): string {
|
|
12
|
+
if (!url) return "";
|
|
13
|
+
if (url.startsWith("pub-")) return url; // Already just the ID
|
|
14
|
+
|
|
15
|
+
// Extract from URL like https://bucket.pub-xxx.r2.dev
|
|
16
|
+
const match = url.match(/pub-[a-zA-Z0-9]+/i);
|
|
17
|
+
return match ? match[0] : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type StorageProviderType =
|
|
21
|
+
| "local"
|
|
22
|
+
| "aws"
|
|
23
|
+
| "r2"
|
|
24
|
+
| "gcs"
|
|
25
|
+
| "digitalocean"
|
|
26
|
+
| "backblaze"
|
|
27
|
+
| "wasabi"
|
|
28
|
+
| "bunny"
|
|
29
|
+
| "cloudinary"
|
|
30
|
+
| "imgix"
|
|
31
|
+
| "ftp";
|
|
32
|
+
|
|
33
|
+
export interface StorageConfig {
|
|
34
|
+
/** The storage provider type */
|
|
35
|
+
provider: StorageProviderType;
|
|
36
|
+
/** The base URL for accessing files */
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
/** The filesystem directory (for local storage only) */
|
|
39
|
+
uploadDir?: string;
|
|
40
|
+
/** Provider-specific config */
|
|
41
|
+
config: {
|
|
42
|
+
bucket?: string;
|
|
43
|
+
region?: string;
|
|
44
|
+
accountId?: string;
|
|
45
|
+
accessKeyId?: string;
|
|
46
|
+
secretAccessKey?: string;
|
|
47
|
+
cdnUrl?: string;
|
|
48
|
+
prefix?: string;
|
|
49
|
+
publicDevUrl?: string;
|
|
50
|
+
cloudName?: string;
|
|
51
|
+
apiKey?: string;
|
|
52
|
+
apiSecret?: string;
|
|
53
|
+
folder?: string;
|
|
54
|
+
uploadPreset?: string;
|
|
55
|
+
domain?: string;
|
|
56
|
+
signKey?: string;
|
|
57
|
+
host?: string;
|
|
58
|
+
port?: number;
|
|
59
|
+
user?: string;
|
|
60
|
+
password?: string;
|
|
61
|
+
secure?: boolean;
|
|
62
|
+
storageZone?: string;
|
|
63
|
+
projectId?: string;
|
|
64
|
+
type?: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RawStorageSettings {
|
|
69
|
+
provider?: string;
|
|
70
|
+
local?: {
|
|
71
|
+
uploadDir?: string;
|
|
72
|
+
baseUrl?: string;
|
|
73
|
+
};
|
|
74
|
+
aws?: Record<string, any>;
|
|
75
|
+
r2?: Record<string, any>;
|
|
76
|
+
gcs?: Record<string, any>;
|
|
77
|
+
digitalocean?: Record<string, any>;
|
|
78
|
+
backblaze?: Record<string, any>;
|
|
79
|
+
wasabi?: Record<string, any>;
|
|
80
|
+
bunny?: Record<string, any>;
|
|
81
|
+
cloudinary?: Record<string, any>;
|
|
82
|
+
imgix?: Record<string, any>;
|
|
83
|
+
ftp?: Record<string, any>;
|
|
84
|
+
limits?: Record<string, any>;
|
|
85
|
+
[key: string]: any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the complete storage configuration from the globals table.
|
|
90
|
+
*/
|
|
91
|
+
export async function getStorageConfig(): Promise<StorageConfig> {
|
|
92
|
+
const dbConfig = getDatabaseConfig();
|
|
93
|
+
let rawSettings: RawStorageSettings = { provider: "local" };
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
if (dbConfig.type === "sqlite") {
|
|
97
|
+
const Database = (await import("better-sqlite3")).default;
|
|
98
|
+
const db = new Database(dbConfig.contentDbPath || "./data/content.db");
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const row = db
|
|
102
|
+
.prepare("SELECT data FROM globals WHERE slug = ?")
|
|
103
|
+
.get("storage-settings") as { data: string } | undefined;
|
|
104
|
+
|
|
105
|
+
if (row) {
|
|
106
|
+
rawSettings =
|
|
107
|
+
typeof row.data === "string" ? JSON.parse(row.data) : row.data;
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Use defaults if anything fails
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const provider = (rawSettings.provider || "local") as StorageProviderType;
|
|
118
|
+
|
|
119
|
+
// Build config based on provider type
|
|
120
|
+
const config: StorageConfig = {
|
|
121
|
+
provider,
|
|
122
|
+
baseUrl: "",
|
|
123
|
+
config: {},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
switch (provider) {
|
|
127
|
+
case "local": {
|
|
128
|
+
const localConfig = rawSettings.local || {};
|
|
129
|
+
const uploadDirRaw = localConfig.uploadDir || "./public/uploads";
|
|
130
|
+
const baseUrlRaw = localConfig.baseUrl || "/uploads";
|
|
131
|
+
|
|
132
|
+
// Resolve uploadDir
|
|
133
|
+
let uploadDir: string;
|
|
134
|
+
if (path.isAbsolute(uploadDirRaw)) {
|
|
135
|
+
uploadDir = uploadDirRaw;
|
|
136
|
+
} else if (uploadDirRaw.includes("/") || uploadDirRaw.includes("\\")) {
|
|
137
|
+
uploadDir = path.resolve(process.cwd(), uploadDirRaw);
|
|
138
|
+
} else {
|
|
139
|
+
uploadDir = path.join(process.cwd(), "public", uploadDirRaw);
|
|
140
|
+
}
|
|
141
|
+
config.uploadDir = uploadDir;
|
|
142
|
+
|
|
143
|
+
// Resolve baseUrl
|
|
144
|
+
config.baseUrl = baseUrlRaw.startsWith("/")
|
|
145
|
+
? baseUrlRaw
|
|
146
|
+
: `/${baseUrlRaw}`;
|
|
147
|
+
if (config.baseUrl.length > 1) {
|
|
148
|
+
config.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case "aws": {
|
|
154
|
+
const awsConfig = rawSettings.aws || {};
|
|
155
|
+
config.baseUrl =
|
|
156
|
+
awsConfig.cdnUrl ||
|
|
157
|
+
`https://${awsConfig.bucket}.s3.${awsConfig.region || "us-east-1"}.amazonaws.com`;
|
|
158
|
+
config.config = {
|
|
159
|
+
bucket: awsConfig.bucket,
|
|
160
|
+
region: awsConfig.region || "us-east-1",
|
|
161
|
+
accessKeyId: awsConfig.accessKeyId,
|
|
162
|
+
secretAccessKey: awsConfig.secretAccessKey,
|
|
163
|
+
cdnUrl: awsConfig.cdnUrl,
|
|
164
|
+
prefix: awsConfig.prefix,
|
|
165
|
+
};
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case "r2": {
|
|
170
|
+
const r2Config = rawSettings.r2 || {};
|
|
171
|
+
// Priority: cdnUrl > publicDevUrl > accountId-based URL
|
|
172
|
+
let baseUrl: string;
|
|
173
|
+
if (r2Config.cdnUrl) {
|
|
174
|
+
baseUrl = r2Config.cdnUrl.replace(/\/$/, "");
|
|
175
|
+
} else if (r2Config.publicDevUrl) {
|
|
176
|
+
// Handle both full URL and just the ID
|
|
177
|
+
const pubId = extractPublicDevUrlId(r2Config.publicDevUrl);
|
|
178
|
+
baseUrl = pubId ? `https://${pubId}.r2.dev` : "";
|
|
179
|
+
} else if (r2Config.accountId) {
|
|
180
|
+
baseUrl = `https://${r2Config.bucket}.${r2Config.accountId}.r2.cloudflarestorage.com`;
|
|
181
|
+
} else {
|
|
182
|
+
baseUrl = "";
|
|
183
|
+
}
|
|
184
|
+
config.baseUrl = baseUrl;
|
|
185
|
+
config.config = {
|
|
186
|
+
bucket: r2Config.bucket,
|
|
187
|
+
accountId: r2Config.accountId,
|
|
188
|
+
publicDevUrl: extractPublicDevUrlId(r2Config.publicDevUrl),
|
|
189
|
+
accessKeyId: r2Config.accessKeyId,
|
|
190
|
+
secretAccessKey: r2Config.secretAccessKey,
|
|
191
|
+
cdnUrl: r2Config.cdnUrl,
|
|
192
|
+
prefix: r2Config.prefix,
|
|
193
|
+
};
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case "gcs": {
|
|
198
|
+
const gcsConfig = rawSettings.gcs || {};
|
|
199
|
+
config.baseUrl =
|
|
200
|
+
gcsConfig.cdnUrl ||
|
|
201
|
+
`https://storage.googleapis.com/${gcsConfig.bucket}`;
|
|
202
|
+
config.config = {
|
|
203
|
+
bucket: gcsConfig.bucket,
|
|
204
|
+
projectId: gcsConfig.projectId,
|
|
205
|
+
accessKeyId: gcsConfig.clientEmail,
|
|
206
|
+
secretAccessKey: gcsConfig.privateKey,
|
|
207
|
+
cdnUrl: gcsConfig.cdnUrl,
|
|
208
|
+
prefix: gcsConfig.prefix,
|
|
209
|
+
};
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "digitalocean": {
|
|
214
|
+
const doConfig = rawSettings.digitalocean || {};
|
|
215
|
+
const region = doConfig.region || "nyc3";
|
|
216
|
+
config.baseUrl =
|
|
217
|
+
doConfig.cdnUrl ||
|
|
218
|
+
`https://${doConfig.bucket}.${region}.cdn.digitaloceanspaces.com`;
|
|
219
|
+
config.config = {
|
|
220
|
+
bucket: doConfig.bucket,
|
|
221
|
+
region,
|
|
222
|
+
accessKeyId: doConfig.accessKeyId,
|
|
223
|
+
secretAccessKey: doConfig.secretAccessKey,
|
|
224
|
+
cdnUrl: doConfig.cdnUrl,
|
|
225
|
+
prefix: doConfig.prefix,
|
|
226
|
+
};
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case "backblaze": {
|
|
231
|
+
const b2Config = rawSettings.backblaze || {};
|
|
232
|
+
config.baseUrl = b2Config.cdnUrl || `https://f000.backblazeb2.com`;
|
|
233
|
+
config.config = {
|
|
234
|
+
bucket: b2Config.bucket,
|
|
235
|
+
accountId: b2Config.accountId,
|
|
236
|
+
accessKeyId: b2Config.applicationKeyId,
|
|
237
|
+
secretAccessKey: b2Config.applicationKey,
|
|
238
|
+
cdnUrl: b2Config.cdnUrl,
|
|
239
|
+
prefix: b2Config.prefix,
|
|
240
|
+
};
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "wasabi": {
|
|
245
|
+
const wasabiConfig = rawSettings.wasabi || {};
|
|
246
|
+
const region = wasabiConfig.region || "us-east-1";
|
|
247
|
+
config.baseUrl =
|
|
248
|
+
wasabiConfig.cdnUrl || `https://s3.${region}.wasabisys.com`;
|
|
249
|
+
config.config = {
|
|
250
|
+
bucket: wasabiConfig.bucket,
|
|
251
|
+
region,
|
|
252
|
+
accessKeyId: wasabiConfig.accessKeyId,
|
|
253
|
+
secretAccessKey: wasabiConfig.secretAccessKey,
|
|
254
|
+
cdnUrl: wasabiConfig.cdnUrl,
|
|
255
|
+
prefix: wasabiConfig.prefix,
|
|
256
|
+
};
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case "bunny": {
|
|
261
|
+
const bunnyConfig = rawSettings.bunny || {};
|
|
262
|
+
config.baseUrl =
|
|
263
|
+
bunnyConfig.cdnUrl || `https://${bunnyConfig.storageZone}.b-cdn.net`;
|
|
264
|
+
config.config = {
|
|
265
|
+
storageZone: bunnyConfig.storageZone,
|
|
266
|
+
apiKey: bunnyConfig.apiKey,
|
|
267
|
+
cdnUrl: bunnyConfig.cdnUrl,
|
|
268
|
+
prefix: bunnyConfig.prefix,
|
|
269
|
+
};
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case "cloudinary": {
|
|
274
|
+
const cloudinaryConfig = rawSettings.cloudinary || {};
|
|
275
|
+
config.baseUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload`;
|
|
276
|
+
config.config = {
|
|
277
|
+
cloudName: cloudinaryConfig.cloudName,
|
|
278
|
+
apiKey: cloudinaryConfig.apiKey,
|
|
279
|
+
apiSecret: cloudinaryConfig.apiSecret,
|
|
280
|
+
folder: cloudinaryConfig.folder,
|
|
281
|
+
uploadPreset: cloudinaryConfig.uploadPreset,
|
|
282
|
+
};
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case "imgix": {
|
|
287
|
+
const imgixConfig = rawSettings.imgix || {};
|
|
288
|
+
config.baseUrl = `https://${imgixConfig.domain}`;
|
|
289
|
+
config.config = {
|
|
290
|
+
domain: imgixConfig.domain,
|
|
291
|
+
signKey: imgixConfig.signKey,
|
|
292
|
+
};
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "ftp": {
|
|
297
|
+
const ftpConfig = rawSettings.ftp || {};
|
|
298
|
+
config.baseUrl = ftpConfig.baseUrl || "";
|
|
299
|
+
config.config = {
|
|
300
|
+
host: ftpConfig.host,
|
|
301
|
+
port: ftpConfig.port || 22,
|
|
302
|
+
user: ftpConfig.user,
|
|
303
|
+
password: ftpConfig.password,
|
|
304
|
+
secure: ftpConfig.secure,
|
|
305
|
+
prefix: ftpConfig.prefix,
|
|
306
|
+
type: "sftp",
|
|
307
|
+
};
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
default:
|
|
312
|
+
// Fallback to local
|
|
313
|
+
config.provider = "local";
|
|
314
|
+
config.uploadDir = path.join(process.cwd(), "public", "uploads");
|
|
315
|
+
config.baseUrl = "/uploads";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return config;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Construct a media URL from filename and optional folder using current storage settings.
|
|
323
|
+
* For cloud providers, this includes the full base URL. For local, it uses relative path.
|
|
324
|
+
*/
|
|
325
|
+
export async function constructMediaUrl(
|
|
326
|
+
filename: string,
|
|
327
|
+
folder?: string | null,
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
const config = await getStorageConfig();
|
|
330
|
+
return constructMediaUrlSync(config.baseUrl, filename, folder);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Construct a media URL synchronously.
|
|
335
|
+
*/
|
|
336
|
+
export function constructMediaUrlSync(
|
|
337
|
+
baseUrl: string,
|
|
338
|
+
filename: string,
|
|
339
|
+
folder?: string | null,
|
|
340
|
+
): string {
|
|
341
|
+
// Cloudinary stores full URL in the database - just return it if baseUrl matches
|
|
342
|
+
// The filename for cloud storage already contains folder path
|
|
343
|
+
if (baseUrl.startsWith("http") && filename.startsWith("http")) {
|
|
344
|
+
// Already a full URL (e.g., from Cloudinary upload response)
|
|
345
|
+
return filename;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Ensure baseUrl has trailing slash for proper URL construction
|
|
349
|
+
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
|
|
350
|
+
const folderPrefix = folder ? `${folder}/` : "";
|
|
351
|
+
|
|
352
|
+
// For local/relative URLs
|
|
353
|
+
return `${normalizedBaseUrl}${folderPrefix}${filename}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get provider display name
|
|
358
|
+
*/
|
|
359
|
+
export function getProviderDisplayName(provider: string): string {
|
|
360
|
+
const names: Record<string, string> = {
|
|
361
|
+
local: "Local Server",
|
|
362
|
+
aws: "AWS S3",
|
|
363
|
+
r2: "Cloudflare R2",
|
|
364
|
+
gcs: "Google Cloud Storage",
|
|
365
|
+
digitalocean: "DigitalOcean Spaces",
|
|
366
|
+
backblaze: "Backblaze B2",
|
|
367
|
+
wasabi: "Wasabi",
|
|
368
|
+
bunny: "Bunny.net",
|
|
369
|
+
cloudinary: "Cloudinary",
|
|
370
|
+
imgix: "Imgix",
|
|
371
|
+
ftp: "FTP",
|
|
372
|
+
};
|
|
373
|
+
return names[provider] || provider;
|
|
374
|
+
}
|
package/src/lib/store.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
|
|
3
|
+
interface EditorState {
|
|
4
|
+
editor: any;
|
|
5
|
+
setEditor: (editor: any) => void;
|
|
6
|
+
|
|
7
|
+
blockDrawerOpen: boolean;
|
|
8
|
+
openBlockDrawer: (options?: { targetColumn?: number; targetNodePos?: number }) => void;
|
|
9
|
+
closeBlockDrawer: () => void;
|
|
10
|
+
toggleBlockDrawer: () => void;
|
|
11
|
+
|
|
12
|
+
selectedBlock: string | null;
|
|
13
|
+
setSelectedBlock: (block: string | null) => void;
|
|
14
|
+
|
|
15
|
+
pendingInsertPos: number | null;
|
|
16
|
+
pendingTargetColumn: number | null;
|
|
17
|
+
pendingTargetStack: number | null;
|
|
18
|
+
pendingTargetGroup: number | null;
|
|
19
|
+
pendingTargetCard: number | null;
|
|
20
|
+
pendingTargetRepeater: number | null;
|
|
21
|
+
setPendingInsert: (pos: number | null, column?: number | null) => void;
|
|
22
|
+
clearPendingTargets: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useEditorStore = create<EditorState>((set) => ({
|
|
26
|
+
editor: null,
|
|
27
|
+
setEditor: (editor) => set({ editor }),
|
|
28
|
+
|
|
29
|
+
blockDrawerOpen: false,
|
|
30
|
+
openBlockDrawer: (options) => set({
|
|
31
|
+
blockDrawerOpen: true,
|
|
32
|
+
pendingTargetColumn: options?.targetColumn ?? null
|
|
33
|
+
}),
|
|
34
|
+
closeBlockDrawer: () => set({
|
|
35
|
+
blockDrawerOpen: false,
|
|
36
|
+
pendingTargetColumn: null,
|
|
37
|
+
pendingInsertPos: null,
|
|
38
|
+
pendingTargetStack: null,
|
|
39
|
+
pendingTargetGroup: null,
|
|
40
|
+
pendingTargetCard: null,
|
|
41
|
+
pendingTargetRepeater: null,
|
|
42
|
+
}),
|
|
43
|
+
toggleBlockDrawer: () => set((state) => ({ blockDrawerOpen: !state.blockDrawerOpen })),
|
|
44
|
+
|
|
45
|
+
selectedBlock: null,
|
|
46
|
+
setSelectedBlock: (block) => set({ selectedBlock: block }),
|
|
47
|
+
|
|
48
|
+
pendingInsertPos: null,
|
|
49
|
+
pendingTargetColumn: null,
|
|
50
|
+
pendingTargetStack: null,
|
|
51
|
+
pendingTargetGroup: null,
|
|
52
|
+
pendingTargetCard: null,
|
|
53
|
+
pendingTargetRepeater: null,
|
|
54
|
+
setPendingInsert: (pos, column) => set({
|
|
55
|
+
pendingInsertPos: pos,
|
|
56
|
+
pendingTargetColumn: column ?? null
|
|
57
|
+
}),
|
|
58
|
+
clearPendingTargets: () => set({
|
|
59
|
+
pendingTargetColumn: null,
|
|
60
|
+
pendingTargetStack: null,
|
|
61
|
+
pendingTargetGroup: null,
|
|
62
|
+
pendingTargetCard: null,
|
|
63
|
+
pendingTargetRepeater: null,
|
|
64
|
+
}),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
interface UIState {
|
|
68
|
+
sidebarOpen: boolean;
|
|
69
|
+
toggleSidebar: () => void;
|
|
70
|
+
setSidebarOpen: (open: boolean) => void;
|
|
71
|
+
|
|
72
|
+
activeModal: string | null;
|
|
73
|
+
openModal: (modal: string) => void;
|
|
74
|
+
closeModal: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const useUIStore = create<UIState>((set) => ({
|
|
78
|
+
sidebarOpen: true,
|
|
79
|
+
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
80
|
+
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
|
81
|
+
|
|
82
|
+
activeModal: null,
|
|
83
|
+
openModal: (modal) => set({ activeModal: modal }),
|
|
84
|
+
closeModal: () => set({ activeModal: null }),
|
|
85
|
+
}));
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
export function isEmail(value: string): boolean {
|
|
2
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3
|
+
return emailRegex.test(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isUrl(value: string): boolean {
|
|
7
|
+
try {
|
|
8
|
+
new URL(value);
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSlug(value: string): boolean {
|
|
16
|
+
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
17
|
+
return slugRegex.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isRequired(value: any): boolean {
|
|
21
|
+
if (value === null || value === undefined) return false;
|
|
22
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
23
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function minLength(value: string, min: number): boolean {
|
|
28
|
+
return typeof value === "string" && value.length >= min;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function maxLength(value: string, max: number): boolean {
|
|
32
|
+
return typeof value === "string" && value.length <= max;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isNumber(value: any): boolean {
|
|
36
|
+
return !isNaN(parseFloat(value)) && isFinite(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isInteger(value: any): boolean {
|
|
40
|
+
return Number.isInteger(Number(value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function inRange(value: number, min: number, max: number): boolean {
|
|
44
|
+
const num = Number(value);
|
|
45
|
+
return num >= min && num <= max;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isJson(value: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
JSON.parse(value);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isHexColor(value: string): boolean {
|
|
58
|
+
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
|
59
|
+
return hexRegex.test(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isAlpha(value: string): boolean {
|
|
63
|
+
const alphaRegex = /^[a-zA-Z]+$/;
|
|
64
|
+
return alphaRegex.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isAlphaNumeric(value: string): boolean {
|
|
68
|
+
const alphaNumRegex = /^[a-zA-Z0-9]+$/;
|
|
69
|
+
return alphaNumRegex.test(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isPhone(value: string): boolean {
|
|
73
|
+
const phoneRegex = /^[\d\s\-+()]+$/;
|
|
74
|
+
return phoneRegex.test(value) && value.replace(/\D/g, "").length >= 10;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isPostalCode(value: string, country: string = "US"): boolean {
|
|
78
|
+
const codes: Record<string, RegExp> = {
|
|
79
|
+
US: /^\d{5}(-\d{4})?$/,
|
|
80
|
+
UK: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i,
|
|
81
|
+
CA: /^[A-Z]\d[A-Z] ?\d[A-Z]\d$/i,
|
|
82
|
+
};
|
|
83
|
+
return codes[country]?.test(value) || false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function matches(value: string, other: string): boolean {
|
|
87
|
+
return value === other;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function notMatches(value: string, other: string): boolean {
|
|
91
|
+
return value !== other;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ValidationRule {
|
|
95
|
+
validate: (value: any, ...args: any[]) => boolean;
|
|
96
|
+
message: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function required(message = "This field is required"): ValidationRule {
|
|
100
|
+
return {
|
|
101
|
+
validate: isRequired,
|
|
102
|
+
message,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function email(message = "Invalid email address"): ValidationRule {
|
|
107
|
+
return {
|
|
108
|
+
validate: isEmail,
|
|
109
|
+
message,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function url(message = "Invalid URL"): ValidationRule {
|
|
114
|
+
return {
|
|
115
|
+
validate: isUrl,
|
|
116
|
+
message,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function minLengthRule(min: number, message?: string): ValidationRule {
|
|
121
|
+
return {
|
|
122
|
+
validate: (value: string) => minLength(value, min),
|
|
123
|
+
message: message || `Minimum ${min} characters required`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function maxLengthRule(max: number, message?: string): ValidationRule {
|
|
128
|
+
return {
|
|
129
|
+
validate: (value: string) => maxLength(value, max),
|
|
130
|
+
message: message || `Maximum ${max} characters allowed`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function pattern(
|
|
135
|
+
regex: RegExp,
|
|
136
|
+
message = "Invalid format",
|
|
137
|
+
): ValidationRule {
|
|
138
|
+
return {
|
|
139
|
+
validate: (value: string) => regex.test(value || ""),
|
|
140
|
+
message,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function matchesRule(
|
|
145
|
+
otherField: string,
|
|
146
|
+
message = "Values do not match",
|
|
147
|
+
): ValidationRule {
|
|
148
|
+
return {
|
|
149
|
+
validate: (value: string, data: Record<string, any>) =>
|
|
150
|
+
matches(value, data[otherField]),
|
|
151
|
+
message,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function notMatchesRule(
|
|
156
|
+
otherField: string,
|
|
157
|
+
message = "Value must be different",
|
|
158
|
+
): ValidationRule {
|
|
159
|
+
return {
|
|
160
|
+
validate: (value: string, data: Record<string, any>) =>
|
|
161
|
+
notMatches(value, data[otherField]),
|
|
162
|
+
message,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function phone(message = "Invalid phone number"): ValidationRule {
|
|
167
|
+
return {
|
|
168
|
+
validate: isPhone,
|
|
169
|
+
message,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function postalCodeRule(
|
|
174
|
+
country: string = "US",
|
|
175
|
+
message?: string,
|
|
176
|
+
): ValidationRule {
|
|
177
|
+
return {
|
|
178
|
+
validate: (value: string) => isPostalCode(value, country),
|
|
179
|
+
message: message || `Invalid postal code for ${country}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function numberRule(message = "Must be a valid number"): ValidationRule {
|
|
184
|
+
return {
|
|
185
|
+
validate: isNumber,
|
|
186
|
+
message,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function integerRule(
|
|
191
|
+
message = "Must be a whole number",
|
|
192
|
+
): ValidationRule {
|
|
193
|
+
return {
|
|
194
|
+
validate: isInteger,
|
|
195
|
+
message,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function rangeRule(
|
|
200
|
+
min: number,
|
|
201
|
+
max: number,
|
|
202
|
+
message?: string,
|
|
203
|
+
): ValidationRule {
|
|
204
|
+
return {
|
|
205
|
+
validate: (value: number) => inRange(Number(value), min, max),
|
|
206
|
+
message: message || `Must be between ${min} and ${max}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function jsonRule(message = "Must be valid JSON"): ValidationRule {
|
|
211
|
+
return {
|
|
212
|
+
validate: isJson,
|
|
213
|
+
message,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function hexColorRule(
|
|
218
|
+
message = "Must be a valid hex color",
|
|
219
|
+
): ValidationRule {
|
|
220
|
+
return {
|
|
221
|
+
validate: isHexColor,
|
|
222
|
+
message,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface ValidationResult {
|
|
227
|
+
valid: boolean;
|
|
228
|
+
errors: Record<string, string>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function validate(
|
|
232
|
+
data: Record<string, any>,
|
|
233
|
+
rules: Record<string, ValidationRule[]>,
|
|
234
|
+
): ValidationResult {
|
|
235
|
+
const errors: Record<string, string> = {};
|
|
236
|
+
|
|
237
|
+
for (const [field, fieldRules] of Object.entries(rules)) {
|
|
238
|
+
for (const rule of fieldRules) {
|
|
239
|
+
if (!rule.validate(data[field], data)) {
|
|
240
|
+
errors[field] = rule.message;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
valid: Object.keys(errors).length === 0,
|
|
248
|
+
errors,
|
|
249
|
+
};
|
|
250
|
+
}
|