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