@ampless/admin 0.2.0-alpha.2 → 0.2.0-alpha.20

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 (49) 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-5JKOPRCO.js +41 -0
  6. package/dist/chunk-5Q6KVRZ2.js +250 -0
  7. package/dist/chunk-7IR4F7GA.js +6 -0
  8. package/dist/chunk-A3SWBQA6.js +71 -0
  9. package/dist/chunk-BC4B6DLO.js +21 -0
  10. package/dist/chunk-CQY55RDG.js +48 -0
  11. package/dist/chunk-CVJCMTYB.js +1197 -0
  12. package/dist/chunk-IM5MVZOH.js +1260 -0
  13. package/dist/chunk-JOASK4AM.js +360 -0
  14. package/dist/{chunk-TJR3ALRJ.js → chunk-OSUTPPAU.js} +171 -68
  15. package/dist/chunk-QXJIIBUQ.js +21 -0
  16. package/dist/chunk-S66L5CDS.js +335 -0
  17. package/dist/chunk-SRNH2IVA.js +149 -0
  18. package/dist/chunk-TZWSXAHD.js +32 -0
  19. package/dist/chunk-VXEVLHGL.js +10 -0
  20. package/dist/chunk-W6BXESPW.js +198 -0
  21. package/dist/chunk-XY4JWSMS.js +33 -0
  22. package/dist/components/admin-dashboard.d.ts +10 -0
  23. package/dist/components/admin-dashboard.js +9 -0
  24. package/dist/components/edit-post-view.d.ts +9 -0
  25. package/dist/components/edit-post-view.js +14 -0
  26. package/dist/components/index.d.ts +40 -21
  27. package/dist/components/index.js +32 -15
  28. package/dist/components/login-view.d.ts +5 -0
  29. package/dist/components/login-view.js +9 -0
  30. package/dist/components/mcp-tokens-view.d.ts +22 -0
  31. package/dist/components/mcp-tokens-view.js +9 -0
  32. package/dist/components/media-view.d.ts +5 -0
  33. package/dist/components/media-view.js +12 -0
  34. package/dist/components/new-post-view.d.ts +5 -0
  35. package/dist/components/new-post-view.js +14 -0
  36. package/dist/components/posts-list-view.d.ts +5 -0
  37. package/dist/components/posts-list-view.js +11 -0
  38. package/dist/components/users-list-view.d.ts +7 -0
  39. package/dist/components/users-list-view.js +9 -0
  40. package/dist/{i18n-ByHM_Bho.d.ts → i18n-MWvAMHzn.d.ts} +253 -2
  41. package/dist/index.d.ts +14 -9
  42. package/dist/index.js +18 -10
  43. package/dist/lib/theme-actions.d.ts +17 -0
  44. package/dist/lib/theme-actions.js +7 -0
  45. package/dist/metafile-esm.json +1 -1
  46. package/dist/pages/index.d.ts +67 -15
  47. package/dist/pages/index.js +147 -659
  48. package/package.json +12 -9
  49. package/dist/chunk-T2RSMFOI.js +0 -2074
