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

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
@@ -0,0 +1,1319 @@
1
+ 'use client';
2
+ import {
3
+ ImageUploadDialog,
4
+ getMediaProcessingDefaults
5
+ } from "./chunk-CTGFMK2J.js";
6
+ import {
7
+ publicMediaUrl
8
+ } from "./chunk-2ITWLRYF.js";
9
+ import {
10
+ useT
11
+ } from "./chunk-Q66BLMNJ.js";
12
+
13
+ // src/lib/upload.ts
14
+ import { uploadData } from "aws-amplify/storage";
15
+ import { processImage } from "ampless/media";
16
+ function sanitizeName(name) {
17
+ return name.replace(/[\x00-\x1f\x7f]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
18
+ }
19
+ async function uploadProcessedImage(file, options) {
20
+ const processed = await processImage(file, options);
21
+ const safeName = sanitizeName(processed.suggestedName);
22
+ const now = /* @__PURE__ */ new Date();
23
+ const yyyy = now.getFullYear();
24
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
25
+ const path = `public/media/${yyyy}/${mm}/${Date.now()}-${safeName}`;
26
+ await uploadData({
27
+ path,
28
+ data: processed.blob,
29
+ options: { contentType: processed.mime }
30
+ }).result;
31
+ return { path, url: publicMediaUrl(path) };
32
+ }
33
+
34
+ // src/components/media-picker.tsx
35
+ import { useEffect, useRef, useState } from "react";
36
+ import { list } from "aws-amplify/storage";
37
+ import { Upload } from "lucide-react";
38
+ import {
39
+ Dialog,
40
+ DialogContent,
41
+ DialogDescription,
42
+ DialogHeader,
43
+ DialogTitle,
44
+ DialogTrigger,
45
+ Button
46
+ } from "@ampless/runtime/ui";
47
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
48
+ function MediaPicker({ trigger, onSelect }) {
49
+ const t = useT();
50
+ const [open, setOpen] = useState(false);
51
+ const [items, setItems] = useState([]);
52
+ const [loading, setLoading] = useState(false);
53
+ const [error, setError] = useState(null);
54
+ const [pendingUpload, setPendingUpload] = useState(null);
55
+ const [uploading, setUploading] = useState(false);
56
+ const fileInputRef = useRef(null);
57
+ useEffect(() => {
58
+ if (!open) return;
59
+ let cancelled = false;
60
+ setLoading(true);
61
+ setError(null);
62
+ list({ path: "public/media/" }).then((result) => {
63
+ if (cancelled) return;
64
+ setItems(result.items.map((i) => i.path));
65
+ }).catch((err) => {
66
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err));
67
+ }).finally(() => {
68
+ if (!cancelled) setLoading(false);
69
+ });
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, [open]);
74
+ function handlePick(path) {
75
+ onSelect(publicMediaUrl(path));
76
+ setOpen(false);
77
+ }
78
+ function handleFileSelected(e) {
79
+ const file = e.target.files?.[0];
80
+ e.target.value = "";
81
+ if (!file) return;
82
+ setPendingUpload(file);
83
+ }
84
+ async function handleUploadConfirm(file, options) {
85
+ setUploading(true);
86
+ setError(null);
87
+ try {
88
+ const { url } = await uploadProcessedImage(file, options);
89
+ onSelect(url);
90
+ setPendingUpload(null);
91
+ setOpen(false);
92
+ } catch (err) {
93
+ setError(err instanceof Error ? err.message : String(err));
94
+ } finally {
95
+ setUploading(false);
96
+ }
97
+ }
98
+ const pickerOpen = open && !pendingUpload;
99
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
100
+ /* @__PURE__ */ jsxs(Dialog, { open: pickerOpen, onOpenChange: (next) => setOpen(next), children: [
101
+ /* @__PURE__ */ jsx(DialogTrigger, { asChild: true, onClick: () => setOpen(true), children: trigger }),
102
+ /* @__PURE__ */ jsxs(DialogContent, { children: [
103
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-4", children: [
104
+ /* @__PURE__ */ jsxs("div", { children: [
105
+ /* @__PURE__ */ jsx(DialogTitle, { children: t("mediaPicker.title") }),
106
+ /* @__PURE__ */ jsx(DialogDescription, { children: t("mediaPicker.description") })
107
+ ] }),
108
+ /* @__PURE__ */ jsxs(
109
+ Button,
110
+ {
111
+ type: "button",
112
+ variant: "outline",
113
+ size: "sm",
114
+ onClick: () => fileInputRef.current?.click(),
115
+ children: [
116
+ /* @__PURE__ */ jsx(Upload, { className: "mr-2 h-3 w-3" }),
117
+ t("mediaPicker.uploadNew")
118
+ ]
119
+ }
120
+ )
121
+ ] }) }),
122
+ /* @__PURE__ */ jsx(
123
+ "input",
124
+ {
125
+ ref: fileInputRef,
126
+ type: "file",
127
+ accept: "image/*",
128
+ className: "hidden",
129
+ onChange: handleFileSelected
130
+ }
131
+ ),
132
+ loading && /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("common.loading") }),
133
+ error && /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: error }),
134
+ !loading && items.length === 0 && /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
135
+ t("mediaPicker.empty"),
136
+ " ",
137
+ t("mediaPicker.emptyHint", { action: t("mediaPicker.emptyAction") })
138
+ ] }),
139
+ /* @__PURE__ */ jsx("div", { className: "grid max-h-[60vh] grid-cols-3 gap-3 overflow-auto sm:grid-cols-4", children: items.map((path) => /* @__PURE__ */ jsxs(
140
+ "button",
141
+ {
142
+ type: "button",
143
+ onClick: () => handlePick(path),
144
+ className: "group overflow-hidden rounded-md border transition hover:border-primary",
145
+ children: [
146
+ /* @__PURE__ */ jsx(
147
+ "img",
148
+ {
149
+ src: publicMediaUrl(path),
150
+ alt: path,
151
+ className: "aspect-square w-full object-cover"
152
+ }
153
+ ),
154
+ /* @__PURE__ */ jsx("div", { className: "truncate p-1 text-xs text-muted-foreground", children: path.split("/").pop() })
155
+ ]
156
+ },
157
+ path
158
+ )) })
159
+ ] })
160
+ ] }),
161
+ /* @__PURE__ */ jsx(
162
+ ImageUploadDialog,
163
+ {
164
+ file: pendingUpload,
165
+ remaining: pendingUpload ? 1 : 0,
166
+ busy: uploading,
167
+ defaults: getMediaProcessingDefaults(),
168
+ onConfirm: handleUploadConfirm,
169
+ onSkip: () => setPendingUpload(null),
170
+ onCancel: () => setPendingUpload(null)
171
+ }
172
+ )
173
+ ] });
174
+ }
175
+
176
+ // src/components/post-form.tsx
177
+ import { useRef as useRef3, useState as useState4 } from "react";
178
+ import { useRouter } from "next/navigation";
179
+ import { Image as ImageIcon3 } from "lucide-react";
180
+ import {
181
+ createPost,
182
+ updatePost,
183
+ deletePost,
184
+ formatDate
185
+ } from "ampless";
186
+ import {
187
+ renderBody,
188
+ tiptapToHtml,
189
+ tiptapToMarkdown,
190
+ markdownToHtml,
191
+ htmlToMarkdown
192
+ } from "@ampless/runtime";
193
+ import { Button as Button6, Input, Label, Textarea } from "@ampless/runtime/ui";
194
+
195
+ // src/editor/tiptap-editor.tsx
196
+ import { useEditor, EditorContent } from "@tiptap/react";
197
+ import StarterKit from "@tiptap/starter-kit";
198
+ import Link from "@tiptap/extension-link";
199
+ import Image from "@tiptap/extension-image";
200
+ import { Table } from "@tiptap/extension-table";
201
+ import { TableRow } from "@tiptap/extension-table-row";
202
+ import { TableHeader } from "@tiptap/extension-table-header";
203
+ import { TableCell } from "@tiptap/extension-table-cell";
204
+ import { TaskList } from "@tiptap/extension-task-list";
205
+ import { TaskItem } from "@tiptap/extension-task-item";
206
+ import { Underline as Underline2 } from "@tiptap/extension-underline";
207
+ import { Highlight } from "@tiptap/extension-highlight";
208
+ import { TextAlign } from "@tiptap/extension-text-align";
209
+
210
+ // src/editor/toolbar.tsx
211
+ import {
212
+ Bold,
213
+ Italic,
214
+ Underline,
215
+ Strikethrough,
216
+ Highlighter,
217
+ Heading1,
218
+ Heading2,
219
+ List,
220
+ ListOrdered,
221
+ ListChecks,
222
+ Code,
223
+ Quote,
224
+ Minus,
225
+ TextAlignStart,
226
+ TextAlignCenter,
227
+ TextAlignEnd,
228
+ TextAlignJustify,
229
+ Link as LinkIcon,
230
+ Image as ImageIcon
231
+ } from "lucide-react";
232
+ import { Button as Button3, cn as cn2 } from "@ampless/runtime/ui";
233
+
234
+ // src/editor/table-controls.tsx
235
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
236
+ import { Table as TableIcon } from "lucide-react";
237
+ import { Button as Button2, cn } from "@ampless/runtime/ui";
238
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
239
+ function TableControls({ editor }) {
240
+ const t = useT();
241
+ const [open, setOpen] = useState2(false);
242
+ const rootRef = useRef2(null);
243
+ useEffect2(() => {
244
+ if (!open) return;
245
+ function onPointerDown(e) {
246
+ if (!rootRef.current) return;
247
+ if (e.target instanceof Node && rootRef.current.contains(e.target)) return;
248
+ setOpen(false);
249
+ }
250
+ function onKey(e) {
251
+ if (e.key === "Escape") setOpen(false);
252
+ }
253
+ document.addEventListener("mousedown", onPointerDown);
254
+ document.addEventListener("keydown", onKey);
255
+ return () => {
256
+ document.removeEventListener("mousedown", onPointerDown);
257
+ document.removeEventListener("keydown", onKey);
258
+ };
259
+ }, [open]);
260
+ const inTable = editor.isActive("table");
261
+ const items = [
262
+ {
263
+ key: "insert",
264
+ label: t("editor.table.insert"),
265
+ run: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
266
+ enabled: !inTable
267
+ },
268
+ {
269
+ key: "addRowBefore",
270
+ label: t("editor.table.addRowBefore"),
271
+ run: () => editor.chain().focus().addRowBefore().run(),
272
+ enabled: inTable && editor.can().addRowBefore()
273
+ },
274
+ {
275
+ key: "addRowAfter",
276
+ label: t("editor.table.addRowAfter"),
277
+ run: () => editor.chain().focus().addRowAfter().run(),
278
+ enabled: inTable && editor.can().addRowAfter()
279
+ },
280
+ {
281
+ key: "addColumnBefore",
282
+ label: t("editor.table.addColumnBefore"),
283
+ run: () => editor.chain().focus().addColumnBefore().run(),
284
+ enabled: inTable && editor.can().addColumnBefore()
285
+ },
286
+ {
287
+ key: "addColumnAfter",
288
+ label: t("editor.table.addColumnAfter"),
289
+ run: () => editor.chain().focus().addColumnAfter().run(),
290
+ enabled: inTable && editor.can().addColumnAfter()
291
+ },
292
+ {
293
+ key: "deleteRow",
294
+ label: t("editor.table.deleteRow"),
295
+ run: () => editor.chain().focus().deleteRow().run(),
296
+ enabled: inTable && editor.can().deleteRow()
297
+ },
298
+ {
299
+ key: "deleteColumn",
300
+ label: t("editor.table.deleteColumn"),
301
+ run: () => editor.chain().focus().deleteColumn().run(),
302
+ enabled: inTable && editor.can().deleteColumn()
303
+ },
304
+ {
305
+ key: "toggleHeaderRow",
306
+ label: t("editor.table.toggleHeaderRow"),
307
+ run: () => editor.chain().focus().toggleHeaderRow().run(),
308
+ enabled: inTable && editor.can().toggleHeaderRow()
309
+ },
310
+ {
311
+ key: "deleteTable",
312
+ label: t("editor.table.deleteTable"),
313
+ run: () => editor.chain().focus().deleteTable().run(),
314
+ enabled: inTable && editor.can().deleteTable()
315
+ }
316
+ ];
317
+ return /* @__PURE__ */ jsxs2("div", { ref: rootRef, className: "relative", children: [
318
+ /* @__PURE__ */ jsx2(
319
+ Button2,
320
+ {
321
+ type: "button",
322
+ variant: "ghost",
323
+ size: "icon",
324
+ onClick: () => setOpen((v) => !v),
325
+ className: cn(inTable && "bg-accent text-accent-foreground"),
326
+ title: t("editor.table.title"),
327
+ "aria-expanded": open,
328
+ "aria-haspopup": "menu",
329
+ children: /* @__PURE__ */ jsx2(TableIcon, { className: "h-4 w-4" })
330
+ }
331
+ ),
332
+ open && /* @__PURE__ */ jsx2(
333
+ "div",
334
+ {
335
+ role: "menu",
336
+ className: "absolute left-0 top-full z-50 mt-1 min-w-[12rem] rounded-md border bg-popover p-1 shadow",
337
+ children: items.map((item) => /* @__PURE__ */ jsx2(
338
+ "button",
339
+ {
340
+ type: "button",
341
+ role: "menuitem",
342
+ disabled: !item.enabled,
343
+ onClick: () => {
344
+ if (!item.enabled) return;
345
+ item.run();
346
+ setOpen(false);
347
+ },
348
+ className: cn(
349
+ "block w-full rounded-sm px-2 py-1 text-left text-sm",
350
+ item.enabled ? "hover:bg-accent hover:text-accent-foreground" : "cursor-not-allowed text-muted-foreground opacity-50"
351
+ ),
352
+ children: item.label
353
+ },
354
+ item.key
355
+ ))
356
+ }
357
+ )
358
+ ] });
359
+ }
360
+
361
+ // src/editor/toolbar.tsx
362
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
363
+ function Toolbar({ editor }) {
364
+ const t = useT();
365
+ if (!editor) return null;
366
+ const tools = [
367
+ { name: "bold", icon: Bold, action: () => editor.chain().focus().toggleBold().run(), isActive: () => editor.isActive("bold") },
368
+ { name: "italic", icon: Italic, action: () => editor.chain().focus().toggleItalic().run(), isActive: () => editor.isActive("italic") },
369
+ { name: "underline", icon: Underline, action: () => editor.chain().focus().toggleUnderline().run(), isActive: () => editor.isActive("underline") },
370
+ { name: "strike", icon: Strikethrough, action: () => editor.chain().focus().toggleStrike().run(), isActive: () => editor.isActive("strike") },
371
+ { name: "highlight", icon: Highlighter, action: () => editor.chain().focus().toggleHighlight().run(), isActive: () => editor.isActive("highlight") },
372
+ { name: "h1", icon: Heading1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: () => editor.isActive("heading", { level: 1 }) },
373
+ { name: "h2", icon: Heading2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => editor.isActive("heading", { level: 2 }) },
374
+ { name: "bulletList", icon: List, action: () => editor.chain().focus().toggleBulletList().run(), isActive: () => editor.isActive("bulletList") },
375
+ { name: "orderedList", icon: ListOrdered, action: () => editor.chain().focus().toggleOrderedList().run(), isActive: () => editor.isActive("orderedList") },
376
+ { name: "taskList", icon: ListChecks, action: () => editor.chain().focus().toggleTaskList().run(), isActive: () => editor.isActive("taskList") },
377
+ { name: "code", icon: Code, action: () => editor.chain().focus().toggleCodeBlock().run(), isActive: () => editor.isActive("codeBlock") },
378
+ { name: "blockquote", icon: Quote, action: () => editor.chain().focus().toggleBlockquote().run(), isActive: () => editor.isActive("blockquote") },
379
+ { name: "hr", icon: Minus, action: () => editor.chain().focus().setHorizontalRule().run(), isActive: () => false },
380
+ { name: "alignLeft", icon: TextAlignStart, action: () => editor.chain().focus().setTextAlign("left").run(), isActive: () => editor.isActive({ textAlign: "left" }) },
381
+ { name: "alignCenter", icon: TextAlignCenter, action: () => editor.chain().focus().setTextAlign("center").run(), isActive: () => editor.isActive({ textAlign: "center" }) },
382
+ { name: "alignRight", icon: TextAlignEnd, action: () => editor.chain().focus().setTextAlign("right").run(), isActive: () => editor.isActive({ textAlign: "right" }) },
383
+ { name: "alignJustify", icon: TextAlignJustify, action: () => editor.chain().focus().setTextAlign("justify").run(), isActive: () => editor.isActive({ textAlign: "justify" }) }
384
+ ];
385
+ const setLink = () => {
386
+ const previousUrl = editor.getAttributes("link").href ?? "";
387
+ const url = window.prompt(t("editor.linkPrompt"), previousUrl);
388
+ if (url === null) return;
389
+ if (url === "") {
390
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
391
+ return;
392
+ }
393
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
394
+ };
395
+ const insertImage = (url) => {
396
+ editor.chain().focus().setImage({ src: url }).run();
397
+ };
398
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-wrap gap-1 border-b p-2", children: [
399
+ tools.map((tool) => {
400
+ const Icon = tool.icon;
401
+ return /* @__PURE__ */ jsx3(
402
+ Button3,
403
+ {
404
+ type: "button",
405
+ variant: "ghost",
406
+ size: "icon",
407
+ onClick: tool.action,
408
+ className: cn2(tool.isActive() && "bg-accent text-accent-foreground"),
409
+ children: /* @__PURE__ */ jsx3(Icon, { className: "h-4 w-4" })
410
+ },
411
+ tool.name
412
+ );
413
+ }),
414
+ /* @__PURE__ */ jsx3(
415
+ Button3,
416
+ {
417
+ type: "button",
418
+ variant: "ghost",
419
+ size: "icon",
420
+ onClick: setLink,
421
+ className: cn2(editor.isActive("link") && "bg-accent text-accent-foreground"),
422
+ children: /* @__PURE__ */ jsx3(LinkIcon, { className: "h-4 w-4" })
423
+ }
424
+ ),
425
+ /* @__PURE__ */ jsx3(
426
+ MediaPicker,
427
+ {
428
+ onSelect: insertImage,
429
+ trigger: /* @__PURE__ */ jsx3(Button3, { type: "button", variant: "ghost", size: "icon", children: /* @__PURE__ */ jsx3(ImageIcon, { className: "h-4 w-4" }) })
430
+ }
431
+ ),
432
+ /* @__PURE__ */ jsx3(TableControls, { editor })
433
+ ] });
434
+ }
435
+
436
+ // src/editor/image-bubble-menu.tsx
437
+ import { BubbleMenu } from "@tiptap/react/menus";
438
+ import { Trash2, Pencil, ImageIcon as ImageIcon2, Maximize2 } from "lucide-react";
439
+ import { Button as Button4, cn as cn3 } from "@ampless/runtime/ui";
440
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
441
+ function ImageBubbleMenu({ editor }) {
442
+ const t = useT();
443
+ const editAlt = () => {
444
+ const current = editor.getAttributes("image").alt ?? "";
445
+ const alt = window.prompt(t("editor.altPrompt"), current);
446
+ if (alt === null) return;
447
+ editor.chain().focus().updateAttributes("image", { alt }).run();
448
+ };
449
+ const remove2 = () => {
450
+ editor.chain().focus().deleteSelection().run();
451
+ };
452
+ const setDisplay = (display) => {
453
+ const current = editor.getAttributes("image").display ?? null;
454
+ const next = current === display ? null : display;
455
+ editor.chain().focus().updateAttributes("image", { display: next }).run();
456
+ };
457
+ const currentDisplay = editor.getAttributes("image").display ?? null;
458
+ return /* @__PURE__ */ jsx4(
459
+ BubbleMenu,
460
+ {
461
+ editor,
462
+ shouldShow: ({ editor: editor2 }) => editor2.isActive("image"),
463
+ options: { placement: "top" },
464
+ children: /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-1 rounded-md border bg-popover p-1 shadow", children: [
465
+ /* @__PURE__ */ jsxs4(
466
+ Button4,
467
+ {
468
+ type: "button",
469
+ variant: "ghost",
470
+ size: "sm",
471
+ onClick: () => setDisplay("inline"),
472
+ className: cn3(currentDisplay === "inline" && "bg-accent text-accent-foreground"),
473
+ title: t("editor.image.inlineTitle"),
474
+ children: [
475
+ /* @__PURE__ */ jsx4(ImageIcon2, { className: "mr-1 h-3 w-3" }),
476
+ t("editor.image.inline")
477
+ ]
478
+ }
479
+ ),
480
+ /* @__PURE__ */ jsxs4(
481
+ Button4,
482
+ {
483
+ type: "button",
484
+ variant: "ghost",
485
+ size: "sm",
486
+ onClick: () => setDisplay("lightbox"),
487
+ className: cn3(currentDisplay === "lightbox" && "bg-accent text-accent-foreground"),
488
+ title: t("editor.image.lightboxTitle"),
489
+ children: [
490
+ /* @__PURE__ */ jsx4(Maximize2, { className: "mr-1 h-3 w-3" }),
491
+ t("editor.image.lightbox")
492
+ ]
493
+ }
494
+ ),
495
+ /* @__PURE__ */ jsx4("span", { className: "mx-1 h-4 w-px bg-border" }),
496
+ /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", onClick: editAlt, children: [
497
+ /* @__PURE__ */ jsx4(Pencil, { className: "mr-1 h-3 w-3" }),
498
+ t("editor.image.alt")
499
+ ] }),
500
+ /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", onClick: remove2, children: [
501
+ /* @__PURE__ */ jsx4(Trash2, { className: "mr-1 h-3 w-3" }),
502
+ t("editor.image.delete")
503
+ ] })
504
+ ] })
505
+ }
506
+ );
507
+ }
508
+
509
+ // src/editor/tiptap-editor.tsx
510
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
511
+ var AmplessImage = Image.extend({
512
+ addAttributes() {
513
+ return {
514
+ ...this.parent?.(),
515
+ display: {
516
+ default: null,
517
+ parseHTML: (el) => el.getAttribute("data-display"),
518
+ renderHTML: (attrs) => {
519
+ const v = attrs.display;
520
+ return v ? { "data-display": v } : {};
521
+ }
522
+ }
523
+ };
524
+ }
525
+ });
526
+ var EDITOR_STYLES = `
527
+ .ProseMirror .tiptap-table,
528
+ .ProseMirror table {
529
+ border-collapse: collapse;
530
+ table-layout: fixed;
531
+ width: 100%;
532
+ margin: 0.75em 0;
533
+ overflow: hidden;
534
+ }
535
+ .ProseMirror .tiptap-table td,
536
+ .ProseMirror .tiptap-table th,
537
+ .ProseMirror table td,
538
+ .ProseMirror table th {
539
+ border: 1px solid var(--border);
540
+ padding: 0.4em 0.6em;
541
+ min-width: 1em;
542
+ vertical-align: top;
543
+ position: relative;
544
+ }
545
+ .ProseMirror .tiptap-table th,
546
+ .ProseMirror table th {
547
+ background: var(--muted);
548
+ font-weight: 600;
549
+ text-align: left;
550
+ }
551
+ .ProseMirror .tiptap-table .selectedCell,
552
+ .ProseMirror table .selectedCell {
553
+ background: var(--accent);
554
+ }
555
+ .ProseMirror .tableWrapper {
556
+ overflow-x: auto;
557
+ margin: 0.75em 0;
558
+ }
559
+ .ProseMirror .column-resize-handle {
560
+ position: absolute;
561
+ right: -2px;
562
+ top: 0;
563
+ bottom: 0;
564
+ width: 4px;
565
+ background: var(--ring);
566
+ pointer-events: none;
567
+ }
568
+ .ProseMirror.resize-cursor {
569
+ cursor: col-resize;
570
+ }
571
+
572
+ .ProseMirror ul[data-type='taskList'] {
573
+ list-style: none;
574
+ padding-left: 0;
575
+ }
576
+ .ProseMirror ul[data-type='taskList'] li {
577
+ display: flex;
578
+ align-items: flex-start;
579
+ gap: 0.5em;
580
+ }
581
+ .ProseMirror ul[data-type='taskList'] li > label {
582
+ flex: 0 0 auto;
583
+ margin-top: 0.25em;
584
+ user-select: none;
585
+ }
586
+ .ProseMirror ul[data-type='taskList'] li > div {
587
+ flex: 1 1 auto;
588
+ min-width: 0;
589
+ }
590
+ .ProseMirror ul[data-type='taskList'] li[data-checked='true'] > div {
591
+ text-decoration: line-through;
592
+ color: var(--muted-foreground);
593
+ }
594
+
595
+ .ProseMirror mark {
596
+ background: #fef08a;
597
+ color: inherit;
598
+ padding: 0 0.1em;
599
+ border-radius: 2px;
600
+ }
601
+ `;
602
+ function TiptapEditor({ initialContent, onChange }) {
603
+ const editor = useEditor({
604
+ extensions: [
605
+ StarterKit,
606
+ Link.configure({ openOnClick: false }),
607
+ AmplessImage.configure({ inline: false, allowBase64: false }),
608
+ Table.configure({ resizable: true, HTMLAttributes: { class: "tiptap-table" } }),
609
+ TableRow,
610
+ TableHeader,
611
+ TableCell,
612
+ TaskList,
613
+ TaskItem.configure({ nested: true }),
614
+ Underline2,
615
+ Highlight.configure({ multicolor: false }),
616
+ TextAlign.configure({ types: ["heading", "paragraph"] })
617
+ ],
618
+ content: initialContent ?? { type: "doc", content: [{ type: "paragraph" }] },
619
+ immediatelyRender: false,
620
+ editorProps: {
621
+ attributes: {
622
+ class: "prose prose-neutral dark:prose-invert max-w-none min-h-[400px] px-4 py-3 focus:outline-none"
623
+ }
624
+ },
625
+ onCreate: ({ editor: editor2 }) => {
626
+ onChange?.(editor2.getJSON());
627
+ },
628
+ onUpdate: ({ editor: editor2 }) => {
629
+ onChange?.(editor2.getJSON());
630
+ }
631
+ });
632
+ return /* @__PURE__ */ jsxs5("div", { className: "rounded-md border", children: [
633
+ /* @__PURE__ */ jsx5("style", { children: EDITOR_STYLES }),
634
+ /* @__PURE__ */ jsx5(Toolbar, { editor }),
635
+ editor && /* @__PURE__ */ jsx5(ImageBubbleMenu, { editor }),
636
+ /* @__PURE__ */ jsx5(EditorContent, { editor })
637
+ ] });
638
+ }
639
+
640
+ // src/components/static-uploader.tsx
641
+ import { useState as useState3 } from "react";
642
+ import { FileText, AlertTriangle, FileArchive, X } from "lucide-react";
643
+ import { Button as Button5 } from "@ampless/runtime/ui";
644
+
645
+ // src/lib/static-bundle.ts
646
+ import JSZip from "jszip";
647
+ import { uploadData as uploadData2, list as list2, remove } from "aws-amplify/storage";
648
+ import {
649
+ mimeTypeFor,
650
+ validateBundlePath,
651
+ findAbsolutePathRefs,
652
+ validateBundle,
653
+ bundlePrefix,
654
+ pickDefaultEntrypoint,
655
+ stripCommonPrefix,
656
+ MAX_BUNDLE_BYTES
657
+ } from "ampless";
658
+ async function extractZip(file) {
659
+ const zip = await JSZip.loadAsync(file);
660
+ const files = [];
661
+ const issues = [];
662
+ let totalBytes = 0;
663
+ for (const entry of Object.values(zip.files)) {
664
+ if (entry.dir) continue;
665
+ const reason = validateBundlePath(entry.name);
666
+ if (reason) {
667
+ const silent = reason === "macOS resource fork" || reason === ".DS_Store junk" || reason === "Thumbs.db junk";
668
+ if (!silent) issues.push({ path: entry.name, reason });
669
+ continue;
670
+ }
671
+ const data = await entry.async("uint8array");
672
+ totalBytes += data.byteLength;
673
+ files.push({ path: entry.name, data });
674
+ }
675
+ return { files: stripCommonPrefix(files), issues, totalBytes };
676
+ }
677
+ async function uploadBundle(opts) {
678
+ if (opts.files.length === 0) {
679
+ throw new Error("Bundle is empty.");
680
+ }
681
+ const totalBytes = opts.files.reduce((sum, f) => sum + f.data.byteLength, 0);
682
+ if (totalBytes > MAX_BUNDLE_BYTES) {
683
+ throw new Error(
684
+ `Bundle too large: ${Math.round(totalBytes / 1024 / 1024)} MB exceeds the ${Math.round(MAX_BUNDLE_BYTES / 1024 / 1024)} MB ceiling for browser-side upload.`
685
+ );
686
+ }
687
+ const entrypoint = opts.entrypoint ?? pickDefaultEntrypoint(opts.files);
688
+ if (!opts.files.some((f) => f.path === entrypoint)) {
689
+ throw new Error(`Entrypoint "${entrypoint}" is not present in the bundle.`);
690
+ }
691
+ await deleteBundle(opts.slug).catch(() => void 0);
692
+ const prefix = bundlePrefix(opts.slug);
693
+ let uploaded = 0;
694
+ for (const f of opts.files) {
695
+ const task = uploadData2({
696
+ path: `${prefix}${f.path}`,
697
+ data: f.data,
698
+ // Forcing Content-Type at upload means CloudFront / browsers see
699
+ // it directly when serving the file via the public bucket URL.
700
+ // (The runtime route handler overrides it for the proxied path,
701
+ // but tooling that hits S3 directly benefits from a correct CT.)
702
+ options: { contentType: mimeTypeFor(f.path) }
703
+ });
704
+ await task.result;
705
+ uploaded += f.data.byteLength;
706
+ opts.onProgress?.(uploaded, totalBytes);
707
+ }
708
+ return {
709
+ entrypoint,
710
+ files: opts.files.map((f) => f.path).sort(),
711
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
712
+ };
713
+ }
714
+ async function deleteBundle(slug) {
715
+ const prefix = bundlePrefix(slug);
716
+ const result = await list2({ path: prefix });
717
+ for (const item of result.items) {
718
+ await remove({ path: item.path });
719
+ }
720
+ }
721
+
722
+ // src/components/static-uploader.tsx
723
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
724
+ function StaticUploader({ initial, onFilesReady, onClear }) {
725
+ const t = useT();
726
+ const [pending, setPending] = useState3(null);
727
+ const [issues, setIssues] = useState3([]);
728
+ const [busy, setBusy] = useState3(false);
729
+ const [error, setError] = useState3(null);
730
+ async function handleZip(file) {
731
+ setBusy(true);
732
+ setError(null);
733
+ try {
734
+ const { files, issues: structuralIssues } = await extractZip(file);
735
+ if (files.length === 0) {
736
+ setError(t("posts.form.static.emptyBundle"));
737
+ return;
738
+ }
739
+ const contentIssues = validateBundle(files);
740
+ const all = [...structuralIssues, ...contentIssues];
741
+ setPending(files);
742
+ setIssues(all);
743
+ if (all.length === 0) {
744
+ onFilesReady(files, guessEntrypoint(files));
745
+ }
746
+ } catch (e) {
747
+ setError(e instanceof Error ? e.message : String(e));
748
+ } finally {
749
+ setBusy(false);
750
+ }
751
+ }
752
+ async function handleLooseFiles(files) {
753
+ setBusy(true);
754
+ setError(null);
755
+ try {
756
+ const extracted = [];
757
+ const structural = [];
758
+ for (const f of Array.from(files)) {
759
+ const rel = f.webkitRelativePath ?? f.name;
760
+ const stripped = rel.includes("/") ? rel.slice(rel.indexOf("/") + 1) : rel;
761
+ const reason = validateBundlePath(stripped);
762
+ if (reason) {
763
+ structural.push({ path: stripped, reason });
764
+ continue;
765
+ }
766
+ const buf = new Uint8Array(await f.arrayBuffer());
767
+ extracted.push({ path: stripped, data: buf });
768
+ }
769
+ if (extracted.length === 0) {
770
+ setError(t("posts.form.static.emptyBundle"));
771
+ return;
772
+ }
773
+ const content = validateBundle(extracted);
774
+ const all = [...structural, ...content];
775
+ setPending(extracted);
776
+ setIssues(all);
777
+ if (all.length === 0) {
778
+ onFilesReady(extracted, guessEntrypoint(extracted));
779
+ }
780
+ } catch (e) {
781
+ setError(e instanceof Error ? e.message : String(e));
782
+ } finally {
783
+ setBusy(false);
784
+ }
785
+ }
786
+ function onPickerChange(e) {
787
+ const fl = e.target.files;
788
+ if (!fl || fl.length === 0) return;
789
+ e.target.value = "";
790
+ if (fl.length === 1 && fl[0].name.toLowerCase().endsWith(".zip")) {
791
+ void handleZip(fl[0]);
792
+ } else {
793
+ void handleLooseFiles(fl);
794
+ }
795
+ }
796
+ function clearPending() {
797
+ setPending(null);
798
+ setIssues([]);
799
+ setError(null);
800
+ onClear();
801
+ }
802
+ const showCurrent = !pending && initial && initial.files.length > 0;
803
+ return /* @__PURE__ */ jsxs6("div", { className: "space-y-4", children: [
804
+ /* @__PURE__ */ jsxs6("div", { className: "rounded-md border border-dashed p-4", children: [
805
+ /* @__PURE__ */ jsxs6("label", { className: "flex flex-col items-start gap-2 text-sm", children: [
806
+ /* @__PURE__ */ jsx6("span", { className: "font-medium", children: t("posts.form.static.pick") }),
807
+ /* @__PURE__ */ jsx6(
808
+ "input",
809
+ {
810
+ type: "file",
811
+ accept: ".zip,application/zip,*/*",
812
+ multiple: true,
813
+ onChange: onPickerChange,
814
+ disabled: busy
815
+ }
816
+ ),
817
+ /* @__PURE__ */ jsx6("span", { className: "text-xs text-muted-foreground", children: t("posts.form.static.pickHint") })
818
+ ] }),
819
+ busy && /* @__PURE__ */ jsx6("p", { className: "mt-2 text-sm text-muted-foreground", children: t("common.loading") }),
820
+ error && /* @__PURE__ */ jsx6("p", { className: "mt-2 text-sm text-destructive", children: error })
821
+ ] }),
822
+ showCurrent && /* @__PURE__ */ jsx6(CurrentBundle, { body: initial }),
823
+ pending && /* @__PURE__ */ jsx6(
824
+ PendingBundle,
825
+ {
826
+ files: pending,
827
+ issues,
828
+ onClear: clearPending
829
+ }
830
+ )
831
+ ] });
832
+ }
833
+ function CurrentBundle({ body }) {
834
+ const t = useT();
835
+ return /* @__PURE__ */ jsxs6("div", { className: "rounded-md border bg-muted/30 p-3", children: [
836
+ /* @__PURE__ */ jsxs6("div", { className: "mb-2 flex items-center gap-2 text-sm font-medium", children: [
837
+ /* @__PURE__ */ jsx6(FileArchive, { className: "h-4 w-4" }),
838
+ t("posts.form.static.currentBundle", {
839
+ count: body.files.length,
840
+ entrypoint: body.entrypoint
841
+ })
842
+ ] }),
843
+ /* @__PURE__ */ jsx6(FileList, { files: body.files })
844
+ ] });
845
+ }
846
+ function PendingBundle({
847
+ files,
848
+ issues,
849
+ onClear
850
+ }) {
851
+ const t = useT();
852
+ const totalBytes = files.reduce((sum, f) => sum + f.data.byteLength, 0);
853
+ return /* @__PURE__ */ jsxs6("div", { className: "rounded-md border p-3", children: [
854
+ /* @__PURE__ */ jsxs6("div", { className: "mb-2 flex items-center justify-between gap-2 text-sm font-medium", children: [
855
+ /* @__PURE__ */ jsxs6("span", { className: "flex items-center gap-2", children: [
856
+ /* @__PURE__ */ jsx6(FileArchive, { className: "h-4 w-4" }),
857
+ t("posts.form.static.pendingBundle", {
858
+ count: files.length,
859
+ size: formatBytes(totalBytes)
860
+ })
861
+ ] }),
862
+ /* @__PURE__ */ jsxs6(Button5, { type: "button", variant: "ghost", size: "sm", onClick: onClear, children: [
863
+ /* @__PURE__ */ jsx6(X, { className: "mr-1 h-3 w-3" }),
864
+ t("common.cancel")
865
+ ] })
866
+ ] }),
867
+ issues.length > 0 && /* @__PURE__ */ jsxs6("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-sm", children: [
868
+ /* @__PURE__ */ jsxs6("div", { className: "mb-1 flex items-center gap-2 font-medium text-destructive", children: [
869
+ /* @__PURE__ */ jsx6(AlertTriangle, { className: "h-4 w-4" }),
870
+ t("posts.form.static.issuesTitle", { count: issues.length })
871
+ ] }),
872
+ /* @__PURE__ */ jsxs6("ul", { className: "space-y-0.5 text-xs", children: [
873
+ issues.slice(0, 20).map((issue, idx) => /* @__PURE__ */ jsxs6("li", { className: "font-mono", children: [
874
+ issue.path,
875
+ ": ",
876
+ issue.reason
877
+ ] }, `${issue.path}-${idx}`)),
878
+ issues.length > 20 && /* @__PURE__ */ jsxs6("li", { className: "font-mono text-muted-foreground", children: [
879
+ "\u2026 ",
880
+ issues.length - 20,
881
+ " more"
882
+ ] })
883
+ ] }),
884
+ /* @__PURE__ */ jsx6("p", { className: "mt-2 text-xs text-muted-foreground", children: t("posts.form.static.issuesHint") })
885
+ ] }),
886
+ /* @__PURE__ */ jsx6(FileList, { files: files.map((f) => f.path) })
887
+ ] });
888
+ }
889
+ function FileList({ files }) {
890
+ return /* @__PURE__ */ jsxs6("ul", { className: "space-y-0.5 text-xs", children: [
891
+ files.slice(0, 40).map((path) => /* @__PURE__ */ jsxs6("li", { className: "flex items-center gap-1.5 font-mono text-muted-foreground", children: [
892
+ /* @__PURE__ */ jsx6(FileText, { className: "h-3 w-3 shrink-0" }),
893
+ path
894
+ ] }, path)),
895
+ files.length > 40 && /* @__PURE__ */ jsxs6("li", { className: "font-mono text-xs text-muted-foreground", children: [
896
+ "\u2026 ",
897
+ files.length - 40,
898
+ " more"
899
+ ] })
900
+ ] });
901
+ }
902
+ function formatBytes(bytes) {
903
+ if (bytes < 1024) return `${bytes} B`;
904
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
905
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
906
+ }
907
+ function guessEntrypoint(files) {
908
+ const exact = files.find((f) => f.path === "index.html");
909
+ if (exact) return "index.html";
910
+ const alt = files.find((f) => f.path === "index.htm");
911
+ if (alt) return "index.htm";
912
+ const rootHtml = files.filter((f) => /^[^/]+\.html?$/.test(f.path)).sort((a, b) => a.path.localeCompare(b.path))[0];
913
+ if (rootHtml) return rootHtml.path;
914
+ return files[0]?.path ?? "index.html";
915
+ }
916
+
917
+ // src/components/post-form.tsx
918
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
919
+ var EMPTY_TIPTAP_DOC = { type: "doc", content: [{ type: "paragraph" }] };
920
+ var IMAGE_URL_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)(\?|$)/i;
921
+ var STYLESHEET_URL_RE = /\.css(\?|$)/i;
922
+ var SCRIPT_URL_RE = /\.m?js(\?|$)/i;
923
+ function snippetFor(url, format) {
924
+ const isImage = IMAGE_URL_RE.test(url);
925
+ if (format === "markdown") {
926
+ return isImage ? `![](${url})` : url;
927
+ }
928
+ if (isImage) return `<img src="${url}" alt="" />`;
929
+ if (STYLESHEET_URL_RE.test(url)) return `<link rel="stylesheet" href="${url}" />`;
930
+ if (SCRIPT_URL_RE.test(url)) return `<script src="${url}"></script>`;
931
+ return url;
932
+ }
933
+ function slugify(s) {
934
+ return s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
935
+ }
936
+ function defaultBodyForFormat(format) {
937
+ if (format === "tiptap") return EMPTY_TIPTAP_DOC;
938
+ if (format === "static") return null;
939
+ return "";
940
+ }
941
+ function isStaticBody(value) {
942
+ return !!value && typeof value === "object" && "entrypoint" in value && "files" in value && Array.isArray(value.files);
943
+ }
944
+ function PostForm({ post }) {
945
+ const router = useRouter();
946
+ const t = useT();
947
+ const isEdit = !!post;
948
+ const bodyTextareaRef = useRef3(null);
949
+ const [title, setTitle] = useState4(post?.title ?? "");
950
+ const [slug, setSlug] = useState4(post?.slug ?? "");
951
+ const [excerpt, setExcerpt] = useState4(post?.excerpt ?? "");
952
+ const [format, setFormat] = useState4(post?.format ?? "tiptap");
953
+ const [body, setBody] = useState4(post?.body ?? EMPTY_TIPTAP_DOC);
954
+ const [status, setStatus] = useState4(post?.status ?? "draft");
955
+ const [tagsInput, setTagsInput] = useState4((post?.tags ?? []).join(", "));
956
+ const [noLayout, setNoLayout] = useState4(post?.metadata?.no_layout === true);
957
+ const [saving, setSaving] = useState4(false);
958
+ const [error, setError] = useState4(null);
959
+ const [view, setView] = useState4("edit");
960
+ const [pendingBundle, setPendingBundle] = useState4(null);
961
+ const initialStaticBody = isStaticBody(post?.body) ? post.body : null;
962
+ function buildMetadata() {
963
+ const next = { ...post?.metadata ?? {} };
964
+ if (noLayout && format === "html") next.no_layout = true;
965
+ else delete next.no_layout;
966
+ return Object.keys(next).length > 0 ? next : void 0;
967
+ }
968
+ function parseTags(raw) {
969
+ return Array.from(
970
+ new Set(
971
+ raw.split(",").map((t2) => t2.trim()).filter(Boolean)
972
+ )
973
+ );
974
+ }
975
+ function insertMediaSnippet(url) {
976
+ if (format === "tiptap") return;
977
+ const snippet = snippetFor(url, format);
978
+ const ta = bodyTextareaRef.current;
979
+ const current = typeof body === "string" ? body : "";
980
+ if (!ta) {
981
+ setBody(current + snippet);
982
+ return;
983
+ }
984
+ const start = ta.selectionStart ?? current.length;
985
+ const end = ta.selectionEnd ?? current.length;
986
+ const next = current.slice(0, start) + snippet + current.slice(end);
987
+ setBody(next);
988
+ requestAnimationFrame(() => {
989
+ const t2 = bodyTextareaRef.current;
990
+ if (!t2) return;
991
+ t2.focus();
992
+ const pos = start + snippet.length;
993
+ t2.setSelectionRange(pos, pos);
994
+ });
995
+ }
996
+ function changeFormat(next) {
997
+ if (next === format) return;
998
+ let nextBody = body;
999
+ if (next === "static" || format === "static") {
1000
+ nextBody = defaultBodyForFormat(next);
1001
+ } else {
1002
+ const k = `${format}\u2192${next}`;
1003
+ switch (k) {
1004
+ case "tiptap\u2192html":
1005
+ nextBody = tiptapToHtml(body);
1006
+ break;
1007
+ case "tiptap\u2192markdown":
1008
+ nextBody = tiptapToMarkdown(body);
1009
+ break;
1010
+ case "html\u2192tiptap":
1011
+ nextBody = String(body ?? "");
1012
+ break;
1013
+ case "markdown\u2192tiptap":
1014
+ nextBody = markdownToHtml(String(body ?? ""));
1015
+ break;
1016
+ case "html\u2192markdown":
1017
+ nextBody = htmlToMarkdown(String(body ?? ""));
1018
+ break;
1019
+ case "markdown\u2192html":
1020
+ nextBody = markdownToHtml(String(body ?? ""));
1021
+ break;
1022
+ default:
1023
+ nextBody = defaultBodyForFormat(next);
1024
+ }
1025
+ }
1026
+ setFormat(next);
1027
+ setBody(nextBody);
1028
+ setPendingBundle(null);
1029
+ if (next !== "html") setNoLayout(false);
1030
+ }
1031
+ async function save(e) {
1032
+ e.preventDefault();
1033
+ setSaving(true);
1034
+ setError(null);
1035
+ try {
1036
+ const tags = parseTags(tagsInput);
1037
+ const metadata = buildMetadata();
1038
+ const finalSlug = slug || slugify(title);
1039
+ let nextBody = body;
1040
+ if (format === "static") {
1041
+ if (pendingBundle) {
1042
+ nextBody = await uploadBundle({
1043
+ slug: finalSlug,
1044
+ files: pendingBundle.files,
1045
+ entrypoint: pendingBundle.entrypoint
1046
+ });
1047
+ } else if (initialStaticBody) {
1048
+ nextBody = initialStaticBody;
1049
+ } else {
1050
+ throw new Error(t("posts.form.static.noBundle"));
1051
+ }
1052
+ }
1053
+ if (isEdit) {
1054
+ await updatePost(post.postId, {
1055
+ title,
1056
+ slug: finalSlug,
1057
+ excerpt: excerpt || void 0,
1058
+ format,
1059
+ body: nextBody,
1060
+ status,
1061
+ publishedAt: status === "published" ? post?.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1062
+ tags,
1063
+ metadata
1064
+ });
1065
+ } else {
1066
+ await createPost({
1067
+ slug: finalSlug,
1068
+ title,
1069
+ excerpt: excerpt || void 0,
1070
+ format,
1071
+ body: nextBody,
1072
+ status,
1073
+ publishedAt: status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1074
+ tags,
1075
+ metadata
1076
+ });
1077
+ }
1078
+ router.push("/admin/posts");
1079
+ router.refresh();
1080
+ } catch (err) {
1081
+ setError(err instanceof Error ? err.message : String(err));
1082
+ } finally {
1083
+ setSaving(false);
1084
+ }
1085
+ }
1086
+ async function handleDelete() {
1087
+ if (!post) return;
1088
+ if (!confirm(t("posts.form.deleteConfirm", { title: post.title }))) return;
1089
+ setSaving(true);
1090
+ try {
1091
+ if (post.format === "static") {
1092
+ await deleteBundle(post.slug).catch(() => void 0);
1093
+ }
1094
+ await deletePost(post.postId);
1095
+ router.push("/admin/posts");
1096
+ router.refresh();
1097
+ } catch (err) {
1098
+ setError(err instanceof Error ? err.message : String(err));
1099
+ setSaving(false);
1100
+ }
1101
+ }
1102
+ const previewPost = {
1103
+ postId: post?.postId ?? "preview",
1104
+ slug: slug || slugify(title) || "preview",
1105
+ title,
1106
+ excerpt: excerpt || void 0,
1107
+ format,
1108
+ body,
1109
+ status,
1110
+ publishedAt: status === "published" ? post?.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString() : void 0,
1111
+ tags: parseTags(tagsInput)
1112
+ };
1113
+ return /* @__PURE__ */ jsxs7("form", { onSubmit: save, className: "space-y-6", children: [
1114
+ /* @__PURE__ */ jsxs7("div", { className: "flex gap-1 border-b", children: [
1115
+ /* @__PURE__ */ jsx7(
1116
+ "button",
1117
+ {
1118
+ type: "button",
1119
+ onClick: () => setView("edit"),
1120
+ "aria-pressed": view === "edit",
1121
+ 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"}`,
1122
+ children: t("posts.form.tabEdit")
1123
+ }
1124
+ ),
1125
+ /* @__PURE__ */ jsx7(
1126
+ "button",
1127
+ {
1128
+ type: "button",
1129
+ onClick: () => setView("preview"),
1130
+ "aria-pressed": view === "preview",
1131
+ 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"}`,
1132
+ children: t("posts.form.tabPreview")
1133
+ }
1134
+ )
1135
+ ] }),
1136
+ view === "preview" && /* @__PURE__ */ jsxs7("article", { className: "space-y-4", children: [
1137
+ /* @__PURE__ */ jsxs7("header", { className: "border-b pb-4", children: [
1138
+ /* @__PURE__ */ jsx7("h1", { className: "text-3xl font-bold tracking-tight", children: title || /* @__PURE__ */ jsx7("span", { className: "text-muted-foreground italic", children: t("posts.form.previewNoTitle") }) }),
1139
+ /* @__PURE__ */ jsxs7("p", { className: "mt-2 text-sm text-muted-foreground", children: [
1140
+ previewPost.publishedAt ? /* @__PURE__ */ jsx7("time", { dateTime: previewPost.publishedAt, children: formatDate(previewPost.publishedAt) }) : /* @__PURE__ */ jsx7("span", { children: t("common.draft") }),
1141
+ /* @__PURE__ */ jsx7("span", { className: "mx-2", children: "\xB7" }),
1142
+ /* @__PURE__ */ jsx7("span", { className: "font-mono text-xs uppercase", children: format })
1143
+ ] }),
1144
+ excerpt && /* @__PURE__ */ jsx7("p", { className: "mt-3 text-base text-muted-foreground", children: excerpt })
1145
+ ] }),
1146
+ format === "static" ? /* @__PURE__ */ jsx7("p", { className: "text-sm text-muted-foreground", children: t("posts.form.static.previewHint") }) : /* @__PURE__ */ jsx7(
1147
+ "div",
1148
+ {
1149
+ className: "prose prose-neutral dark:prose-invert max-w-none",
1150
+ dangerouslySetInnerHTML: { __html: renderBody(previewPost) }
1151
+ }
1152
+ ),
1153
+ previewPost.tags && previewPost.tags.length > 0 && /* @__PURE__ */ jsx7("div", { className: "flex flex-wrap gap-2 border-t pt-4 text-sm", children: previewPost.tags.map((tag) => /* @__PURE__ */ jsxs7(
1154
+ "span",
1155
+ {
1156
+ className: "rounded-full border px-2 py-0.5 text-xs text-muted-foreground",
1157
+ children: [
1158
+ "#",
1159
+ tag
1160
+ ]
1161
+ },
1162
+ tag
1163
+ )) }),
1164
+ /* @__PURE__ */ jsx7("p", { className: "text-xs text-muted-foreground", children: t("posts.form.previewHint") })
1165
+ ] }),
1166
+ /* @__PURE__ */ jsxs7("div", { className: view === "edit" ? "space-y-6" : "hidden", children: [
1167
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1168
+ /* @__PURE__ */ jsx7(Label, { htmlFor: "title", children: t("posts.form.title") }),
1169
+ /* @__PURE__ */ jsx7(
1170
+ Input,
1171
+ {
1172
+ id: "title",
1173
+ required: true,
1174
+ value: title,
1175
+ onChange: (e) => {
1176
+ setTitle(e.target.value);
1177
+ if (!isEdit && !slug) setSlug(slugify(e.target.value));
1178
+ }
1179
+ }
1180
+ )
1181
+ ] }),
1182
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1183
+ /* @__PURE__ */ jsx7(Label, { htmlFor: "slug", children: t("posts.form.slug") }),
1184
+ /* @__PURE__ */ jsx7(
1185
+ Input,
1186
+ {
1187
+ id: "slug",
1188
+ value: slug,
1189
+ onChange: (e) => setSlug(e.target.value),
1190
+ placeholder: slugify(title) || t("posts.form.slugPlaceholder")
1191
+ }
1192
+ )
1193
+ ] }),
1194
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1195
+ /* @__PURE__ */ jsx7(Label, { htmlFor: "excerpt", children: t("posts.form.excerpt") }),
1196
+ /* @__PURE__ */ jsx7(
1197
+ Textarea,
1198
+ {
1199
+ id: "excerpt",
1200
+ rows: 2,
1201
+ value: excerpt,
1202
+ onChange: (e) => setExcerpt(e.target.value)
1203
+ }
1204
+ )
1205
+ ] }),
1206
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1207
+ /* @__PURE__ */ jsx7(Label, { htmlFor: "format", children: t("posts.form.format") }),
1208
+ /* @__PURE__ */ jsxs7(
1209
+ "select",
1210
+ {
1211
+ id: "format",
1212
+ value: format,
1213
+ onChange: (e) => changeFormat(e.target.value),
1214
+ className: "flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
1215
+ children: [
1216
+ /* @__PURE__ */ jsx7("option", { value: "tiptap", children: "Tiptap (rich editor)" }),
1217
+ /* @__PURE__ */ jsx7("option", { value: "markdown", children: "Markdown" }),
1218
+ /* @__PURE__ */ jsx7("option", { value: "html", children: "HTML" }),
1219
+ /* @__PURE__ */ jsx7("option", { value: "static", children: t("posts.form.formatStaticLabel") })
1220
+ ]
1221
+ }
1222
+ ),
1223
+ /* @__PURE__ */ jsx7("p", { className: "text-xs text-muted-foreground", children: t("posts.form.formatHint") })
1224
+ ] }),
1225
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1226
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-center justify-between", children: [
1227
+ /* @__PURE__ */ jsx7(Label, { children: t("posts.form.body") }),
1228
+ format !== "tiptap" && format !== "static" && // For textarea-based formats (markdown / html) there's no
1229
+ // embedded toolbar, so we surface the MediaPicker as a
1230
+ // standalone button. Selecting an asset inserts a
1231
+ // format-aware snippet at the cursor.
1232
+ /* @__PURE__ */ jsx7(
1233
+ MediaPicker,
1234
+ {
1235
+ onSelect: insertMediaSnippet,
1236
+ trigger: /* @__PURE__ */ jsxs7(Button6, { type: "button", variant: "outline", size: "sm", children: [
1237
+ /* @__PURE__ */ jsx7(ImageIcon3, { className: "mr-2 h-3 w-3" }),
1238
+ t("posts.form.insertMedia")
1239
+ ] })
1240
+ }
1241
+ )
1242
+ ] }),
1243
+ format === "tiptap" ? /* @__PURE__ */ jsx7(TiptapEditor, { initialContent: body, onChange: setBody }) : format === "static" ? /* @__PURE__ */ jsx7(
1244
+ StaticUploader,
1245
+ {
1246
+ initial: initialStaticBody,
1247
+ onFilesReady: (files, entrypoint) => setPendingBundle({ files, entrypoint }),
1248
+ onClear: () => setPendingBundle(null)
1249
+ }
1250
+ ) : /* @__PURE__ */ jsx7(
1251
+ Textarea,
1252
+ {
1253
+ ref: bodyTextareaRef,
1254
+ rows: 20,
1255
+ value: typeof body === "string" ? body : "",
1256
+ onChange: (e) => setBody(e.target.value),
1257
+ className: "font-mono text-xs"
1258
+ }
1259
+ )
1260
+ ] }),
1261
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1262
+ /* @__PURE__ */ jsx7(Label, { htmlFor: "tags", children: t("posts.form.tags") }),
1263
+ /* @__PURE__ */ jsx7(
1264
+ Input,
1265
+ {
1266
+ id: "tags",
1267
+ value: tagsInput,
1268
+ onChange: (e) => setTagsInput(e.target.value),
1269
+ placeholder: t("posts.form.tagsPlaceholder")
1270
+ }
1271
+ ),
1272
+ /* @__PURE__ */ jsx7("p", { className: "text-xs text-muted-foreground", children: t("posts.form.tagsHint") })
1273
+ ] }),
1274
+ /* @__PURE__ */ jsxs7("div", { className: "space-y-2", children: [
1275
+ /* @__PURE__ */ jsx7(Label, { htmlFor: "status", children: t("posts.form.status") }),
1276
+ /* @__PURE__ */ jsxs7(
1277
+ "select",
1278
+ {
1279
+ id: "status",
1280
+ value: status,
1281
+ onChange: (e) => setStatus(e.target.value),
1282
+ className: "flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
1283
+ children: [
1284
+ /* @__PURE__ */ jsx7("option", { value: "draft", children: t("common.draft") }),
1285
+ /* @__PURE__ */ jsx7("option", { value: "published", children: t("common.published") })
1286
+ ]
1287
+ }
1288
+ )
1289
+ ] }),
1290
+ format === "html" && /* @__PURE__ */ jsx7("div", { className: "space-y-2", children: /* @__PURE__ */ jsxs7("label", { className: "flex items-start gap-2 text-sm", children: [
1291
+ /* @__PURE__ */ jsx7(
1292
+ "input",
1293
+ {
1294
+ type: "checkbox",
1295
+ checked: noLayout,
1296
+ onChange: (e) => setNoLayout(e.target.checked),
1297
+ className: "mt-1"
1298
+ }
1299
+ ),
1300
+ /* @__PURE__ */ jsxs7("span", { children: [
1301
+ /* @__PURE__ */ jsx7("span", { className: "font-medium", children: t("posts.form.noLayout") }),
1302
+ /* @__PURE__ */ jsx7("span", { className: "block text-xs text-muted-foreground", children: t("posts.form.noLayoutHint") })
1303
+ ] })
1304
+ ] }) }),
1305
+ error && /* @__PURE__ */ jsx7("p", { className: "text-sm text-destructive", children: error }),
1306
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-center gap-2", children: [
1307
+ /* @__PURE__ */ jsx7(Button6, { type: "submit", disabled: saving, children: saving ? t("common.saving") : isEdit ? t("posts.form.saveChanges") : t("posts.form.createPost") }),
1308
+ isEdit && /* @__PURE__ */ jsx7(Button6, { type: "button", variant: "destructive", onClick: handleDelete, disabled: saving, children: t("posts.form.delete") })
1309
+ ] })
1310
+ ] })
1311
+ ] });
1312
+ }
1313
+
1314
+ export {
1315
+ sanitizeName,
1316
+ uploadProcessedImage,
1317
+ MediaPicker,
1318
+ PostForm
1319
+ };