@kyro-cms/admin 0.1.6 → 0.1.7

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 (163) hide show
  1. package/README.md +149 -51
  2. package/package.json +53 -6
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +136 -27
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +1417 -661
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +3 -3
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +199 -57
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +786 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +191 -53
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +149 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/UserManagement.tsx +204 -0
  26. package/src/components/VersionHistoryPanel.tsx +3 -3
  27. package/src/components/WebhookManager.tsx +608 -0
  28. package/src/components/blocks/AccordionBlock.tsx +97 -0
  29. package/src/components/blocks/ArrayBlock.tsx +75 -0
  30. package/src/components/blocks/BlockEditModal.MARKER +12 -0
  31. package/src/components/blocks/BlockEditModal.tsx +774 -0
  32. package/src/components/blocks/ButtonBlock.tsx +165 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +66 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +151 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +64 -0
  38. package/src/components/blocks/HeadingBlock.tsx +81 -0
  39. package/src/components/blocks/HeroBlock.tsx +157 -0
  40. package/src/components/blocks/ImageBlock.tsx +83 -0
  41. package/src/components/blocks/LinkBlock.tsx +71 -0
  42. package/src/components/blocks/ListBlock.tsx +39 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +279 -0
  45. package/src/components/blocks/VStackBlock.tsx +75 -0
  46. package/src/components/blocks/VideoBlock.tsx +45 -0
  47. package/src/components/blocks/index.ts +10 -0
  48. package/src/components/fields/BlocksField.tsx +323 -0
  49. package/src/components/fields/CheckboxField.tsx +15 -9
  50. package/src/components/fields/CodeField.tsx +234 -0
  51. package/src/components/fields/DateField.tsx +38 -11
  52. package/src/components/fields/EditorClient.tsx +271 -0
  53. package/src/components/fields/FileField.tsx +390 -0
  54. package/src/components/fields/HybridContentField.tsx +109 -0
  55. package/src/components/fields/ImageField.tsx +429 -0
  56. package/src/components/fields/JSONField.tsx +361 -0
  57. package/src/components/fields/MarkdownField.tsx +282 -0
  58. package/src/components/fields/NumberField.tsx +42 -12
  59. package/src/components/fields/PortableTextField.tsx +143 -0
  60. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  61. package/src/components/fields/RelationshipField.tsx +231 -59
  62. package/src/components/fields/SelectField.tsx +25 -15
  63. package/src/components/fields/TextField.tsx +45 -14
  64. package/src/components/fields/extensions/blockComponents.tsx +237 -0
  65. package/src/components/fields/extensions/blocksStore.ts +273 -0
  66. package/src/components/fields/index.ts +13 -0
  67. package/src/components/index.ts +1 -2
  68. package/src/components/layout/Header.tsx +2 -2
  69. package/src/components/layout/Layout.tsx +2 -2
  70. package/src/components/ui/Badge.tsx +9 -4
  71. package/src/components/ui/BlockDrawer.tsx +79 -0
  72. package/src/components/ui/Button.tsx +1 -1
  73. package/src/components/ui/CommandPalette.tsx +362 -0
  74. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  75. package/src/components/ui/Dropdown.tsx +1 -1
  76. package/src/components/ui/Modal.tsx +37 -12
  77. package/src/components/ui/PromptModal.tsx +94 -0
  78. package/src/components/ui/SlidePanel.tsx +43 -16
  79. package/src/components/ui/Toast.tsx +80 -14
  80. package/src/env.d.ts +16 -0
  81. package/src/env.ts +20 -0
  82. package/src/index.ts +0 -1
  83. package/src/layouts/AdminLayout.astro +164 -170
  84. package/src/layouts/AuthLayout.astro +23 -6
  85. package/src/lib/MediaService.ts +541 -0
  86. package/src/lib/auth/sqlite-adapter.ts +319 -0
  87. package/src/lib/config.ts +22 -6
  88. package/src/lib/dataStore.ts +132 -74
  89. package/src/lib/db/adapter.ts +54 -0
  90. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  91. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  92. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  93. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  94. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  95. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  96. package/src/lib/db/index.ts +449 -0
  97. package/src/lib/db/mongodb-adapter.ts +207 -0
  98. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  99. package/src/lib/db/schema/mysql-auth.ts +113 -0
  100. package/src/lib/db/schema/mysql-content.ts +20 -0
  101. package/src/lib/db/schema/postgres-auth.ts +116 -0
  102. package/src/lib/db/schema/postgres-content.ts +35 -0
  103. package/src/lib/db/schema/postgres-media.ts +52 -0
  104. package/src/lib/db/schema/postgres-settings.ts +11 -0
  105. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  106. package/src/lib/db/schema/sqlite-content.ts +20 -0
  107. package/src/lib/graphql/index.ts +1 -0
  108. package/src/lib/graphql/schema.ts +443 -0
  109. package/src/lib/rate-limit.ts +267 -0
  110. package/src/lib/storage.ts +374 -0
  111. package/src/lib/store.ts +85 -0
  112. package/src/middleware.ts +70 -11
  113. package/src/pages/[collection]/[id].astro +178 -122
  114. package/src/pages/[collection]/index.astro +24 -156
  115. package/src/pages/admin/api-explorer.astro +98 -0
  116. package/src/pages/admin/graphql-explorer.astro +40 -0
  117. package/src/pages/admin/graphql.astro +97 -0
  118. package/src/pages/admin/index.astro +200 -139
  119. package/src/pages/admin/keys.astro +8 -0
  120. package/src/pages/admin/rest-playground.astro +44 -0
  121. package/src/pages/admin/webhooks.astro +8 -0
  122. package/src/pages/api/[collection]/[id]/publish.ts +44 -0
  123. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  124. package/src/pages/api/[collection]/[id]/versions.ts +36 -0
  125. package/src/pages/api/[collection]/[id].ts +102 -159
  126. package/src/pages/api/[collection]/index.ts +151 -230
  127. package/src/pages/api/auth/[id].ts +48 -69
  128. package/src/pages/api/auth/audit-logs.ts +20 -43
  129. package/src/pages/api/auth/login.ts +159 -45
  130. package/src/pages/api/auth/logout.ts +42 -24
  131. package/src/pages/api/auth/refresh.ts +119 -0
  132. package/src/pages/api/auth/register.ts +110 -40
  133. package/src/pages/api/auth/users.ts +22 -97
  134. package/src/pages/api/collections.ts +59 -0
  135. package/src/pages/api/globals/[slug]/test.ts +172 -0
  136. package/src/pages/api/globals/[slug].ts +42 -0
  137. package/src/pages/api/graphql.ts +90 -0
  138. package/src/pages/api/health.ts +417 -40
  139. package/src/pages/api/keys/[id].ts +26 -0
  140. package/src/pages/api/keys/index.ts +75 -0
  141. package/src/pages/api/media/[id].ts +309 -0
  142. package/src/pages/api/media/folders.ts +609 -0
  143. package/src/pages/api/media/index.ts +146 -0
  144. package/src/pages/api/media/resize.ts +267 -0
  145. package/src/pages/api/search.ts +82 -0
  146. package/src/pages/api/slug-availability.ts +70 -0
  147. package/src/pages/api/storage-config.ts +20 -0
  148. package/src/pages/api/storage-status.ts +206 -0
  149. package/src/pages/api/upload.ts +334 -0
  150. package/src/pages/api/webhooks/index.ts +71 -0
  151. package/src/pages/audit/index.astro +2 -104
  152. package/src/pages/login.astro +11 -11
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +13 -13
  156. package/src/pages/roles/index.astro +21 -21
  157. package/src/pages/settings/[slug].astro +162 -0
  158. package/src/pages/settings/index.astro +9 -0
  159. package/src/pages/users/[id].astro +29 -21
  160. package/src/pages/users/index.astro +22 -17
  161. package/src/pages/users/new.astro +18 -17
  162. package/src/styles/main.css +553 -128
  163. package/src/components/layout/Sidebar.tsx +0 -497
