@ampless/admin 0.2.0-alpha.8 → 1.0.0-alpha.27

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 (48) hide show
  1. package/README.ja.md +73 -0
  2. package/README.md +3 -0
  3. package/dist/api/index.d.ts +1 -1
  4. package/dist/chunk-2ITWLRYF.js +38 -0
  5. package/dist/chunk-2U3POKAZ.js +198 -0
  6. package/dist/{chunk-VXEVLHGL.js → chunk-6LQGVDCW.js} +2 -2
  7. package/dist/chunk-6NPYUTV6.js +250 -0
  8. package/dist/chunk-6SB7YICQ.js +48 -0
  9. package/dist/chunk-6W3JIOOR.js +37 -0
  10. package/dist/chunk-CTGFMK2J.js +335 -0
  11. package/dist/chunk-G4CF5ZWV.js +1319 -0
  12. package/dist/chunk-KQOE5CT6.js +21 -0
  13. package/dist/chunk-MWSCSCCU.js +67 -0
  14. package/dist/chunk-Q66BLMNJ.js +33 -0
  15. package/dist/chunk-TZ5F24BG.js +149 -0
  16. package/dist/chunk-VL6MMF2P.js +21 -0
  17. package/dist/chunk-VSS5FWSR.js +334 -0
  18. package/dist/{chunk-KKM2MCM4.js → chunk-WL4IBW2D.js} +121 -43
  19. package/dist/chunk-YFWHKIVH.js +1187 -0
  20. package/dist/components/admin-dashboard.d.ts +10 -0
  21. package/dist/components/admin-dashboard.js +9 -0
  22. package/dist/components/edit-post-view.d.ts +9 -0
  23. package/dist/components/edit-post-view.js +12 -0
  24. package/dist/components/index.d.ts +14 -42
  25. package/dist/components/index.js +22 -33
  26. package/dist/components/login-view.d.ts +5 -0
  27. package/dist/components/login-view.js +9 -0
  28. package/dist/components/mcp-tokens-view.d.ts +16 -0
  29. package/dist/components/mcp-tokens-view.js +9 -0
  30. package/dist/components/media-view.d.ts +5 -0
  31. package/dist/components/media-view.js +12 -0
  32. package/dist/components/new-post-view.d.ts +5 -0
  33. package/dist/components/new-post-view.js +12 -0
  34. package/dist/components/posts-list-view.d.ts +5 -0
  35. package/dist/components/posts-list-view.js +9 -0
  36. package/dist/components/users-list-view.d.ts +7 -0
  37. package/dist/components/users-list-view.js +9 -0
  38. package/dist/{i18n-DzXXcIQQ.d.ts → i18n-BhMBRfio.d.ts} +179 -1
  39. package/dist/index.d.ts +18 -18
  40. package/dist/index.js +17 -38
  41. package/dist/lib/theme-actions.d.ts +3 -3
  42. package/dist/lib/theme-actions.js +1 -1
  43. package/dist/metafile-esm.json +1 -1
  44. package/dist/pages/index.d.ts +35 -16
  45. package/dist/pages/index.js +90 -257
  46. package/package.json +19 -8
  47. package/dist/chunk-QDPB5W35.js +0 -3251
  48. package/dist/login-view-BKrSZLJu.d.ts +0 -24
