@acms-subir/core 0.0.1

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 (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,270 @@
1
+ import { Node } from '@tiptap/core';
2
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
3
+ import type { NodeViewProps } from '@tiptap/react';
4
+
5
+ // Info cards: a *static* grid of title / badge / description / detail-line
6
+ // cards — services, opening hours, team members, feature grids. Unlike the
7
+ // calendar card there is nothing to keep fresh, so renderHTML emits the full
8
+ // public `.pc-info*` markup (real text, SEO-visible, works in any theme
9
+ // template that splats bodyHtml) plus one data-items JSON attribute that
10
+ // parseHTML round-trips from. Only cards with a title are rendered publicly;
11
+ // data-items keeps unfinished drafts intact across saves.
12
+ //
13
+ // No inline style attributes are emitted — everything is styled by the
14
+ // .pc-info-* / --info-* API in src/styles/cards.css so theme CSS (unlayered)
15
+ // always wins.
16
+
17
+ export interface InfoLine {
18
+ icon: string; // id from INFO_ICONS
19
+ text: string;
20
+ }
21
+
22
+ export interface InfoItem {
23
+ title: string;
24
+ badge: string; // empty = no pill
25
+ badgeStyle: 'filled' | 'outline';
26
+ description: string;
27
+ href: string; // empty = plain card; set = whole card is a link
28
+ lines: InfoLine[];
29
+ }
30
+
31
+ export const MAX_INFO_CARDS = 12;
32
+ export const MAX_INFO_LINES = 6;
33
+
34
+ // The public side mirrors these paths as CSS mask data-URIs on
35
+ // .pc-info-row[data-icon=…] in cards.css — keep both lists in sync.
36
+ export const INFO_ICONS: { id: string; label: string; d: string }[] = [
37
+ { id: 'calendar', label: 'Calendar', d: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4' },
38
+ { id: 'clock', label: 'Clock', d: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18M12 7v5l3 2' },
39
+ { id: 'pin', label: 'Map pin', d: 'M12 21s-7-5.6-7-11a7 7 0 0 1 14 0c0 5.4-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5' },
40
+ { id: 'person', label: 'Person', d: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5' },
41
+ { id: 'phone', label: 'Phone', d: 'M22 16.5v3a2 2 0 0 1-2.2 2A19 19 0 0 1 3 4.2 2 2 0 0 1 5 2h3l2 5-2.5 2a15 15 0 0 0 7.5 7.5L17 14l5 2.5z' },
42
+ { id: 'mail', label: 'Email', d: 'M3 6h18v13H3zM3 8l9 6 9-6' },
43
+ { id: 'link', label: 'Link', d: 'M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5' },
44
+ { id: 'tag', label: 'Tag', d: 'M4 4h7l9 9-7 7-9-9zM8.5 8.5h.01' },
45
+ { id: 'check', label: 'Check', d: 'M4 12.5l5 5 11-11' },
46
+ ];
47
+
48
+ const ICON_IDS = new Set(INFO_ICONS.map((i) => i.id));
49
+
50
+ const str = (v: unknown): string => (typeof v === 'string' ? v : '');
51
+
52
+ const sanitizeItem = (raw: unknown): InfoItem | null => {
53
+ if (!raw || typeof raw !== 'object') return null;
54
+ const it = raw as Record<string, unknown>;
55
+ const lines = Array.isArray(it.lines)
56
+ ? it.lines
57
+ .slice(0, MAX_INFO_LINES)
58
+ .flatMap((l): InfoLine[] => {
59
+ if (!l || typeof l !== 'object') return [];
60
+ const { icon, text } = l as Record<string, unknown>;
61
+ return [{ icon: ICON_IDS.has(str(icon)) ? str(icon) : 'calendar', text: str(text) }];
62
+ })
63
+ : [];
64
+ return {
65
+ title: str(it.title),
66
+ badge: str(it.badge),
67
+ badgeStyle: it.badgeStyle === 'outline' ? 'outline' : 'filled',
68
+ description: str(it.description),
69
+ href: str(it.href),
70
+ lines,
71
+ };
72
+ };
73
+
74
+ export const parseInfoItems = (raw: string | null): InfoItem[] => {
75
+ try {
76
+ const parsed = JSON.parse(raw ?? '[]');
77
+ if (!Array.isArray(parsed)) return [];
78
+ return parsed.slice(0, MAX_INFO_CARDS).flatMap((it) => {
79
+ const item = sanitizeItem(it);
80
+ return item ? [item] : [];
81
+ });
82
+ } catch {
83
+ return [];
84
+ }
85
+ };
86
+
87
+ const newItem = (): InfoItem => ({
88
+ title: '',
89
+ badge: '',
90
+ badgeStyle: 'filled',
91
+ description: '',
92
+ href: '',
93
+ lines: [
94
+ { icon: 'calendar', text: '' },
95
+ { icon: 'clock', text: '' },
96
+ ],
97
+ });
98
+
99
+ type Spec = [string, Record<string, string>, ...unknown[]];
100
+
101
+ export const InfoCards = Node.create({
102
+ name: 'infoCards',
103
+ group: 'block',
104
+ atom: true,
105
+ draggable: true,
106
+
107
+ addAttributes() {
108
+ return {
109
+ items: { default: [] as InfoItem[] },
110
+ };
111
+ },
112
+
113
+ parseHTML() {
114
+ return [
115
+ {
116
+ tag: 'div.pc-infocards',
117
+ getAttrs: (el) => ({ items: parseInfoItems((el as HTMLElement).getAttribute('data-items')) }),
118
+ },
119
+ ];
120
+ },
121
+
122
+ renderHTML({ node }) {
123
+ const items = (node.attrs.items as InfoItem[]) ?? [];
124
+ const cards = items
125
+ .filter((it) => it.title.trim())
126
+ .map((it): Spec => {
127
+ const children: Spec[] = [];
128
+ const head: Spec = ['header', { class: 'pc-info-head' }, ['h3', { class: 'pc-info-title' }, it.title]];
129
+ if (it.badge.trim()) {
130
+ head.push(['span', { class: `pc-info-badge${it.badgeStyle === 'filled' ? ' is-filled' : ''}` }, it.badge]);
131
+ }
132
+ children.push(head);
133
+ if (it.description.trim()) children.push(['p', { class: 'pc-info-desc' }, it.description]);
134
+ const lines = it.lines.filter((l) => l.text.trim());
135
+ if (lines.length) {
136
+ children.push([
137
+ 'div',
138
+ { class: 'pc-info-meta' },
139
+ ...lines.map((l): Spec => ['span', { class: 'pc-info-row', 'data-icon': l.icon }, l.text]),
140
+ ]);
141
+ }
142
+ const href = it.href.trim();
143
+ return href
144
+ ? ['a', { class: 'pc-info-card', href }, ...children]
145
+ : ['article', { class: 'pc-info-card' }, ...children];
146
+ });
147
+ return ['div', { class: 'pc-infocards', 'data-items': JSON.stringify(items) }, ...cards];
148
+ },
149
+
150
+ addNodeView() {
151
+ return ReactNodeViewRenderer(InfoCardsView);
152
+ },
153
+ });
154
+
155
+ function LineIcon({ icon }: { icon: string }) {
156
+ const d = INFO_ICONS.find((i) => i.id === icon)?.d ?? INFO_ICONS[0].d;
157
+ return (
158
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
159
+ <path d={d} />
160
+ </svg>
161
+ );
162
+ }
163
+
164
+ function InfoCardsView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
165
+ const items = (node.attrs.items as InfoItem[]) ?? [];
166
+
167
+ const setItems = (next: InfoItem[]) => updateAttributes({ items: next });
168
+ const patch = (i: number, part: Partial<InfoItem>) =>
169
+ setItems(items.map((it, j) => (j === i ? { ...it, ...part } : it)));
170
+ const patchLine = (i: number, li: number, part: Partial<InfoLine>) =>
171
+ patch(i, { lines: items[i].lines.map((l, j) => (j === li ? { ...l, ...part } : l)) });
172
+ const move = (i: number, dir: -1 | 1) => {
173
+ const next = [...items];
174
+ const [it] = next.splice(i, 1);
175
+ next.splice(i + dir, 0, it);
176
+ setItems(next);
177
+ };
178
+
179
+ return (
180
+ <NodeViewWrapper className={`pe-card pe-infocards ${selected ? 'is-selected' : ''}`} data-drag-handle>
181
+ <div className="pe-card-tools" contentEditable={false}>
182
+ <span className="pe-provider">Info cards · {items.length}</span>
183
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
184
+ </div>
185
+ <div contentEditable={false}>
186
+ {items.length === 0 && (
187
+ <div className="pe-infocards-empty">A grid of info cards — services, opening hours, team, features…</div>
188
+ )}
189
+ {items.map((it, i) => (
190
+ <div className="pe-infocard" key={i}>
191
+ <div className="pe-infocard-row">
192
+ <input
193
+ type="text"
194
+ className="pe-infocard-title"
195
+ placeholder="Card title"
196
+ value={it.title}
197
+ onChange={(e) => patch(i, { title: e.target.value })}
198
+ />
199
+ <input
200
+ type="text"
201
+ className="pe-infocard-badge"
202
+ placeholder="Badge (optional)"
203
+ value={it.badge}
204
+ onChange={(e) => patch(i, { badge: e.target.value })}
205
+ />
206
+ {it.badge.trim() !== '' && (
207
+ <select
208
+ value={it.badgeStyle}
209
+ onChange={(e) => patch(i, { badgeStyle: e.target.value as InfoItem['badgeStyle'] })}
210
+ >
211
+ <option value="filled">Filled</option>
212
+ <option value="outline">Outline</option>
213
+ </select>
214
+ )}
215
+ <span className="pe-infocard-ops">
216
+ <button type="button" title="Move up" disabled={i === 0} onClick={() => move(i, -1)}>↑</button>
217
+ <button type="button" title="Move down" disabled={i === items.length - 1} onClick={() => move(i, 1)}>↓</button>
218
+ <button type="button" title="Remove card" onClick={() => setItems(items.filter((_, j) => j !== i))}>✕</button>
219
+ </span>
220
+ </div>
221
+ <textarea
222
+ className="pe-infocard-desc"
223
+ rows={2}
224
+ placeholder="Description (optional)"
225
+ value={it.description}
226
+ onChange={(e) => patch(i, { description: e.target.value })}
227
+ />
228
+ {it.lines.map((l, li) => (
229
+ <div className="pe-infocard-line" key={li}>
230
+ <span className="pe-infocard-icon"><LineIcon icon={l.icon} /></span>
231
+ <select value={l.icon} onChange={(e) => patchLine(i, li, { icon: e.target.value })}>
232
+ {INFO_ICONS.map((ic) => <option key={ic.id} value={ic.id}>{ic.label}</option>)}
233
+ </select>
234
+ <input
235
+ type="text"
236
+ placeholder={l.icon === 'clock' ? 'e.g. 7:30 PM' : 'e.g. Every Thursday'}
237
+ value={l.text}
238
+ onChange={(e) => patchLine(i, li, { text: e.target.value })}
239
+ />
240
+ <button
241
+ type="button" title="Remove line"
242
+ onClick={() => patch(i, { lines: it.lines.filter((_, j) => j !== li) })}
243
+ >✕</button>
244
+ </div>
245
+ ))}
246
+ <div className="pe-infocard-foot">
247
+ {it.lines.length < MAX_INFO_LINES && (
248
+ <button type="button" className="pe-btn sm" onClick={() => patch(i, { lines: [...it.lines, { icon: 'calendar', text: '' }] })}>
249
+ + Add line
250
+ </button>
251
+ )}
252
+ <input
253
+ type="url"
254
+ className="pe-infocard-href"
255
+ placeholder="Link URL (optional — makes the card clickable)"
256
+ value={it.href}
257
+ onChange={(e) => patch(i, { href: e.target.value })}
258
+ />
259
+ </div>
260
+ </div>
261
+ ))}
262
+ {items.length < MAX_INFO_CARDS && (
263
+ <button type="button" className="pe-btn primary sm" onClick={() => setItems([...items, newItem()])}>
264
+ + Add card
265
+ </button>
266
+ )}
267
+ </div>
268
+ </NodeViewWrapper>
269
+ );
270
+ }
@@ -0,0 +1,376 @@
1
+ import { Node } from '@tiptap/react';
2
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
3
+ import type { NodeViewProps } from '@tiptap/react';
4
+ import { useRef, useState } from 'react';
5
+ import { actions } from 'astro:actions';
6
+ import { uploadFile, mediaUrl } from '../imaging';
7
+ import { ImagePicker } from '../ImagePicker';
8
+
9
+ // Media cards beyond the basic image: file/download, gallery, bookmark.
10
+ // Same contract as cards.tsx — renderHTML emits public `.pc-*` markup,
11
+ // parseHTML round-trips it.
12
+
13
+ // ---- file / download -------------------------------------------------------
14
+
15
+ export function formatBytes(n: number): string {
16
+ if (!n) return '';
17
+ if (n < 1024) return `${n} B`;
18
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
19
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
20
+ }
21
+
22
+ export const FileCard = Node.create({
23
+ name: 'fileCard',
24
+ group: 'block',
25
+ atom: true,
26
+ draggable: true,
27
+
28
+ addAttributes() {
29
+ return {
30
+ href: { default: '' },
31
+ filename: { default: '' },
32
+ size: { default: 0 },
33
+ };
34
+ },
35
+
36
+ parseHTML() {
37
+ return [
38
+ {
39
+ tag: 'a.pc-filecard',
40
+ getAttrs: (el) => {
41
+ const e = el as HTMLElement;
42
+ return {
43
+ href: e.getAttribute('href') ?? '',
44
+ filename: e.querySelector('.pc-file-name')?.textContent ?? e.getAttribute('download') ?? '',
45
+ size: Number(e.getAttribute('data-size') ?? 0),
46
+ };
47
+ },
48
+ },
49
+ ];
50
+ },
51
+
52
+ renderHTML({ node }) {
53
+ const a = node.attrs;
54
+ return [
55
+ 'a',
56
+ { class: 'pc-filecard', href: a.href, download: a.filename, 'data-size': String(a.size) },
57
+ ['span', { class: 'pc-file-icon', 'aria-hidden': 'true' }, '↓'],
58
+ ['span', { class: 'pc-file-text' },
59
+ ['span', { class: 'pc-file-name' }, a.filename || 'Download'],
60
+ ['span', { class: 'pc-file-meta' }, formatBytes(a.size)],
61
+ ],
62
+ ];
63
+ },
64
+
65
+ addNodeView() {
66
+ return ReactNodeViewRenderer(FileCardView);
67
+ },
68
+ });
69
+
70
+ function FileCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
71
+ const a = node.attrs;
72
+ const inputRef = useRef<HTMLInputElement>(null);
73
+ const [busy, setBusy] = useState(false);
74
+ const [error, setError] = useState('');
75
+
76
+ if (!a.href) {
77
+ return (
78
+ <NodeViewWrapper className={`pe-card pe-filecard is-empty ${selected ? 'is-selected' : ''}`}>
79
+ <div className="pe-embed-form" contentEditable={false}>
80
+ <span className="pe-embed-label">File</span>
81
+ <input
82
+ ref={inputRef}
83
+ type="file"
84
+ accept=".pdf,.zip,.txt,.csv,.md,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.epub"
85
+ onChange={async (e) => {
86
+ const f = e.target.files?.[0];
87
+ if (!f) return;
88
+ setBusy(true);
89
+ setError('');
90
+ try {
91
+ const media = await uploadFile(f);
92
+ updateAttributes({ href: mediaUrl(media.key), filename: media.filename, size: media.size });
93
+ } catch (err) {
94
+ setError(err instanceof Error ? err.message : 'Upload failed');
95
+ } finally {
96
+ setBusy(false);
97
+ }
98
+ }}
99
+ />
100
+ {busy && <span className="pe-embed-label">Uploading…</span>}
101
+ {error && <span className="pe-embed-error">{error}</span>}
102
+ <button type="button" className="pe-btn sm" onClick={() => deleteNode()}>Cancel</button>
103
+ </div>
104
+ </NodeViewWrapper>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <NodeViewWrapper className={`pe-card pe-filecard ${selected ? 'is-selected' : ''}`} data-drag-handle>
110
+ <div className="pe-card-tools" contentEditable={false}>
111
+ <span className="pe-provider">File</span>
112
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
113
+ </div>
114
+ <span className="pc-filecard" contentEditable={false}>
115
+ <span className="pc-file-icon" aria-hidden="true">↓</span>
116
+ <span className="pc-file-text">
117
+ <span className="pc-file-name">{a.filename || 'Download'}</span>
118
+ <span className="pc-file-meta">{formatBytes(a.size)}</span>
119
+ </span>
120
+ </span>
121
+ </NodeViewWrapper>
122
+ );
123
+ }
124
+
125
+ // ---- gallery ---------------------------------------------------------------
126
+
127
+ interface GalleryImage {
128
+ src: string;
129
+ srcset?: string;
130
+ alt?: string;
131
+ }
132
+
133
+ export const GalleryCard = Node.create<{ unsplashEnabled: boolean }>({
134
+ name: 'galleryCard',
135
+ group: 'block',
136
+ atom: true,
137
+ draggable: true,
138
+
139
+ addOptions() {
140
+ return { unsplashEnabled: false };
141
+ },
142
+
143
+ addAttributes() {
144
+ return {
145
+ images: { default: [] as GalleryImage[] },
146
+ caption: { default: '' },
147
+ };
148
+ },
149
+
150
+ parseHTML() {
151
+ return [
152
+ {
153
+ tag: 'figure.pc-gallery',
154
+ getAttrs: (el) => {
155
+ const e = el as HTMLElement;
156
+ return {
157
+ images: [...e.querySelectorAll('img')].map((img) => ({
158
+ src: img.getAttribute('src') ?? '',
159
+ srcset: img.getAttribute('srcset') ?? undefined,
160
+ alt: img.getAttribute('alt') ?? '',
161
+ })),
162
+ caption: e.querySelector('figcaption')?.textContent ?? '',
163
+ };
164
+ },
165
+ },
166
+ ];
167
+ },
168
+
169
+ renderHTML({ node }) {
170
+ const a = node.attrs;
171
+ const images = (a.images as GalleryImage[]).map(
172
+ (img): [string, Record<string, string>] => [
173
+ 'img',
174
+ {
175
+ src: img.src,
176
+ alt: img.alt ?? '',
177
+ loading: 'lazy',
178
+ ...(img.srcset ? { srcset: img.srcset, sizes: '(max-width: 760px) 100vw, 380px' } : {}),
179
+ },
180
+ ]
181
+ );
182
+ const grid: [string, Record<string, string>, ...unknown[]] = [
183
+ 'div',
184
+ { class: 'pc-gallery-grid', 'data-count': String(images.length) },
185
+ ...images,
186
+ ];
187
+ const fig = { class: 'pc-gallery' };
188
+ return a.caption
189
+ ? ['figure', fig, grid, ['figcaption', {}, a.caption]]
190
+ : ['figure', fig, grid];
191
+ },
192
+
193
+ addNodeView() {
194
+ return ReactNodeViewRenderer(GalleryCardView);
195
+ },
196
+ });
197
+
198
+ function GalleryCardView({ node, updateAttributes, deleteNode, selected, extension }: NodeViewProps) {
199
+ const images = node.attrs.images as GalleryImage[];
200
+ const [pickerOpen, setPickerOpen] = useState(images.length === 0);
201
+ const unsplashEnabled = Boolean((extension.options as { unsplashEnabled?: boolean }).unsplashEnabled);
202
+
203
+ return (
204
+ <NodeViewWrapper className={`pe-card pe-gallerycard ${selected ? 'is-selected' : ''}`} data-drag-handle>
205
+ <div className="pe-card-tools" contentEditable={false}>
206
+ <span className="pe-provider">Gallery · {images.length}</span>
207
+ <button type="button" onClick={() => setPickerOpen(true)}>+ Add image</button>
208
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
209
+ </div>
210
+ {images.length === 0 ? (
211
+ <div className="pe-gallery-empty" contentEditable={false}>Empty gallery — add at least two images.</div>
212
+ ) : (
213
+ <div className="pc-gallery-grid" data-count={String(images.length)} contentEditable={false}>
214
+ {images.map((img, i) => (
215
+ <span key={`${img.src}-${i}`} className="pe-gallery-item">
216
+ <img src={img.src} alt={img.alt ?? ''} />
217
+ <button
218
+ type="button"
219
+ className="pe-gallery-x"
220
+ title="Remove image"
221
+ onClick={() => updateAttributes({ images: images.filter((_, j) => j !== i) })}
222
+ >
223
+ ✕
224
+ </button>
225
+ </span>
226
+ ))}
227
+ </div>
228
+ )}
229
+ <input
230
+ className="pe-caption"
231
+ type="text"
232
+ placeholder="Add a caption (optional)"
233
+ value={node.attrs.caption}
234
+ onChange={(e) => updateAttributes({ caption: e.target.value })}
235
+ />
236
+ <ImagePicker
237
+ open={pickerOpen}
238
+ unsplashEnabled={unsplashEnabled}
239
+ onClose={() => setPickerOpen(false)}
240
+ onSelect={(img) => {
241
+ updateAttributes({ images: [...images, { src: img.src, srcset: img.srcset || undefined, alt: img.alt }] });
242
+ setPickerOpen(false);
243
+ }}
244
+ />
245
+ </NodeViewWrapper>
246
+ );
247
+ }
248
+
249
+ // ---- bookmark --------------------------------------------------------------
250
+
251
+ export const BookmarkCard = Node.create({
252
+ name: 'bookmarkCard',
253
+ group: 'block',
254
+ atom: true,
255
+ draggable: true,
256
+
257
+ addAttributes() {
258
+ return {
259
+ url: { default: '' },
260
+ title: { default: '' },
261
+ description: { default: '' },
262
+ image: { default: '' },
263
+ favicon: { default: '' },
264
+ siteName: { default: '' },
265
+ };
266
+ },
267
+
268
+ parseHTML() {
269
+ return [
270
+ {
271
+ tag: 'a.pc-bookmark',
272
+ getAttrs: (el) => {
273
+ const e = el as HTMLElement;
274
+ return {
275
+ url: e.getAttribute('href') ?? '',
276
+ title: e.querySelector('.pc-bookmark-title')?.textContent ?? '',
277
+ description: e.querySelector('.pc-bookmark-desc')?.textContent ?? '',
278
+ image: e.querySelector<HTMLElement>('.pc-bookmark-thumb img')?.getAttribute('src') ?? '',
279
+ favicon: e.querySelector<HTMLElement>('.pc-bookmark-favicon')?.getAttribute('src') ?? '',
280
+ siteName: e.querySelector('.pc-bookmark-site')?.textContent ?? '',
281
+ };
282
+ },
283
+ },
284
+ ];
285
+ },
286
+
287
+ renderHTML({ node }) {
288
+ const a = node.attrs;
289
+ const meta: unknown[] = ['div', { class: 'pc-bookmark-meta' }];
290
+ if (a.favicon) meta.push(['img', { class: 'pc-bookmark-favicon', src: a.favicon, alt: '', loading: 'lazy' }]);
291
+ meta.push(['span', { class: 'pc-bookmark-site' }, a.siteName || safeHostname(a.url)]);
292
+ const content: unknown[] = [
293
+ 'div',
294
+ { class: 'pc-bookmark-content' },
295
+ ['div', { class: 'pc-bookmark-title' }, a.title || a.url],
296
+ ];
297
+ if (a.description) content.push(['div', { class: 'pc-bookmark-desc' }, a.description]);
298
+ content.push(meta);
299
+ const out: unknown[] = ['a', { class: 'pc-bookmark', href: a.url, target: '_blank', rel: 'noopener' }, content];
300
+ if (a.image) out.push(['div', { class: 'pc-bookmark-thumb' }, ['img', { src: a.image, alt: '', loading: 'lazy' }]]);
301
+ return out as [string, Record<string, string>, ...unknown[]];
302
+ },
303
+
304
+ addNodeView() {
305
+ return ReactNodeViewRenderer(BookmarkCardView);
306
+ },
307
+ });
308
+
309
+ function BookmarkCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
310
+ const a = node.attrs;
311
+ const [draft, setDraft] = useState('');
312
+ const [busy, setBusy] = useState(false);
313
+ const [error, setError] = useState('');
314
+
315
+ if (!a.url) {
316
+ return (
317
+ <NodeViewWrapper className={`pe-card pe-bookmarkcard is-empty ${selected ? 'is-selected' : ''}`}>
318
+ <form
319
+ className="pe-embed-form"
320
+ contentEditable={false}
321
+ onSubmit={async (e) => {
322
+ e.preventDefault();
323
+ const url = draft.trim();
324
+ if (!/^https?:\/\//.test(url)) return setError('Paste a full URL, starting with https://');
325
+ setBusy(true);
326
+ setError('');
327
+ const r = await actions.bookmark.fetch({ url });
328
+ setBusy(false);
329
+ if (r.error) return setError('Couldn’t read that page — check the URL.');
330
+ updateAttributes({ url, ...r.data.meta });
331
+ }}
332
+ >
333
+ <span className="pe-embed-label">Bookmark</span>
334
+ <input
335
+ autoFocus
336
+ type="url"
337
+ placeholder="Paste a URL to create a rich link preview…"
338
+ value={draft}
339
+ onChange={(e) => { setDraft(e.target.value); setError(''); }}
340
+ onKeyDown={(e) => { if (e.key === 'Escape') deleteNode(); }}
341
+ />
342
+ <button type="submit" className="pe-btn primary sm" disabled={busy}>{busy ? 'Fetching…' : 'Create'}</button>
343
+ {error && <span className="pe-embed-error">{error}</span>}
344
+ </form>
345
+ </NodeViewWrapper>
346
+ );
347
+ }
348
+
349
+ return (
350
+ <NodeViewWrapper className={`pe-card pe-bookmarkcard ${selected ? 'is-selected' : ''}`} data-drag-handle>
351
+ <div className="pe-card-tools" contentEditable={false}>
352
+ <span className="pe-provider">Bookmark</span>
353
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
354
+ </div>
355
+ <span className="pc-bookmark" contentEditable={false}>
356
+ <span className="pc-bookmark-content">
357
+ <span className="pc-bookmark-title">{a.title || a.url}</span>
358
+ {a.description && <span className="pc-bookmark-desc">{a.description}</span>}
359
+ <span className="pc-bookmark-meta">
360
+ {a.favicon && <img className="pc-bookmark-favicon" src={a.favicon} alt="" />}
361
+ <span className="pc-bookmark-site">{a.siteName || safeHostname(a.url)}</span>
362
+ </span>
363
+ </span>
364
+ {a.image && <span className="pc-bookmark-thumb"><img src={a.image} alt="" /></span>}
365
+ </span>
366
+ </NodeViewWrapper>
367
+ );
368
+ }
369
+
370
+ function safeHostname(url: string): string {
371
+ try {
372
+ return new URL(url).hostname;
373
+ } catch {
374
+ return url;
375
+ }
376
+ }