@@ -0,0 +1,267 @@
1
+ import Database from "better-sqlite3";
2
+ import { randomBytes } from "crypto";
3
+
4
+ const DB_PATH =
5
+ process.env.KYRO_AUTH_DB_PATH || process.env.KYRO_DB_PATH || "./data/auth.db";
6
+
7
+ interface RateLimitConfig {
8
+ maxAttempts: number;
9
+ windowMs: number;
10
+ lockoutMs: number;
11
+ }
12
+
13
+ interface RateLimitResult {
14
+ allowed: boolean;
15
+ remaining: number;
16
+ resetAt: Date | null;
17
+ lockedUntil: Date | null;
18
+ }
19
+
20
+ const DEFAULT_CONFIG: RateLimitConfig = {
21
+ maxAttempts: 5,
22
+ windowMs: 15 * 60 * 1000, // 15 minutes
23
+ lockoutMs: 15 * 60 * 1000, // 15 minutes lockout
24
+ };
25
+
26
+ function getDb() {
27
+ return new Database(DB_PATH);
28
+ }
29
+
30
+ export async function checkRateLimit(
31
+ identifier: string,
32
+ action: string = "login",
33
+ config: RateLimitConfig = DEFAULT_CONFIG,
34
+ ): Promise<RateLimitResult> {
35
+ const db = getDb();
36
+ const now = new Date();
37
+
38
+ try {
39
+ // Clean up expired records first
40
+ db.prepare(
41
+ `
42
+ DELETE FROM rate_limits
43
+ WHERE expires_at IS NOT NULL AND expires_at < ?
44
+ `,
45
+ ).run(now.toISOString());
46
+
47
+ // Get existing record
48
+ const existing = db
49
+ .prepare(
50
+ `
51
+ SELECT * FROM rate_limits
52
+ WHERE identifier = ? AND action = ?
53
+ `,
54
+ )
55
+ .get(identifier, action) as any;
56
+
57
+ if (!existing) {
58
+ // First attempt - create record
59
+ const id = randomBytes(16).toString("hex");
60
+ db.prepare(
61
+ `
62
+ INSERT INTO rate_limits (id, identifier, action, attempts, first_attempt, last_attempt, created_at)
63
+ VALUES (?, ?, ?, 1, ?, ?, ?)
64
+ `,
65
+ ).run(
66
+ id,
67
+ identifier,
68
+ action,
69
+ now.toISOString(),
70
+ now.toISOString(),
71
+ now.toISOString(),
72
+ );
73
+
74
+ return {
75
+ allowed: true,
76
+ remaining: config.maxAttempts - 1,
77
+ resetAt: new Date(now.getTime() + config.windowMs),
78
+ lockedUntil: null,
79
+ };
80
+ }
81
+
82
+ // Check if currently locked
83
+ if (existing.expires_at && new Date(existing.expires_at) > now) {
84
+ return {
85
+ allowed: false,
86
+ remaining: 0,
87
+ resetAt: null,
88
+ lockedUntil: new Date(existing.expires_at),
89
+ };
90
+ }
91
+
92
+ // Check if within window
93
+ const firstAttempt = existing.first_attempt
94
+ ? new Date(existing.first_attempt)
95
+ : now;
96
+ const windowEnd = new Date(firstAttempt.getTime() + config.windowMs);
97
+
98
+ if (now > windowEnd) {
99
+ // Window expired - reset attempts
100
+ db.prepare(
101
+ `
102
+ UPDATE rate_limits
103
+ SET attempts = 1, first_attempt = ?, last_attempt = ?, expires_at = NULL
104
+ WHERE identifier = ? AND action = ?
105
+ `,
106
+ ).run(now.toISOString(), now.toISOString(), identifier, action);
107
+
108
+ return {
109
+ allowed: true,
110
+ remaining: config.maxAttempts - 1,
111
+ resetAt: new Date(now.getTime() + config.windowMs),
112
+ lockedUntil: null,
113
+ };
114
+ }
115
+
116
+ // Within window - check attempts
117
+ const attempts = existing.attempts || 0;
118
+ const remaining = config.maxAttempts - attempts;
119
+
120
+ if (attempts >= config.maxAttempts) {
121
+ // Lock out the user
122
+ const lockUntil = new Date(now.getTime() + config.lockoutMs);
123
+ db.prepare(
124
+ `
125
+ UPDATE rate_limits
126
+ SET last_attempt = ?, expires_at = ?
127
+ WHERE identifier = ? AND action = ?
128
+ `,
129
+ ).run(now.toISOString(), lockUntil.toISOString(), identifier, action);
130
+
131
+ return {
132
+ allowed: false,
133
+ remaining: 0,
134
+ resetAt: windowEnd,
135
+ lockedUntil: lockUntil,
136
+ };
137
+ }
138
+
139
+ // Increment attempts
140
+ db.prepare(
141
+ `
142
+ UPDATE rate_limits
143
+ SET attempts = attempts + 1, last_attempt = ?
144
+ WHERE identifier = ? AND action = ?
145
+ `,
146
+ ).run(now.toISOString(), identifier, action);
147
+
148
+ return {
149
+ allowed: true,
150
+ remaining: remaining - 1,
151
+ resetAt: windowEnd,
152
+ lockedUntil: null,
153
+ };
154
+ } finally {
155
+ db.close();
156
+ }
157
+ }
158
+
159
+ export async function resetRateLimit(
160
+ identifier: string,
161
+ action: string = "login",
162
+ ): Promise<void> {
163
+ const db = getDb();
164
+
165
+ try {
166
+ db.prepare(
167
+ `
168
+ DELETE FROM rate_limits WHERE identifier = ? AND action = ?
169
+ `,
170
+ ).run(identifier, action);
171
+ } finally {
172
+ db.close();
173
+ }
174
+ }
175
+
176
+ export async function getAccountLockStatus(email: string): Promise<{
177
+ isLocked: boolean;
178
+ lockedUntil: Date | null;
179
+ failedAttempts: number;
180
+ }> {
181
+ const db = getDb();
182
+
183
+ try {
184
+ const user = db
185
+ .prepare(
186
+ `
187
+ SELECT locked, locked_until, failed_login_attempts
188
+ FROM users
189
+ WHERE LOWER(email) = LOWER(?)
190
+ `,
191
+ )
192
+ .get(email) as any;
193
+
194
+ if (!user) {
195
+ return { isLocked: false, lockedUntil: null, failedAttempts: 0 };
196
+ }
197
+
198
+ const now = new Date();
199
+ const lockedUntil = user.locked_until ? new Date(user.locked_until) : null;
200
+ const isLocked = user.locked === 1 && (!lockedUntil || lockedUntil > now);
201
+
202
+ return {
203
+ isLocked,
204
+ lockedUntil: isLocked ? lockedUntil : null,
205
+ failedAttempts: user.failed_login_attempts || 0,
206
+ };
207
+ } finally {
208
+ db.close();
209
+ }
210
+ }
211
+
212
+ export async function recordFailedLogin(email: string): Promise<void> {
213
+ const db = getDb();
214
+
215
+ try {
216
+ const now = new Date();
217
+ const user = db
218
+ .prepare(
219
+ `
220
+ SELECT id FROM users WHERE LOWER(email) = LOWER(?)
221
+ `,
222
+ )
223
+ .get(email) as any;
224
+
225
+ if (!user) return;
226
+
227
+ // Increment failed attempts
228
+ db.prepare(
229
+ `
230
+ UPDATE users
231
+ SET failed_login_attempts = COALESCE(failed_login_attempts, 0) + 1,
232
+ last_login = ?,
233
+ locked = CASE
234
+ WHEN COALESCE(failed_login_attempts, 0) >= 4 THEN 1
235
+ ELSE 0
236
+ END,
237
+ locked_until = CASE
238
+ WHEN COALESCE(failed_login_attempts, 0) >= 4 THEN ?
239
+ ELSE NULL
240
+ END
241
+ WHERE id = ?
242
+ `,
243
+ ).run(
244
+ now.toISOString(),
245
+ new Date(now.getTime() + 15 * 60 * 1000).toISOString(),
246
+ user.id,
247
+ );
248
+ } finally {
249
+ db.close();
250
+ }
251
+ }
252
+
253
+ export async function unlockAccount(email: string): Promise<void> {
254
+ const db = getDb();
255
+
256
+ try {
257
+ db.prepare(
258
+ `
259
+ UPDATE users
260
+ SET locked = 0, locked_until = NULL, failed_login_attempts = 0
261
+ WHERE LOWER(email) = LOWER(?)
262
+ `,
263
+ ).run(email);
264
+ } finally {
265
+ db.close();
266
+ }
267
+ }
@@ -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
+ }
@@ -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
+ }));