@@ -1,2074 +0,0 @@
1
- import {
2
- ADMIN_SITE_COOKIE,
3
- publicMediaUrl,
4
- readAdminSiteIdFromCookie,
5
- translate
6
- } from "./chunk-TJR3ALRJ.js";
7
-
8
- // src/components/i18n-provider.tsx
9
- import { createContext, useContext, useMemo } from "react";
10
- import { jsx } from "react/jsx-runtime";
11
- var I18nContext = createContext(null);
12
- function I18nProvider({ locale, dict, children }) {
13
- const value = useMemo(() => ({ locale, dict }), [locale, dict]);
14
- return /* @__PURE__ */ jsx(I18nContext.Provider, { value, children });
15
- }
16
- function useT() {
17
- const ctx = useContext(I18nContext);
18
- if (!ctx) {
19
- throw new Error(
20
- "useT() called outside <I18nProvider>. Wrap the admin layout (or root layout) with <I18nProvider locale={...} dict={...}>."
21
- );
22
- }
23
- return (key, vars) => translate(ctx.dict, key, vars);
24
- }
25
- function useLocale() {
26
- const ctx = useContext(I18nContext);
27
- if (!ctx) throw new Error("useLocale() called outside <I18nProvider>.");
28
- return ctx.locale;
29
- }
30
-
31
- // src/lib/upload.ts
32
- import { uploadData } from "aws-amplify/storage";
33
- import { processImage } from "ampless/media";
34
- function sanitizeName(name) {
35
- return name.replace(/[\x00-\x1f\x7f]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
36
- }
37
- async function uploadProcessedImage(file, options) {
38
- const processed = await processImage(file, options);
39
- const safeName = sanitizeName(processed.suggestedName);
40
- const now = /* @__PURE__ */ new Date();
41
- const yyyy = now.getFullYear();
42
- const mm = String(now.getMonth() + 1).padStart(2, "0");
43
- const path = `public/media/${yyyy}/${mm}/${Date.now()}-${safeName}`;
44
- await uploadData({
45
- path,
46
- data: processed.blob,
47
- options: { contentType: processed.mime }
48
- }).result;
49
- return { path, url: publicMediaUrl(path) };
50
- }
51
-
52
- // src/lib/theme-actions.ts
53
- import { updateTag } from "next/cache";
54
- async function invalidateSiteSettingsCache(siteId) {
55
- updateTag(`site-settings:${siteId}`);
56
- }
57
-
58
- // src/components/sidebar.tsx
59
- import Link from "next/link";
60
- import { usePathname } from "next/navigation";
61
- import { signOut } from "aws-amplify/auth";
62
- import { LayoutDashboard, FileText, Image, Globe, LogOut, ExternalLink } from "lucide-react";
63
- import { Button, cn } from "@ampless/runtime/ui";
64
- import { jsx as jsx2, jsxs } from "react/jsx-runtime";
65
- var navItems = [
66
- { href: "/admin", key: "sidebar.dashboard", icon: LayoutDashboard },
67
- { href: "/admin/posts", key: "sidebar.posts", icon: FileText },
68
- { href: "/admin/media", key: "sidebar.media", icon: Image },
69
- { href: "/admin/sites", key: "sidebar.sites", icon: Globe }
70
- ];
71
- function Sidebar({
72
- email,
73
- siteSelector
74
- }) {
75
- const pathname = usePathname();
76
- const t = useT();
77
- return /* @__PURE__ */ jsxs("aside", { className: "flex h-screen w-60 flex-col border-r bg-muted/30", children: [
78
- /* @__PURE__ */ jsx2("div", { className: "border-b p-4", children: /* @__PURE__ */ jsx2(Link, { href: "/admin", className: "font-semibold", children: t("sidebar.brand") }) }),
79
- siteSelector ? /* @__PURE__ */ jsx2("div", { className: "border-b", children: siteSelector }) : null,
80
- /* @__PURE__ */ jsx2("nav", { className: "flex-1 space-y-1 p-2", children: navItems.map((item) => {
81
- const Icon = item.icon;
82
- const isActive = pathname === item.href || item.href !== "/admin" && pathname?.startsWith(item.href);
83
- return /* @__PURE__ */ jsxs(
84
- Link,
85
- {
86
- href: item.href,
87
- className: cn(
88
- "flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
89
- isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
90
- ),
91
- children: [
92
- /* @__PURE__ */ jsx2(Icon, { className: "h-4 w-4" }),
93
- t(item.key)
94
- ]
95
- },
96
- item.href
97
- );
98
- }) }),
99
- /* @__PURE__ */ jsxs("div", { className: "border-t p-2 space-y-1", children: [
100
- /* @__PURE__ */ jsxs(
101
- Link,
102
- {
103
- href: "/",
104
- target: "_blank",
105
- className: "flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground",
106
- children: [
107
- /* @__PURE__ */ jsx2(ExternalLink, { className: "h-4 w-4" }),
108
- t("sidebar.viewSite")
109
- ]
110
- }
111
- ),
112
- /* @__PURE__ */ jsx2("div", { className: "px-3 py-2 text-xs text-muted-foreground truncate", children: email }),
113
- /* @__PURE__ */ jsxs(
114
- Button,
115
- {
116
- variant: "ghost",
117
- size: "sm",
118
- className: "w-full justify-start gap-3",
119
- onClick: async () => {
120
- await signOut();
121
- window.location.href = "/login";
122
- },
123
- children: [
124
- /* @__PURE__ */ jsx2(LogOut, { className: "h-4 w-4" }),
125
- t("sidebar.signOut")
126
- ]
127
- }
128
- )
129
- ] })
130
- ] });
131
- }
132
-
133
- // src/components/site-selector.tsx
134
- import { useRouter } from "next/navigation";
135
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
136
- function SiteSelector({ current, sites }) {
137
- const router = useRouter();
138
- const t = useT();
139
- function onChange(e) {
140
- const next = e.target.value;
141
- document.cookie = `${ADMIN_SITE_COOKIE}=${encodeURIComponent(next)}; Path=/; Max-Age=${60 * 60 * 24 * 365}; SameSite=Lax`;
142
- router.refresh();
143
- }
144
- return /* @__PURE__ */ jsxs2("div", { className: "px-3 py-2", children: [
145
- /* @__PURE__ */ jsx3("label", { className: "block text-xs uppercase tracking-wide text-muted-foreground mb-1", children: t("sites.selector.label") }),
146
- /* @__PURE__ */ jsx3(
147
- "select",
148
- {
149
- value: current,
150
- onChange,
151
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
152
- children: sites.map((s) => /* @__PURE__ */ jsx3("option", { value: s.id, children: s.name }, s.id))
153
- }
154
- )
155
- ] });
156
- }
157
-
158
- // src/components/image-upload-dialog.tsx
159
- import { useEffect, useRef, useState } from "react";
160
- import ReactCrop, { centerCrop, makeAspectCrop } from "react-image-crop";
161
- import "react-image-crop/dist/ReactCrop.css";
162
- import { shouldSkipProcessing } from "ampless/media";
163
- import {
164
- Dialog,
165
- DialogContent,
166
- DialogDescription,
167
- DialogHeader,
168
- DialogTitle,
169
- Button as Button2,
170
- Input,
171
- Label
172
- } from "@ampless/runtime/ui";
173
- import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
174
- var ASPECTS = {
175
- free: void 0,
176
- "1:1": 1,
177
- "4:3": 4 / 3,
178
- "16:9": 16 / 9,
179
- "3:2": 3 / 2
180
- };
181
- var ASPECT_CHOICES = ["free", "1:1", "4:3", "16:9", "3:2"];
182
- var FORMAT_CHOICES = ["auto", "webp", "jpeg"];
183
- var MAX_DIMENSION_PRESETS = [640, 1024, 1600, 2400, 4e3];
184
- var MIN_DIMENSION = 100;
185
- var MAX_DIMENSION_CEILING = 8e3;
186
- function clampMaxDimension(value, fallback) {
187
- if (!Number.isFinite(value) || value <= 0) return fallback;
188
- return Math.min(MAX_DIMENSION_CEILING, Math.max(MIN_DIMENSION, Math.round(value)));
189
- }
190
- function clampQuality(value) {
191
- if (!Number.isFinite(value)) return 0.85;
192
- return Math.min(1, Math.max(0, value));
193
- }
194
- function resolveFormat(choice, inputMime, losslessForPng) {
195
- if (choice === "auto") {
196
- return {
197
- format: "webp",
198
- lossless: losslessForPng && inputMime === "image/png"
199
- };
200
- }
201
- return { format: choice, lossless: false };
202
- }
203
- function buildInitialCrop(naturalWidth, naturalHeight, aspect) {
204
- if (aspect) {
205
- return centerCrop(
206
- makeAspectCrop({ unit: "%", width: 100 }, aspect, naturalWidth, naturalHeight),
207
- naturalWidth,
208
- naturalHeight
209
- );
210
- }
211
- return { unit: "%", x: 0, y: 0, width: 100, height: 100 };
212
- }
213
- function ImageUploadDialog({
214
- file,
215
- remaining,
216
- busy = false,
217
- defaults,
218
- onConfirm,
219
- onSkip,
220
- onCancel
221
- }) {
222
- const t = useT();
223
- const defaultMaxDimension = defaults?.maxDimension ?? 2400;
224
- const defaultQuality = defaults?.quality ?? 0.85;
225
- const losslessForPng = defaults?.losslessForPng ?? true;
226
- const [original, setOriginal] = useState(false);
227
- const [aspect, setAspect] = useState("free");
228
- const [crop, setCrop] = useState(void 0);
229
- const [percentCrop, setPercentCrop] = useState(null);
230
- const [naturalSize, setNaturalSize] = useState(null);
231
- const [formatChoice, setFormatChoice] = useState("auto");
232
- const [losslessOverride, setLosslessOverride] = useState(null);
233
- const [quality, setQuality] = useState(defaultQuality);
234
- const [maxDimension, setMaxDimension] = useState(defaultMaxDimension);
235
- const [previewUrl, setPreviewUrl] = useState(null);
236
- const imgRef = useRef(null);
237
- useEffect(() => {
238
- setOriginal(false);
239
- setAspect("free");
240
- setCrop(void 0);
241
- setPercentCrop(null);
242
- setNaturalSize(null);
243
- setFormatChoice("auto");
244
- setLosslessOverride(null);
245
- setQuality(defaultQuality);
246
- setMaxDimension(defaultMaxDimension);
247
- }, [file, defaultQuality, defaultMaxDimension]);
248
- useEffect(() => {
249
- if (!naturalSize) return;
250
- const next = buildInitialCrop(naturalSize.width, naturalSize.height, ASPECTS[aspect]);
251
- setCrop(next);
252
- setPercentCrop(next);
253
- }, [aspect, naturalSize]);
254
- useEffect(() => {
255
- if (!file) {
256
- setPreviewUrl(null);
257
- return;
258
- }
259
- const url = URL.createObjectURL(file);
260
- setPreviewUrl(url);
261
- return () => URL.revokeObjectURL(url);
262
- }, [file]);
263
- if (!file) return null;
264
- const isImage = file.type.startsWith("image/");
265
- const passthrough = !isImage || shouldSkipProcessing(file.type);
266
- const showCropper = !passthrough && !original;
267
- const { format, lossless: autoLossless } = resolveFormat(formatChoice, file.type, losslessForPng);
268
- const lossless = losslessOverride ?? autoLossless;
269
- const showLosslessToggle = !original && !passthrough && format === "webp";
270
- const showQualitySlider = !original && !passthrough && (format === "jpeg" || format === "webp" && !lossless);
271
- function handleImageLoad(e) {
272
- const { naturalWidth, naturalHeight } = e.currentTarget;
273
- setNaturalSize({ width: naturalWidth, height: naturalHeight });
274
- const initial = buildInitialCrop(naturalWidth, naturalHeight, ASPECTS[aspect]);
275
- setCrop(initial);
276
- setPercentCrop(initial);
277
- }
278
- function handleConfirm() {
279
- if (!file || busy) return;
280
- if (original || passthrough) {
281
- onConfirm(file, { original: true });
282
- return;
283
- }
284
- let cropArea = void 0;
285
- if (percentCrop && naturalSize) {
286
- const x = Math.round(percentCrop.x / 100 * naturalSize.width);
287
- const y = Math.round(percentCrop.y / 100 * naturalSize.height);
288
- const width = Math.round(percentCrop.width / 100 * naturalSize.width);
289
- const height = Math.round(percentCrop.height / 100 * naturalSize.height);
290
- if (width > 0 && height > 0 && (x !== 0 || y !== 0 || width !== naturalSize.width || height !== naturalSize.height)) {
291
- cropArea = { x, y, width, height };
292
- }
293
- }
294
- onConfirm(file, {
295
- crop: cropArea,
296
- maxDimension: clampMaxDimension(maxDimension, defaultMaxDimension),
297
- format,
298
- quality: clampQuality(quality),
299
- lossless: format === "webp" ? lossless : false
300
- });
301
- }
302
- return /* @__PURE__ */ jsx4(
303
- Dialog,
304
- {
305
- open: true,
306
- onOpenChange: (open) => {
307
- if (open) return;
308
- onCancel();
309
- },
310
- children: /* @__PURE__ */ jsxs3(DialogContent, { className: "max-h-[90vh] max-w-4xl overflow-y-auto", children: [
311
- /* @__PURE__ */ jsxs3(DialogHeader, { children: [
312
- /* @__PURE__ */ jsx4(DialogTitle, { className: "truncate", children: file.name }),
313
- /* @__PURE__ */ jsxs3(DialogDescription, { children: [
314
- remaining > 1 ? t("media.dialog.remaining", { count: remaining }) : `${formatBytes(file.size)} \xB7 ${file.type || "unknown"}`,
315
- busy && t("media.dialog.uploading")
316
- ] })
317
- ] }),
318
- previewUrl && showCropper && /* @__PURE__ */ jsx4("div", { className: "flex items-center justify-center rounded-md bg-black/90 p-2", children: /* @__PURE__ */ jsx4(
319
- ReactCrop,
320
- {
321
- crop,
322
- aspect: ASPECTS[aspect],
323
- minWidth: 20,
324
- minHeight: 20,
325
- onChange: (_pixel, percent) => {
326
- setCrop(percent);
327
- setPercentCrop(percent);
328
- },
329
- children: /* @__PURE__ */ jsx4(
330
- "img",
331
- {
332
- ref: imgRef,
333
- src: previewUrl,
334
- alt: "preview",
335
- className: "block max-h-[60vh] max-w-full",
336
- onLoad: handleImageLoad
337
- }
338
- )
339
- }
340
- ) }),
341
- previewUrl && !showCropper && isImage && /* @__PURE__ */ jsx4("div", { className: "flex h-48 items-center justify-center rounded-md bg-muted", children: /* @__PURE__ */ jsx4("img", { src: previewUrl, alt: "preview", className: "max-h-full max-w-full object-contain" }) }),
342
- !isImage && // Non-image upload: skip the broken-img preview. Show the
343
- // file's name / size / mime so the admin can confirm before
344
- // committing the bytes to S3.
345
- /* @__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: [
346
- /* @__PURE__ */ jsx4("span", { className: "font-medium", children: file.name }),
347
- /* @__PURE__ */ jsxs3("span", { className: "font-mono text-xs", children: [
348
- formatBytes(file.size),
349
- " \xB7 ",
350
- file.type || "unknown"
351
- ] })
352
- ] }),
353
- /* @__PURE__ */ jsxs3("div", { className: "space-y-4", children: [
354
- /* @__PURE__ */ jsxs3("label", { className: "flex items-center gap-2 text-sm", children: [
355
- /* @__PURE__ */ jsx4(
356
- "input",
357
- {
358
- type: "checkbox",
359
- checked: original,
360
- disabled: busy,
361
- onChange: (e) => setOriginal(e.target.checked)
362
- }
363
- ),
364
- /* @__PURE__ */ jsx4("span", { children: t("media.dialog.useOriginal") }),
365
- passthrough && /* @__PURE__ */ jsx4("span", { className: "text-xs text-muted-foreground", children: t("media.dialog.passthroughNote") })
366
- ] }),
367
- !original && !passthrough && /* @__PURE__ */ jsxs3(Fragment, { children: [
368
- /* @__PURE__ */ jsxs3("div", { children: [
369
- /* @__PURE__ */ jsx4(Label, { children: t("media.dialog.aspectRatio") }),
370
- /* @__PURE__ */ jsx4("div", { className: "mt-2 flex flex-wrap gap-2", children: ASPECT_CHOICES.map((choice) => /* @__PURE__ */ jsx4(
371
- Button2,
372
- {
373
- type: "button",
374
- variant: aspect === choice ? "default" : "outline",
375
- size: "sm",
376
- disabled: busy,
377
- onClick: () => setAspect(choice),
378
- children: choice
379
- },
380
- choice
381
- )) })
382
- ] }),
383
- /* @__PURE__ */ jsxs3("div", { children: [
384
- /* @__PURE__ */ jsx4(Label, { children: t("media.dialog.outputFormat") }),
385
- /* @__PURE__ */ jsx4("div", { className: "mt-2 flex flex-wrap gap-2", children: FORMAT_CHOICES.map((choice) => /* @__PURE__ */ jsx4(
386
- Button2,
387
- {
388
- type: "button",
389
- variant: formatChoice === choice ? "default" : "outline",
390
- size: "sm",
391
- disabled: busy,
392
- onClick: () => {
393
- setFormatChoice(choice);
394
- setLosslessOverride(null);
395
- },
396
- children: choice
397
- },
398
- choice
399
- )) })
400
- ] }),
401
- showLosslessToggle && /* @__PURE__ */ jsxs3("label", { className: "flex items-center gap-2 text-sm", children: [
402
- /* @__PURE__ */ jsx4(
403
- "input",
404
- {
405
- type: "checkbox",
406
- checked: lossless,
407
- disabled: busy,
408
- onChange: (e) => setLosslessOverride(e.target.checked)
409
- }
410
- ),
411
- /* @__PURE__ */ jsx4("span", { children: t("media.dialog.losslessWebp") })
412
- ] }),
413
- showQualitySlider && /* @__PURE__ */ jsxs3("div", { children: [
414
- /* @__PURE__ */ jsx4(Label, { children: t("media.dialog.quality", { value: Math.round(quality * 100) }) }),
415
- /* @__PURE__ */ jsx4(
416
- "input",
417
- {
418
- type: "range",
419
- min: 50,
420
- max: 100,
421
- step: 1,
422
- disabled: busy,
423
- value: Math.round(quality * 100),
424
- onChange: (e) => setQuality(Number(e.target.value) / 100),
425
- className: "mt-2 w-full"
426
- }
427
- )
428
- ] }),
429
- /* @__PURE__ */ jsxs3("div", { className: "max-w-xs", children: [
430
- /* @__PURE__ */ jsx4(Label, { htmlFor: "maxDimension", children: t("media.dialog.maxDimension") }),
431
- /* @__PURE__ */ jsx4("div", { className: "mt-2 flex flex-wrap gap-2", children: MAX_DIMENSION_PRESETS.map((preset) => /* @__PURE__ */ jsx4(
432
- Button2,
433
- {
434
- type: "button",
435
- variant: maxDimension === preset ? "default" : "outline",
436
- size: "sm",
437
- disabled: busy,
438
- onClick: () => setMaxDimension(preset),
439
- children: preset
440
- },
441
- preset
442
- )) }),
443
- /* @__PURE__ */ jsx4(
444
- Input,
445
- {
446
- id: "maxDimension",
447
- type: "number",
448
- className: "mt-2",
449
- min: MIN_DIMENSION,
450
- max: MAX_DIMENSION_CEILING,
451
- disabled: busy,
452
- value: maxDimension,
453
- onChange: (e) => setMaxDimension(Number(e.target.value) || defaultMaxDimension)
454
- }
455
- )
456
- ] })
457
- ] })
458
- ] }),
459
- /* @__PURE__ */ jsxs3("div", { className: "flex justify-end gap-2", children: [
460
- /* @__PURE__ */ jsx4(Button2, { variant: "ghost", type: "button", onClick: onCancel, children: t("media.dialog.cancelAll") }),
461
- /* @__PURE__ */ jsx4(Button2, { variant: "outline", type: "button", disabled: busy, onClick: onSkip, children: t("media.dialog.skip") }),
462
- /* @__PURE__ */ jsx4(Button2, { type: "button", disabled: busy, onClick: handleConfirm, children: busy ? t("media.dialog.uploadingButton") : t("media.dialog.upload") })
463
- ] })
464
- ] })
465
- }
466
- );
467
- }
468
- function formatBytes(bytes) {
469
- if (bytes < 1024) return `${bytes} B`;
470
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
471
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
472
- }
473
-
474
- // src/components/media-picker.tsx
475
- import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
476
- import { list } from "aws-amplify/storage";
477
- import { Upload } from "lucide-react";
478
- import {
479
- Dialog as Dialog2,
480
- DialogContent as DialogContent2,
481
- DialogDescription as DialogDescription2,
482
- DialogHeader as DialogHeader2,
483
- DialogTitle as DialogTitle2,
484
- DialogTrigger,
485
- Button as Button3
486
- } from "@ampless/runtime/ui";
487
-
488
- // src/lib/admin-config-client.ts
489
- var cmsConfig = null;
490
- function setAdminCmsConfigClient(config) {
491
- cmsConfig = config;
492
- }
493
- function getMediaProcessingDefaults() {
494
- return cmsConfig?.media?.processing;
495
- }
496
-
497
- // src/components/media-picker.tsx
498
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
499
- function MediaPicker({ trigger, onSelect }) {
500
- const t = useT();
501
- const [open, setOpen] = useState2(false);
502
- const [items, setItems] = useState2([]);
503
- const [loading, setLoading] = useState2(false);
504
- const [error, setError] = useState2(null);
505
- const [pendingUpload, setPendingUpload] = useState2(null);
506
- const [uploading, setUploading] = useState2(false);
507
- const fileInputRef = useRef2(null);
508
- useEffect2(() => {
509
- if (!open) return;
510
- let cancelled = false;
511
- setLoading(true);
512
- setError(null);
513
- list({ path: "public/media/" }).then((result) => {
514
- if (cancelled) return;
515
- setItems(result.items.map((i) => i.path));
516
- }).catch((err) => {
517
- if (!cancelled) setError(err instanceof Error ? err.message : String(err));
518
- }).finally(() => {
519
- if (!cancelled) setLoading(false);
520
- });
521
- return () => {
522
- cancelled = true;
523
- };
524
- }, [open]);
525
- function handlePick(path) {
526
- onSelect(publicMediaUrl(path));
527
- setOpen(false);
528
- }
529
- function handleFileSelected(e) {
530
- const file = e.target.files?.[0];
531
- e.target.value = "";
532
- if (!file) return;
533
- setPendingUpload(file);
534
- }
535
- async function handleUploadConfirm(file, options) {
536
- setUploading(true);
537
- setError(null);
538
- try {
539
- const { url } = await uploadProcessedImage(file, options);
540
- onSelect(url);
541
- setPendingUpload(null);
542
- setOpen(false);
543
- } catch (err) {
544
- setError(err instanceof Error ? err.message : String(err));
545
- } finally {
546
- setUploading(false);
547
- }
548
- }
549
- const pickerOpen = open && !pendingUpload;
550
- return /* @__PURE__ */ jsxs4(Fragment2, { children: [
551
- /* @__PURE__ */ jsxs4(Dialog2, { open: pickerOpen, onOpenChange: (next) => setOpen(next), children: [
552
- /* @__PURE__ */ jsx5(DialogTrigger, { asChild: true, onClick: () => setOpen(true), children: trigger }),
553
- /* @__PURE__ */ jsxs4(DialogContent2, { children: [
554
- /* @__PURE__ */ jsx5(DialogHeader2, { children: /* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-between gap-4", children: [
555
- /* @__PURE__ */ jsxs4("div", { children: [
556
- /* @__PURE__ */ jsx5(DialogTitle2, { children: t("mediaPicker.title") }),
557
- /* @__PURE__ */ jsx5(DialogDescription2, { children: t("mediaPicker.description") })
558
- ] }),
559
- /* @__PURE__ */ jsxs4(
560
- Button3,
561
- {
562
- type: "button",
563
- variant: "outline",
564
- size: "sm",
565
- onClick: () => fileInputRef.current?.click(),
566
- children: [
567
- /* @__PURE__ */ jsx5(Upload, { className: "mr-2 h-3 w-3" }),
568
- t("mediaPicker.uploadNew")
569
- ]
570
- }
571
- )
572
- ] }) }),
573
- /* @__PURE__ */ jsx5(
574
- "input",
575
- {
576
- ref: fileInputRef,
577
- type: "file",
578
- accept: "image/*",
579
- className: "hidden",
580
- onChange: handleFileSelected
581
- }
582
- ),
583
- loading && /* @__PURE__ */ jsx5("p", { className: "text-sm text-muted-foreground", children: t("common.loading") }),
584
- error && /* @__PURE__ */ jsx5("p", { className: "text-sm text-destructive", children: error }),
585
- !loading && items.length === 0 && /* @__PURE__ */ jsxs4("p", { className: "text-sm text-muted-foreground", children: [
586
- t("mediaPicker.empty"),
587
- " ",
588
- t("mediaPicker.emptyHint", { action: t("mediaPicker.emptyAction") })
589
- ] }),
590
- /* @__PURE__ */ jsx5("div", { className: "grid max-h-[60vh] grid-cols-3 gap-3 overflow-auto sm:grid-cols-4", children: items.map((path) => /* @__PURE__ */ jsxs4(
591
- "button",
592
- {
593
- type: "button",
594
- onClick: () => handlePick(path),
595
- className: "group overflow-hidden rounded-md border transition hover:border-primary",
596
- children: [
597
- /* @__PURE__ */ jsx5(
598
- "img",
599
- {
600
- src: publicMediaUrl(path),
601
- alt: path,
602
- className: "aspect-square w-full object-cover"
603
- }
604
- ),
605
- /* @__PURE__ */ jsx5("div", { className: "truncate p-1 text-xs text-muted-foreground", children: path.split("/").pop() })
606
- ]
607
- },
608
- path
609
- )) })
610
- ] })
611
- ] }),
612
- /* @__PURE__ */ jsx5(
613
- ImageUploadDialog,
614
- {
615
- file: pendingUpload,
616
- remaining: pendingUpload ? 1 : 0,
617
- busy: uploading,
618
- defaults: getMediaProcessingDefaults(),
619
- onConfirm: handleUploadConfirm,
620
- onSkip: () => setPendingUpload(null),
621
- onCancel: () => setPendingUpload(null)
622
- }
623
- )
624
- ] });
625
- }
626
-
627
- // src/components/post-form.tsx
628
- import { useRef as useRef3, useState as useState3 } from "react";
629
- import { useRouter as useRouter2 } from "next/navigation";
630
- import { Image as ImageIcon3 } from "lucide-react";
631
- import {
632
- createPost,
633
- updatePost,
634
- deletePost,
635
- formatDate
636
- } from "ampless";
637
- import {
638
- renderBody,
639
- tiptapToHtml,
640
- tiptapToMarkdown,
641
- markdownToHtml,
642
- htmlToMarkdown
643
- } from "@ampless/runtime";
644
- import { Button as Button6, Input as Input2, Label as Label2, Textarea } from "@ampless/runtime/ui";
645
-
646
- // src/editor/tiptap-editor.tsx
647
- import { useEditor, EditorContent } from "@tiptap/react";
648
- import StarterKit from "@tiptap/starter-kit";
649
- import Link2 from "@tiptap/extension-link";
650
- import Image2 from "@tiptap/extension-image";
651
-
652
- // src/editor/toolbar.tsx
653
- import {
654
- Bold,
655
- Italic,
656
- Heading1,
657
- Heading2,
658
- List,
659
- ListOrdered,
660
- Code,
661
- Link as LinkIcon,
662
- Image as ImageIcon
663
- } from "lucide-react";
664
- import { Button as Button4, cn as cn2 } from "@ampless/runtime/ui";
665
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
666
- function Toolbar({ editor }) {
667
- const t = useT();
668
- if (!editor) return null;
669
- const tools = [
670
- { name: "bold", icon: Bold, action: () => editor.chain().focus().toggleBold().run(), isActive: () => editor.isActive("bold") },
671
- { name: "italic", icon: Italic, action: () => editor.chain().focus().toggleItalic().run(), isActive: () => editor.isActive("italic") },
672
- { name: "h1", icon: Heading1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: () => editor.isActive("heading", { level: 1 }) },
673
- { name: "h2", icon: Heading2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => editor.isActive("heading", { level: 2 }) },
674
- { name: "bulletList", icon: List, action: () => editor.chain().focus().toggleBulletList().run(), isActive: () => editor.isActive("bulletList") },
675
- { name: "orderedList", icon: ListOrdered, action: () => editor.chain().focus().toggleOrderedList().run(), isActive: () => editor.isActive("orderedList") },
676
- { name: "code", icon: Code, action: () => editor.chain().focus().toggleCodeBlock().run(), isActive: () => editor.isActive("codeBlock") }
677
- ];
678
- const setLink = () => {
679
- const previousUrl = editor.getAttributes("link").href ?? "";
680
- const url = window.prompt(t("editor.linkPrompt"), previousUrl);
681
- if (url === null) return;
682
- if (url === "") {
683
- editor.chain().focus().extendMarkRange("link").unsetLink().run();
684
- return;
685
- }
686
- editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
687
- };
688
- const insertImage = (url) => {
689
- editor.chain().focus().setImage({ src: url }).run();
690
- };
691
- return /* @__PURE__ */ jsxs5("div", { className: "flex flex-wrap gap-1 border-b p-2", children: [
692
- tools.map((tool) => {
693
- const Icon = tool.icon;
694
- return /* @__PURE__ */ jsx6(
695
- Button4,
696
- {
697
- type: "button",
698
- variant: "ghost",
699
- size: "icon",
700
- onClick: tool.action,
701
- className: cn2(tool.isActive() && "bg-accent text-accent-foreground"),
702
- children: /* @__PURE__ */ jsx6(Icon, { className: "h-4 w-4" })
703
- },
704
- tool.name
705
- );
706
- }),
707
- /* @__PURE__ */ jsx6(
708
- Button4,
709
- {
710
- type: "button",
711
- variant: "ghost",
712
- size: "icon",
713
- onClick: setLink,
714
- className: cn2(editor.isActive("link") && "bg-accent text-accent-foreground"),
715
- children: /* @__PURE__ */ jsx6(LinkIcon, { className: "h-4 w-4" })
716
- }
717
- ),
718
- /* @__PURE__ */ jsx6(
719
- MediaPicker,
720
- {
721
- onSelect: insertImage,
722
- trigger: /* @__PURE__ */ jsx6(Button4, { type: "button", variant: "ghost", size: "icon", children: /* @__PURE__ */ jsx6(ImageIcon, { className: "h-4 w-4" }) })
723
- }
724
- )
725
- ] });
726
- }
727
-
728
- // src/editor/image-bubble-menu.tsx
729
- import { BubbleMenu } from "@tiptap/react/menus";
730
- import { Trash2, Pencil, ImageIcon as ImageIcon2, Maximize2 } from "lucide-react";
731
- import { Button as Button5, cn as cn3 } from "@ampless/runtime/ui";
732
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
733
- function ImageBubbleMenu({ editor }) {
734
- const t = useT();
735
- const editAlt = () => {
736
- const current = editor.getAttributes("image").alt ?? "";
737
- const alt = window.prompt(t("editor.altPrompt"), current);
738
- if (alt === null) return;
739
- editor.chain().focus().updateAttributes("image", { alt }).run();
740
- };
741
- const remove2 = () => {
742
- editor.chain().focus().deleteSelection().run();
743
- };
744
- const setDisplay = (display) => {
745
- const current = editor.getAttributes("image").display ?? null;
746
- const next = current === display ? null : display;
747
- editor.chain().focus().updateAttributes("image", { display: next }).run();
748
- };
749
- const currentDisplay = editor.getAttributes("image").display ?? null;
750
- return /* @__PURE__ */ jsx7(
751
- BubbleMenu,
752
- {
753
- editor,
754
- shouldShow: ({ editor: editor2 }) => editor2.isActive("image"),
755
- options: { placement: "top" },
756
- children: /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1 rounded-md border bg-popover p-1 shadow", children: [
757
- /* @__PURE__ */ jsxs6(
758
- Button5,
759
- {
760
- type: "button",
761
- variant: "ghost",
762
- size: "sm",
763
- onClick: () => setDisplay("inline"),
764
- className: cn3(currentDisplay === "inline" && "bg-accent text-accent-foreground"),
765
- title: t("editor.image.inlineTitle"),
766
- children: [
767
- /* @__PURE__ */ jsx7(ImageIcon2, { className: "mr-1 h-3 w-3" }),
768
- t("editor.image.inline")
769
- ]
770
- }
771
- ),
772
- /* @__PURE__ */ jsxs6(
773
- Button5,
774
- {
775
- type: "button",
776
- variant: "ghost",
777
- size: "sm",
778
- onClick: () => setDisplay("lightbox"),
779
- className: cn3(currentDisplay === "lightbox" && "bg-accent text-accent-foreground"),
780
- title: t("editor.image.lightboxTitle"),
781
- children: [
782
- /* @__PURE__ */ jsx7(Maximize2, { className: "mr-1 h-3 w-3" }),
783
- t("editor.image.lightbox")
784
- ]
785
- }
786
- ),
787
- /* @__PURE__ */ jsx7("span", { className: "mx-1 h-4 w-px bg-border" }),
788
- /* @__PURE__ */ jsxs6(Button5, { type: "button", variant: "ghost", size: "sm", onClick: editAlt, children: [
789
- /* @__PURE__ */ jsx7(Pencil, { className: "mr-1 h-3 w-3" }),
790
- t("editor.image.alt")
791
- ] }),
792
- /* @__PURE__ */ jsxs6(Button5, { type: "button", variant: "ghost", size: "sm", onClick: remove2, children: [
793
- /* @__PURE__ */ jsx7(Trash2, { className: "mr-1 h-3 w-3" }),
794
- t("editor.image.delete")
795
- ] })
796
- ] })
797
- }
798
- );
799
- }
800
-
801
- // src/editor/tiptap-editor.tsx
802
- import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
803
- var AmplessImage = Image2.extend({
804
- addAttributes() {
805
- return {
806
- ...this.parent?.(),
807
- display: {
808
- default: null,
809
- parseHTML: (el) => el.getAttribute("data-display"),
810
- renderHTML: (attrs) => {
811
- const v = attrs.display;
812
- return v ? { "data-display": v } : {};
813
- }
814
- }
815
- };
816
- }
817
- });
818
- function TiptapEditor({ initialContent, onChange }) {
819
- const editor = useEditor({
820
- extensions: [
821
- StarterKit,
822
- Link2.configure({ openOnClick: false }),
823
- AmplessImage.configure({ inline: false, allowBase64: false })
824
- ],
825
- content: initialContent ?? { type: "doc", content: [{ type: "paragraph" }] },
826
- immediatelyRender: false,
827
- editorProps: {
828
- attributes: {
829
- class: "prose prose-neutral dark:prose-invert max-w-none min-h-[400px] px-4 py-3 focus:outline-none"
830
- }
831
- },
832
- onCreate: ({ editor: editor2 }) => {
833
- onChange?.(editor2.getJSON());
834
- },
835
- onUpdate: ({ editor: editor2 }) => {
836
- onChange?.(editor2.getJSON());
837
- }
838
- });
839
- return /* @__PURE__ */ jsxs7("div", { className: "rounded-md border", children: [
840
- /* @__PURE__ */ jsx8(Toolbar, { editor }),
841
- editor && /* @__PURE__ */ jsx8(ImageBubbleMenu, { editor }),
842
- /* @__PURE__ */ jsx8(EditorContent, { editor })
843
- ] });
844
- }
845
-
846
- // src/components/post-form.tsx
847
- import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
848
- var EMPTY_TIPTAP_DOC = { type: "doc", content: [{ type: "paragraph" }] };
849
- var IMAGE_URL_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)(\?|$)/i;
850
- var STYLESHEET_URL_RE = /\.css(\?|$)/i;
851
- var SCRIPT_URL_RE = /\.m?js(\?|$)/i;
852
- function snippetFor(url, format) {
853
- const isImage = IMAGE_URL_RE.test(url);
854
- if (format === "markdown") {
855
- return isImage ? `![](${url})` : url;
856
- }
857
- if (isImage) return `<img src="${url}" alt="" />`;
858
- if (STYLESHEET_URL_RE.test(url)) return `<link rel="stylesheet" href="${url}" />`;
859
- if (SCRIPT_URL_RE.test(url)) return `<script src="${url}"></script>`;
860
- return url;
861
- }
862
- function slugify(s) {
863
- return s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
864
- }
865
- function defaultBodyForFormat(format) {
866
- if (format === "tiptap") return EMPTY_TIPTAP_DOC;
867
- return "";
868
- }
869
- function PostForm({ post }) {
870
- const router = useRouter2();
871
- const t = useT();
872
- const isEdit = !!post;
873
- const bodyTextareaRef = useRef3(null);
874
- const [title, setTitle] = useState3(post?.title ?? "");
875
- const [slug, setSlug] = useState3(post?.slug ?? "");
876
- const [excerpt, setExcerpt] = useState3(post?.excerpt ?? "");
877
- const [format, setFormat] = useState3(post?.format ?? "tiptap");
878
- const [body, setBody] = useState3(post?.body ?? EMPTY_TIPTAP_DOC);
879
- const [status, setStatus] = useState3(post?.status ?? "draft");
880
- const [tagsInput, setTagsInput] = useState3((post?.tags ?? []).join(", "));
881
- const [saving, setSaving] = useState3(false);
882
- const [error, setError] = useState3(null);
883
- const [view, setView] = useState3("edit");
884
- function parseTags(raw) {
885
- return Array.from(
886
- new Set(
887
- raw.split(",").map((t2) => t2.trim()).filter(Boolean)
888
- )
889
- );
890
- }
891
- function insertMediaSnippet(url) {
892
- if (format === "tiptap") return;
893
- const snippet = snippetFor(url, format);
894
- const ta = bodyTextareaRef.current;
895
- const current = typeof body === "string" ? body : "";
896
- if (!ta) {
897
- setBody(current + snippet);
898
- return;
899
- }
900
- const start = ta.selectionStart ?? current.length;
901
- const end = ta.selectionEnd ?? current.length;
902
- const next = current.slice(0, start) + snippet + current.slice(end);
903
- setBody(next);
904
- requestAnimationFrame(() => {
905
- const t2 = bodyTextareaRef.current;
906
- if (!t2) return;
907
- t2.focus();
908
- const pos = start + snippet.length;
909
- t2.setSelectionRange(pos, pos);
910
- });
911
- }
912
- function changeFormat(next) {
913
- if (next === format) return;
914
- let nextBody = body;
915
- const k = `${format}\u2192${next}`;
916
- switch (k) {
917
- case "tiptap\u2192html":
918
- nextBody = tiptapToHtml(body);
919
- break;
920
- case "tiptap\u2192markdown":
921
- nextBody = tiptapToMarkdown(body);
922
- break;
923
- case "html\u2192tiptap":
924
- nextBody = String(body ?? "");
925
- break;
926
- case "markdown\u2192tiptap":
927
- nextBody = markdownToHtml(String(body ?? ""));
928
- break;
929
- case "html\u2192markdown":
930
- nextBody = htmlToMarkdown(String(body ?? ""));
931
- break;
932
- case "markdown\u2192html":
933
- nextBody = markdownToHtml(String(body ?? ""));
934
- break;
935
- default:
936
- nextBody = defaultBodyForFormat(next);
937
- }
938
- setFormat(next);
939
- setBody(nextBody);
940
- }
941
- async function save(e) {
942
- e.preventDefault();
943
- setSaving(true);
944
- setError(null);
945
- try {
946
- const tags = parseTags(tagsInput);
947
- if (isEdit) {
948
- await updatePost(
949
- post.postId,
950
- {
951
- title,
952
- slug: slug || slugify(title),
953
- excerpt: excerpt || void 0,
954
- format,
955
- body,
956
- status,
957
- publishedAt: status === "published" ? post?.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString() : void 0,
958
- tags
959
- },
960
- { siteId: post.siteId }
961
- );
962
- } else {
963
- await createPost({
964
- siteId: readAdminSiteIdFromCookie(),
965
- slug: slug || slugify(title),
966
- title,
967
- excerpt: excerpt || void 0,
968
- format,
969
- body,
970
- status,
971
- publishedAt: status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0,
972
- tags
973
- });
974
- }
975
- router.push("/admin/posts");
976
- router.refresh();
977
- } catch (err) {
978
- setError(err instanceof Error ? err.message : String(err));
979
- } finally {
980
- setSaving(false);
981
- }
982
- }
983
- async function handleDelete() {
984
- if (!post) return;
985
- if (!confirm(t("posts.form.deleteConfirm", { title: post.title }))) return;
986
- setSaving(true);
987
- try {
988
- await deletePost(post.postId);
989
- router.push("/admin/posts");
990
- router.refresh();
991
- } catch (err) {
992
- setError(err instanceof Error ? err.message : String(err));
993
- setSaving(false);
994
- }
995
- }
996
- const previewPost = {
997
- postId: post?.postId ?? "preview",
998
- siteId: post?.siteId ?? readAdminSiteIdFromCookie(),
999
- slug: slug || slugify(title) || "preview",
1000
- title,
1001
- excerpt: excerpt || void 0,
1002
- format,
1003
- body,
1004
- status,
1005
- publishedAt: status === "published" ? post?.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1006
- tags: parseTags(tagsInput)
1007
- };
1008
- return /* @__PURE__ */ jsxs8("form", { onSubmit: save, className: "space-y-6", children: [
1009
- /* @__PURE__ */ jsxs8("div", { className: "flex gap-1 border-b", children: [
1010
- /* @__PURE__ */ jsx9(
1011
- "button",
1012
- {
1013
- type: "button",
1014
- onClick: () => setView("edit"),
1015
- "aria-pressed": view === "edit",
1016
- 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"}`,
1017
- children: t("posts.form.tabEdit")
1018
- }
1019
- ),
1020
- /* @__PURE__ */ jsx9(
1021
- "button",
1022
- {
1023
- type: "button",
1024
- onClick: () => setView("preview"),
1025
- "aria-pressed": view === "preview",
1026
- 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"}`,
1027
- children: t("posts.form.tabPreview")
1028
- }
1029
- )
1030
- ] }),
1031
- view === "preview" && /* @__PURE__ */ jsxs8("article", { className: "space-y-4", children: [
1032
- /* @__PURE__ */ jsxs8("header", { className: "border-b pb-4", children: [
1033
- /* @__PURE__ */ jsx9("h1", { className: "text-3xl font-bold tracking-tight", children: title || /* @__PURE__ */ jsx9("span", { className: "text-muted-foreground italic", children: t("posts.form.previewNoTitle") }) }),
1034
- /* @__PURE__ */ jsxs8("p", { className: "mt-2 text-sm text-muted-foreground", children: [
1035
- previewPost.publishedAt ? /* @__PURE__ */ jsx9("time", { dateTime: previewPost.publishedAt, children: formatDate(previewPost.publishedAt) }) : /* @__PURE__ */ jsx9("span", { children: t("common.draft") }),
1036
- /* @__PURE__ */ jsx9("span", { className: "mx-2", children: "\xB7" }),
1037
- /* @__PURE__ */ jsx9("span", { className: "font-mono text-xs uppercase", children: format })
1038
- ] }),
1039
- excerpt && /* @__PURE__ */ jsx9("p", { className: "mt-3 text-base text-muted-foreground", children: excerpt })
1040
- ] }),
1041
- /* @__PURE__ */ jsx9(
1042
- "div",
1043
- {
1044
- className: "prose prose-neutral dark:prose-invert max-w-none",
1045
- dangerouslySetInnerHTML: { __html: renderBody(previewPost) }
1046
- }
1047
- ),
1048
- previewPost.tags && previewPost.tags.length > 0 && /* @__PURE__ */ jsx9("div", { className: "flex flex-wrap gap-2 border-t pt-4 text-sm", children: previewPost.tags.map((tag) => /* @__PURE__ */ jsxs8(
1049
- "span",
1050
- {
1051
- className: "rounded-full border px-2 py-0.5 text-xs text-muted-foreground",
1052
- children: [
1053
- "#",
1054
- tag
1055
- ]
1056
- },
1057
- tag
1058
- )) }),
1059
- /* @__PURE__ */ jsx9("p", { className: "text-xs text-muted-foreground", children: t("posts.form.previewHint") })
1060
- ] }),
1061
- /* @__PURE__ */ jsxs8("div", { className: view === "edit" ? "space-y-6" : "hidden", children: [
1062
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1063
- /* @__PURE__ */ jsx9(Label2, { htmlFor: "title", children: t("posts.form.title") }),
1064
- /* @__PURE__ */ jsx9(
1065
- Input2,
1066
- {
1067
- id: "title",
1068
- required: true,
1069
- value: title,
1070
- onChange: (e) => {
1071
- setTitle(e.target.value);
1072
- if (!isEdit && !slug) setSlug(slugify(e.target.value));
1073
- }
1074
- }
1075
- )
1076
- ] }),
1077
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1078
- /* @__PURE__ */ jsx9(Label2, { htmlFor: "slug", children: t("posts.form.slug") }),
1079
- /* @__PURE__ */ jsx9(
1080
- Input2,
1081
- {
1082
- id: "slug",
1083
- value: slug,
1084
- onChange: (e) => setSlug(e.target.value),
1085
- placeholder: slugify(title) || t("posts.form.slugPlaceholder")
1086
- }
1087
- )
1088
- ] }),
1089
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1090
- /* @__PURE__ */ jsx9(Label2, { htmlFor: "excerpt", children: t("posts.form.excerpt") }),
1091
- /* @__PURE__ */ jsx9(
1092
- Textarea,
1093
- {
1094
- id: "excerpt",
1095
- rows: 2,
1096
- value: excerpt,
1097
- onChange: (e) => setExcerpt(e.target.value)
1098
- }
1099
- )
1100
- ] }),
1101
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1102
- /* @__PURE__ */ jsx9(Label2, { htmlFor: "format", children: t("posts.form.format") }),
1103
- /* @__PURE__ */ jsxs8(
1104
- "select",
1105
- {
1106
- id: "format",
1107
- value: format,
1108
- onChange: (e) => changeFormat(e.target.value),
1109
- className: "flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
1110
- children: [
1111
- /* @__PURE__ */ jsx9("option", { value: "tiptap", children: "Tiptap (rich editor)" }),
1112
- /* @__PURE__ */ jsx9("option", { value: "markdown", children: "Markdown" }),
1113
- /* @__PURE__ */ jsx9("option", { value: "html", children: "HTML" })
1114
- ]
1115
- }
1116
- ),
1117
- /* @__PURE__ */ jsx9("p", { className: "text-xs text-muted-foreground", children: t("posts.form.formatHint") })
1118
- ] }),
1119
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1120
- /* @__PURE__ */ jsxs8("div", { className: "flex items-center justify-between", children: [
1121
- /* @__PURE__ */ jsx9(Label2, { children: t("posts.form.body") }),
1122
- format !== "tiptap" && // For textarea-based formats (markdown / html) there's no
1123
- // embedded toolbar, so we surface the MediaPicker as a
1124
- // standalone button. Selecting an asset inserts a
1125
- // format-aware snippet at the cursor.
1126
- /* @__PURE__ */ jsx9(
1127
- MediaPicker,
1128
- {
1129
- onSelect: insertMediaSnippet,
1130
- trigger: /* @__PURE__ */ jsxs8(Button6, { type: "button", variant: "outline", size: "sm", children: [
1131
- /* @__PURE__ */ jsx9(ImageIcon3, { className: "mr-2 h-3 w-3" }),
1132
- t("posts.form.insertMedia")
1133
- ] })
1134
- }
1135
- )
1136
- ] }),
1137
- format === "tiptap" ? /* @__PURE__ */ jsx9(TiptapEditor, { initialContent: body, onChange: setBody }) : /* @__PURE__ */ jsx9(
1138
- Textarea,
1139
- {
1140
- ref: bodyTextareaRef,
1141
- rows: 20,
1142
- value: typeof body === "string" ? body : "",
1143
- onChange: (e) => setBody(e.target.value),
1144
- className: "font-mono text-xs"
1145
- }
1146
- )
1147
- ] }),
1148
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1149
- /* @__PURE__ */ jsx9(Label2, { htmlFor: "tags", children: t("posts.form.tags") }),
1150
- /* @__PURE__ */ jsx9(
1151
- Input2,
1152
- {
1153
- id: "tags",
1154
- value: tagsInput,
1155
- onChange: (e) => setTagsInput(e.target.value),
1156
- placeholder: t("posts.form.tagsPlaceholder")
1157
- }
1158
- ),
1159
- /* @__PURE__ */ jsx9("p", { className: "text-xs text-muted-foreground", children: t("posts.form.tagsHint") })
1160
- ] }),
1161
- /* @__PURE__ */ jsxs8("div", { className: "space-y-2", children: [
1162
- /* @__PURE__ */ jsx9(Label2, { htmlFor: "status", children: t("posts.form.status") }),
1163
- /* @__PURE__ */ jsxs8(
1164
- "select",
1165
- {
1166
- id: "status",
1167
- value: status,
1168
- onChange: (e) => setStatus(e.target.value),
1169
- className: "flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
1170
- children: [
1171
- /* @__PURE__ */ jsx9("option", { value: "draft", children: t("common.draft") }),
1172
- /* @__PURE__ */ jsx9("option", { value: "published", children: t("common.published") })
1173
- ]
1174
- }
1175
- )
1176
- ] }),
1177
- error && /* @__PURE__ */ jsx9("p", { className: "text-sm text-destructive", children: error }),
1178
- /* @__PURE__ */ jsxs8("div", { className: "flex items-center gap-2", children: [
1179
- /* @__PURE__ */ jsx9(Button6, { type: "submit", disabled: saving, children: saving ? t("common.saving") : isEdit ? t("posts.form.saveChanges") : t("posts.form.createPost") }),
1180
- isEdit && /* @__PURE__ */ jsx9(Button6, { type: "button", variant: "destructive", onClick: handleDelete, disabled: saving, children: t("posts.form.delete") })
1181
- ] })
1182
- ] })
1183
- ] });
1184
- }
1185
-
1186
- // src/components/site-settings-form.tsx
1187
- import { useState as useState4 } from "react";
1188
- import { useRouter as useRouter3 } from "next/navigation";
1189
- import { setSiteSetting } from "ampless";
1190
- import { Button as Button7, Input as Input3, Label as Label3, Textarea as Textarea2 } from "@ampless/runtime/ui";
1191
- import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1192
- var KEYS = [
1193
- "site.name",
1194
- "site.url",
1195
- "site.description",
1196
- "media.imageDisplay",
1197
- "media.imageMaxWidth",
1198
- "dateFormat",
1199
- "timezone"
1200
- ];
1201
- function SiteSettingsForm({ siteId, initial, fallback }) {
1202
- const router = useRouter3();
1203
- const t = useT();
1204
- const [values, setValues] = useState4(initial);
1205
- const [saving, setSaving] = useState4(false);
1206
- const [error, setError] = useState4(null);
1207
- const [info, setInfo] = useState4(null);
1208
- function update(key, value) {
1209
- setValues((prev) => ({ ...prev, [key]: value }));
1210
- }
1211
- async function save(e) {
1212
- e.preventDefault();
1213
- setSaving(true);
1214
- setError(null);
1215
- setInfo(null);
1216
- try {
1217
- await Promise.all(
1218
- KEYS.map((key) => {
1219
- const value = values[key];
1220
- if (value === void 0 || value === "") return Promise.resolve();
1221
- return setSiteSetting(siteId, key, value);
1222
- })
1223
- );
1224
- setInfo(t("sites.edit.saved"));
1225
- router.refresh();
1226
- } catch (err) {
1227
- setError(err instanceof Error ? err.message : String(err));
1228
- } finally {
1229
- setSaving(false);
1230
- }
1231
- }
1232
- return /* @__PURE__ */ jsxs9("form", { onSubmit: save, className: "space-y-6 max-w-xl", children: [
1233
- /* @__PURE__ */ jsxs9("fieldset", { className: "space-y-4", children: [
1234
- /* @__PURE__ */ jsx10("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: t("sites.edit.site") }),
1235
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1236
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "name", children: t("common.name") }),
1237
- /* @__PURE__ */ jsx10(
1238
- Input3,
1239
- {
1240
- id: "name",
1241
- value: values["site.name"] ?? "",
1242
- placeholder: fallback["site.name"] ?? "",
1243
- onChange: (e) => update("site.name", e.target.value)
1244
- }
1245
- )
1246
- ] }),
1247
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1248
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "url", children: t("common.url") }),
1249
- /* @__PURE__ */ jsx10(
1250
- Input3,
1251
- {
1252
- id: "url",
1253
- value: values["site.url"] ?? "",
1254
- placeholder: fallback["site.url"] ?? "",
1255
- onChange: (e) => update("site.url", e.target.value)
1256
- }
1257
- )
1258
- ] }),
1259
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1260
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "description", children: t("common.description") }),
1261
- /* @__PURE__ */ jsx10(
1262
- Textarea2,
1263
- {
1264
- id: "description",
1265
- value: values["site.description"] ?? "",
1266
- placeholder: fallback["site.description"] ?? "",
1267
- rows: 2,
1268
- onChange: (e) => update("site.description", e.target.value)
1269
- }
1270
- )
1271
- ] })
1272
- ] }),
1273
- /* @__PURE__ */ jsxs9("fieldset", { className: "space-y-4", children: [
1274
- /* @__PURE__ */ jsx10("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: t("sites.edit.media") }),
1275
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1276
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "imageDisplay", children: t("sites.edit.imageDisplay") }),
1277
- /* @__PURE__ */ jsxs9(
1278
- "select",
1279
- {
1280
- id: "imageDisplay",
1281
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
1282
- value: values["media.imageDisplay"] ?? "",
1283
- onChange: (e) => update("media.imageDisplay", e.target.value),
1284
- children: [
1285
- /* @__PURE__ */ jsx10("option", { value: "", children: t("sites.edit.defaultPlaceholder", {
1286
- value: fallback["media.imageDisplay"] ?? "inline"
1287
- }) }),
1288
- /* @__PURE__ */ jsx10("option", { value: "inline", children: t("sites.edit.imageDisplayInline") }),
1289
- /* @__PURE__ */ jsx10("option", { value: "lightbox", children: t("sites.edit.imageDisplayLightbox") })
1290
- ]
1291
- }
1292
- )
1293
- ] }),
1294
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1295
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "imageMaxWidth", children: t("sites.edit.imageMaxWidth") }),
1296
- /* @__PURE__ */ jsx10(
1297
- Input3,
1298
- {
1299
- id: "imageMaxWidth",
1300
- value: values["media.imageMaxWidth"] ?? "",
1301
- placeholder: fallback["media.imageMaxWidth"] ?? "100%",
1302
- onChange: (e) => update("media.imageMaxWidth", e.target.value)
1303
- }
1304
- )
1305
- ] })
1306
- ] }),
1307
- /* @__PURE__ */ jsxs9("fieldset", { className: "space-y-4", children: [
1308
- /* @__PURE__ */ jsx10("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: t("sites.edit.dateDisplay") }),
1309
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1310
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "dateFormat", children: t("sites.edit.dateFormat") }),
1311
- /* @__PURE__ */ jsxs9(
1312
- "select",
1313
- {
1314
- id: "dateFormat",
1315
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
1316
- value: values["dateFormat"] ?? "",
1317
- onChange: (e) => update("dateFormat", e.target.value),
1318
- children: [
1319
- /* @__PURE__ */ jsx10("option", { value: "", children: t("sites.edit.defaultPlaceholder", {
1320
- value: fallback["dateFormat"] ?? "iso"
1321
- }) }),
1322
- /* @__PURE__ */ jsx10("option", { value: "iso", children: t("sites.edit.dateFormatIso") }),
1323
- /* @__PURE__ */ jsx10("option", { value: "long", children: t("sites.edit.dateFormatLong") }),
1324
- /* @__PURE__ */ jsx10("option", { value: "locale", children: t("sites.edit.dateFormatLocale") })
1325
- ]
1326
- }
1327
- )
1328
- ] }),
1329
- /* @__PURE__ */ jsxs9("div", { className: "space-y-2", children: [
1330
- /* @__PURE__ */ jsx10(Label3, { htmlFor: "timezone", children: t("sites.edit.timezone") }),
1331
- /* @__PURE__ */ jsx10(
1332
- Input3,
1333
- {
1334
- id: "timezone",
1335
- value: values["timezone"] ?? "",
1336
- placeholder: fallback["timezone"] ?? "UTC",
1337
- onChange: (e) => update("timezone", e.target.value)
1338
- }
1339
- )
1340
- ] })
1341
- ] }),
1342
- info && /* @__PURE__ */ jsx10("p", { className: "text-sm text-muted-foreground", children: info }),
1343
- error && /* @__PURE__ */ jsx10("p", { className: "text-sm text-destructive", children: error }),
1344
- /* @__PURE__ */ jsx10(Button7, { type: "submit", disabled: saving, children: saving ? t("common.saving") : t("sites.edit.saveButton") })
1345
- ] });
1346
- }
1347
-
1348
- // src/components/theme-settings-form.tsx
1349
- import { useState as useState5 } from "react";
1350
- import { useRouter as useRouter4 } from "next/navigation";
1351
- import {
1352
- setSiteSetting as setSiteSetting2,
1353
- deleteSiteSetting,
1354
- themeSettingKey,
1355
- validateThemeValue,
1356
- resolveLocalized,
1357
- parseLinkList,
1358
- stringifyLinkList
1359
- } from "ampless";
1360
- import { Button as Button8, Input as Input4, Label as Label4 } from "@ampless/runtime/ui";
1361
- import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
1362
- var CACHE_REBUILD_DELAY_MS = 8e3;
1363
- function ThemeSettingsForm({
1364
- siteId,
1365
- manifest,
1366
- activeTheme,
1367
- themeOptions,
1368
- initial
1369
- }) {
1370
- const router = useRouter4();
1371
- const t = useT();
1372
- const locale = useLocale();
1373
- const [state, setState] = useState5({ values: initial, touched: {} });
1374
- const [pendingTheme, setPendingTheme] = useState5(activeTheme);
1375
- const [optimisticActive, setOptimisticActive] = useState5(activeTheme);
1376
- const [saving, setSaving] = useState5(false);
1377
- const [switching, setSwitching] = useState5(false);
1378
- const [error, setError] = useState5(null);
1379
- const [info, setInfo] = useState5(null);
1380
- const [invalid, setInvalid] = useState5({});
1381
- function update(key, value) {
1382
- setState((prev) => ({
1383
- values: { ...prev.values, [key]: value },
1384
- touched: { ...prev.touched, [key]: true }
1385
- }));
1386
- }
1387
- function scheduleCacheInvalidation() {
1388
- setTimeout(async () => {
1389
- try {
1390
- await invalidateSiteSettingsCache(siteId);
1391
- } catch (err) {
1392
- console.warn("[theme] cache invalidation failed", err);
1393
- }
1394
- }, CACHE_REBUILD_DELAY_MS);
1395
- }
1396
- function scheduleHardReload() {
1397
- setTimeout(async () => {
1398
- try {
1399
- await invalidateSiteSettingsCache(siteId);
1400
- } catch (err) {
1401
- console.warn("[theme] cache invalidation failed", err);
1402
- }
1403
- window.location.reload();
1404
- }, CACHE_REBUILD_DELAY_MS);
1405
- }
1406
- async function switchTheme(e) {
1407
- e.preventDefault();
1408
- if (pendingTheme === optimisticActive) return;
1409
- setSwitching(true);
1410
- setError(null);
1411
- setInfo(null);
1412
- try {
1413
- await setSiteSetting2(siteId, "theme.active", pendingTheme);
1414
- setOptimisticActive(pendingTheme);
1415
- setInfo(t("theme.switched", { theme: pendingTheme }));
1416
- scheduleHardReload();
1417
- } catch (err) {
1418
- console.error("[theme] switch failed", err);
1419
- setError(err instanceof Error ? err.message : String(err));
1420
- } finally {
1421
- setSwitching(false);
1422
- }
1423
- }
1424
- async function save(e) {
1425
- e.preventDefault();
1426
- setSaving(true);
1427
- setError(null);
1428
- setInfo(null);
1429
- setInvalid({});
1430
- const newInvalid = {};
1431
- const writes = [];
1432
- for (const field of manifest.fields) {
1433
- if (!state.touched[field.key]) continue;
1434
- const raw = (state.values[field.key] ?? "").trim();
1435
- const storeKey = themeSettingKey(field.key);
1436
- if (raw === "") {
1437
- writes.push(deleteSiteSetting(siteId, storeKey));
1438
- continue;
1439
- }
1440
- const validated = validateThemeValue(field, raw);
1441
- if (validated === null) {
1442
- newInvalid[field.key] = true;
1443
- continue;
1444
- }
1445
- writes.push(setSiteSetting2(siteId, storeKey, validated));
1446
- }
1447
- if (Object.keys(newInvalid).length > 0) {
1448
- setInvalid(newInvalid);
1449
- setSaving(false);
1450
- setError(t("theme.invalidValues"));
1451
- return;
1452
- }
1453
- try {
1454
- await Promise.all(writes);
1455
- setInfo(t("theme.saved"));
1456
- setState((prev) => ({ values: prev.values, touched: {} }));
1457
- scheduleCacheInvalidation();
1458
- } catch (err) {
1459
- console.error("[theme] save failed", err);
1460
- setError(err instanceof Error ? err.message : String(err));
1461
- } finally {
1462
- setSaving(false);
1463
- }
1464
- }
1465
- const groups = groupFields(manifest.fields);
1466
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-8", children: [
1467
- /* @__PURE__ */ jsxs10("form", { onSubmit: switchTheme, className: "max-w-xl space-y-3 rounded-md border p-4", children: [
1468
- /* @__PURE__ */ jsxs10("div", { className: "space-y-1", children: [
1469
- /* @__PURE__ */ jsx11(Label4, { htmlFor: "active-theme", className: "text-sm font-medium", children: t("theme.activeLabel") }),
1470
- /* @__PURE__ */ jsxs10("p", { className: "text-xs text-muted-foreground", children: [
1471
- t("theme.currentlyActive", { theme: optimisticActive }),
1472
- optimisticActive !== activeTheme && t("theme.propagating")
1473
- ] })
1474
- ] }),
1475
- /* @__PURE__ */ jsx11(
1476
- "select",
1477
- {
1478
- id: "active-theme",
1479
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
1480
- value: pendingTheme,
1481
- onChange: (e) => setPendingTheme(e.target.value),
1482
- children: themeOptions.map((opt) => /* @__PURE__ */ jsxs10("option", { value: opt.value, children: [
1483
- resolveLocalized(opt.label, locale),
1484
- " (",
1485
- opt.value,
1486
- ")"
1487
- ] }, opt.value))
1488
- }
1489
- ),
1490
- (() => {
1491
- const desc = themeOptions.find((o) => o.value === pendingTheme)?.description;
1492
- return desc ? /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: resolveLocalized(desc, locale) }) : null;
1493
- })(),
1494
- /* @__PURE__ */ jsx11(
1495
- Button8,
1496
- {
1497
- type: "submit",
1498
- disabled: switching || pendingTheme === optimisticActive,
1499
- variant: pendingTheme === optimisticActive ? "outline" : "default",
1500
- children: switching ? t("theme.switching") : t("theme.switchButton")
1501
- }
1502
- )
1503
- ] }),
1504
- /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1505
- /* @__PURE__ */ jsx11(Label4, { className: "text-sm font-medium", children: t("theme.previewLabel") }),
1506
- /* @__PURE__ */ jsx11(
1507
- "iframe",
1508
- {
1509
- src: `/?previewTheme=${encodeURIComponent(pendingTheme)}`,
1510
- title: t("theme.previewLabel"),
1511
- className: "h-[600px] w-full rounded-md border bg-[var(--background)]"
1512
- },
1513
- pendingTheme
1514
- ),
1515
- /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: t("theme.previewHint") })
1516
- ] }),
1517
- /* @__PURE__ */ jsxs10("form", { onSubmit: save, className: "max-w-xl space-y-6", children: [
1518
- /* @__PURE__ */ jsxs10("div", { children: [
1519
- /* @__PURE__ */ jsx11("h2", { className: "text-lg font-semibold", children: t("theme.customizationHeading", {
1520
- theme: resolveLocalized(manifest.label, locale)
1521
- }) }),
1522
- manifest.description && /* @__PURE__ */ jsx11("p", { className: "text-sm text-muted-foreground", children: resolveLocalized(manifest.description, locale) }),
1523
- /* @__PURE__ */ jsx11("p", { className: "mt-1 text-xs text-muted-foreground", children: t("theme.customizationHint") })
1524
- ] }),
1525
- groups.map(({ key, name, fields }) => /* @__PURE__ */ jsxs10("fieldset", { className: "space-y-4", children: [
1526
- /* @__PURE__ */ jsx11("legend", { className: "text-sm font-medium uppercase tracking-wide text-muted-foreground", children: resolveLocalized(name, locale) }),
1527
- fields.map((field) => /* @__PURE__ */ jsx11(
1528
- FieldRow,
1529
- {
1530
- field,
1531
- value: state.values[field.key] ?? "",
1532
- invalid: !!invalid[field.key],
1533
- onChange: (v) => update(field.key, v)
1534
- },
1535
- field.key
1536
- ))
1537
- ] }, key)),
1538
- info && /* @__PURE__ */ jsx11("p", { className: "text-sm text-muted-foreground", children: info }),
1539
- error && /* @__PURE__ */ jsx11("p", { className: "text-sm text-destructive", children: error }),
1540
- /* @__PURE__ */ jsx11(Button8, { type: "submit", disabled: saving, children: saving ? t("theme.saving") : t("theme.saveButton") })
1541
- ] })
1542
- ] });
1543
- }
1544
- function groupFields(fields) {
1545
- const map = /* @__PURE__ */ new Map();
1546
- for (const field of fields) {
1547
- const g = field.group ?? "General";
1548
- const k = typeof g === "string" ? g : JSON.stringify(g);
1549
- const existing = map.get(k);
1550
- if (existing) {
1551
- existing.fields.push(field);
1552
- } else {
1553
- map.set(k, { name: g, fields: [field] });
1554
- }
1555
- }
1556
- return Array.from(map.entries()).map(([key, { name, fields: fields2 }]) => ({ key, name, fields: fields2 }));
1557
- }
1558
- function FieldRow({ field, value, invalid, onChange }) {
1559
- const t = useT();
1560
- const locale = useLocale();
1561
- const id = `theme-${field.key}`;
1562
- const labelEl = /* @__PURE__ */ jsx11(Label4, { htmlFor: id, className: invalid ? "text-destructive" : void 0, children: resolveLocalized(field.label, locale) });
1563
- const description = field.description ? /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: resolveLocalized(field.description, locale) }) : null;
1564
- switch (field.type) {
1565
- case "color":
1566
- return /* @__PURE__ */ jsx11(ColorField, { field, id, labelEl, description, value, invalid, onChange });
1567
- case "length":
1568
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1569
- labelEl,
1570
- description,
1571
- /* @__PURE__ */ jsx11(
1572
- Input4,
1573
- {
1574
- id,
1575
- value,
1576
- placeholder: field.default,
1577
- onChange: (e) => onChange(e.target.value),
1578
- "aria-invalid": invalid,
1579
- className: "font-mono text-xs"
1580
- }
1581
- ),
1582
- /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: t("theme.lengthHelp") })
1583
- ] });
1584
- case "select":
1585
- case "fontFamily":
1586
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1587
- labelEl,
1588
- description,
1589
- /* @__PURE__ */ jsxs10(
1590
- "select",
1591
- {
1592
- id,
1593
- className: "w-full rounded-md border bg-background px-2 py-1.5 text-sm",
1594
- value: value || "",
1595
- onChange: (e) => onChange(e.target.value),
1596
- "aria-invalid": invalid,
1597
- children: [
1598
- /* @__PURE__ */ jsx11("option", { value: "", children: t("common.default") }),
1599
- field.options.map((opt) => /* @__PURE__ */ jsx11("option", { value: opt.value, children: resolveLocalized(opt.label, locale) }, opt.value))
1600
- ]
1601
- }
1602
- )
1603
- ] });
1604
- case "image":
1605
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1606
- labelEl,
1607
- description,
1608
- /* @__PURE__ */ jsx11(
1609
- Input4,
1610
- {
1611
- id,
1612
- value,
1613
- placeholder: field.default || t("theme.imagePlaceholder"),
1614
- onChange: (e) => onChange(e.target.value),
1615
- "aria-invalid": invalid
1616
- }
1617
- )
1618
- ] });
1619
- case "text":
1620
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1621
- labelEl,
1622
- description,
1623
- /* @__PURE__ */ jsx11(
1624
- Input4,
1625
- {
1626
- id,
1627
- value,
1628
- placeholder: field.default,
1629
- maxLength: field.maxLength,
1630
- onChange: (e) => onChange(e.target.value),
1631
- "aria-invalid": invalid
1632
- }
1633
- )
1634
- ] });
1635
- case "linkList":
1636
- return /* @__PURE__ */ jsx11(
1637
- LinkListField,
1638
- {
1639
- field,
1640
- labelEl,
1641
- description,
1642
- value,
1643
- onChange
1644
- }
1645
- );
1646
- }
1647
- }
1648
- function LinkListField({ field, labelEl, description, value, onChange }) {
1649
- const items = parseLinkList(value);
1650
- const max = field.maxItems ?? 50;
1651
- function commit(next) {
1652
- onChange(stringifyLinkList(next));
1653
- }
1654
- function update(idx, patch) {
1655
- commit(items.map((it, i) => i === idx ? { ...it, ...patch } : it));
1656
- }
1657
- function add() {
1658
- if (items.length >= max) return;
1659
- commit([...items, { label: "", url: "" }]);
1660
- }
1661
- function remove2(idx) {
1662
- commit(items.filter((_, i) => i !== idx));
1663
- }
1664
- function move(idx, delta) {
1665
- const target = idx + delta;
1666
- if (target < 0 || target >= items.length) return;
1667
- const next = items.slice();
1668
- const [moved] = next.splice(idx, 1);
1669
- next.splice(target, 0, moved);
1670
- commit(next);
1671
- }
1672
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1673
- labelEl,
1674
- description,
1675
- /* @__PURE__ */ jsxs10("div", { className: "space-y-2 rounded-md border bg-muted/20 p-3", children: [
1676
- items.length === 0 && /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground", children: "No links yet." }),
1677
- items.map((item, idx) => {
1678
- const isTagRef = /^tag:/.test(item.url.trim());
1679
- return /* @__PURE__ */ jsxs10("div", { className: "flex flex-wrap items-start gap-2", children: [
1680
- /* @__PURE__ */ jsxs10("div", { className: "grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2", children: [
1681
- /* @__PURE__ */ jsx11(
1682
- Input4,
1683
- {
1684
- value: item.label,
1685
- placeholder: "Label",
1686
- onChange: (e) => update(idx, { label: e.target.value })
1687
- }
1688
- ),
1689
- /* @__PURE__ */ jsx11(
1690
- Input4,
1691
- {
1692
- value: item.url,
1693
- placeholder: "/path or https://\u2026 or tag:name",
1694
- onChange: (e) => update(idx, { url: e.target.value }),
1695
- className: isTagRef ? "font-mono text-xs" : void 0
1696
- }
1697
- )
1698
- ] }),
1699
- /* @__PURE__ */ jsxs10("div", { className: "flex shrink-0 items-center gap-1", children: [
1700
- /* @__PURE__ */ jsx11(
1701
- Button8,
1702
- {
1703
- type: "button",
1704
- variant: "ghost",
1705
- size: "icon",
1706
- onClick: () => move(idx, -1),
1707
- disabled: idx === 0,
1708
- "aria-label": "Move up",
1709
- children: "\u2191"
1710
- }
1711
- ),
1712
- /* @__PURE__ */ jsx11(
1713
- Button8,
1714
- {
1715
- type: "button",
1716
- variant: "ghost",
1717
- size: "icon",
1718
- onClick: () => move(idx, 1),
1719
- disabled: idx === items.length - 1,
1720
- "aria-label": "Move down",
1721
- children: "\u2193"
1722
- }
1723
- ),
1724
- /* @__PURE__ */ jsx11(
1725
- Button8,
1726
- {
1727
- type: "button",
1728
- variant: "ghost",
1729
- size: "icon",
1730
- onClick: () => remove2(idx),
1731
- "aria-label": "Remove",
1732
- children: "\xD7"
1733
- }
1734
- )
1735
- ] })
1736
- ] }, idx);
1737
- }),
1738
- /* @__PURE__ */ jsx11(
1739
- Button8,
1740
- {
1741
- type: "button",
1742
- variant: "outline",
1743
- size: "sm",
1744
- onClick: add,
1745
- disabled: items.length >= max,
1746
- children: "+ Add link"
1747
- }
1748
- ),
1749
- /* @__PURE__ */ jsxs10("p", { className: "text-xs text-muted-foreground", children: [
1750
- "Tip: use ",
1751
- /* @__PURE__ */ jsx11("code", { children: "tag:<name>" }),
1752
- " as a URL to render a list of posts with that tag instead of a single link."
1753
- ] })
1754
- ] })
1755
- ] });
1756
- }
1757
- function ColorField({
1758
- field,
1759
- id,
1760
- labelEl,
1761
- description,
1762
- value,
1763
- invalid,
1764
- onChange
1765
- }) {
1766
- const effective = value || field.default;
1767
- const hex = useColorAsHex(effective);
1768
- return /* @__PURE__ */ jsxs10("div", { className: "space-y-2", children: [
1769
- labelEl,
1770
- description,
1771
- /* @__PURE__ */ jsxs10("div", { className: "flex items-center gap-2", children: [
1772
- /* @__PURE__ */ jsx11(
1773
- "input",
1774
- {
1775
- type: "color",
1776
- value: hex,
1777
- onChange: (e) => onChange(e.target.value),
1778
- className: "h-9 w-12 cursor-pointer rounded border border-input bg-background p-0",
1779
- "aria-label": `${field.label} swatch`
1780
- }
1781
- ),
1782
- /* @__PURE__ */ jsx11(
1783
- Input4,
1784
- {
1785
- id,
1786
- value,
1787
- placeholder: field.default,
1788
- onChange: (e) => onChange(e.target.value),
1789
- "aria-invalid": invalid,
1790
- className: "font-mono text-xs"
1791
- }
1792
- )
1793
- ] }),
1794
- /* @__PURE__ */ jsxs10("div", { className: "flex items-center gap-2", children: [
1795
- /* @__PURE__ */ jsx11(
1796
- "span",
1797
- {
1798
- className: "inline-block h-4 w-4 rounded border",
1799
- style: { background: effective },
1800
- "aria-hidden": true
1801
- }
1802
- ),
1803
- /* @__PURE__ */ jsx11("code", { className: "text-xs text-muted-foreground", children: effective })
1804
- ] })
1805
- ] });
1806
- }
1807
- function useColorAsHex(value) {
1808
- if (typeof document === "undefined") return "#000000";
1809
- const m = /^#([0-9a-fA-F]{6})$/.exec(value);
1810
- if (m) return value.toLowerCase();
1811
- try {
1812
- const ctx = document.createElement("canvas").getContext("2d");
1813
- if (!ctx) return "#000000";
1814
- ctx.fillStyle = "#000000";
1815
- ctx.fillStyle = value;
1816
- const out = ctx.fillStyle;
1817
- return /^#[0-9a-fA-F]{6}$/.test(out) ? out : "#000000";
1818
- } catch {
1819
- return "#000000";
1820
- }
1821
- }
1822
-
1823
- // src/components/media-uploader.tsx
1824
- import { useState as useState6, useEffect as useEffect3, useCallback, useRef as useRef4 } from "react";
1825
- import { uploadData as uploadData2, list as list2, remove, isCancelError } from "aws-amplify/storage";
1826
- import { processImage as processImage2 } from "ampless/media";
1827
- import { Button as Button9, Input as Input5 } from "@ampless/runtime/ui";
1828
- import { Trash2 as Trash22, Copy, Check, FileText as FileText2, Code2 } from "lucide-react";
1829
- import { jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
1830
- var IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)$/i;
1831
- var STYLESHEET_EXT_RE = /\.css$/i;
1832
- var SCRIPT_EXT_RE = /\.m?js$/i;
1833
- function getExtension(path) {
1834
- const dot = path.lastIndexOf(".");
1835
- return dot >= 0 ? path.slice(dot + 1).toUpperCase() : "FILE";
1836
- }
1837
- function snippetFor2(url, path) {
1838
- if (IMAGE_EXT_RE.test(path)) {
1839
- return `<img src="${url}" alt="" />`;
1840
- }
1841
- if (STYLESHEET_EXT_RE.test(path)) {
1842
- return `<link rel="stylesheet" href="${url}" />`;
1843
- }
1844
- if (SCRIPT_EXT_RE.test(path)) {
1845
- return `<script src="${url}"></script>`;
1846
- }
1847
- return url;
1848
- }
1849
- function sanitizeName2(name) {
1850
- return name.replace(/[ -]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
1851
- }
1852
- function MediaUploader() {
1853
- const t = useT();
1854
- const [items, setItems] = useState6([]);
1855
- const [queue, setQueue] = useState6([]);
1856
- const [uploading, setUploading] = useState6(false);
1857
- const [error, setError] = useState6(null);
1858
- const [copiedPath, setCopiedPath] = useState6(null);
1859
- const uploadTaskRef = useRef4(null);
1860
- const cancelTokenRef = useRef4({ cancelled: false });
1861
- const refresh = useCallback(async () => {
1862
- try {
1863
- const result = await list2({ path: "public/media/" });
1864
- setItems(
1865
- result.items.map((item) => ({
1866
- path: item.path,
1867
- url: publicMediaUrl(item.path)
1868
- }))
1869
- );
1870
- } catch (err) {
1871
- setError(err instanceof Error ? err.message : String(err));
1872
- }
1873
- }, []);
1874
- useEffect3(() => {
1875
- refresh();
1876
- }, [refresh]);
1877
- function handleFiles(e) {
1878
- const files = Array.from(e.target.files ?? []);
1879
- e.target.value = "";
1880
- if (files.length === 0) return;
1881
- setError(null);
1882
- setQueue((prev) => [...prev, ...files]);
1883
- }
1884
- async function handleDialogConfirm(file, options) {
1885
- const token = { cancelled: false };
1886
- cancelTokenRef.current = token;
1887
- setUploading(true);
1888
- setError(null);
1889
- let advance = true;
1890
- try {
1891
- const processed = await processImage2(file, options);
1892
- if (token.cancelled) {
1893
- advance = false;
1894
- return;
1895
- }
1896
- const safeName = sanitizeName2(processed.suggestedName);
1897
- const now = /* @__PURE__ */ new Date();
1898
- const yyyy = now.getFullYear();
1899
- const mm = String(now.getMonth() + 1).padStart(2, "0");
1900
- const path = `public/media/${yyyy}/${mm}/${Date.now()}-${safeName}`;
1901
- const task = uploadData2({
1902
- path,
1903
- data: processed.blob,
1904
- options: { contentType: processed.mime }
1905
- });
1906
- uploadTaskRef.current = task;
1907
- await task.result;
1908
- await refresh();
1909
- } catch (err) {
1910
- if (isCancelError(err) || token.cancelled) {
1911
- advance = false;
1912
- } else {
1913
- setError(err instanceof Error ? err.message : String(err));
1914
- }
1915
- } finally {
1916
- uploadTaskRef.current = null;
1917
- setUploading(false);
1918
- if (advance) {
1919
- setQueue((prev) => prev.slice(1));
1920
- }
1921
- }
1922
- }
1923
- function handleDialogSkip() {
1924
- if (uploading) return;
1925
- setQueue((prev) => prev.slice(1));
1926
- }
1927
- function handleDialogCancel() {
1928
- cancelTokenRef.current.cancelled = true;
1929
- uploadTaskRef.current?.cancel();
1930
- setQueue([]);
1931
- }
1932
- async function handleDelete(path) {
1933
- if (!confirm(t("media.deleteConfirm"))) return;
1934
- try {
1935
- await remove({ path });
1936
- await refresh();
1937
- } catch (err) {
1938
- setError(err instanceof Error ? err.message : String(err));
1939
- }
1940
- }
1941
- async function handleCopy(item, mode) {
1942
- const text = mode === "url" ? item.url : snippetFor2(item.url, item.path);
1943
- await navigator.clipboard.writeText(text);
1944
- const key = `${item.path}:${mode}`;
1945
- setCopiedPath(key);
1946
- setTimeout(() => setCopiedPath((p) => p === key ? null : p), 1500);
1947
- }
1948
- const currentFile = queue[0] ?? null;
1949
- return /* @__PURE__ */ jsxs11("div", { className: "space-y-6", children: [
1950
- /* @__PURE__ */ jsxs11("div", { className: "rounded-md border p-4", children: [
1951
- /* @__PURE__ */ jsx12(
1952
- Input5,
1953
- {
1954
- type: "file",
1955
- multiple: true,
1956
- onChange: handleFiles,
1957
- disabled: uploading
1958
- }
1959
- ),
1960
- uploading && /* @__PURE__ */ jsx12("p", { className: "mt-2 text-sm text-muted-foreground", children: t("media.uploading") }),
1961
- !uploading && queue.length > 0 && /* @__PURE__ */ jsx12("p", { className: "mt-2 text-sm text-muted-foreground", children: t("media.queued", { count: queue.length }) })
1962
- ] }),
1963
- error && /* @__PURE__ */ jsx12("p", { className: "text-sm text-destructive", children: error }),
1964
- /* @__PURE__ */ jsx12(
1965
- ImageUploadDialog,
1966
- {
1967
- file: currentFile,
1968
- remaining: queue.length,
1969
- busy: uploading,
1970
- defaults: getMediaProcessingDefaults(),
1971
- onConfirm: handleDialogConfirm,
1972
- onSkip: handleDialogSkip,
1973
- onCancel: handleDialogCancel
1974
- }
1975
- ),
1976
- /* @__PURE__ */ jsx12("div", { className: "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4", children: items.map((item) => {
1977
- const isImage = IMAGE_EXT_RE.test(item.path);
1978
- const isStylesheet = STYLESHEET_EXT_RE.test(item.path);
1979
- const isScript = SCRIPT_EXT_RE.test(item.path);
1980
- const filename = item.path.split("/").pop() ?? "";
1981
- const ext = getExtension(item.path);
1982
- const tagSnippet = snippetFor2(item.url, item.path);
1983
- const tagDiffersFromUrl = tagSnippet !== item.url;
1984
- const urlCopied = copiedPath === `${item.path}:url`;
1985
- const tagCopied = copiedPath === `${item.path}:tag`;
1986
- return /* @__PURE__ */ jsxs11(
1987
- "div",
1988
- {
1989
- className: "group relative overflow-hidden rounded-md border bg-[var(--card)]",
1990
- children: [
1991
- isImage ? (
1992
- // eslint-disable-next-line @next/next/no-img-element
1993
- /* @__PURE__ */ jsx12(
1994
- "img",
1995
- {
1996
- src: item.url,
1997
- alt: item.path,
1998
- className: "aspect-square w-full object-cover"
1999
- }
2000
- )
2001
- ) : /* @__PURE__ */ jsxs11("div", { className: "flex aspect-square w-full flex-col items-center justify-center gap-2 bg-muted text-muted-foreground", children: [
2002
- isStylesheet || isScript ? /* @__PURE__ */ jsx12(Code2, { className: "h-8 w-8" }) : /* @__PURE__ */ jsx12(FileText2, { className: "h-8 w-8" }),
2003
- /* @__PURE__ */ jsxs11("span", { className: "font-mono text-xs font-semibold", children: [
2004
- ".",
2005
- ext.toLowerCase()
2006
- ] })
2007
- ] }),
2008
- /* @__PURE__ */ jsxs11("div", { className: "flex items-center justify-between border-t px-2 py-1 text-xs", children: [
2009
- /* @__PURE__ */ jsx12("span", { className: "truncate", title: filename, children: filename }),
2010
- /* @__PURE__ */ jsxs11("div", { className: "flex shrink-0 items-center gap-0.5", children: [
2011
- /* @__PURE__ */ jsx12(
2012
- Button9,
2013
- {
2014
- type: "button",
2015
- variant: "ghost",
2016
- size: "icon",
2017
- className: "h-6 w-6",
2018
- onClick: () => handleCopy(item, "url"),
2019
- title: t("media.copyUrl"),
2020
- children: urlCopied ? /* @__PURE__ */ jsx12(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx12(Copy, { className: "h-3 w-3" })
2021
- }
2022
- ),
2023
- tagDiffersFromUrl && /* @__PURE__ */ jsx12(
2024
- Button9,
2025
- {
2026
- type: "button",
2027
- variant: "ghost",
2028
- size: "icon",
2029
- className: "h-6 w-6",
2030
- onClick: () => handleCopy(item, "tag"),
2031
- title: t("media.copyTag"),
2032
- children: tagCopied ? /* @__PURE__ */ jsx12(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx12(Code2, { className: "h-3 w-3" })
2033
- }
2034
- ),
2035
- /* @__PURE__ */ jsx12(
2036
- Button9,
2037
- {
2038
- type: "button",
2039
- variant: "ghost",
2040
- size: "icon",
2041
- className: "h-6 w-6",
2042
- onClick: () => handleDelete(item.path),
2043
- title: t("media.delete"),
2044
- children: /* @__PURE__ */ jsx12(Trash22, { className: "h-3 w-3" })
2045
- }
2046
- )
2047
- ] })
2048
- ] })
2049
- ]
2050
- },
2051
- item.path
2052
- );
2053
- }) }),
2054
- items.length === 0 && /* @__PURE__ */ jsx12("p", { className: "text-center text-sm text-muted-foreground", children: t("media.empty") })
2055
- ] });
2056
- }
2057
-
2058
- export {
2059
- I18nProvider,
2060
- useT,
2061
- useLocale,
2062
- sanitizeName,
2063
- uploadProcessedImage,
2064
- invalidateSiteSettingsCache,
2065
- Sidebar,
2066
- SiteSelector,
2067
- ImageUploadDialog,
2068
- setAdminCmsConfigClient,
2069
- MediaPicker,
2070
- PostForm,
2071
- SiteSettingsForm,
2072
- ThemeSettingsForm,
2073
- MediaUploader
2074
- };