@@ -1,3251 +0,0 @@
1
- 'use client';
2
- import {
3
- ADMIN_SITE_COOKIE,
4
- publicMediaUrl,
5
- setAdminMediaContext,
6
- translate
7
- } from "./chunk-KKM2MCM4.js";
8
- import {
9
- invalidateSiteSettingsCache
10
- } from "./chunk-VXEVLHGL.js";
11
-
12
- // src/components/i18n-provider.tsx
13
- import { createContext, useContext, useMemo } from "react";
14
- import { jsx } from "react/jsx-runtime";
15
- var I18nContext = createContext(null);
16
- function I18nProvider({ locale, dict, children }) {
17
- const value = useMemo(() => ({ locale, dict }), [locale, dict]);
18
- return /* @__PURE__ */ jsx(I18nContext.Provider, { value, children });
19
- }
20
- function useT() {
21
- const ctx = useContext(I18nContext);
22
- if (!ctx) {
23
- throw new Error(
24
- "useT() called outside <I18nProvider>. Wrap the admin layout (or root layout) with <I18nProvider locale={...} dict={...}>."
25
- );
26
- }
27
- return (key, vars) => translate(ctx.dict, key, vars);
28
- }
29
- function useLocale() {
30
- const ctx = useContext(I18nContext);
31
- if (!ctx) throw new Error("useLocale() called outside <I18nProvider>.");
32
- return ctx.locale;
33
- }
34
-
35
- // src/lib/admin-site-client.ts
36
- import { DEFAULT_SITE_ID, isMultiSite } from "ampless";
37
- var cmsConfig = null;
38
- function setAdminCmsConfig(config) {
39
- cmsConfig = config;
40
- }
41
- function readAdminSiteIdFromCookie() {
42
- if (!cmsConfig) return DEFAULT_SITE_ID;
43
- if (!isMultiSite(cmsConfig)) return DEFAULT_SITE_ID;
44
- const sites = cmsConfig.sites ?? {};
45
- if (typeof document !== "undefined") {
46
- const match = document.cookie.match(
47
- new RegExp(`(?:^|;\\s*)${ADMIN_SITE_COOKIE}=([^;]+)`)
48
- );
49
- if (match) {
50
- const v = decodeURIComponent(match[1]);
51
- if (sites[v]) return v;
52
- }
53
- }
54
- const first = Object.keys(sites)[0];
55
- return first ?? DEFAULT_SITE_ID;
56
- }
57
-
58
- // src/lib/amplify-client.ts
59
- import { Amplify } from "aws-amplify";
60
- var configured = false;
61
- function configureAmplify(outputs) {
62
- if (configured) return;
63
- Amplify.configure(outputs, { ssr: true });
64
- configured = true;
65
- }
66
-
67
- // src/lib/posts-provider.ts
68
- import { generateClient } from "aws-amplify/api";
69
- import {
70
- setPostsProvider,
71
- composeSiteIdStatus,
72
- composeSiteIdSlug
73
- } from "ampless";
74
- function encodeBody(value) {
75
- return JSON.stringify(value ?? null);
76
- }
77
- function decodeBody(value) {
78
- if (typeof value !== "string") return value;
79
- try {
80
- return JSON.parse(value);
81
- } catch {
82
- return value;
83
- }
84
- }
85
- function decodeMetadata(value) {
86
- if (value === null || value === void 0) return void 0;
87
- const parsed = typeof value === "string" ? safeJsonParse(value) : value;
88
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
89
- }
90
- function safeJsonParse(value) {
91
- try {
92
- return JSON.parse(value);
93
- } catch {
94
- return value;
95
- }
96
- }
97
- function toCorePost(p) {
98
- return {
99
- postId: p.postId,
100
- siteId: p.siteId,
101
- slug: p.slug,
102
- title: p.title,
103
- excerpt: p.excerpt ?? void 0,
104
- format: p.format ?? "markdown",
105
- body: decodeBody(p.body),
106
- status: p.status ?? "draft",
107
- publishedAt: p.publishedAt ?? void 0,
108
- tags: (p.tags ?? []).filter((t) => typeof t === "string"),
109
- metadata: decodeMetadata(p.metadata)
110
- };
111
- }
112
- function postTagEntries(post) {
113
- if (post.status !== "published" || !post.publishedAt || !post.tags?.length) return [];
114
- return post.tags.map((tag) => ({
115
- siteIdTag: `${post.siteId}#${tag}`,
116
- publishedAtPostId: `${post.publishedAt}#${post.postId}`
117
- }));
118
- }
119
- function entryKey(e) {
120
- return `${e.siteIdTag}|${e.publishedAtPostId}`;
121
- }
122
- var installed = false;
123
- function installAdminPostsProvider() {
124
- if (installed) return;
125
- installed = true;
126
- const client = generateClient();
127
- async function syncPostTags(post, oldPost) {
128
- const oldEntries = oldPost ? postTagEntries(oldPost) : [];
129
- const newEntries = postTagEntries(post);
130
- const oldKeys = new Set(oldEntries.map(entryKey));
131
- const newKeys = new Set(newEntries.map(entryKey));
132
- function fullRow(e) {
133
- return {
134
- siteIdTag: e.siteIdTag,
135
- publishedAtPostId: e.publishedAtPostId,
136
- siteId: post.siteId,
137
- tag: e.siteIdTag.slice(post.siteId.length + 1),
138
- postId: post.postId,
139
- publishedAt: post.publishedAt,
140
- slug: post.slug,
141
- title: post.title,
142
- excerpt: post.excerpt,
143
- tags: post.tags ?? []
144
- };
145
- }
146
- async function upsertPostTag(e) {
147
- const row = fullRow(e);
148
- const upd = await client.models.PostTag.update(row);
149
- if (!upd.errors) return;
150
- const cre = await client.models.PostTag.create(row);
151
- if (cre.errors) {
152
- throw new Error(upd.errors[0]?.message ?? "PostTag.update failed");
153
- }
154
- }
155
- await Promise.all(
156
- oldEntries.filter((e) => !newKeys.has(entryKey(e))).map((e) => client.models.PostTag.delete(e))
157
- );
158
- await Promise.all(
159
- newEntries.filter((e) => !oldKeys.has(entryKey(e))).map(async (e) => {
160
- const cre = await client.models.PostTag.create(fullRow(e));
161
- if (!cre.errors) return;
162
- const upd = await client.models.PostTag.update(fullRow(e));
163
- if (upd.errors)
164
- throw new Error(cre.errors[0]?.message ?? "PostTag.create failed");
165
- })
166
- );
167
- await Promise.all(
168
- newEntries.filter((e) => oldKeys.has(entryKey(e))).map(upsertPostTag)
169
- );
170
- }
171
- const provider = {
172
- async list(opts = {}) {
173
- const siteId = opts.siteId ?? "default";
174
- const status = opts.status ?? "published";
175
- const filter = { siteId: { eq: siteId } };
176
- if (status !== "all") filter.status = { eq: status };
177
- const { data } = await client.models.Post.list({ filter, limit: opts.limit ?? 100 });
178
- return data.map(toCorePost);
179
- },
180
- async get(slug, opts = {}) {
181
- const siteId = opts.siteId ?? "default";
182
- const { data } = await client.models.Post.list({
183
- filter: { siteId: { eq: siteId }, slug: { eq: slug } },
184
- limit: 1
185
- });
186
- return data[0] ? toCorePost(data[0]) : null;
187
- },
188
- async getById(postId, opts = {}) {
189
- const siteId = opts.siteId ?? "default";
190
- const { data } = await client.models.Post.get({ siteId, postId });
191
- return data ? toCorePost(data) : null;
192
- },
193
- async create(input) {
194
- const postId = input.postId ?? `post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
195
- const { data, errors } = await client.models.Post.create({
196
- siteId: input.siteId,
197
- postId,
198
- slug: input.slug,
199
- title: input.title,
200
- excerpt: input.excerpt,
201
- format: input.format,
202
- body: encodeBody(input.body),
203
- status: input.status,
204
- publishedAt: input.publishedAt,
205
- tags: input.tags,
206
- ...input.metadata !== void 0 && { metadata: encodeBody(input.metadata) },
207
- // Denormalized GSI keys. Must match every change to slug /
208
- // status — see the update() branch below.
209
- siteIdStatus: composeSiteIdStatus(input.siteId, input.status),
210
- siteIdSlug: composeSiteIdSlug(input.siteId, input.slug)
211
- });
212
- if (errors || !data) throw new Error(errors?.[0]?.message ?? "Failed to create post");
213
- const created = toCorePost(data);
214
- await syncPostTags(created, null);
215
- return created;
216
- },
217
- async update(postId, patch, opts = {}) {
218
- const siteId = opts.siteId ?? "default";
219
- const oldPost = await this.getById(postId, { siteId });
220
- const nextStatus = patch.status ?? oldPost?.status;
221
- const nextSlug = patch.slug ?? oldPost?.slug;
222
- const { data, errors } = await client.models.Post.update({
223
- siteId,
224
- postId,
225
- ...patch.slug !== void 0 && { slug: patch.slug },
226
- ...patch.title !== void 0 && { title: patch.title },
227
- ...patch.excerpt !== void 0 && { excerpt: patch.excerpt },
228
- ...patch.format !== void 0 && { format: patch.format },
229
- ...patch.body !== void 0 && { body: encodeBody(patch.body) },
230
- ...patch.status !== void 0 && { status: patch.status },
231
- ...patch.publishedAt !== void 0 && { publishedAt: patch.publishedAt },
232
- ...patch.tags !== void 0 && { tags: patch.tags },
233
- ...patch.metadata !== void 0 && { metadata: encodeBody(patch.metadata) },
234
- ...patch.status !== void 0 && nextStatus && { siteIdStatus: composeSiteIdStatus(siteId, nextStatus) },
235
- ...patch.slug !== void 0 && nextSlug && { siteIdSlug: composeSiteIdSlug(siteId, nextSlug) }
236
- });
237
- if (errors || !data) throw new Error(errors?.[0]?.message ?? "Failed to update post");
238
- const updated = toCorePost(data);
239
- await syncPostTags(updated, oldPost);
240
- return updated;
241
- },
242
- async remove(postId, opts = {}) {
243
- const siteId = opts.siteId ?? "default";
244
- const oldPost = await this.getById(postId, { siteId });
245
- if (oldPost) {
246
- await syncPostTags({ ...oldPost, status: "draft" }, oldPost);
247
- }
248
- const { errors } = await client.models.Post.delete({ siteId, postId });
249
- if (errors) throw new Error(errors[0]?.message ?? "Failed to delete post");
250
- }
251
- };
252
- setPostsProvider(provider);
253
- }
254
-
255
- // src/lib/kv-provider.ts
256
- import { generateClient as generateClient2 } from "aws-amplify/api";
257
- import { setKvStore } from "ampless";
258
- var installed2 = false;
259
- function installAdminKvProvider() {
260
- if (installed2) return;
261
- installed2 = true;
262
- const client = generateClient2();
263
- function requireModel() {
264
- const m = client.models.KvStore;
265
- if (!m) {
266
- throw new Error(
267
- "KvStore model is not available on the AppSync client. Did you redeploy the sandbox? Run `npx ampx sandbox` and wait for it to finish, then reload this page."
268
- );
269
- }
270
- return m;
271
- }
272
- function encodeValue(value) {
273
- return JSON.stringify(value ?? null);
274
- }
275
- function decodeValue(raw) {
276
- if (typeof raw !== "string") return raw;
277
- try {
278
- return JSON.parse(raw);
279
- } catch {
280
- return raw;
281
- }
282
- }
283
- const store = {
284
- async get(pk, sk) {
285
- const model = requireModel();
286
- const { data } = await model.get({ pk, sk });
287
- return data ? decodeValue(data.value) : null;
288
- },
289
- async query(pk) {
290
- const model = requireModel();
291
- const { data } = await model.list({
292
- filter: { pk: { eq: pk } },
293
- limit: 1e3
294
- });
295
- return (data ?? []).map((row) => ({
296
- pk: row.pk,
297
- sk: row.sk,
298
- value: decodeValue(row.value),
299
- ttl: row.ttl ?? void 0
300
- }));
301
- },
302
- async put(pk, sk, value, opts) {
303
- const model = requireModel();
304
- const ttl = opts?.ttlSeconds ? Math.floor(Date.now() / 1e3) + opts.ttlSeconds : void 0;
305
- const existing = await model.get({ pk, sk });
306
- if (existing.data) {
307
- const { errors } = await model.update({
308
- pk,
309
- sk,
310
- value: encodeValue(value),
311
- ttl: ttl ?? null
312
- });
313
- if (errors) throw new Error(errors[0]?.message ?? "KvStore.update failed");
314
- } else {
315
- const { errors } = await model.create({
316
- pk,
317
- sk,
318
- value: encodeValue(value),
319
- ttl
320
- });
321
- if (errors) throw new Error(errors[0]?.message ?? "KvStore.create failed");
322
- }
323
- },
324
- async remove(pk, sk) {
325
- const model = requireModel();
326
- const { errors } = await model.delete({ pk, sk });
327
- if (errors) throw new Error(errors[0]?.message ?? "KvStore.delete failed");
328
- }
329
- };
330
- setKvStore(store);
331
- }
332
-
333
- // src/lib/admin-config-client.ts
334
- var cmsConfig2 = null;
335
- function setAdminCmsConfigClient(config) {
336
- cmsConfig2 = config;
337
- }
338
- function getMediaProcessingDefaults() {
339
- return cmsConfig2?.media?.processing;
340
- }
341
-
342
- // src/components/admin-providers.tsx
343
- import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
344
- function AdminProviders({ outputs, cmsConfig: cmsConfig3, children }) {
345
- configureAmplify(outputs);
346
- setAdminCmsConfig(cmsConfig3);
347
- setAdminCmsConfigClient(cmsConfig3);
348
- setAdminMediaContext(outputs, cmsConfig3);
349
- installAdminPostsProvider();
350
- installAdminKvProvider();
351
- return /* @__PURE__ */ jsx2(Fragment, { children });
352
- }
353
-
354
- // src/components/admin-dashboard.tsx
355
- import { useEffect, useState } from "react";
356
- import Link from "next/link";
357
- import { listPosts } from "ampless";
358
- import { Button, Card, CardContent, CardHeader, CardTitle } from "@ampless/runtime/ui";
359
- import { jsx as jsx3, jsxs } from "react/jsx-runtime";
360
- function AdminDashboard() {
361
- const t = useT();
362
- const [posts, setPosts] = useState([]);
363
- const [loading, setLoading] = useState(true);
364
- useEffect(() => {
365
- listPosts({ status: "all" }).then(setPosts).finally(() => setLoading(false));
366
- }, []);
367
- const published = posts.filter((p) => p.status === "published").length;
368
- const drafts = posts.filter((p) => p.status === "draft").length;
369
- return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
370
- /* @__PURE__ */ jsxs("div", { className: "mb-6 flex flex-wrap items-center justify-between gap-3 md:mb-8", children: [
371
- /* @__PURE__ */ jsx3("h1", { className: "text-2xl font-bold md:text-3xl", children: t("dashboard.title") }),
372
- /* @__PURE__ */ jsx3(Button, { asChild: true, children: /* @__PURE__ */ jsx3(Link, { href: "/admin/posts/new", children: t("dashboard.newPost") }) })
373
- ] }),
374
- /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", children: [
375
- /* @__PURE__ */ jsxs(Card, { children: [
376
- /* @__PURE__ */ jsx3(CardHeader, { children: /* @__PURE__ */ jsx3(CardTitle, { children: t("dashboard.totalPosts") }) }),
377
- /* @__PURE__ */ jsxs(CardContent, { children: [
378
- /* @__PURE__ */ jsx3("p", { className: "text-3xl font-bold", children: loading ? "\u2014" : posts.length }),
379
- /* @__PURE__ */ jsx3("p", { className: "text-sm text-muted-foreground", children: t("dashboard.totalLabel") })
380
- ] })
381
- ] }),
382
- /* @__PURE__ */ jsxs(Card, { children: [
383
- /* @__PURE__ */ jsx3(CardHeader, { children: /* @__PURE__ */ jsx3(CardTitle, { children: t("dashboard.published") }) }),
384
- /* @__PURE__ */ jsx3(CardContent, { children: /* @__PURE__ */ jsx3("p", { className: "text-3xl font-bold", children: loading ? "\u2014" : published }) })
385
- ] }),
386
- /* @__PURE__ */ jsxs(Card, { children: [
387
- /* @__PURE__ */ jsx3(CardHeader, { children: /* @__PURE__ */ jsx3(CardTitle, { children: t("dashboard.drafts") }) }),
388
- /* @__PURE__ */ jsx3(CardContent, { children: /* @__PURE__ */ jsx3("p", { className: "text-3xl font-bold", children: loading ? "\u2014" : drafts }) })
389
- ] })
390
- ] })
391
- ] });
392
- }
393
-
394
- // src/components/posts-list-view.tsx
395
- import { useEffect as useEffect2, useState as useState2 } from "react";
396
- import Link2 from "next/link";
397
- import { listPosts as listPosts2 } from "ampless";
398
- import {
399
- Button as Button2,
400
- Table,
401
- TableBody,
402
- TableCell,
403
- TableHead,
404
- TableHeader,
405
- TableRow
406
- } from "@ampless/runtime/ui";
407
- import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
408
- function PostsList() {
409
- const t = useT();
410
- const [posts, setPosts] = useState2([]);
411
- const [loading, setLoading] = useState2(true);
412
- useEffect2(() => {
413
- const siteId = readAdminSiteIdFromCookie();
414
- listPosts2({ status: "all", siteId }).then(setPosts).finally(() => setLoading(false));
415
- }, []);
416
- return /* @__PURE__ */ jsxs2("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
417
- /* @__PURE__ */ jsxs2("div", { className: "mb-6 flex flex-wrap items-center justify-between gap-3 md:mb-8", children: [
418
- /* @__PURE__ */ jsx4("h1", { className: "text-2xl font-bold md:text-3xl", children: t("posts.list.title") }),
419
- /* @__PURE__ */ jsx4(Button2, { asChild: true, children: /* @__PURE__ */ jsx4(Link2, { href: "/admin/posts/new", children: t("posts.list.newButton") }) })
420
- ] }),
421
- loading ? /* @__PURE__ */ jsx4("p", { className: "text-muted-foreground", children: t("common.loading") }) : posts.length === 0 ? /* @__PURE__ */ jsxs2("div", { className: "rounded-md border p-12 text-center", children: [
422
- /* @__PURE__ */ jsx4("p", { className: "text-muted-foreground", children: t("posts.list.empty") }),
423
- /* @__PURE__ */ jsx4(Button2, { asChild: true, className: "mt-4", children: /* @__PURE__ */ jsx4(Link2, { href: "/admin/posts/new", children: t("posts.list.createFirst") }) })
424
- ] }) : /* @__PURE__ */ jsx4("div", { className: "overflow-x-auto rounded-md border", children: /* @__PURE__ */ jsxs2(Table, { children: [
425
- /* @__PURE__ */ jsx4(TableHeader, { children: /* @__PURE__ */ jsxs2(TableRow, { children: [
426
- /* @__PURE__ */ jsx4(TableHead, { children: t("posts.list.columnTitle") }),
427
- /* @__PURE__ */ jsx4(TableHead, { children: t("posts.list.columnStatus") }),
428
- /* @__PURE__ */ jsx4(TableHead, { children: t("posts.list.columnSlug") }),
429
- /* @__PURE__ */ jsx4(TableHead, { children: t("posts.list.columnUpdated") })
430
- ] }) }),
431
- /* @__PURE__ */ jsx4(TableBody, { children: posts.map((post) => /* @__PURE__ */ jsxs2(TableRow, { children: [
432
- /* @__PURE__ */ jsx4(TableCell, { children: /* @__PURE__ */ jsx4(
433
- Link2,
434
- {
435
- href: `/admin/posts/${post.postId}`,
436
- className: "font-medium hover:underline",
437
- children: post.title
438
- }
439
- ) }),
440
- /* @__PURE__ */ jsx4(TableCell, { children: /* @__PURE__ */ jsx4(
441
- "span",
442
- {
443
- className: post.status === "published" ? "inline-block rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700" : "inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700",
444
- children: t(`common.${post.status}`)
445
- }
446
- ) }),
447
- /* @__PURE__ */ jsx4(TableCell, { className: "font-mono text-xs text-muted-foreground", children: post.slug }),
448
- /* @__PURE__ */ jsx4(TableCell, { className: "text-sm text-muted-foreground", children: post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : "\u2014" })
449
- ] }, post.postId)) })
450
- ] }) })
451
- ] });
452
- }
453
-
454
- // src/lib/upload.ts
455
- import { uploadData } from "aws-amplify/storage";
456
- import { processImage } from "ampless/media";
457
- function sanitizeName(name) {
458
- return name.replace(/[\x00-\x1f\x7f]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
459
- }
460
- async function uploadProcessedImage(file, options) {
461
- const processed = await processImage(file, options);
462
- const safeName = sanitizeName(processed.suggestedName);
463
- const now = /* @__PURE__ */ new Date();
464
- const yyyy = now.getFullYear();
465
- const mm = String(now.getMonth() + 1).padStart(2, "0");
466
- const path = `public/media/${yyyy}/${mm}/${Date.now()}-${safeName}`;
467
- await uploadData({
468
- path,
469
- data: processed.blob,
470
- options: { contentType: processed.mime }
471
- }).result;
472
- return { path, url: publicMediaUrl(path) };
473
- }
474
-
475
- // src/components/image-upload-dialog.tsx
476
- import { useEffect as useEffect3, useRef, useState as useState3 } from "react";
477
- import ReactCrop, { centerCrop, makeAspectCrop } from "react-image-crop";
478
- import "react-image-crop/dist/ReactCrop.css";
479
- import { shouldSkipProcessing } from "ampless/media";
480
- import {
481
- Dialog,
482
- DialogContent,
483
- DialogDescription,
484
- DialogHeader,
485
- DialogTitle,
486
- Button as Button3,
487
- Input,
488
- Label
489
- } from "@ampless/runtime/ui";
490
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
491
- var ASPECTS = {
492
- free: void 0,
493
- "1:1": 1,
494
- "4:3": 4 / 3,
495
- "16:9": 16 / 9,
496
- "3:2": 3 / 2
497
- };
498
- var ASPECT_CHOICES = ["free", "1:1", "4:3", "16:9", "3:2"];
499
- var FORMAT_CHOICES = ["auto", "webp", "jpeg"];
500
- var MAX_DIMENSION_PRESETS = [640, 1024, 1600, 2400, 4e3];
501
- var MIN_DIMENSION = 100;
502
- var MAX_DIMENSION_CEILING = 8e3;
503
- function clampMaxDimension(value, fallback) {
504
- if (!Number.isFinite(value) || value <= 0) return fallback;
505
- return Math.min(MAX_DIMENSION_CEILING, Math.max(MIN_DIMENSION, Math.round(value)));
506
- }
507
- function clampQuality(value) {
508
- if (!Number.isFinite(value)) return 0.85;
509
- return Math.min(1, Math.max(0, value));
510
- }
511
- function resolveFormat(choice, inputMime, losslessForPng) {
512
- if (choice === "auto") {
513
- return {
514
- format: "webp",
515
- lossless: losslessForPng && inputMime === "image/png"
516
- };
517
- }
518
- return { format: choice, lossless: false };
519
- }
520
- function buildInitialCrop(naturalWidth, naturalHeight, aspect) {
521
- if (aspect) {
522
- return centerCrop(
523
- makeAspectCrop({ unit: "%", width: 100 }, aspect, naturalWidth, naturalHeight),
524
- naturalWidth,
525
- naturalHeight
526
- );
527
- }
528
- return { unit: "%", x: 0, y: 0, width: 100, height: 100 };
529
- }
530
- function ImageUploadDialog({
531
- file,
532
- remaining,
533
- busy = false,
534
- defaults,
535
- onConfirm,
536
- onSkip,
537
- onCancel
538
- }) {
539
- const t = useT();
540
- const defaultMaxDimension = defaults?.maxDimension ?? 2400;
541
- const defaultQuality = defaults?.quality ?? 0.85;
542
- const losslessForPng = defaults?.losslessForPng ?? true;
543
- const [original, setOriginal] = useState3(false);
544
- const [aspect, setAspect] = useState3("free");
545
- const [crop, setCrop] = useState3(void 0);
546
- const [percentCrop, setPercentCrop] = useState3(null);
547
- const [naturalSize, setNaturalSize] = useState3(null);
548
- const [formatChoice, setFormatChoice] = useState3("auto");
549
- const [losslessOverride, setLosslessOverride] = useState3(null);
550
- const [quality, setQuality] = useState3(defaultQuality);
551
- const [maxDimension, setMaxDimension] = useState3(defaultMaxDimension);
552
- const [previewUrl, setPreviewUrl] = useState3(null);
553
- const imgRef = useRef(null);
554
- useEffect3(() => {
555
- setOriginal(false);
556
- setAspect("free");
557
- setCrop(void 0);
558
- setPercentCrop(null);
559
- setNaturalSize(null);
560
- setFormatChoice("auto");
561
- setLosslessOverride(null);
562
- setQuality(defaultQuality);
563
- setMaxDimension(defaultMaxDimension);
564
- }, [file, defaultQuality, defaultMaxDimension]);
565
- useEffect3(() => {
566
- if (!naturalSize) return;
567
- const next = buildInitialCrop(naturalSize.width, naturalSize.height, ASPECTS[aspect]);
568
- setCrop(next);
569
- setPercentCrop(next);
570
- }, [aspect, naturalSize]);
571
- useEffect3(() => {
572
- if (!file) {
573
- setPreviewUrl(null);
574
- return;
575
- }
576
- const url = URL.createObjectURL(file);
577
- setPreviewUrl(url);
578
- return () => URL.revokeObjectURL(url);
579
- }, [file]);
580
- if (!file) return null;
581
- const isImage = file.type.startsWith("image/");
582
- const passthrough = !isImage || shouldSkipProcessing(file.type);
583
- const showCropper = !passthrough && !original;
584
- const { format, lossless: autoLossless } = resolveFormat(formatChoice, file.type, losslessForPng);
585
- const lossless = losslessOverride ?? autoLossless;
586
- const showLosslessToggle = !original && !passthrough && format === "webp";
587
- const showQualitySlider = !original && !passthrough && (format === "jpeg" || format === "webp" && !lossless);
588
- function handleImageLoad(e) {
589
- const { naturalWidth, naturalHeight } = e.currentTarget;
590
- setNaturalSize({ width: naturalWidth, height: naturalHeight });
591
- const initial = buildInitialCrop(naturalWidth, naturalHeight, ASPECTS[aspect]);
592
- setCrop(initial);
593
- setPercentCrop(initial);
594
- }
595
- function handleConfirm() {
596
- if (!file || busy) return;
597
- if (original || passthrough) {
598
- onConfirm(file, { original: true });
599
- return;
600
- }
601
- let cropArea = void 0;
602
- if (percentCrop && naturalSize) {
603
- const x = Math.round(percentCrop.x / 100 * naturalSize.width);
604
- const y = Math.round(percentCrop.y / 100 * naturalSize.height);
605
- const width = Math.round(percentCrop.width / 100 * naturalSize.width);
606
- const height = Math.round(percentCrop.height / 100 * naturalSize.height);
607
- if (width > 0 && height > 0 && (x !== 0 || y !== 0 || width !== naturalSize.width || height !== naturalSize.height)) {
608
- cropArea = { x, y, width, height };
609
- }
610
- }
611
- onConfirm(file, {
612
- crop: cropArea,
613
- maxDimension: clampMaxDimension(maxDimension, defaultMaxDimension),
614
- format,
615
- quality: clampQuality(quality),
616
- lossless: format === "webp" ? lossless : false
617
- });
618
- }
619
- return /* @__PURE__ */ jsx5(
620
- Dialog,
621
- {
622
- open: true,
623
- onOpenChange: (open) => {
624
- if (open) return;
625
- onCancel();
626
- },
627
- children: /* @__PURE__ */ jsxs3(DialogContent, { className: "max-h-[90vh] max-w-4xl overflow-y-auto", children: [
628
- /* @__PURE__ */ jsxs3(DialogHeader, { children: [
629
- /* @__PURE__ */ jsx5(DialogTitle, { className: "truncate", children: file.name }),
630
- /* @__PURE__ */ jsxs3(DialogDescription, { children: [
631
- remaining > 1 ? t("media.dialog.remaining", { count: remaining }) : `${formatBytes(file.size)} \xB7 ${file.type || "unknown"}`,
632
- busy && t("media.dialog.uploading")
633
- ] })
634
- ] }),
635
- previewUrl && showCropper && /* @__PURE__ */ jsx5("div", { className: "flex items-center justify-center rounded-md bg-black/90 p-2", children: /* @__PURE__ */ jsx5(
636
- ReactCrop,
637
- {
638
- crop,
639
- aspect: ASPECTS[aspect],
640
- minWidth: 20,
641
- minHeight: 20,
642
- onChange: (_pixel, percent) => {
643
- setCrop(percent);
644
- setPercentCrop(percent);
645
- },
646
- children: /* @__PURE__ */ jsx5(
647
- "img",
648
- {
649
- ref: imgRef,
650
- src: previewUrl,
651
- alt: "preview",
652
- className: "block max-h-[60vh] max-w-full",
653
- onLoad: handleImageLoad
654
- }
655
- )
656
- }
657
- ) }),
658
- previewUrl && !showCropper && isImage && /* @__PURE__ */ jsx5("div", { className: "flex h-48 items-center justify-center rounded-md bg-muted", children: /* @__PURE__ */ jsx5("img", { src: previewUrl, alt: "preview", className: "max-h-full max-w-full object-contain" }) }),
659
- !isImage && // Non-image upload: skip the broken-img preview. Show the
660
- // file's name / size / mime so the admin can confirm before
661
- // committing the bytes to S3.
662
- /* @__PURE__ */ jsxs3("div", { className: "flex h-32 flex-col items-center justify-center gap-1 rounded-md bg-muted text-sm text-muted-foreground", children: [
663
- /* @__PURE__ */ jsx5("span", { className: "font-medium", children: file.name }),
664
- /* @__PURE__ */ jsxs3("span", { className: "font-mono text-xs", children: [
665
- formatBytes(file.size),
666
- " \xB7 ",
667
- file.type || "unknown"
668
- ] })
669
- ] }),
670
- /* @__PURE__ */ jsxs3("div", { className: "space-y-4", children: [
671
- /* @__PURE__ */ jsxs3("label", { className: "flex items-center gap-2 text-sm", children: [
672
- /* @__PURE__ */ jsx5(
673
- "input",
674
- {
675
- type: "checkbox",
676
- checked: original,
677
- disabled: busy,
678
- onChange: (e) => setOriginal(e.target.checked)
679
- }
680
- ),
681
- /* @__PURE__ */ jsx5("span", { children: t("media.dialog.useOriginal") }),
682
- passthrough && /* @__PURE__ */ jsx5("span", { className: "text-xs text-muted-foreground", children: t("media.dialog.passthroughNote") })
683
- ] }),
684
- !original && !passthrough && /* @__PURE__ */ jsxs3(Fragment2, { children: [
685
- /* @__PURE__ */ jsxs3("div", { children: [
686
- /* @__PURE__ */ jsx5(Label, { children: t("media.dialog.aspectRatio") }),
687
- /* @__PURE__ */ jsx5("div", { className: "mt-2 flex flex-wrap gap-2", children: ASPECT_CHOICES.map((choice) => /* @__PURE__ */ jsx5(
688
- Button3,
689
- {
690
- type: "button",
691
- variant: aspect === choice ? "default" : "outline",
692
- size: "sm",
693
- disabled: busy,
694
- onClick: () => setAspect(choice),
695
- children: choice
696
- },
697
- choice
698
- )) })
699
- ] }),
700
- /* @__PURE__ */ jsxs3("div", { children: [
701
- /* @__PURE__ */ jsx5(Label, { children: t("media.dialog.outputFormat") }),
702
- /* @__PURE__ */ jsx5("div", { className: "mt-2 flex flex-wrap gap-2", children: FORMAT_CHOICES.map((choice) => /* @__PURE__ */ jsx5(
703
- Button3,
704
- {
705
- type: "button",
706
- variant: formatChoice === choice ? "default" : "outline",
707
- size: "sm",
708
- disabled: busy,
709
- onClick: () => {
710
- setFormatChoice(choice);
711
- setLosslessOverride(null);
712
- },
713
- children: choice
714
- },
715
- choice
716
- )) })
717
- ] }),
718
- showLosslessToggle && /* @__PURE__ */ jsxs3("label", { className: "flex items-center gap-2 text-sm", children: [
719
- /* @__PURE__ */ jsx5(
720
- "input",
721
- {
722
- type: "checkbox",
723
- checked: lossless,
724
- disabled: busy,
725
- onChange: (e) => setLosslessOverride(e.target.checked)
726
- }
727
- ),
728
- /* @__PURE__ */ jsx5("span", { children: t("media.dialog.losslessWebp") })
729
- ] }),
730
- showQualitySlider && /* @__PURE__ */ jsxs3("div", { children: [
731
- /* @__PURE__ */ jsx5(Label, { children: t("media.dialog.quality", { value: Math.round(quality * 100) }) }),
732
- /* @__PURE__ */ jsx5(
733
- "input",
734
- {
735
- type: "range",
736
- min: 50,
737
- max: 100,
738
- step: 1,
739
- disabled: busy,
740
- value: Math.round(quality * 100),
741
- onChange: (e) => setQuality(Number(e.target.value) / 100),
742
- className: "mt-2 w-full"
743
- }
744
- )
745
- ] }),
746
- /* @__PURE__ */ jsxs3("div", { className: "max-w-xs", children: [
747
- /* @__PURE__ */ jsx5(Label, { htmlFor: "maxDimension", children: t("media.dialog.maxDimension") }),
748
- /* @__PURE__ */ jsx5("div", { className: "mt-2 flex flex-wrap gap-2", children: MAX_DIMENSION_PRESETS.map((preset) => /* @__PURE__ */ jsx5(
749
- Button3,
750
- {
751
- type: "button",
752
- variant: maxDimension === preset ? "default" : "outline",
753
- size: "sm",
754
- disabled: busy,
755
- onClick: () => setMaxDimension(preset),
756
- children: preset
757
- },
758
- preset
759
- )) }),
760
- /* @__PURE__ */ jsx5(
761
- Input,
762
- {
763
- id: "maxDimension",
764
- type: "number",
765
- className: "mt-2",
766
- min: MIN_DIMENSION,
767
- max: MAX_DIMENSION_CEILING,
768
- disabled: busy,
769
- value: maxDimension,
770
- onChange: (e) => setMaxDimension(Number(e.target.value) || defaultMaxDimension)
771
- }
772
- )
773
- ] })
774
- ] })
775
- ] }),
776
- /* @__PURE__ */ jsxs3("div", { className: "flex justify-end gap-2", children: [
777
- /* @__PURE__ */ jsx5(Button3, { variant: "ghost", type: "button", onClick: onCancel, children: t("media.dialog.cancelAll") }),
778
- /* @__PURE__ */ jsx5(Button3, { variant: "outline", type: "button", disabled: busy, onClick: onSkip, children: t("media.dialog.skip") }),
779
- /* @__PURE__ */ jsx5(Button3, { type: "button", disabled: busy, onClick: handleConfirm, children: busy ? t("media.dialog.uploadingButton") : t("media.dialog.upload") })
780
- ] })
781
- ] })
782
- }
783
- );
784
- }
785
- function formatBytes(bytes) {
786
- if (bytes < 1024) return `${bytes} B`;
787
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
788
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
789
- }
790
-
791
- // src/components/media-picker.tsx
792
- import { useEffect as useEffect4, useRef as useRef2, useState as useState4 } from "react";
793
- import { list } from "aws-amplify/storage";
794
- import { Upload } from "lucide-react";
795
- import {
796
- Dialog as Dialog2,
797
- DialogContent as DialogContent2,
798
- DialogDescription as DialogDescription2,
799
- DialogHeader as DialogHeader2,
800
- DialogTitle as DialogTitle2,
801
- DialogTrigger,
802
- Button as Button4
803
- } from "@ampless/runtime/ui";
804
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
805
- function MediaPicker({ trigger, onSelect }) {
806
- const t = useT();
807
- const [open, setOpen] = useState4(false);
808
- const [items, setItems] = useState4([]);
809
- const [loading, setLoading] = useState4(false);
810
- const [error, setError] = useState4(null);
811
- const [pendingUpload, setPendingUpload] = useState4(null);
812
- const [uploading, setUploading] = useState4(false);
813
- const fileInputRef = useRef2(null);
814
- useEffect4(() => {
815
- if (!open) return;
816
- let cancelled = false;
817
- setLoading(true);
818
- setError(null);
819
- list({ path: "public/media/" }).then((result) => {
820
- if (cancelled) return;
821
- setItems(result.items.map((i) => i.path));
822
- }).catch((err) => {
823
- if (!cancelled) setError(err instanceof Error ? err.message : String(err));
824
- }).finally(() => {
825
- if (!cancelled) setLoading(false);
826
- });
827
- return () => {
828
- cancelled = true;
829
- };
830
- }, [open]);
831
- function handlePick(path) {
832
- onSelect(publicMediaUrl(path));
833
- setOpen(false);
834
- }
835
- function handleFileSelected(e) {
836
- const file = e.target.files?.[0];
837
- e.target.value = "";
838
- if (!file) return;
839
- setPendingUpload(file);
840
- }
841
- async function handleUploadConfirm(file, options) {
842
- setUploading(true);
843
- setError(null);
844
- try {
845
- const { url } = await uploadProcessedImage(file, options);
846
- onSelect(url);
847
- setPendingUpload(null);
848
- setOpen(false);
849
- } catch (err) {
850
- setError(err instanceof Error ? err.message : String(err));
851
- } finally {
852
- setUploading(false);
853
- }
854
- }
855
- const pickerOpen = open && !pendingUpload;
856
- return /* @__PURE__ */ jsxs4(Fragment3, { children: [
857
- /* @__PURE__ */ jsxs4(Dialog2, { open: pickerOpen, onOpenChange: (next) => setOpen(next), children: [
858
- /* @__PURE__ */ jsx6(DialogTrigger, { asChild: true, onClick: () => setOpen(true), children: trigger }),
859
- /* @__PURE__ */ jsxs4(DialogContent2, { children: [
860
- /* @__PURE__ */ jsx6(DialogHeader2, { children: /* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-between gap-4", children: [
861
- /* @__PURE__ */ jsxs4("div", { children: [
862
- /* @__PURE__ */ jsx6(DialogTitle2, { children: t("mediaPicker.title") }),
863
- /* @__PURE__ */ jsx6(DialogDescription2, { children: t("mediaPicker.description") })
864
- ] }),
865
- /* @__PURE__ */ jsxs4(
866
- Button4,
867
- {
868
- type: "button",
869
- variant: "outline",
870
- size: "sm",
871
- onClick: () => fileInputRef.current?.click(),
872
- children: [
873
- /* @__PURE__ */ jsx6(Upload, { className: "mr-2 h-3 w-3" }),
874
- t("mediaPicker.uploadNew")
875
- ]
876
- }
877
- )
878
- ] }) }),
879
- /* @__PURE__ */ jsx6(
880
- "input",
881
- {
882
- ref: fileInputRef,
883
- type: "file",
884
- accept: "image/*",
885
- className: "hidden",
886
- onChange: handleFileSelected
887
- }
888
- ),
889
- loading && /* @__PURE__ */ jsx6("p", { className: "text-sm text-muted-foreground", children: t("common.loading") }),
890
- error && /* @__PURE__ */ jsx6("p", { className: "text-sm text-destructive", children: error }),
891
- !loading && items.length === 0 && /* @__PURE__ */ jsxs4("p", { className: "text-sm text-muted-foreground", children: [
892
- t("mediaPicker.empty"),
893
- " ",
894
- t("mediaPicker.emptyHint", { action: t("mediaPicker.emptyAction") })
895
- ] }),
896
- /* @__PURE__ */ jsx6("div", { className: "grid max-h-[60vh] grid-cols-3 gap-3 overflow-auto sm:grid-cols-4", children: items.map((path) => /* @__PURE__ */ jsxs4(
897
- "button",
898
- {
899
- type: "button",
900
- onClick: () => handlePick(path),
901
- className: "group overflow-hidden rounded-md border transition hover:border-primary",
902
- children: [
903
- /* @__PURE__ */ jsx6(
904
- "img",
905
- {
906
- src: publicMediaUrl(path),
907
- alt: path,
908
- className: "aspect-square w-full object-cover"
909
- }
910
- ),
911
- /* @__PURE__ */ jsx6("div", { className: "truncate p-1 text-xs text-muted-foreground", children: path.split("/").pop() })
912
- ]
913
- },
914
- path
915
- )) })
916
- ] })
917
- ] }),
918
- /* @__PURE__ */ jsx6(
919
- ImageUploadDialog,
920
- {
921
- file: pendingUpload,
922
- remaining: pendingUpload ? 1 : 0,
923
- busy: uploading,
924
- defaults: getMediaProcessingDefaults(),
925
- onConfirm: handleUploadConfirm,
926
- onSkip: () => setPendingUpload(null),
927
- onCancel: () => setPendingUpload(null)
928
- }
929
- )
930
- ] });
931
- }
932
-
933
- // src/components/post-form.tsx
934
- import { useRef as useRef3, useState as useState6 } from "react";
935
- import { useRouter } from "next/navigation";
936
- import { Image as ImageIcon3 } from "lucide-react";
937
- import {
938
- createPost,
939
- updatePost,
940
- deletePost,
941
- formatDate
942
- } from "ampless";
943
- import {
944
- renderBody,
945
- tiptapToHtml,
946
- tiptapToMarkdown,
947
- markdownToHtml,
948
- htmlToMarkdown
949
- } from "@ampless/runtime";
950
- import { Button as Button8, Input as Input2, Label as Label2, Textarea } from "@ampless/runtime/ui";
951
-
952
- // src/editor/tiptap-editor.tsx
953
- import { useEditor, EditorContent } from "@tiptap/react";
954
- import StarterKit from "@tiptap/starter-kit";
955
- import Link3 from "@tiptap/extension-link";
956
- import Image from "@tiptap/extension-image";
957
-
958
- // src/editor/toolbar.tsx
959
- import {
960
- Bold,
961
- Italic,
962
- Heading1,
963
- Heading2,
964
- List,
965
- ListOrdered,
966
- Code,
967
- Link as LinkIcon,
968
- Image as ImageIcon
969
- } from "lucide-react";
970
- import { Button as Button5, cn } from "@ampless/runtime/ui";
971
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
972
- function Toolbar({ editor }) {
973
- const t = useT();
974
- if (!editor) return null;
975
- const tools = [
976
- { name: "bold", icon: Bold, action: () => editor.chain().focus().toggleBold().run(), isActive: () => editor.isActive("bold") },
977
- { name: "italic", icon: Italic, action: () => editor.chain().focus().toggleItalic().run(), isActive: () => editor.isActive("italic") },
978
- { name: "h1", icon: Heading1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: () => editor.isActive("heading", { level: 1 }) },
979
- { name: "h2", icon: Heading2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => editor.isActive("heading", { level: 2 }) },
980
- { name: "bulletList", icon: List, action: () => editor.chain().focus().toggleBulletList().run(), isActive: () => editor.isActive("bulletList") },
981
- { name: "orderedList", icon: ListOrdered, action: () => editor.chain().focus().toggleOrderedList().run(), isActive: () => editor.isActive("orderedList") },
982
- { name: "code", icon: Code, action: () => editor.chain().focus().toggleCodeBlock().run(), isActive: () => editor.isActive("codeBlock") }
983
- ];
984
- const setLink = () => {
985
- const previousUrl = editor.getAttributes("link").href ?? "";
986
- const url = window.prompt(t("editor.linkPrompt"), previousUrl);
987
- if (url === null) return;
988
- if (url === "") {
989
- editor.chain().focus().extendMarkRange("link").unsetLink().run();
990
- return;
991
- }
992
- editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
993
- };
994
- const insertImage = (url) => {
995
- editor.chain().focus().setImage({ src: url }).run();
996
- };
997
- return /* @__PURE__ */ jsxs5("div", { className: "flex flex-wrap gap-1 border-b p-2", children: [
998
- tools.map((tool) => {
999
- const Icon = tool.icon;
1000
- return /* @__PURE__ */ jsx7(
1001
- Button5,
1002
- {
1003
- type: "button",
1004
- variant: "ghost",
1005
- size: "icon",
1006
- onClick: tool.action,
1007
- className: cn(tool.isActive() && "bg-accent text-accent-foreground"),
1008
- children: /* @__PURE__ */ jsx7(Icon, { className: "h-4 w-4" })
1009
- },
1010
- tool.name
1011
- );
1012
- }),
1013
- /* @__PURE__ */ jsx7(
1014
- Button5,
1015
- {
1016
- type: "button",
1017
- variant: "ghost",
1018
- size: "icon",
1019
- onClick: setLink,
1020
- className: cn(editor.isActive("link") && "bg-accent text-accent-foreground"),
1021
- children: /* @__PURE__ */ jsx7(LinkIcon, { className: "h-4 w-4" })
1022
- }
1023
- ),
1024
- /* @__PURE__ */ jsx7(
1025
- MediaPicker,
1026
- {
1027
- onSelect: insertImage,
1028
- trigger: /* @__PURE__ */ jsx7(Button5, { type: "button", variant: "ghost", size: "icon", children: /* @__PURE__ */ jsx7(ImageIcon, { className: "h-4 w-4" }) })
1029
- }
1030
- )
1031
- ] });
1032
- }
1033
-
1034
- // src/editor/image-bubble-menu.tsx
1035
- import { BubbleMenu } from "@tiptap/react/menus";
1036
- import { Trash2, Pencil, ImageIcon as ImageIcon2, Maximize2 } from "lucide-react";
1037
- import { Button as Button6, cn as cn2 } from "@ampless/runtime/ui";
1038
- import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1039
- function ImageBubbleMenu({ editor }) {
1040
- const t = useT();
1041
- const editAlt = () => {
1042
- const current = editor.getAttributes("image").alt ?? "";
1043
- const alt = window.prompt(t("editor.altPrompt"), current);
1044
- if (alt === null) return;
1045
- editor.chain().focus().updateAttributes("image", { alt }).run();
1046
- };
1047
- const remove3 = () => {
1048
- editor.chain().focus().deleteSelection().run();
1049
- };
1050
- const setDisplay = (display) => {
1051
- const current = editor.getAttributes("image").display ?? null;
1052
- const next = current === display ? null : display;
1053
- editor.chain().focus().updateAttributes("image", { display: next }).run();
1054
- };
1055
- const currentDisplay = editor.getAttributes("image").display ?? null;
1056
- return /* @__PURE__ */ jsx8(
1057
- BubbleMenu,
1058
- {
1059
- editor,
1060
- shouldShow: ({ editor: editor2 }) => editor2.isActive("image"),
1061
- options: { placement: "top" },
1062
- children: /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1 rounded-md border bg-popover p-1 shadow", children: [
1063
- /* @__PURE__ */ jsxs6(
1064
- Button6,
1065
- {
1066
- type: "button",
1067
- variant: "ghost",
1068
- size: "sm",
1069
- onClick: () => setDisplay("inline"),
1070
- className: cn2(currentDisplay === "inline" && "bg-accent text-accent-foreground"),
1071
- title: t("editor.image.inlineTitle"),
1072
- children: [
1073
- /* @__PURE__ */ jsx8(ImageIcon2, { className: "mr-1 h-3 w-3" }),
1074
- t("editor.image.inline")
1075
- ]
1076
- }
1077
- ),
1078
- /* @__PURE__ */ jsxs6(
1079
- Button6,
1080
- {
1081
- type: "button",
1082
- variant: "ghost",
1083
- size: "sm",
1084
- onClick: () => setDisplay("lightbox"),
1085
- className: cn2(currentDisplay === "lightbox" && "bg-accent text-accent-foreground"),
1086
- title: t("editor.image.lightboxTitle"),
1087
- children: [
1088
- /* @__PURE__ */ jsx8(Maximize2, { className: "mr-1 h-3 w-3" }),
1089
- t("editor.image.lightbox")
1090
- ]
1091
- }
1092
- ),
1093
- /* @__PURE__ */ jsx8("span", { className: "mx-1 h-4 w-px bg-border" }),
1094
- /* @__PURE__ */ jsxs6(Button6, { type: "button", variant: "ghost", size: "sm", onClick: editAlt, children: [
1095
- /* @__PURE__ */ jsx8(Pencil, { className: "mr-1 h-3 w-3" }),
1096
- t("editor.image.alt")
1097
- ] }),
1098
- /* @__PURE__ */ jsxs6(Button6, { type: "button", variant: "ghost", size: "sm", onClick: remove3, children: [
1099
- /* @__PURE__ */ jsx8(Trash2, { className: "mr-1 h-3 w-3" }),
1100
- t("editor.image.delete")
1101
- ] })
1102
- ] })
1103
- }
1104
- );
1105
- }
1106
-
1107
- // src/editor/tiptap-editor.tsx
1108
- import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1109
- var AmplessImage = Image.extend({
1110
- addAttributes() {
1111
- return {
1112
- ...this.parent?.(),
1113
- display: {
1114
- default: null,
1115
- parseHTML: (el) => el.getAttribute("data-display"),
1116
- renderHTML: (attrs) => {
1117
- const v = attrs.display;
1118
- return v ? { "data-display": v } : {};
1119
- }
1120
- }
1121
- };
1122
- }
1123
- });
1124
- function TiptapEditor({ initialContent, onChange }) {
1125
- const editor = useEditor({
1126
- extensions: [
1127
- StarterKit,
1128
- Link3.configure({ openOnClick: false }),
1129
- AmplessImage.configure({ inline: false, allowBase64: false })
1130
- ],
1131
- content: initialContent ?? { type: "doc", content: [{ type: "paragraph" }] },
1132
- immediatelyRender: false,
1133
- editorProps: {
1134
- attributes: {
1135
- class: "prose prose-neutral dark:prose-invert max-w-none min-h-[400px] px-4 py-3 focus:outline-none"
1136
- }
1137
- },
1138
- onCreate: ({ editor: editor2 }) => {
1139
- onChange?.(editor2.getJSON());
1140
- },
1141
- onUpdate: ({ editor: editor2 }) => {
1142
- onChange?.(editor2.getJSON());
1143
- }
1144
- });
1145
- return /* @__PURE__ */ jsxs7("div", { className: "rounded-md border", children: [
1146
- /* @__PURE__ */ jsx9(Toolbar, { editor }),
1147
- editor && /* @__PURE__ */ jsx9(ImageBubbleMenu, { editor }),
1148
- /* @__PURE__ */ jsx9(EditorContent, { editor })
1149
- ] });
1150
- }
1151
-
1152
- // src/components/static-uploader.tsx
1153
- import { useState as useState5 } from "react";
1154
- import { FileText, AlertTriangle, FileArchive, X } from "lucide-react";
1155
- import { Button as Button7 } from "@ampless/runtime/ui";
1156
-
1157
- // src/lib/static-bundle.ts
1158
- import JSZip from "jszip";
1159
- import { uploadData as uploadData2, list as list2, remove } from "aws-amplify/storage";
1160
- var DEFAULT_ENTRYPOINT = "index.html";
1161
- var MAX_BUNDLE_BYTES = 50 * 1024 * 1024;
1162
- var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm", ".css", ".svg"]);
1163
- var MIME_TYPES = {
1164
- ".html": "text/html; charset=utf-8",
1165
- ".htm": "text/html; charset=utf-8",
1166
- ".css": "text/css; charset=utf-8",
1167
- ".js": "application/javascript; charset=utf-8",
1168
- ".mjs": "application/javascript; charset=utf-8",
1169
- ".json": "application/json; charset=utf-8",
1170
- ".svg": "image/svg+xml",
1171
- ".png": "image/png",
1172
- ".jpg": "image/jpeg",
1173
- ".jpeg": "image/jpeg",
1174
- ".gif": "image/gif",
1175
- ".webp": "image/webp",
1176
- ".avif": "image/avif",
1177
- ".ico": "image/x-icon",
1178
- ".woff": "font/woff",
1179
- ".woff2": "font/woff2",
1180
- ".ttf": "font/ttf",
1181
- ".otf": "font/otf",
1182
- ".eot": "application/vnd.ms-fontobject",
1183
- ".txt": "text/plain; charset=utf-8",
1184
- ".xml": "application/xml; charset=utf-8",
1185
- ".map": "application/json; charset=utf-8",
1186
- ".pdf": "application/pdf"
1187
- };
1188
- function mimeTypeFor(path) {
1189
- const lower = path.toLowerCase();
1190
- const dot = lower.lastIndexOf(".");
1191
- if (dot < 0) return "application/octet-stream";
1192
- return MIME_TYPES[lower.slice(dot)] ?? "application/octet-stream";
1193
- }
1194
- function validateBundlePath(path) {
1195
- if (path === "" || path.endsWith("/")) return "directory entry";
1196
- if (path.includes("\0")) return "contains null byte";
1197
- if (path.startsWith("/") || path.startsWith("\\")) return "absolute path";
1198
- if (path.split(/[/\\]/).some((seg) => seg === "..")) return "parent-directory traversal";
1199
- if (path.startsWith("__MACOSX/") || /(^|\/)\._/.test(path)) return "macOS resource fork";
1200
- if (/(^|\/)\.DS_Store$/.test(path)) return ".DS_Store junk";
1201
- if (/(^|\/)Thumbs\.db$/i.test(path)) return "Thumbs.db junk";
1202
- return null;
1203
- }
1204
- var HTML_URL_ATTR_RE = /\b(?:href|src|action|data|poster|cite|formaction|manifest|srcset)\s*=\s*["']([^"']+)["']/gi;
1205
- var CSS_URL_RE = /url\(\s*["']?([^"')\s]+)["']?\s*\)|@import\s+["']([^"']+)["']/gi;
1206
- function findAbsolutePathRefs(path, content) {
1207
- const ext = path.toLowerCase().slice(path.lastIndexOf("."));
1208
- if (!TEXT_EXTENSIONS.has(ext)) return [];
1209
- const issues = [];
1210
- function check(url, lineHint) {
1211
- const trimmed = url.trim();
1212
- if (!trimmed) return;
1213
- if (trimmed.startsWith("#")) return;
1214
- if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return;
1215
- if (trimmed.startsWith("//")) {
1216
- issues.push({ path, reason: `protocol-relative URL: ${lineHint}` });
1217
- return;
1218
- }
1219
- if (trimmed.startsWith("/")) {
1220
- issues.push({ path, reason: `absolute path: ${lineHint}` });
1221
- return;
1222
- }
1223
- }
1224
- if (ext === ".html" || ext === ".htm" || ext === ".svg") {
1225
- HTML_URL_ATTR_RE.lastIndex = 0;
1226
- let m;
1227
- while ((m = HTML_URL_ATTR_RE.exec(content)) !== null) {
1228
- const val = m[1] ?? "";
1229
- if (/\bsrcset\s*=/i.test(m[0])) {
1230
- for (const candidate of val.split(",")) {
1231
- const urlPart = candidate.trim().split(/\s+/)[0];
1232
- if (urlPart) check(urlPart, candidate.trim());
1233
- }
1234
- } else {
1235
- check(val, m[0]);
1236
- }
1237
- }
1238
- }
1239
- if (ext === ".css" || ext === ".svg") {
1240
- CSS_URL_RE.lastIndex = 0;
1241
- let m;
1242
- while ((m = CSS_URL_RE.exec(content)) !== null) {
1243
- const val = (m[1] ?? m[2] ?? "").trim();
1244
- check(val, m[0]);
1245
- }
1246
- }
1247
- return issues;
1248
- }
1249
- async function extractZip(file) {
1250
- const zip = await JSZip.loadAsync(file);
1251
- const files = [];
1252
- const issues = [];
1253
- let totalBytes = 0;
1254
- for (const entry of Object.values(zip.files)) {
1255
- if (entry.dir) continue;
1256
- const reason = validateBundlePath(entry.name);
1257
- if (reason) {
1258
- const silent = reason === "macOS resource fork" || reason === ".DS_Store junk" || reason === "Thumbs.db junk";
1259
- if (!silent) issues.push({ path: entry.name, reason });
1260
- continue;
1261
- }
1262
- const data = await entry.async("uint8array");
1263
- totalBytes += data.byteLength;
1264
- files.push({ path: entry.name, data });
1265
- }
1266
- return { files: stripCommonPrefix(files), issues, totalBytes };
1267
- }
1268
- function stripCommonPrefix(files) {
1269
- if (files.length === 0) return files;
1270
- const firstSlash = files[0].path.indexOf("/");
1271
- if (firstSlash < 0) return files;
1272
- const prefix = files[0].path.slice(0, firstSlash + 1);
1273
- if (!files.every((f) => f.path.startsWith(prefix))) return files;
1274
- return files.map((f) => ({ ...f, path: f.path.slice(prefix.length) }));
1275
- }
1276
- function validateBundle(files) {
1277
- const issues = [];
1278
- const decoder = new TextDecoder("utf-8", { fatal: false });
1279
- for (const f of files) {
1280
- const ext = f.path.toLowerCase().slice(f.path.lastIndexOf("."));
1281
- if (!TEXT_EXTENSIONS.has(ext)) continue;
1282
- const text = decoder.decode(f.data);
1283
- issues.push(...findAbsolutePathRefs(f.path, text));
1284
- }
1285
- return issues;
1286
- }
1287
- function bundlePrefix(siteId, slug) {
1288
- return `public/static/${siteId}/${slug}/`;
1289
- }
1290
- async function uploadBundle(opts) {
1291
- if (opts.files.length === 0) {
1292
- throw new Error("Bundle is empty.");
1293
- }
1294
- const totalBytes = opts.files.reduce((sum, f) => sum + f.data.byteLength, 0);
1295
- if (totalBytes > MAX_BUNDLE_BYTES) {
1296
- throw new Error(
1297
- `Bundle too large: ${Math.round(totalBytes / 1024 / 1024)} MB exceeds the ${Math.round(MAX_BUNDLE_BYTES / 1024 / 1024)} MB ceiling for browser-side upload.`
1298
- );
1299
- }
1300
- const entrypoint = opts.entrypoint ?? pickDefaultEntrypoint(opts.files);
1301
- if (!opts.files.some((f) => f.path === entrypoint)) {
1302
- throw new Error(`Entrypoint "${entrypoint}" is not present in the bundle.`);
1303
- }
1304
- await deleteBundle(opts.siteId, opts.slug).catch(() => void 0);
1305
- const prefix = bundlePrefix(opts.siteId, opts.slug);
1306
- let uploaded = 0;
1307
- for (const f of opts.files) {
1308
- const task = uploadData2({
1309
- path: `${prefix}${f.path}`,
1310
- data: f.data,
1311
- // Forcing Content-Type at upload means CloudFront / browsers see
1312
- // it directly when serving the file via the public bucket URL.
1313
- // (The runtime route handler overrides it for the proxied path,
1314
- // but tooling that hits S3 directly benefits from a correct CT.)
1315
- options: { contentType: mimeTypeFor(f.path) }
1316
- });
1317
- await task.result;
1318
- uploaded += f.data.byteLength;
1319
- opts.onProgress?.(uploaded, totalBytes);
1320
- }
1321
- return {
1322
- entrypoint,
1323
- files: opts.files.map((f) => f.path).sort(),
1324
- uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1325
- };
1326
- }
1327
- async function deleteBundle(siteId, slug) {
1328
- const prefix = bundlePrefix(siteId, slug);
1329
- const result = await list2({ path: prefix });
1330
- for (const item of result.items) {
1331
- await remove({ path: item.path });
1332
- }
1333
- }
1334
- function pickDefaultEntrypoint(files) {
1335
- const exact = files.find((f) => f.path === DEFAULT_ENTRYPOINT);
1336
- if (exact) return exact.path;
1337
- const altRoot = files.find((f) => f.path === "index.htm");
1338
- if (altRoot) return altRoot.path;
1339
- const htmlRoot = files.filter((f) => /^[^/]+\.html?$/.test(f.path)).sort((a, b) => a.path.localeCompare(b.path));
1340
- if (htmlRoot.length > 0) return htmlRoot[0].path;
1341
- const htmlAny = files.filter((f) => /\.html?$/.test(f.path)).sort((a, b) => a.path.localeCompare(b.path));
1342
- if (htmlAny.length > 0) return htmlAny[0].path;
1343
- return DEFAULT_ENTRYPOINT;
1344
- }
1345
-
1346
- // src/components/static-uploader.tsx
1347
- import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
1348
- function StaticUploader({ initial, onFilesReady, onClear }) {
1349
- const t = useT();
1350
- const [pending, setPending] = useState5(null);
1351
- const [issues, setIssues] = useState5([]);
1352
- const [busy, setBusy] = useState5(false);
1353
- const [error, setError] = useState5(null);
1354
- async function handleZip(file) {
1355
- setBusy(true);
1356
- setError(null);
1357
- try {
1358
- const { files, issues: structuralIssues } = await extractZip(file);
1359
- if (files.length === 0) {
1360
- setError(t("posts.form.static.emptyBundle"));
1361
- return;
1362
- }
1363
- const contentIssues = validateBundle(files);
1364
- const all = [...structuralIssues, ...contentIssues];
1365
- setPending(files);
1366
- setIssues(all);
1367
- if (all.length === 0) {
1368
- onFilesReady(files, guessEntrypoint(files));
1369
- }
1370
- } catch (e) {
1371
- setError(e instanceof Error ? e.message : String(e));
1372
- } finally {
1373
- setBusy(false);
1374
- }
1375
- }
1376
- async function handleLooseFiles(files) {
1377
- setBusy(true);
1378
- setError(null);
1379
- try {
1380
- const extracted = [];
1381
- const structural = [];
1382
- for (const f of Array.from(files)) {
1383
- const rel = f.webkitRelativePath ?? f.name;
1384
- const stripped = rel.includes("/") ? rel.slice(rel.indexOf("/") + 1) : rel;
1385
- const reason = validateBundlePath(stripped);
1386
- if (reason) {
1387
- structural.push({ path: stripped, reason });
1388
- continue;
1389
- }
1390
- const buf = new Uint8Array(await f.arrayBuffer());
1391
- extracted.push({ path: stripped, data: buf });
1392
- }
1393
- if (extracted.length === 0) {
1394
- setError(t("posts.form.static.emptyBundle"));
1395
- return;
1396
- }
1397
- const content = validateBundle(extracted);
1398
- const all = [...structural, ...content];
1399
- setPending(extracted);
1400
- setIssues(all);
1401
- if (all.length === 0) {
1402
- onFilesReady(extracted, guessEntrypoint(extracted));
1403
- }
1404
- } catch (e) {
1405
- setError(e instanceof Error ? e.message : String(e));
1406
- } finally {
1407
- setBusy(false);
1408
- }
1409
- }
1410
- function onPickerChange(e) {
1411
- const fl = e.target.files;
1412
- if (!fl || fl.length === 0) return;
1413
- e.target.value = "";
1414
- if (fl.length === 1 && fl[0].name.toLowerCase().endsWith(".zip")) {
1415
- void handleZip(fl[0]);
1416
- } else {
1417
- void handleLooseFiles(fl);
1418
- }
1419
- }
1420
- function clearPending() {
1421
- setPending(null);
1422
- setIssues([]);
1423
- setError(null);
1424
- onClear();
1425
- }
1426
- const showCurrent = !pending && initial && initial.files.length > 0;
1427
- return /* @__PURE__ */ jsxs8("div", { className: "space-y-4", children: [
1428
- /* @__PURE__ */ jsxs8("div", { className: "rounded-md border border-dashed p-4", children: [
1429
- /* @__PURE__ */ jsxs8("label", { className: "flex flex-col items-start gap-2 text-sm", children: [
1430
- /* @__PURE__ */ jsx10("span", { className: "font-medium", children: t("posts.form.static.pick") }),
1431
- /* @__PURE__ */ jsx10(
1432
- "input",
1433
- {
1434
- type: "file",
1435
- accept: ".zip,application/zip,*/*",
1436
- multiple: true,
1437
- onChange: onPickerChange,
1438
- disabled: busy
1439
- }
1440
- ),
1441
- /* @__PURE__ */ jsx10("span", { className: "text-xs text-muted-foreground", children: t("posts.form.static.pickHint") })
1442
- ] }),
1443
- busy && /* @__PURE__ */ jsx10("p", { className: "mt-2 text-sm text-muted-foreground", children: t("common.loading") }),
1444
- error && /* @__PURE__ */ jsx10("p", { className: "mt-2 text-sm text-destructive", children: error })
1445
- ] }),
1446
- showCurrent && /* @__PURE__ */ jsx10(CurrentBundle, { body: initial }),
1447
- pending && /* @__PURE__ */ jsx10(
1448
- PendingBundle,
1449
- {
1450
- files: pending,
1451
- issues,
1452
- onClear: clearPending
1453
- }
1454
- )
1455
- ] });
1456
- }
1457
- function CurrentBundle({ body }) {
1458
- const t = useT();
1459
- return /* @__PURE__ */ jsxs8("div", { className: "rounded-md border bg-muted/30 p-3", children: [
1460
- /* @__PURE__ */ jsxs8("div", { className: "mb-2 flex items-center gap-2 text-sm font-medium", children: [
1461
- /* @__PURE__ */ jsx10(FileArchive, { className: "h-4 w-4" }),
1462
- t("posts.form.static.currentBundle", {
1463
- count: body.files.length,
1464
- entrypoint: body.entrypoint
1465
- })
1466
- ] }),
1467
- /* @__PURE__ */ jsx10(FileList, { files: body.files })
1468
- ] });
1469
- }
1470
- function PendingBundle({
1471
- files,
1472
- issues,
1473
- onClear
1474
- }) {
1475
- const t = useT();
1476
- const totalBytes = files.reduce((sum, f) => sum + f.data.byteLength, 0);
1477
- return /* @__PURE__ */ jsxs8("div", { className: "rounded-md border p-3", children: [
1478
- /* @__PURE__ */ jsxs8("div", { className: "mb-2 flex items-center justify-between gap-2 text-sm font-medium", children: [
1479
- /* @__PURE__ */ jsxs8("span", { className: "flex items-center gap-2", children: [
1480
- /* @__PURE__ */ jsx10(FileArchive, { className: "h-4 w-4" }),
1481
- t("posts.form.static.pendingBundle", {
1482
- count: files.length,
1483
- size: formatBytes2(totalBytes)
1484
- })
1485
- ] }),
1486
- /* @__PURE__ */ jsxs8(Button7, { type: "button", variant: "ghost", size: "sm", onClick: onClear, children: [
1487
- /* @__PURE__ */ jsx10(X, { className: "mr-1 h-3 w-3" }),
1488
- t("common.cancel")
1489
- ] })
1490
- ] }),
1491
- issues.length > 0 && /* @__PURE__ */ jsxs8("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-sm", children: [
1492
- /* @__PURE__ */ jsxs8("div", { className: "mb-1 flex items-center gap-2 font-medium text-destructive", children: [
1493
- /* @__PURE__ */ jsx10(AlertTriangle, { className: "h-4 w-4" }),
1494
- t("posts.form.static.issuesTitle", { count: issues.length })
1495
- ] }),
1496
- /* @__PURE__ */ jsxs8("ul", { className: "space-y-0.5 text-xs", children: [
1497
- issues.slice(0, 20).map((issue, idx) => /* @__PURE__ */ jsxs8("li", { className: "font-mono", children: [
1498
- issue.path,
1499
- ": ",
1500
- issue.reason
1501
- ] }, `${issue.path}-${idx}`)),
1502
- issues.length > 20 && /* @__PURE__ */ jsxs8("li", { className: "font-mono text-muted-foreground", children: [
1503
- "\u2026 ",
1504
- issues.length - 20,
1505
- " more"
1506
- ] })
1507
- ] }),
1508
- /* @__PURE__ */ jsx10("p", { className: "mt-2 text-xs text-muted-foreground", children: t("posts.form.static.issuesHint") })
1509
- ] }),
1510
- /* @__PURE__ */ jsx10(FileList, { files: files.map((f) => f.path) })
1511
- ] });
1512
- }
1513
- function FileList({ files }) {
1514
- return /* @__PURE__ */ jsxs8("ul", { className: "space-y-0.5 text-xs", children: [
1515
- files.slice(0, 40).map((path) => /* @__PURE__ */ jsxs8("li", { className: "flex items-center gap-1.5 font-mono text-muted-foreground", children: [
1516
- /* @__PURE__ */ jsx10(FileText, { className: "h-3 w-3 shrink-0" }),
1517
- path
1518
- ] }, path)),
1519
- files.length > 40 && /* @__PURE__ */ jsxs8("li", { className: "font-mono text-xs text-muted-foreground", children: [
1520
- "\u2026 ",
1521
- files.length - 40,
1522
- " more"
1523
- ] })
1524
- ] });
1525
- }
1526
- function formatBytes2(bytes) {
1527
- if (bytes < 1024) return `${bytes} B`;
1528
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1529
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1530
- }
1531
- function guessEntrypoint(files) {
1532
- const exact = files.find((f) => f.path === "index.html");
1533
- if (exact) return "index.html";
1534
- const alt = files.find((f) => f.path === "index.htm");
1535
- if (alt) return "index.htm";
1536
- const rootHtml = files.filter((f) => /^[^/]+\.html?$/.test(f.path)).sort((a, b) => a.path.localeCompare(b.path))[0];
1537
- if (rootHtml) return rootHtml.path;
1538
- return files[0]?.path ?? "index.html";
1539
- }
1540
-
1541
- // src/components/post-form.tsx
1542
- import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
1543
- var EMPTY_TIPTAP_DOC = { type: "doc", content: [{ type: "paragraph" }] };
1544
- var IMAGE_URL_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)(\?|$)/i;
1545
- var STYLESHEET_URL_RE = /\.css(\?|$)/i;
1546
- var SCRIPT_URL_RE = /\.m?js(\?|$)/i;
1547
- function snippetFor(url, format) {
1548
- const isImage = IMAGE_URL_RE.test(url);
1549
- if (format === "markdown") {
1550
- return isImage ? `![](${url})` : url;
1551
- }
1552
- if (isImage) return `<img src="${url}" alt="" />`;
1553
- if (STYLESHEET_URL_RE.test(url)) return `<link rel="stylesheet" href="${url}" />`;
1554
- if (SCRIPT_URL_RE.test(url)) return `<script src="${url}"></script>`;
1555
- return url;
1556
- }
1557
- function slugify(s) {
1558
- return s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
1559
- }
1560
- function defaultBodyForFormat(format) {
1561
- if (format === "tiptap") return EMPTY_TIPTAP_DOC;
1562
- if (format === "static") return null;
1563
- return "";
1564
- }
1565
- function isStaticBody(value) {
1566
- return !!value && typeof value === "object" && "entrypoint" in value && "files" in value && Array.isArray(value.files);
1567
- }
1568
- function PostForm({ post }) {
1569
- const router = useRouter();
1570
- const t = useT();
1571
- const isEdit = !!post;
1572
- const bodyTextareaRef = useRef3(null);
1573
- const [title, setTitle] = useState6(post?.title ?? "");
1574
- const [slug, setSlug] = useState6(post?.slug ?? "");
1575
- const [excerpt, setExcerpt] = useState6(post?.excerpt ?? "");
1576
- const [format, setFormat] = useState6(post?.format ?? "tiptap");
1577
- const [body, setBody] = useState6(post?.body ?? EMPTY_TIPTAP_DOC);
1578
- const [status, setStatus] = useState6(post?.status ?? "draft");
1579
- const [tagsInput, setTagsInput] = useState6((post?.tags ?? []).join(", "));
1580
- const [noLayout, setNoLayout] = useState6(post?.metadata?.no_layout === true);
1581
- const [saving, setSaving] = useState6(false);
1582
- const [error, setError] = useState6(null);
1583
- const [view, setView] = useState6("edit");
1584
- const [pendingBundle, setPendingBundle] = useState6(null);
1585
- const initialStaticBody = isStaticBody(post?.body) ? post.body : null;
1586
- function buildMetadata() {
1587
- const next = { ...post?.metadata ?? {} };
1588
- if (noLayout && format === "html") next.no_layout = true;
1589
- else delete next.no_layout;
1590
- return Object.keys(next).length > 0 ? next : void 0;
1591
- }
1592
- function parseTags(raw) {
1593
- return Array.from(
1594
- new Set(
1595
- raw.split(",").map((t2) => t2.trim()).filter(Boolean)
1596
- )
1597
- );
1598
- }
1599
- function insertMediaSnippet(url) {
1600
- if (format === "tiptap") return;
1601
- const snippet = snippetFor(url, format);
1602
- const ta = bodyTextareaRef.current;
1603
- const current = typeof body === "string" ? body : "";
1604
- if (!ta) {
1605
- setBody(current + snippet);
1606
- return;
1607
- }
1608
- const start = ta.selectionStart ?? current.length;
1609
- const end = ta.selectionEnd ?? current.length;
1610
- const next = current.slice(0, start) + snippet + current.slice(end);
1611
- setBody(next);
1612
- requestAnimationFrame(() => {
1613
- const t2 = bodyTextareaRef.current;
1614
- if (!t2) return;
1615
- t2.focus();
1616
- const pos = start + snippet.length;
1617
- t2.setSelectionRange(pos, pos);
1618
- });
1619
- }
1620
- function changeFormat(next) {
1621
- if (next === format) return;
1622
- let nextBody = body;
1623
- if (next === "static" || format === "static") {
1624
- nextBody = defaultBodyForFormat(next);
1625
- } else {
1626
- const k = `${format}\u2192${next}`;
1627
- switch (k) {
1628
- case "tiptap\u2192html":
1629
- nextBody = tiptapToHtml(body);
1630
- break;
1631
- case "tiptap\u2192markdown":
1632
- nextBody = tiptapToMarkdown(body);
1633
- break;
1634
- case "html\u2192tiptap":
1635
- nextBody = String(body ?? "");
1636
- break;
1637
- case "markdown\u2192tiptap":
1638
- nextBody = markdownToHtml(String(body ?? ""));
1639
- break;
1640
- case "html\u2192markdown":
1641
- nextBody = htmlToMarkdown(String(body ?? ""));
1642
- break;
1643
- case "markdown\u2192html":
1644
- nextBody = markdownToHtml(String(body ?? ""));
1645
- break;
1646
- default:
1647
- nextBody = defaultBodyForFormat(next);
1648
- }
1649
- }
1650
- setFormat(next);
1651
- setBody(nextBody);
1652
- setPendingBundle(null);
1653
- if (next !== "html") setNoLayout(false);
1654
- }
1655
- async function save(e) {
1656
- e.preventDefault();
1657
- setSaving(true);
1658
- setError(null);
1659
- try {
1660
- const tags = parseTags(tagsInput);
1661
- const metadata = buildMetadata();
1662
- const finalSlug = slug || slugify(title);
1663
- const finalSiteId = post?.siteId ?? readAdminSiteIdFromCookie();
1664
- let nextBody = body;
1665
- if (format === "static") {
1666
- if (pendingBundle) {
1667
- nextBody = await uploadBundle({
1668
- siteId: finalSiteId,
1669
- slug: finalSlug,
1670
- files: pendingBundle.files,
1671
- entrypoint: pendingBundle.entrypoint
1672
- });
1673
- } else if (initialStaticBody) {
1674
- nextBody = initialStaticBody;
1675
- } else {
1676
- throw new Error(t("posts.form.static.noBundle"));
1677
- }
1678
- }
1679
- if (isEdit) {
1680
- await updatePost(
1681
- post.postId,
1682
- {
1683
- title,
1684
- slug: finalSlug,
1685
- excerpt: excerpt || void 0,
1686
- format,
1687
- body: nextBody,
1688
- status,
1689
- publishedAt: status === "published" ? post?.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1690
- tags,
1691
- metadata
1692
- },
1693
- { siteId: post.siteId }
1694
- );
1695
- } else {
1696
- await createPost({
1697
- siteId: finalSiteId,
1698
- slug: finalSlug,
1699
- title,
1700
- excerpt: excerpt || void 0,
1701
- format,
1702
- body: nextBody,
1703
- status,
1704
- publishedAt: status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1705
- tags,
1706
- metadata
1707
- });
1708
- }
1709
- router.push("/admin/posts");
1710
- router.refresh();
1711
- } catch (err) {
1712
- setError(err instanceof Error ? err.message : String(err));
1713
- } finally {
1714
- setSaving(false);
1715
- }
1716
- }
1717
- async function handleDelete() {
1718
- if (!post) return;
1719
- if (!confirm(t("posts.form.deleteConfirm", { title: post.title }))) return;
1720
- setSaving(true);
1721
- try {
1722
- if (post.format === "static") {
1723
- await deleteBundle(post.siteId, post.slug).catch(() => void 0);
1724
- }
1725
- await deletePost(post.postId);
1726
- router.push("/admin/posts");
1727
- router.refresh();
1728
- } catch (err) {
1729
- setError(err instanceof Error ? err.message : String(err));
1730
- setSaving(false);
1731
- }
1732
- }
1733
- const previewPost = {
1734
- postId: post?.postId ?? "preview",
1735
- siteId: post?.siteId ?? readAdminSiteIdFromCookie(),
1736
- slug: slug || slugify(title) || "preview",
1737
- title,
1738
- excerpt: excerpt || void 0,
1739
- format,
1740
- body,
1741
- status,
1742
- publishedAt: status === "published" ? post?.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1743
- tags: parseTags(tagsInput)
1744
- };
1745
- return /* @__PURE__ */ jsxs9("form", { onSubmit: save, className: "space-y-6", children: [
1746
- /* @__PURE__ */ jsxs9("div", { className: "flex gap-1 border-b", children: [
1747
- /* @__PURE__ */ jsx11(
1748
- "button",
1749
- {
1750
- type: "button",
1751
- onClick: () => setView("edit"),
1752
- "aria-pressed": view === "edit",
1753
- className: `px-4 py-2 text-sm font-medium transition ${view === "edit" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-muted-foreground hover:text-foreground"}`,
1754
- children: t("posts.form.tabEdit")
1755
- }
1756
- ),
1757
- /* @__PURE__ */ jsx11(
1758
- "button",
1759
- {
1760
- type: "button",
1761
- onClick: () => setView("preview"),
1762
- "aria-pressed": view === "preview",
1763
- className: `px-4 py-2 text-sm font-medium transition ${view === "preview" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-muted-foreground hover:text-foreground"}`,
1764
- children: t("posts.form.tabPreview")
1765
- }
1766
- )
1767
- ] }),
1768
- view === "preview" && /* @__PURE__ */ jsxs9("article", { className: "space-y-4", children: [
1769
- /* @__PURE__ */ jsxs9("header", { className: "border-b pb-4", children: [
1770
- /* @__PURE__ */ jsx11("h1", { className: "text-3xl font-bold tracking-tight", children: title || /* @__PURE__ */ jsx11("span", { className: "text-muted-foreground italic", children: t("posts.form.previewNoTitle") }) }),
1771
- /* @__PURE__ */ jsxs9("p", { className: "mt-2 text-sm text-muted-foreground", children: [
1772
- previewPost.publishedAt ? /* @__PURE__ */ jsx11("time", { dateTime: previewPost.publishedAt, children: formatDate(previewPost.publishedAt) }) : /* @__PURE__ */ jsx11("span", { children: t("common.draft") }),
1773
- /* @__PURE__ */ jsx11("span", { className: "mx-2", children: "\xB7" }),
1774
- /* @__PURE__ */ jsx11("span", { className: "font-mono text-xs uppercase", children: format })
1775
- ] }),
1776
- excerpt && /* @__PURE__ */ jsx11("p", { className: "mt-3 text-base text-muted-foreground", children: excerpt })
1777
- ] }),
1778
- format === "static" ? /* @__PURE__ */ jsx11("p", { className: "text-sm text-muted-foreground", children: t("posts.form.static.previewHint") }) : /* @__PURE__ */ jsx11(
1779
- "div",
1780
- {
1781
- className: "prose prose-neutral dark:prose-invert max-w-none",
1782
- dangerouslySetInnerHTML: { __html: renderBody(previewPost) }
1783
- }
1784
- ),
1785
- previewPost.tags && previewPost.tags.length > 0 && /* @__PURE__ */ jsx11("div", { className: "flex flex-wrap gap-2 border-t pt-4 text-sm", children: previewPost.tags.map((tag) => /* @__PURE__ */ jsxs9(
1786
- "span",
1787
- {
1788
- className: "rounded-full border px-2 py-0.5 text-xs text-muted-foreground",
1789
- children: [
1790
- "#",
1791
- tag
1792
- ]
1793
- },
1794
- tag
1795
- )) }),
1796
- /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: t("posts.form.previewHint") })
1797
- ] }),
1798
- /* @__PURE__ */ jsxs9("div", { className: view === "edit" ? "space-y-6" : "hidden", children: [
1799
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1800
- /* @__PURE__ */ jsx11(Label2, { htmlFor: "title", children: t("posts.form.title") }),
1801
- /* @__PURE__ */ jsx11(
1802
- Input2,
1803
- {
1804
- id: "title",
1805
- required: true,
1806
- value: title,
1807
- onChange: (e) => {
1808
- setTitle(e.target.value);
1809
- if (!isEdit && !slug) setSlug(slugify(e.target.value));
1810
- }
1811
- }
1812
- )
1813
- ] }),
1814
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1815
- /* @__PURE__ */ jsx11(Label2, { htmlFor: "slug", children: t("posts.form.slug") }),
1816
- /* @__PURE__ */ jsx11(
1817
- Input2,
1818
- {
1819
- id: "slug",
1820
- value: slug,
1821
- onChange: (e) => setSlug(e.target.value),
1822
- placeholder: slugify(title) || t("posts.form.slugPlaceholder")
1823
- }
1824
- )
1825
- ] }),
1826
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1827
- /* @__PURE__ */ jsx11(Label2, { htmlFor: "excerpt", children: t("posts.form.excerpt") }),
1828
- /* @__PURE__ */ jsx11(
1829
- Textarea,
1830
- {
1831
- id: "excerpt",
1832
- rows: 2,
1833
- value: excerpt,
1834
- onChange: (e) => setExcerpt(e.target.value)
1835
- }
1836
- )
1837
- ] }),
1838
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1839
- /* @__PURE__ */ jsx11(Label2, { htmlFor: "format", children: t("posts.form.format") }),
1840
- /* @__PURE__ */ jsxs9(
1841
- "select",
1842
- {
1843
- id: "format",
1844
- value: format,
1845
- onChange: (e) => changeFormat(e.target.value),
1846
- className: "flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
1847
- children: [
1848
- /* @__PURE__ */ jsx11("option", { value: "tiptap", children: "Tiptap (rich editor)" }),
1849
- /* @__PURE__ */ jsx11("option", { value: "markdown", children: "Markdown" }),
1850
- /* @__PURE__ */ jsx11("option", { value: "html", children: "HTML" }),
1851
- /* @__PURE__ */ jsx11("option", { value: "static", children: t("posts.form.formatStaticLabel") })
1852
- ]
1853
- }
1854
- ),
1855
- /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: t("posts.form.formatHint") })
1856
- ] }),
1857
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1858
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between", children: [
1859
- /* @__PURE__ */ jsx11(Label2, { children: t("posts.form.body") }),
1860
- format !== "tiptap" && format !== "static" && // For textarea-based formats (markdown / html) there's no
1861
- // embedded toolbar, so we surface the MediaPicker as a
1862
- // standalone button. Selecting an asset inserts a
1863
- // format-aware snippet at the cursor.
1864
- /* @__PURE__ */ jsx11(
1865
- MediaPicker,
1866
- {
1867
- onSelect: insertMediaSnippet,
1868
- trigger: /* @__PURE__ */ jsxs9(Button8, { type: "button", variant: "outline", size: "sm", children: [
1869
- /* @__PURE__ */ jsx11(ImageIcon3, { className: "mr-2 h-3 w-3" }),
1870
- t("posts.form.insertMedia")
1871
- ] })
1872
- }
1873
- )
1874
- ] }),
1875
- format === "tiptap" ? /* @__PURE__ */ jsx11(TiptapEditor, { initialContent: body, onChange: setBody }) : format === "static" ? /* @__PURE__ */ jsx11(
1876
- StaticUploader,
1877
- {
1878
- initial: initialStaticBody,
1879
- onFilesReady: (files, entrypoint) => setPendingBundle({ files, entrypoint }),
1880
- onClear: () => setPendingBundle(null)
1881
- }
1882
- ) : /* @__PURE__ */ jsx11(
1883
- Textarea,
1884
- {
1885
- ref: bodyTextareaRef,
1886
- rows: 20,
1887
- value: typeof body === "string" ? body : "",
1888
- onChange: (e) => setBody(e.target.value),
1889
- className: "font-mono text-xs"
1890
- }
1891
- )
1892
- ] }),
1893
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1894
- /* @__PURE__ */ jsx11(Label2, { htmlFor: "tags", children: t("posts.form.tags") }),
1895
- /* @__PURE__ */ jsx11(
1896
- Input2,
1897
- {
1898
- id: "tags",
1899
- value: tagsInput,
1900
- onChange: (e) => setTagsInput(e.target.value),
1901
- placeholder: t("posts.form.tagsPlaceholder")
1902
- }
1903
- ),
1904
- /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: t("posts.form.tagsHint") })
1905
- ] }),
1906
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1907
- /* @__PURE__ */ jsx11(Label2, { htmlFor: "status", children: t("posts.form.status") }),
1908
- /* @__PURE__ */ jsxs9(
1909
- "select",
1910
- {
1911
- id: "status",
1912
- value: status,
1913
- onChange: (e) => setStatus(e.target.value),
1914
- className: "flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
1915
- children: [
1916
- /* @__PURE__ */ jsx11("option", { value: "draft", children: t("common.draft") }),
1917
- /* @__PURE__ */ jsx11("option", { value: "published", children: t("common.published") })
1918
- ]
1919
- }
1920
- )
1921
- ] }),
1922
- format === "html" && /* @__PURE__ */ jsx11("div", { className: "space-y-2", children: /* @__PURE__ */ jsxs9("label", { className: "flex items-start gap-2 text-sm", children: [
1923
- /* @__PURE__ */ jsx11(
1924
- "input",
1925
- {
1926
- type: "checkbox",
1927
- checked: noLayout,
1928
- onChange: (e) => setNoLayout(e.target.checked),
1929
- className: "mt-1"
1930
- }
1931
- ),
1932
- /* @__PURE__ */ jsxs9("span", { children: [
1933
- /* @__PURE__ */ jsx11("span", { className: "font-medium", children: t("posts.form.noLayout") }),
1934
- /* @__PURE__ */ jsx11("span", { className: "block text-xs text-muted-foreground", children: t("posts.form.noLayoutHint") })
1935
- ] })
1936
- ] }) }),
1937
- error && /* @__PURE__ */ jsx11("p", { className: "text-sm text-destructive", children: error }),
1938
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-2", children: [
1939
- /* @__PURE__ */ jsx11(Button8, { type: "submit", disabled: saving, children: saving ? t("common.saving") : isEdit ? t("posts.form.saveChanges") : t("posts.form.createPost") }),
1940
- isEdit && /* @__PURE__ */ jsx11(Button8, { type: "button", variant: "destructive", onClick: handleDelete, disabled: saving, children: t("posts.form.delete") })
1941
- ] })
1942
- ] })
1943
- ] });
1944
- }
1945
-
1946
- // src/components/new-post-view.tsx
1947
- import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
1948
- function NewPostPage() {
1949
- const t = useT();
1950
- return /* @__PURE__ */ jsxs10("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
1951
- /* @__PURE__ */ jsx12("h1", { className: "mb-6 text-2xl font-bold md:mb-8 md:text-3xl", children: t("posts.form.newTitle") }),
1952
- /* @__PURE__ */ jsx12(PostForm, {})
1953
- ] });
1954
- }
1955
-
1956
- // src/components/edit-post-view.tsx
1957
- import { useEffect as useEffect5, useState as useState7, use } from "react";
1958
- import { notFound } from "next/navigation";
1959
- import { getPostById } from "ampless";
1960
- import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1961
- function EditPostPage({ params }) {
1962
- const t = useT();
1963
- const { postId } = use(params);
1964
- const [post, setPost] = useState7(null);
1965
- const [loading, setLoading] = useState7(true);
1966
- const [missing, setMissing] = useState7(false);
1967
- useEffect5(() => {
1968
- const siteId = readAdminSiteIdFromCookie();
1969
- getPostById(postId, { siteId }).then((p) => {
1970
- if (!p) setMissing(true);
1971
- else setPost(p);
1972
- }).finally(() => setLoading(false));
1973
- }, [postId]);
1974
- if (loading)
1975
- return /* @__PURE__ */ jsx13("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: t("common.loading") });
1976
- if (missing) notFound();
1977
- return /* @__PURE__ */ jsxs11("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
1978
- /* @__PURE__ */ jsx13("h1", { className: "mb-6 text-2xl font-bold md:mb-8 md:text-3xl", children: t("posts.form.editTitle") }),
1979
- post && /* @__PURE__ */ jsx13(PostForm, { post })
1980
- ] });
1981
- }
1982
-
1983
- // src/components/media-uploader.tsx
1984
- import { useState as useState8, useEffect as useEffect6, useCallback, useRef as useRef4 } from "react";
1985
- import { uploadData as uploadData3, list as list3, remove as remove2, isCancelError } from "aws-amplify/storage";
1986
- import { processImage as processImage2 } from "ampless/media";
1987
- import { Button as Button9, Input as Input3 } from "@ampless/runtime/ui";
1988
- import { Trash2 as Trash22, Copy, Check, FileText as FileText2, Code2 } from "lucide-react";
1989
- import { jsx as jsx14, jsxs as jsxs12 } from "react/jsx-runtime";
1990
- var IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)$/i;
1991
- var STYLESHEET_EXT_RE = /\.css$/i;
1992
- var SCRIPT_EXT_RE = /\.m?js$/i;
1993
- function getExtension(path) {
1994
- const dot = path.lastIndexOf(".");
1995
- return dot >= 0 ? path.slice(dot + 1).toUpperCase() : "FILE";
1996
- }
1997
- function snippetFor2(url, path) {
1998
- if (IMAGE_EXT_RE.test(path)) {
1999
- return `<img src="${url}" alt="" />`;
2000
- }
2001
- if (STYLESHEET_EXT_RE.test(path)) {
2002
- return `<link rel="stylesheet" href="${url}" />`;
2003
- }
2004
- if (SCRIPT_EXT_RE.test(path)) {
2005
- return `<script src="${url}"></script>`;
2006
- }
2007
- return url;
2008
- }
2009
- function sanitizeName2(name) {
2010
- return name.replace(/[ -]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
2011
- }
2012
- function MediaUploader() {
2013
- const t = useT();
2014
- const [items, setItems] = useState8([]);
2015
- const [queue, setQueue] = useState8([]);
2016
- const [uploading, setUploading] = useState8(false);
2017
- const [error, setError] = useState8(null);
2018
- const [copiedPath, setCopiedPath] = useState8(null);
2019
- const uploadTaskRef = useRef4(null);
2020
- const cancelTokenRef = useRef4({ cancelled: false });
2021
- const refresh = useCallback(async () => {
2022
- try {
2023
- const result = await list3({ path: "public/media/" });
2024
- setItems(
2025
- result.items.map((item) => ({
2026
- path: item.path,
2027
- url: publicMediaUrl(item.path)
2028
- }))
2029
- );
2030
- } catch (err) {
2031
- setError(err instanceof Error ? err.message : String(err));
2032
- }
2033
- }, []);
2034
- useEffect6(() => {
2035
- refresh();
2036
- }, [refresh]);
2037
- function handleFiles(e) {
2038
- const files = Array.from(e.target.files ?? []);
2039
- e.target.value = "";
2040
- if (files.length === 0) return;
2041
- setError(null);
2042
- setQueue((prev) => [...prev, ...files]);
2043
- }
2044
- async function handleDialogConfirm(file, options) {
2045
- const token = { cancelled: false };
2046
- cancelTokenRef.current = token;
2047
- setUploading(true);
2048
- setError(null);
2049
- let advance = true;
2050
- try {
2051
- const processed = await processImage2(file, options);
2052
- if (token.cancelled) {
2053
- advance = false;
2054
- return;
2055
- }
2056
- const safeName = sanitizeName2(processed.suggestedName);
2057
- const now = /* @__PURE__ */ new Date();
2058
- const yyyy = now.getFullYear();
2059
- const mm = String(now.getMonth() + 1).padStart(2, "0");
2060
- const path = `public/media/${yyyy}/${mm}/${Date.now()}-${safeName}`;
2061
- const task = uploadData3({
2062
- path,
2063
- data: processed.blob,
2064
- options: { contentType: processed.mime }
2065
- });
2066
- uploadTaskRef.current = task;
2067
- await task.result;
2068
- await refresh();
2069
- } catch (err) {
2070
- if (isCancelError(err) || token.cancelled) {
2071
- advance = false;
2072
- } else {
2073
- setError(err instanceof Error ? err.message : String(err));
2074
- }
2075
- } finally {
2076
- uploadTaskRef.current = null;
2077
- setUploading(false);
2078
- if (advance) {
2079
- setQueue((prev) => prev.slice(1));
2080
- }
2081
- }
2082
- }
2083
- function handleDialogSkip() {
2084
- if (uploading) return;
2085
- setQueue((prev) => prev.slice(1));
2086
- }
2087
- function handleDialogCancel() {
2088
- cancelTokenRef.current.cancelled = true;
2089
- uploadTaskRef.current?.cancel();
2090
- setQueue([]);
2091
- }
2092
- async function handleDelete(path) {
2093
- if (!confirm(t("media.deleteConfirm"))) return;
2094
- try {
2095
- await remove2({ path });
2096
- await refresh();
2097
- } catch (err) {
2098
- setError(err instanceof Error ? err.message : String(err));
2099
- }
2100
- }
2101
- async function handleCopy(item, mode) {
2102
- const text = mode === "url" ? item.url : snippetFor2(item.url, item.path);
2103
- await navigator.clipboard.writeText(text);
2104
- const key = `${item.path}:${mode}`;
2105
- setCopiedPath(key);
2106
- setTimeout(() => setCopiedPath((p) => p === key ? null : p), 1500);
2107
- }
2108
- const currentFile = queue[0] ?? null;
2109
- return /* @__PURE__ */ jsxs12("div", { className: "space-y-6", children: [
2110
- /* @__PURE__ */ jsxs12("div", { className: "rounded-md border p-4", children: [
2111
- /* @__PURE__ */ jsx14(
2112
- Input3,
2113
- {
2114
- type: "file",
2115
- multiple: true,
2116
- onChange: handleFiles,
2117
- disabled: uploading
2118
- }
2119
- ),
2120
- uploading && /* @__PURE__ */ jsx14("p", { className: "mt-2 text-sm text-muted-foreground", children: t("media.uploading") }),
2121
- !uploading && queue.length > 0 && /* @__PURE__ */ jsx14("p", { className: "mt-2 text-sm text-muted-foreground", children: t("media.queued", { count: queue.length }) })
2122
- ] }),
2123
- error && /* @__PURE__ */ jsx14("p", { className: "text-sm text-destructive", children: error }),
2124
- /* @__PURE__ */ jsx14(
2125
- ImageUploadDialog,
2126
- {
2127
- file: currentFile,
2128
- remaining: queue.length,
2129
- busy: uploading,
2130
- defaults: getMediaProcessingDefaults(),
2131
- onConfirm: handleDialogConfirm,
2132
- onSkip: handleDialogSkip,
2133
- onCancel: handleDialogCancel
2134
- }
2135
- ),
2136
- /* @__PURE__ */ jsx14("div", { className: "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4", children: items.map((item) => {
2137
- const isImage = IMAGE_EXT_RE.test(item.path);
2138
- const isStylesheet = STYLESHEET_EXT_RE.test(item.path);
2139
- const isScript = SCRIPT_EXT_RE.test(item.path);
2140
- const filename = item.path.split("/").pop() ?? "";
2141
- const ext = getExtension(item.path);
2142
- const tagSnippet = snippetFor2(item.url, item.path);
2143
- const tagDiffersFromUrl = tagSnippet !== item.url;
2144
- const urlCopied = copiedPath === `${item.path}:url`;
2145
- const tagCopied = copiedPath === `${item.path}:tag`;
2146
- return /* @__PURE__ */ jsxs12(
2147
- "div",
2148
- {
2149
- className: "group relative overflow-hidden rounded-md border bg-[var(--card)]",
2150
- children: [
2151
- isImage ? (
2152
- // eslint-disable-next-line @next/next/no-img-element
2153
- /* @__PURE__ */ jsx14(
2154
- "img",
2155
- {
2156
- src: item.url,
2157
- alt: item.path,
2158
- className: "aspect-square w-full object-cover"
2159
- }
2160
- )
2161
- ) : /* @__PURE__ */ jsxs12("div", { className: "flex aspect-square w-full flex-col items-center justify-center gap-2 bg-muted text-muted-foreground", children: [
2162
- isStylesheet || isScript ? /* @__PURE__ */ jsx14(Code2, { className: "h-8 w-8" }) : /* @__PURE__ */ jsx14(FileText2, { className: "h-8 w-8" }),
2163
- /* @__PURE__ */ jsxs12("span", { className: "font-mono text-xs font-semibold", children: [
2164
- ".",
2165
- ext.toLowerCase()
2166
- ] })
2167
- ] }),
2168
- /* @__PURE__ */ jsxs12("div", { className: "flex items-center justify-between border-t px-2 py-1 text-xs", children: [
2169
- /* @__PURE__ */ jsx14("span", { className: "truncate", title: filename, children: filename }),
2170
- /* @__PURE__ */ jsxs12("div", { className: "flex shrink-0 items-center gap-0.5", children: [
2171
- /* @__PURE__ */ jsx14(
2172
- Button9,
2173
- {
2174
- type: "button",
2175
- variant: "ghost",
2176
- size: "icon",
2177
- className: "h-6 w-6",
2178
- onClick: () => handleCopy(item, "url"),
2179
- title: t("media.copyUrl"),
2180
- children: urlCopied ? /* @__PURE__ */ jsx14(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx14(Copy, { className: "h-3 w-3" })
2181
- }
2182
- ),
2183
- tagDiffersFromUrl && /* @__PURE__ */ jsx14(
2184
- Button9,
2185
- {
2186
- type: "button",
2187
- variant: "ghost",
2188
- size: "icon",
2189
- className: "h-6 w-6",
2190
- onClick: () => handleCopy(item, "tag"),
2191
- title: t("media.copyTag"),
2192
- children: tagCopied ? /* @__PURE__ */ jsx14(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx14(Code2, { className: "h-3 w-3" })
2193
- }
2194
- ),
2195
- /* @__PURE__ */ jsx14(
2196
- Button9,
2197
- {
2198
- type: "button",
2199
- variant: "ghost",
2200
- size: "icon",
2201
- className: "h-6 w-6",
2202
- onClick: () => handleDelete(item.path),
2203
- title: t("media.delete"),
2204
- children: /* @__PURE__ */ jsx14(Trash22, { className: "h-3 w-3" })
2205
- }
2206
- )
2207
- ] })
2208
- ] })
2209
- ]
2210
- },
2211
- item.path
2212
- );
2213
- }) }),
2214
- items.length === 0 && /* @__PURE__ */ jsx14("p", { className: "text-center text-sm text-muted-foreground", children: t("media.empty") })
2215
- ] });
2216
- }
2217
-
2218
- // src/components/media-view.tsx
2219
- import { jsx as jsx15, jsxs as jsxs13 } from "react/jsx-runtime";
2220
- function MediaPage() {
2221
- const t = useT();
2222
- return /* @__PURE__ */ jsxs13("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
2223
- /* @__PURE__ */ jsx15("h1", { className: "mb-6 text-2xl font-bold md:mb-8 md:text-3xl", children: t("media.title") }),
2224
- /* @__PURE__ */ jsx15(MediaUploader, {})
2225
- ] });
2226
- }
2227
-
2228
- // src/components/login-view.tsx
2229
- import { useState as useState9 } from "react";
2230
- import { useRouter as useRouter2 } from "next/navigation";
2231
- import {
2232
- signIn,
2233
- signUp,
2234
- confirmSignUp,
2235
- resetPassword,
2236
- confirmResetPassword
2237
- } from "aws-amplify/auth";
2238
- import {
2239
- Button as Button10,
2240
- Input as Input4,
2241
- Label as Label3,
2242
- Card as Card2,
2243
- CardContent as CardContent2,
2244
- CardHeader as CardHeader2,
2245
- CardTitle as CardTitle2,
2246
- CardDescription
2247
- } from "@ampless/runtime/ui";
2248
- import { Fragment as Fragment4, jsx as jsx16, jsxs as jsxs14 } from "react/jsx-runtime";
2249
- function LoginPage() {
2250
- const router = useRouter2();
2251
- const t = useT();
2252
- const [mode, setMode] = useState9("signIn");
2253
- const [email, setEmail] = useState9("");
2254
- const [password, setPassword] = useState9("");
2255
- const [code, setCode] = useState9("");
2256
- const [error, setError] = useState9(null);
2257
- const [info, setInfo] = useState9(null);
2258
- const [loading, setLoading] = useState9(false);
2259
- function go(next) {
2260
- setMode(next);
2261
- setError(null);
2262
- setInfo(null);
2263
- setCode("");
2264
- if (next === "signIn" || next === "signUp" || next === "forgot") setPassword("");
2265
- }
2266
- async function handleSubmit(e) {
2267
- e.preventDefault();
2268
- setError(null);
2269
- setInfo(null);
2270
- setLoading(true);
2271
- try {
2272
- if (mode === "signIn") {
2273
- const result = await signIn({ username: email, password });
2274
- if (result.isSignedIn) {
2275
- router.push("/admin");
2276
- router.refresh();
2277
- } else {
2278
- setError(t("auth.additionalStep", { step: result.nextStep.signInStep }));
2279
- }
2280
- } else if (mode === "signUp") {
2281
- await signUp({
2282
- username: email,
2283
- password,
2284
- options: { userAttributes: { email } }
2285
- });
2286
- go("confirm");
2287
- } else if (mode === "confirm") {
2288
- await confirmSignUp({ username: email, confirmationCode: code });
2289
- const result = await signIn({ username: email, password });
2290
- if (result.isSignedIn) {
2291
- router.push("/admin");
2292
- router.refresh();
2293
- }
2294
- } else if (mode === "forgot") {
2295
- await resetPassword({ username: email });
2296
- setMode("reset");
2297
- setInfo(t("auth.forgot.codeSent"));
2298
- } else if (mode === "reset") {
2299
- await confirmResetPassword({
2300
- username: email,
2301
- confirmationCode: code,
2302
- newPassword: password
2303
- });
2304
- const result = await signIn({ username: email, password });
2305
- if (result.isSignedIn) {
2306
- router.push("/admin");
2307
- router.refresh();
2308
- } else {
2309
- setMode("signIn");
2310
- setInfo(t("auth.reset.passwordUpdated"));
2311
- }
2312
- }
2313
- } catch (err) {
2314
- setError(err instanceof Error ? err.message : String(err));
2315
- } finally {
2316
- setLoading(false);
2317
- }
2318
- }
2319
- const showEmail = mode !== "confirm" && mode !== "reset";
2320
- const showPassword = mode === "signIn" || mode === "signUp" || mode === "reset";
2321
- const showCode = mode === "confirm" || mode === "reset";
2322
- return /* @__PURE__ */ jsx16("main", { className: "flex min-h-screen items-center justify-center bg-muted/30 p-4", children: /* @__PURE__ */ jsxs14(Card2, { className: "w-full max-w-md", children: [
2323
- /* @__PURE__ */ jsxs14(CardHeader2, { children: [
2324
- /* @__PURE__ */ jsx16(CardTitle2, { children: t(`auth.${mode}.title`) }),
2325
- /* @__PURE__ */ jsx16(CardDescription, { children: t(`auth.${mode}.description`) })
2326
- ] }),
2327
- /* @__PURE__ */ jsx16(CardContent2, { children: /* @__PURE__ */ jsxs14("form", { onSubmit: handleSubmit, className: "space-y-4", children: [
2328
- showEmail && /* @__PURE__ */ jsxs14("div", { className: "space-y-2", children: [
2329
- /* @__PURE__ */ jsx16(Label3, { htmlFor: "email", children: t("auth.common.email") }),
2330
- /* @__PURE__ */ jsx16(
2331
- Input4,
2332
- {
2333
- id: "email",
2334
- type: "email",
2335
- required: true,
2336
- value: email,
2337
- onChange: (e) => setEmail(e.target.value),
2338
- autoComplete: "email"
2339
- }
2340
- )
2341
- ] }),
2342
- showCode && /* @__PURE__ */ jsxs14("div", { className: "space-y-2", children: [
2343
- /* @__PURE__ */ jsx16(Label3, { htmlFor: "code", children: t("auth.common.code") }),
2344
- /* @__PURE__ */ jsx16(
2345
- Input4,
2346
- {
2347
- id: "code",
2348
- required: true,
2349
- value: code,
2350
- onChange: (e) => setCode(e.target.value),
2351
- autoComplete: "one-time-code"
2352
- }
2353
- )
2354
- ] }),
2355
- showPassword && /* @__PURE__ */ jsxs14("div", { className: "space-y-2", children: [
2356
- /* @__PURE__ */ jsx16(Label3, { htmlFor: "password", children: mode === "reset" ? t("auth.common.newPassword") : t("auth.common.password") }),
2357
- /* @__PURE__ */ jsx16(
2358
- Input4,
2359
- {
2360
- id: "password",
2361
- type: "password",
2362
- required: true,
2363
- minLength: 8,
2364
- value: password,
2365
- onChange: (e) => setPassword(e.target.value),
2366
- autoComplete: mode === "signIn" ? "current-password" : "new-password"
2367
- }
2368
- ),
2369
- (mode === "signUp" || mode === "reset") && /* @__PURE__ */ jsx16("p", { className: "text-xs text-muted-foreground", children: t("auth.common.passwordHint") })
2370
- ] }),
2371
- info && /* @__PURE__ */ jsx16("p", { className: "text-sm text-muted-foreground", children: info }),
2372
- error && /* @__PURE__ */ jsx16("p", { className: "text-sm text-destructive", children: error }),
2373
- /* @__PURE__ */ jsx16(Button10, { type: "submit", className: "w-full", disabled: loading, children: loading ? t("auth.common.working") : t(`auth.${mode}.submit`) }),
2374
- /* @__PURE__ */ jsxs14("div", { className: "space-y-1 text-center text-sm", children: [
2375
- mode === "signIn" && /* @__PURE__ */ jsxs14(Fragment4, { children: [
2376
- /* @__PURE__ */ jsx16("p", { children: /* @__PURE__ */ jsx16(
2377
- "button",
2378
- {
2379
- type: "button",
2380
- className: "text-primary hover:underline",
2381
- onClick: () => go("forgot"),
2382
- children: t("auth.signIn.forgotPassword")
2383
- }
2384
- ) }),
2385
- /* @__PURE__ */ jsx16("p", { children: /* @__PURE__ */ jsx16(
2386
- "button",
2387
- {
2388
- type: "button",
2389
- className: "text-primary hover:underline",
2390
- onClick: () => go("signUp"),
2391
- children: t("auth.signIn.createAccount")
2392
- }
2393
- ) })
2394
- ] }),
2395
- (mode === "signUp" || mode === "forgot" || mode === "reset") && /* @__PURE__ */ jsx16("p", { children: /* @__PURE__ */ jsx16(
2396
- "button",
2397
- {
2398
- type: "button",
2399
- className: "text-primary hover:underline",
2400
- onClick: () => go("signIn"),
2401
- children: t("auth.signUp.backToSignIn")
2402
- }
2403
- ) }),
2404
- mode === "reset" && /* @__PURE__ */ jsx16("p", { children: /* @__PURE__ */ jsx16(
2405
- "button",
2406
- {
2407
- type: "button",
2408
- className: "text-primary hover:underline",
2409
- onClick: () => go("forgot"),
2410
- children: t("auth.reset.resendCode")
2411
- }
2412
- ) })
2413
- ] })
2414
- ] }) })
2415
- ] }) });
2416
- }
2417
-
2418
- // src/components/sidebar.tsx
2419
- import { useEffect as useEffect7, useState as useState10 } from "react";
2420
- import Link4 from "next/link";
2421
- import { usePathname } from "next/navigation";
2422
- import { signOut } from "aws-amplify/auth";
2423
- import {
2424
- LayoutDashboard,
2425
- FileText as FileText3,
2426
- Image as Image2,
2427
- Globe,
2428
- Users,
2429
- LogOut,
2430
- ExternalLink,
2431
- Menu,
2432
- X as X2
2433
- } from "lucide-react";
2434
- import { Button as Button11, cn as cn3 } from "@ampless/runtime/ui";
2435
- import { Fragment as Fragment5, jsx as jsx17, jsxs as jsxs15 } from "react/jsx-runtime";
2436
- var navItems = [
2437
- { href: "/admin", key: "sidebar.dashboard", icon: LayoutDashboard },
2438
- { href: "/admin/posts", key: "sidebar.posts", icon: FileText3 },
2439
- { href: "/admin/media", key: "sidebar.media", icon: Image2 },
2440
- { href: "/admin/sites", key: "sidebar.sites", icon: Globe },
2441
- { href: "/admin/users", key: "sidebar.users", icon: Users, adminOnly: true }
2442
- ];
2443
- function Sidebar({
2444
- email,
2445
- siteSelector,
2446
- isAdmin
2447
- }) {
2448
- const pathname = usePathname();
2449
- const t = useT();
2450
- const [open, setOpen] = useState10(false);
2451
- useEffect7(() => {
2452
- setOpen(false);
2453
- }, [pathname]);
2454
- useEffect7(() => {
2455
- if (!open) return;
2456
- const prev = document.body.style.overflow;
2457
- document.body.style.overflow = "hidden";
2458
- return () => {
2459
- document.body.style.overflow = prev;
2460
- };
2461
- }, [open]);
2462
- return /* @__PURE__ */ jsxs15(Fragment5, { children: [
2463
- /* @__PURE__ */ jsxs15("header", { className: "sticky top-0 z-30 flex h-14 items-center justify-between border-b bg-background px-4 md:hidden", children: [
2464
- /* @__PURE__ */ jsx17(Link4, { href: "/admin", className: "font-semibold", children: t("sidebar.brand") }),
2465
- /* @__PURE__ */ jsx17(
2466
- Button11,
2467
- {
2468
- variant: "ghost",
2469
- size: "icon",
2470
- "aria-label": t("sidebar.openMenu"),
2471
- "aria-expanded": open,
2472
- onClick: () => setOpen(true),
2473
- children: /* @__PURE__ */ jsx17(Menu, { className: "h-5 w-5" })
2474
- }
2475
- )
2476
- ] }),
2477
- open && /* @__PURE__ */ jsx17(
2478
- "div",
2479
- {
2480
- className: "fixed inset-0 z-40 bg-black/40 md:hidden",
2481
- "aria-hidden": "true",
2482
- onClick: () => setOpen(false)
2483
- }
2484
- ),
2485
- /* @__PURE__ */ jsxs15(
2486
- "aside",
2487
- {
2488
- className: cn3(
2489
- "fixed inset-y-0 left-0 z-50 flex w-60 flex-col border-r bg-muted/30 transition-transform md:sticky md:top-0 md:h-screen md:translate-x-0",
2490
- open ? "translate-x-0" : "-translate-x-full md:translate-x-0"
2491
- ),
2492
- "aria-label": t("sidebar.brand"),
2493
- children: [
2494
- /* @__PURE__ */ jsxs15("div", { className: "flex items-center justify-between border-b p-4", children: [
2495
- /* @__PURE__ */ jsx17(Link4, { href: "/admin", className: "font-semibold", children: t("sidebar.brand") }),
2496
- /* @__PURE__ */ jsx17(
2497
- Button11,
2498
- {
2499
- variant: "ghost",
2500
- size: "icon",
2501
- className: "md:hidden",
2502
- "aria-label": t("sidebar.closeMenu"),
2503
- onClick: () => setOpen(false),
2504
- children: /* @__PURE__ */ jsx17(X2, { className: "h-5 w-5" })
2505
- }
2506
- )
2507
- ] }),
2508
- siteSelector ? /* @__PURE__ */ jsx17("div", { className: "border-b", children: siteSelector }) : null,
2509
- /* @__PURE__ */ jsx17("nav", { className: "flex-1 space-y-1 overflow-y-auto p-2", children: navItems.map((item) => {
2510
- if (item.adminOnly && !isAdmin) return null;
2511
- const Icon = item.icon;
2512
- const isActive = pathname === item.href || item.href !== "/admin" && pathname?.startsWith(item.href);
2513
- return /* @__PURE__ */ jsxs15(
2514
- Link4,
2515
- {
2516
- href: item.href,
2517
- className: cn3(
2518
- "flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
2519
- isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
2520
- ),
2521
- children: [
2522
- /* @__PURE__ */ jsx17(Icon, { className: "h-4 w-4" }),
2523
- t(item.key)
2524
- ]
2525
- },
2526
- item.href
2527
- );
2528
- }) }),
2529
- /* @__PURE__ */ jsxs15("div", { className: "border-t p-2 space-y-1", children: [
2530
- /* @__PURE__ */ jsxs15(
2531
- Link4,
2532
- {
2533
- href: "/",
2534
- target: "_blank",
2535
- className: "flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground",
2536
- children: [
2537
- /* @__PURE__ */ jsx17(ExternalLink, { className: "h-4 w-4" }),
2538
- t("sidebar.viewSite")
2539
- ]
2540
- }
2541
- ),
2542
- /* @__PURE__ */ jsx17("div", { className: "px-3 py-2 text-xs text-muted-foreground truncate", children: email }),
2543
- /* @__PURE__ */ jsxs15(
2544
- Button11,
2545
- {
2546
- variant: "ghost",
2547
- size: "sm",
2548
- className: "w-full justify-start gap-3",
2549
- onClick: async () => {
2550
- await signOut();
2551
- window.location.href = "/login";
2552
- },
2553
- children: [
2554
- /* @__PURE__ */ jsx17(LogOut, { className: "h-4 w-4" }),
2555
- t("sidebar.signOut")
2556
- ]
2557
- }
2558
- )
2559
- ] })
2560
- ]
2561
- }
2562
- )
2563
- ] });
2564
- }
2565
-
2566
- // src/components/site-selector.tsx
2567
- import { useRouter as useRouter3 } from "next/navigation";
2568
- import { jsx as jsx18, jsxs as jsxs16 } from "react/jsx-runtime";
2569
- function SiteSelector({ current, sites }) {
2570
- const router = useRouter3();
2571
- const t = useT();
2572
- function onChange(e) {
2573
- const next = e.target.value;
2574
- document.cookie = `${ADMIN_SITE_COOKIE}=${encodeURIComponent(next)}; Path=/; Max-Age=${60 * 60 * 24 * 365}; SameSite=Lax`;
2575
- router.refresh();
2576
- }
2577
- return /* @__PURE__ */ jsxs16("div", { className: "px-3 py-2", children: [
2578
- /* @__PURE__ */ jsx18("label", { className: "block text-xs uppercase tracking-wide text-muted-foreground mb-1", children: t("sites.selector.label") }),
2579
- /* @__PURE__ */ jsx18(
2580
- "select",
2581
- {
2582
- value: current,
2583
- onChange,
2584
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
2585
- children: sites.map((s) => /* @__PURE__ */ jsx18("option", { value: s.id, children: s.name }, s.id))
2586
- }
2587
- )
2588
- ] });
2589
- }
2590
-
2591
- // src/components/site-settings-form.tsx
2592
- import { useState as useState11 } from "react";
2593
- import { useRouter as useRouter4 } from "next/navigation";
2594
- import { setSiteSetting } from "ampless";
2595
- import { Button as Button12, Input as Input5, Label as Label4, Textarea as Textarea2 } from "@ampless/runtime/ui";
2596
- import { jsx as jsx19, jsxs as jsxs17 } from "react/jsx-runtime";
2597
- var KEYS = [
2598
- "site.name",
2599
- "site.url",
2600
- "site.description",
2601
- "media.imageDisplay",
2602
- "media.imageMaxWidth",
2603
- "dateFormat",
2604
- "timezone"
2605
- ];
2606
- function SiteSettingsForm({ siteId, initial, fallback }) {
2607
- const router = useRouter4();
2608
- const t = useT();
2609
- const [values, setValues] = useState11(initial);
2610
- const [saving, setSaving] = useState11(false);
2611
- const [error, setError] = useState11(null);
2612
- const [info, setInfo] = useState11(null);
2613
- function update(key, value) {
2614
- setValues((prev) => ({ ...prev, [key]: value }));
2615
- }
2616
- async function save(e) {
2617
- e.preventDefault();
2618
- setSaving(true);
2619
- setError(null);
2620
- setInfo(null);
2621
- try {
2622
- await Promise.all(
2623
- KEYS.map((key) => {
2624
- const value = values[key];
2625
- if (value === void 0 || value === "") return Promise.resolve();
2626
- return setSiteSetting(siteId, key, value);
2627
- })
2628
- );
2629
- setInfo(t("sites.edit.saved"));
2630
- router.refresh();
2631
- } catch (err) {
2632
- setError(err instanceof Error ? err.message : String(err));
2633
- } finally {
2634
- setSaving(false);
2635
- }
2636
- }
2637
- return /* @__PURE__ */ jsxs17("form", { onSubmit: save, className: "space-y-6 max-w-xl", children: [
2638
- /* @__PURE__ */ jsxs17("fieldset", { className: "space-y-4", children: [
2639
- /* @__PURE__ */ jsx19("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: t("sites.edit.site") }),
2640
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2641
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "name", children: t("common.name") }),
2642
- /* @__PURE__ */ jsx19(
2643
- Input5,
2644
- {
2645
- id: "name",
2646
- value: values["site.name"] ?? "",
2647
- placeholder: fallback["site.name"] ?? "",
2648
- onChange: (e) => update("site.name", e.target.value)
2649
- }
2650
- )
2651
- ] }),
2652
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2653
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "url", children: t("common.url") }),
2654
- /* @__PURE__ */ jsx19(
2655
- Input5,
2656
- {
2657
- id: "url",
2658
- value: values["site.url"] ?? "",
2659
- placeholder: fallback["site.url"] ?? "",
2660
- onChange: (e) => update("site.url", e.target.value)
2661
- }
2662
- )
2663
- ] }),
2664
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2665
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "description", children: t("common.description") }),
2666
- /* @__PURE__ */ jsx19(
2667
- Textarea2,
2668
- {
2669
- id: "description",
2670
- value: values["site.description"] ?? "",
2671
- placeholder: fallback["site.description"] ?? "",
2672
- rows: 2,
2673
- onChange: (e) => update("site.description", e.target.value)
2674
- }
2675
- )
2676
- ] })
2677
- ] }),
2678
- /* @__PURE__ */ jsxs17("fieldset", { className: "space-y-4", children: [
2679
- /* @__PURE__ */ jsx19("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: t("sites.edit.media") }),
2680
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2681
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "imageDisplay", children: t("sites.edit.imageDisplay") }),
2682
- /* @__PURE__ */ jsxs17(
2683
- "select",
2684
- {
2685
- id: "imageDisplay",
2686
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
2687
- value: values["media.imageDisplay"] ?? "",
2688
- onChange: (e) => update("media.imageDisplay", e.target.value),
2689
- children: [
2690
- /* @__PURE__ */ jsx19("option", { value: "", children: t("sites.edit.defaultPlaceholder", {
2691
- value: fallback["media.imageDisplay"] ?? "inline"
2692
- }) }),
2693
- /* @__PURE__ */ jsx19("option", { value: "inline", children: t("sites.edit.imageDisplayInline") }),
2694
- /* @__PURE__ */ jsx19("option", { value: "lightbox", children: t("sites.edit.imageDisplayLightbox") })
2695
- ]
2696
- }
2697
- )
2698
- ] }),
2699
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2700
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "imageMaxWidth", children: t("sites.edit.imageMaxWidth") }),
2701
- /* @__PURE__ */ jsx19(
2702
- Input5,
2703
- {
2704
- id: "imageMaxWidth",
2705
- value: values["media.imageMaxWidth"] ?? "",
2706
- placeholder: fallback["media.imageMaxWidth"] ?? "100%",
2707
- onChange: (e) => update("media.imageMaxWidth", e.target.value)
2708
- }
2709
- )
2710
- ] })
2711
- ] }),
2712
- /* @__PURE__ */ jsxs17("fieldset", { className: "space-y-4", children: [
2713
- /* @__PURE__ */ jsx19("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: t("sites.edit.dateDisplay") }),
2714
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2715
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "dateFormat", children: t("sites.edit.dateFormat") }),
2716
- /* @__PURE__ */ jsxs17(
2717
- "select",
2718
- {
2719
- id: "dateFormat",
2720
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
2721
- value: values["dateFormat"] ?? "",
2722
- onChange: (e) => update("dateFormat", e.target.value),
2723
- children: [
2724
- /* @__PURE__ */ jsx19("option", { value: "", children: t("sites.edit.defaultPlaceholder", {
2725
- value: fallback["dateFormat"] ?? "iso"
2726
- }) }),
2727
- /* @__PURE__ */ jsx19("option", { value: "iso", children: t("sites.edit.dateFormatIso") }),
2728
- /* @__PURE__ */ jsx19("option", { value: "long", children: t("sites.edit.dateFormatLong") }),
2729
- /* @__PURE__ */ jsx19("option", { value: "locale", children: t("sites.edit.dateFormatLocale") })
2730
- ]
2731
- }
2732
- )
2733
- ] }),
2734
- /* @__PURE__ */ jsxs17("div", { className: "space-y-2", children: [
2735
- /* @__PURE__ */ jsx19(Label4, { htmlFor: "timezone", children: t("sites.edit.timezone") }),
2736
- /* @__PURE__ */ jsx19(
2737
- Input5,
2738
- {
2739
- id: "timezone",
2740
- value: values["timezone"] ?? "",
2741
- placeholder: fallback["timezone"] ?? "UTC",
2742
- onChange: (e) => update("timezone", e.target.value)
2743
- }
2744
- )
2745
- ] })
2746
- ] }),
2747
- info && /* @__PURE__ */ jsx19("p", { className: "text-sm text-muted-foreground", children: info }),
2748
- error && /* @__PURE__ */ jsx19("p", { className: "text-sm text-destructive", children: error }),
2749
- /* @__PURE__ */ jsx19(Button12, { type: "submit", disabled: saving, children: saving ? t("common.saving") : t("sites.edit.saveButton") })
2750
- ] });
2751
- }
2752
-
2753
- // src/components/theme-settings-form.tsx
2754
- import { useState as useState12 } from "react";
2755
- import { useRouter as useRouter5 } from "next/navigation";
2756
- import {
2757
- setSiteSetting as setSiteSetting2,
2758
- deleteSiteSetting,
2759
- themeSettingKey,
2760
- validateThemeValue,
2761
- resolveLocalized,
2762
- parseLinkList,
2763
- stringifyLinkList
2764
- } from "ampless";
2765
- import { Button as Button13, Input as Input6, Label as Label5 } from "@ampless/runtime/ui";
2766
- import { jsx as jsx20, jsxs as jsxs18 } from "react/jsx-runtime";
2767
- var CACHE_REBUILD_DELAY_MS = 8e3;
2768
- function ThemeSettingsForm({
2769
- siteId,
2770
- manifest,
2771
- activeTheme,
2772
- themeOptions,
2773
- initial
2774
- }) {
2775
- const router = useRouter5();
2776
- const t = useT();
2777
- const locale = useLocale();
2778
- const [state, setState] = useState12({ values: initial, touched: {} });
2779
- const [pendingTheme, setPendingTheme] = useState12(activeTheme);
2780
- const [optimisticActive, setOptimisticActive] = useState12(activeTheme);
2781
- const [saving, setSaving] = useState12(false);
2782
- const [switching, setSwitching] = useState12(false);
2783
- const [error, setError] = useState12(null);
2784
- const [info, setInfo] = useState12(null);
2785
- const [invalid, setInvalid] = useState12({});
2786
- function update(key, value) {
2787
- setState((prev) => ({
2788
- values: { ...prev.values, [key]: value },
2789
- touched: { ...prev.touched, [key]: true }
2790
- }));
2791
- }
2792
- function scheduleCacheInvalidation() {
2793
- setTimeout(async () => {
2794
- try {
2795
- await invalidateSiteSettingsCache(siteId);
2796
- } catch (err) {
2797
- console.warn("[theme] cache invalidation failed", err);
2798
- }
2799
- }, CACHE_REBUILD_DELAY_MS);
2800
- }
2801
- function scheduleHardReload() {
2802
- setTimeout(async () => {
2803
- try {
2804
- await invalidateSiteSettingsCache(siteId);
2805
- } catch (err) {
2806
- console.warn("[theme] cache invalidation failed", err);
2807
- }
2808
- window.location.reload();
2809
- }, CACHE_REBUILD_DELAY_MS);
2810
- }
2811
- async function switchTheme(e) {
2812
- e.preventDefault();
2813
- if (pendingTheme === optimisticActive) return;
2814
- setSwitching(true);
2815
- setError(null);
2816
- setInfo(null);
2817
- try {
2818
- await setSiteSetting2(siteId, "theme.active", pendingTheme);
2819
- setOptimisticActive(pendingTheme);
2820
- setInfo(t("theme.switched", { theme: pendingTheme }));
2821
- scheduleHardReload();
2822
- } catch (err) {
2823
- console.error("[theme] switch failed", err);
2824
- setError(err instanceof Error ? err.message : String(err));
2825
- } finally {
2826
- setSwitching(false);
2827
- }
2828
- }
2829
- async function save(e) {
2830
- e.preventDefault();
2831
- setSaving(true);
2832
- setError(null);
2833
- setInfo(null);
2834
- setInvalid({});
2835
- const newInvalid = {};
2836
- const writes = [];
2837
- for (const field of manifest.fields) {
2838
- if (!state.touched[field.key]) continue;
2839
- const raw = (state.values[field.key] ?? "").trim();
2840
- const storeKey = themeSettingKey(field.key);
2841
- if (raw === "") {
2842
- writes.push(deleteSiteSetting(siteId, storeKey));
2843
- continue;
2844
- }
2845
- const validated = validateThemeValue(field, raw);
2846
- if (validated === null) {
2847
- newInvalid[field.key] = true;
2848
- continue;
2849
- }
2850
- writes.push(setSiteSetting2(siteId, storeKey, validated));
2851
- }
2852
- if (Object.keys(newInvalid).length > 0) {
2853
- setInvalid(newInvalid);
2854
- setSaving(false);
2855
- setError(t("theme.invalidValues"));
2856
- return;
2857
- }
2858
- try {
2859
- await Promise.all(writes);
2860
- setInfo(t("theme.saved"));
2861
- setState((prev) => ({ values: prev.values, touched: {} }));
2862
- scheduleCacheInvalidation();
2863
- } catch (err) {
2864
- console.error("[theme] save failed", err);
2865
- setError(err instanceof Error ? err.message : String(err));
2866
- } finally {
2867
- setSaving(false);
2868
- }
2869
- }
2870
- const groups = groupFields(manifest.fields);
2871
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-8", children: [
2872
- /* @__PURE__ */ jsxs18("form", { onSubmit: switchTheme, className: "max-w-xl space-y-3 rounded-md border p-4", children: [
2873
- /* @__PURE__ */ jsxs18("div", { className: "space-y-1", children: [
2874
- /* @__PURE__ */ jsx20(Label5, { htmlFor: "active-theme", className: "text-sm font-medium", children: t("theme.activeLabel") }),
2875
- /* @__PURE__ */ jsxs18("p", { className: "text-xs text-muted-foreground", children: [
2876
- t("theme.currentlyActive", { theme: optimisticActive }),
2877
- optimisticActive !== activeTheme && t("theme.propagating")
2878
- ] })
2879
- ] }),
2880
- /* @__PURE__ */ jsx20(
2881
- "select",
2882
- {
2883
- id: "active-theme",
2884
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
2885
- value: pendingTheme,
2886
- onChange: (e) => setPendingTheme(e.target.value),
2887
- children: themeOptions.map((opt) => /* @__PURE__ */ jsxs18("option", { value: opt.value, children: [
2888
- resolveLocalized(opt.label, locale),
2889
- " (",
2890
- opt.value,
2891
- ")"
2892
- ] }, opt.value))
2893
- }
2894
- ),
2895
- (() => {
2896
- const desc = themeOptions.find((o) => o.value === pendingTheme)?.description;
2897
- return desc ? /* @__PURE__ */ jsx20("p", { className: "text-xs text-muted-foreground", children: resolveLocalized(desc, locale) }) : null;
2898
- })(),
2899
- /* @__PURE__ */ jsx20(
2900
- Button13,
2901
- {
2902
- type: "submit",
2903
- disabled: switching || pendingTheme === optimisticActive,
2904
- variant: pendingTheme === optimisticActive ? "outline" : "default",
2905
- children: switching ? t("theme.switching") : t("theme.switchButton")
2906
- }
2907
- )
2908
- ] }),
2909
- /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
2910
- /* @__PURE__ */ jsx20(Label5, { className: "text-sm font-medium", children: t("theme.previewLabel") }),
2911
- /* @__PURE__ */ jsx20(
2912
- "iframe",
2913
- {
2914
- src: `/?previewTheme=${encodeURIComponent(pendingTheme)}`,
2915
- title: t("theme.previewLabel"),
2916
- className: "h-[600px] w-full rounded-md border bg-[var(--background)]"
2917
- },
2918
- pendingTheme
2919
- ),
2920
- /* @__PURE__ */ jsx20("p", { className: "text-xs text-muted-foreground", children: t("theme.previewHint") })
2921
- ] }),
2922
- /* @__PURE__ */ jsxs18("form", { onSubmit: save, className: "max-w-xl space-y-6", children: [
2923
- /* @__PURE__ */ jsxs18("div", { children: [
2924
- /* @__PURE__ */ jsx20("h2", { className: "text-lg font-semibold", children: t("theme.customizationHeading", {
2925
- theme: resolveLocalized(manifest.label, locale)
2926
- }) }),
2927
- manifest.description && /* @__PURE__ */ jsx20("p", { className: "text-sm text-muted-foreground", children: resolveLocalized(manifest.description, locale) }),
2928
- /* @__PURE__ */ jsx20("p", { className: "mt-1 text-xs text-muted-foreground", children: t("theme.customizationHint") })
2929
- ] }),
2930
- groups.map(({ key, name, fields }) => /* @__PURE__ */ jsxs18("fieldset", { className: "space-y-4", children: [
2931
- /* @__PURE__ */ jsx20("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: resolveLocalized(name, locale) }),
2932
- fields.map((field) => /* @__PURE__ */ jsx20(
2933
- FieldRow,
2934
- {
2935
- field,
2936
- value: state.values[field.key] ?? "",
2937
- invalid: !!invalid[field.key],
2938
- onChange: (v) => update(field.key, v)
2939
- },
2940
- field.key
2941
- ))
2942
- ] }, key)),
2943
- info && /* @__PURE__ */ jsx20("p", { className: "text-sm text-muted-foreground", children: info }),
2944
- error && /* @__PURE__ */ jsx20("p", { className: "text-sm text-destructive", children: error }),
2945
- /* @__PURE__ */ jsx20(Button13, { type: "submit", disabled: saving, children: saving ? t("theme.saving") : t("theme.saveButton") })
2946
- ] })
2947
- ] });
2948
- }
2949
- function groupFields(fields) {
2950
- const map = /* @__PURE__ */ new Map();
2951
- for (const field of fields) {
2952
- const g = field.group ?? "General";
2953
- const k = typeof g === "string" ? g : JSON.stringify(g);
2954
- const existing = map.get(k);
2955
- if (existing) {
2956
- existing.fields.push(field);
2957
- } else {
2958
- map.set(k, { name: g, fields: [field] });
2959
- }
2960
- }
2961
- return Array.from(map.entries()).map(([key, { name, fields: fields2 }]) => ({ key, name, fields: fields2 }));
2962
- }
2963
- function FieldRow({ field, value, invalid, onChange }) {
2964
- const t = useT();
2965
- const locale = useLocale();
2966
- const id = `theme-${field.key}`;
2967
- const labelEl = /* @__PURE__ */ jsx20(Label5, { htmlFor: id, className: invalid ? "text-destructive" : void 0, children: resolveLocalized(field.label, locale) });
2968
- const description = field.description ? /* @__PURE__ */ jsx20("p", { className: "text-xs text-muted-foreground", children: resolveLocalized(field.description, locale) }) : null;
2969
- switch (field.type) {
2970
- case "color":
2971
- return /* @__PURE__ */ jsx20(ColorField, { field, id, labelEl, description, value, invalid, onChange });
2972
- case "length":
2973
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
2974
- labelEl,
2975
- description,
2976
- /* @__PURE__ */ jsx20(
2977
- Input6,
2978
- {
2979
- id,
2980
- value,
2981
- placeholder: field.default,
2982
- onChange: (e) => onChange(e.target.value),
2983
- "aria-invalid": invalid,
2984
- className: "font-mono text-xs"
2985
- }
2986
- ),
2987
- /* @__PURE__ */ jsx20("p", { className: "text-xs text-muted-foreground", children: t("theme.lengthHelp") })
2988
- ] });
2989
- case "select":
2990
- case "fontFamily":
2991
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
2992
- labelEl,
2993
- description,
2994
- /* @__PURE__ */ jsxs18(
2995
- "select",
2996
- {
2997
- id,
2998
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
2999
- value: value || "",
3000
- onChange: (e) => onChange(e.target.value),
3001
- "aria-invalid": invalid,
3002
- children: [
3003
- /* @__PURE__ */ jsx20("option", { value: "", children: t("common.default") }),
3004
- field.options.map((opt) => /* @__PURE__ */ jsx20("option", { value: opt.value, children: resolveLocalized(opt.label, locale) }, opt.value))
3005
- ]
3006
- }
3007
- )
3008
- ] });
3009
- case "image":
3010
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
3011
- labelEl,
3012
- description,
3013
- /* @__PURE__ */ jsx20(
3014
- Input6,
3015
- {
3016
- id,
3017
- value,
3018
- placeholder: field.default || t("theme.imagePlaceholder"),
3019
- onChange: (e) => onChange(e.target.value),
3020
- "aria-invalid": invalid
3021
- }
3022
- )
3023
- ] });
3024
- case "text":
3025
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
3026
- labelEl,
3027
- description,
3028
- /* @__PURE__ */ jsx20(
3029
- Input6,
3030
- {
3031
- id,
3032
- value,
3033
- placeholder: field.default,
3034
- maxLength: field.maxLength,
3035
- onChange: (e) => onChange(e.target.value),
3036
- "aria-invalid": invalid
3037
- }
3038
- )
3039
- ] });
3040
- case "linkList":
3041
- return /* @__PURE__ */ jsx20(
3042
- LinkListField,
3043
- {
3044
- field,
3045
- labelEl,
3046
- description,
3047
- value,
3048
- onChange
3049
- }
3050
- );
3051
- }
3052
- }
3053
- function LinkListField({ field, labelEl, description, value, onChange }) {
3054
- const items = parseLinkList(value);
3055
- const max = field.maxItems ?? 50;
3056
- function commit(next) {
3057
- onChange(stringifyLinkList(next));
3058
- }
3059
- function update(idx, patch) {
3060
- commit(items.map((it, i) => i === idx ? { ...it, ...patch } : it));
3061
- }
3062
- function add() {
3063
- if (items.length >= max) return;
3064
- commit([...items, { label: "", url: "" }]);
3065
- }
3066
- function remove3(idx) {
3067
- commit(items.filter((_, i) => i !== idx));
3068
- }
3069
- function move(idx, delta) {
3070
- const target = idx + delta;
3071
- if (target < 0 || target >= items.length) return;
3072
- const next = items.slice();
3073
- const [moved] = next.splice(idx, 1);
3074
- next.splice(target, 0, moved);
3075
- commit(next);
3076
- }
3077
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
3078
- labelEl,
3079
- description,
3080
- /* @__PURE__ */ jsxs18("div", { className: "space-y-2 rounded-md border bg-muted/20 p-3", children: [
3081
- items.length === 0 && /* @__PURE__ */ jsx20("p", { className: "text-xs text-muted-foreground", children: "No links yet." }),
3082
- items.map((item, idx) => {
3083
- const isTagRef = /^tag:/.test(item.url.trim());
3084
- return /* @__PURE__ */ jsxs18("div", { className: "flex flex-wrap items-start gap-2", children: [
3085
- /* @__PURE__ */ jsxs18("div", { className: "grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2", children: [
3086
- /* @__PURE__ */ jsx20(
3087
- Input6,
3088
- {
3089
- value: item.label,
3090
- placeholder: "Label",
3091
- onChange: (e) => update(idx, { label: e.target.value })
3092
- }
3093
- ),
3094
- /* @__PURE__ */ jsx20(
3095
- Input6,
3096
- {
3097
- value: item.url,
3098
- placeholder: "/path or https://\u2026 or tag:name",
3099
- onChange: (e) => update(idx, { url: e.target.value }),
3100
- className: isTagRef ? "font-mono text-xs" : void 0
3101
- }
3102
- )
3103
- ] }),
3104
- /* @__PURE__ */ jsxs18("div", { className: "flex shrink-0 items-center gap-1", children: [
3105
- /* @__PURE__ */ jsx20(
3106
- Button13,
3107
- {
3108
- type: "button",
3109
- variant: "ghost",
3110
- size: "icon",
3111
- onClick: () => move(idx, -1),
3112
- disabled: idx === 0,
3113
- "aria-label": "Move up",
3114
- children: "\u2191"
3115
- }
3116
- ),
3117
- /* @__PURE__ */ jsx20(
3118
- Button13,
3119
- {
3120
- type: "button",
3121
- variant: "ghost",
3122
- size: "icon",
3123
- onClick: () => move(idx, 1),
3124
- disabled: idx === items.length - 1,
3125
- "aria-label": "Move down",
3126
- children: "\u2193"
3127
- }
3128
- ),
3129
- /* @__PURE__ */ jsx20(
3130
- Button13,
3131
- {
3132
- type: "button",
3133
- variant: "ghost",
3134
- size: "icon",
3135
- onClick: () => remove3(idx),
3136
- "aria-label": "Remove",
3137
- children: "\xD7"
3138
- }
3139
- )
3140
- ] })
3141
- ] }, idx);
3142
- }),
3143
- /* @__PURE__ */ jsx20(
3144
- Button13,
3145
- {
3146
- type: "button",
3147
- variant: "outline",
3148
- size: "sm",
3149
- onClick: add,
3150
- disabled: items.length >= max,
3151
- children: "+ Add link"
3152
- }
3153
- ),
3154
- /* @__PURE__ */ jsxs18("p", { className: "text-xs text-muted-foreground", children: [
3155
- "Tip: use ",
3156
- /* @__PURE__ */ jsx20("code", { children: "tag:<name>" }),
3157
- " as a URL to render a list of posts with that tag instead of a single link."
3158
- ] })
3159
- ] })
3160
- ] });
3161
- }
3162
- function ColorField({
3163
- field,
3164
- id,
3165
- labelEl,
3166
- description,
3167
- value,
3168
- invalid,
3169
- onChange
3170
- }) {
3171
- const effective = value || field.default;
3172
- const hex = useColorAsHex(effective);
3173
- return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
3174
- labelEl,
3175
- description,
3176
- /* @__PURE__ */ jsxs18("div", { className: "flex items-center gap-2", children: [
3177
- /* @__PURE__ */ jsx20(
3178
- "input",
3179
- {
3180
- type: "color",
3181
- value: hex,
3182
- onChange: (e) => onChange(e.target.value),
3183
- className: "h-9 w-12 cursor-pointer rounded border border-input bg-background p-0",
3184
- "aria-label": `${field.label} swatch`
3185
- }
3186
- ),
3187
- /* @__PURE__ */ jsx20(
3188
- Input6,
3189
- {
3190
- id,
3191
- value,
3192
- placeholder: field.default,
3193
- onChange: (e) => onChange(e.target.value),
3194
- "aria-invalid": invalid,
3195
- className: "font-mono text-xs"
3196
- }
3197
- )
3198
- ] }),
3199
- /* @__PURE__ */ jsxs18("div", { className: "flex items-center gap-2", children: [
3200
- /* @__PURE__ */ jsx20(
3201
- "span",
3202
- {
3203
- className: "inline-block h-4 w-4 rounded border",
3204
- style: { background: effective },
3205
- "aria-hidden": true
3206
- }
3207
- ),
3208
- /* @__PURE__ */ jsx20("code", { className: "text-xs text-muted-foreground", children: effective })
3209
- ] })
3210
- ] });
3211
- }
3212
- function useColorAsHex(value) {
3213
- if (typeof document === "undefined") return "#000000";
3214
- const m = /^#([0-9a-fA-F]{6})$/.exec(value);
3215
- if (m) return value.toLowerCase();
3216
- try {
3217
- const ctx = document.createElement("canvas").getContext("2d");
3218
- if (!ctx) return "#000000";
3219
- ctx.fillStyle = "#000000";
3220
- ctx.fillStyle = value;
3221
- const out = ctx.fillStyle;
3222
- return /^#[0-9a-fA-F]{6}$/.test(out) ? out : "#000000";
3223
- } catch {
3224
- return "#000000";
3225
- }
3226
- }
3227
-
3228
- export {
3229
- I18nProvider,
3230
- useT,
3231
- useLocale,
3232
- setAdminCmsConfig,
3233
- readAdminSiteIdFromCookie,
3234
- AdminProviders,
3235
- AdminDashboard,
3236
- PostsList,
3237
- sanitizeName,
3238
- uploadProcessedImage,
3239
- ImageUploadDialog,
3240
- MediaPicker,
3241
- PostForm,
3242
- NewPostPage,
3243
- EditPostPage,
3244
- MediaUploader,
3245
- MediaPage,
3246
- LoginPage,
3247
- Sidebar,
3248
- SiteSelector,
3249
- SiteSettingsForm,
3250
- ThemeSettingsForm
3251
- };