@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,694 @@
1
+ import { useRef, useState } from 'react';
2
+ import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
+ import type { DragEndEvent } from '@dnd-kit/core';
4
+ import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
5
+ import { CSS } from '@dnd-kit/utilities';
6
+ import Prism from 'prismjs';
7
+ import {
8
+ siGithub, siX, siInstagram, siYoutube, siTiktok, siFacebook, siTwitch, siDiscord,
9
+ siSpotify, siReddit, siBluesky, siMastodon, siSubstack, siPatreon, siWhatsapp,
10
+ siTelegram, siPinterest, siMedium, siKofi, siBuymeacoffee, siThreads, siSnapchat, siDribbble,
11
+ } from 'simple-icons';
12
+ import { actions } from 'astro:actions';
13
+ import type { Link, Settings } from '../lib/schema';
14
+ import { ImagePicker } from './editor/ImagePicker';
15
+
16
+ // Links manager: profile (name + bio), an "add block" picker with typed blocks
17
+ // (brand-preset links, custom links, email, phone, heading, spacer, text,
18
+ // vcard, raw HTML), drag-to-reorder, inline editing with an icon picker
19
+ // (emoji / image / favicon), and a phone-framed live preview of /links.
20
+
21
+ type SaveState = 'saved' | 'saving' | 'error';
22
+ type Kind = Link['kind'];
23
+
24
+ interface Props {
25
+ links: Link[];
26
+ settings: Settings;
27
+ unsplashEnabled: boolean;
28
+ }
29
+
30
+ const svgOf = (si: { path: string }) =>
31
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${si.path}"/></svg>`;
32
+
33
+ const SITES = [
34
+ { si: siGithub, name: 'GitHub', url: 'https://github.com/' },
35
+ { si: siX, name: 'X', url: 'https://x.com/' },
36
+ { si: siInstagram, name: 'Instagram', url: 'https://instagram.com/' },
37
+ { si: siYoutube, name: 'YouTube', url: 'https://youtube.com/@' },
38
+ { si: siTiktok, name: 'TikTok', url: 'https://tiktok.com/@' },
39
+ { si: siFacebook, name: 'Facebook', url: 'https://facebook.com/' },
40
+ { si: siTwitch, name: 'Twitch', url: 'https://twitch.tv/' },
41
+ { si: siDiscord, name: 'Discord', url: 'https://discord.gg/' },
42
+ { si: siSpotify, name: 'Spotify', url: 'https://open.spotify.com/' },
43
+ { si: siReddit, name: 'Reddit', url: 'https://reddit.com/u/' },
44
+ { si: siBluesky, name: 'Bluesky', url: 'https://bsky.app/profile/' },
45
+ { si: siMastodon, name: 'Mastodon', url: 'https://mastodon.social/@' },
46
+ { si: siThreads, name: 'Threads', url: 'https://threads.net/@' },
47
+ { si: siSubstack, name: 'Substack', url: 'https://substack.com/@' },
48
+ { si: siMedium, name: 'Medium', url: 'https://medium.com/@' },
49
+ { si: siPatreon, name: 'Patreon', url: 'https://patreon.com/' },
50
+ { si: siKofi, name: 'Ko-fi', url: 'https://ko-fi.com/' },
51
+ { si: siBuymeacoffee, name: 'Buy Me a Coffee', url: 'https://buymeacoffee.com/' },
52
+ { si: siWhatsapp, name: 'WhatsApp', url: 'https://wa.me/' },
53
+ { si: siTelegram, name: 'Telegram', url: 'https://t.me/' },
54
+ { si: siSnapchat, name: 'Snapchat', url: 'https://snapchat.com/add/' },
55
+ { si: siPinterest, name: 'Pinterest', url: 'https://pinterest.com/' },
56
+ { si: siDribbble, name: 'Dribbble', url: 'https://dribbble.com/' },
57
+ ];
58
+
59
+ const TILE_ICONS: Record<string, string> = {
60
+ site: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 3l4 2.3v4.4L12 12 8 9.7V5.3L12 3zM6 13l4 2.3v4.4L6 22l-4-2.3v-4.4L6 13zM18 13l4 2.3v4.4L18 22l-4-2.3v-4.4L18 13z"/></svg>',
61
+ link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M10 14a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11 5.93M14 10a5 5 0 00-7.07 0L4.1 12.83a5 5 0 007.07 7.07L13 18.07"/></svg>',
62
+ email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>',
63
+ phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M5 4h4l2 5-2.5 1.5a12 12 0 005 5L15 13l5 2v4a2 2 0 01-2 2A16 16 0 013 6a2 2 0 012-2z"/></svg>',
64
+ heading: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h10M7 12.5h6"/></svg>',
65
+ spacer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 9h16M4 15h16M12 3v2M12 19v2"/></svg>',
66
+ text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M5 5h14M12 5v14M8 19h8"/></svg>',
67
+ vcard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="11" r="2.2"/><path d="M5.5 17.5c.6-2 2-3 3.5-3s2.9 1 3.5 3M15 9h4M15 13h4"/></svg>',
68
+ html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 6l-5 6 5 6M15 6l5 6-5 6"/></svg>',
69
+ };
70
+
71
+ interface Tile { key: string; kind: Kind | 'site'; name: string; desc: string }
72
+ const TILE_GROUPS: { label: string; tiles: Tile[] }[] = [
73
+ {
74
+ label: 'Buttons',
75
+ tiles: [
76
+ { key: 'site', kind: 'site', name: 'Predefined site', desc: 'Auto brand color + icon' },
77
+ { key: 'link', kind: 'link', name: 'Custom link', desc: 'Emoji, image or favicon' },
78
+ { key: 'email', kind: 'email', name: 'E-mail', desc: 'Opens the mail app' },
79
+ { key: 'phone', kind: 'phone', name: 'Phone', desc: 'Opens the dialer' },
80
+ { key: 'vcard', kind: 'vcard', name: 'Vcard', desc: 'Downloadable contact' },
81
+ ],
82
+ },
83
+ {
84
+ label: 'Content & layout',
85
+ tiles: [
86
+ { key: 'heading', kind: 'heading', name: 'Heading', desc: 'Group your links' },
87
+ { key: 'text', kind: 'text', name: 'Text', desc: 'Non-clickable copy' },
88
+ { key: 'spacer', kind: 'spacer', name: 'Spacer', desc: 'Blank vertical space' },
89
+ { key: 'html', kind: 'html', name: 'HTML block', desc: 'Custom HTML/CSS/JS' },
90
+ ],
91
+ },
92
+ ];
93
+
94
+ const NEW_ITEM: Record<Kind, Partial<Link>> = {
95
+ link: { kind: 'link', label: 'New link', url: '', icon: '' },
96
+ email: { kind: 'email', label: 'E-mail me', url: '', icon: '✉️' },
97
+ phone: { kind: 'phone', label: 'Call me', url: '', icon: '📞' },
98
+ heading: { kind: 'heading', label: 'Section', url: '' },
99
+ text: { kind: 'text', label: '', url: '', html: '' },
100
+ spacer: { kind: 'spacer', label: '', url: '', meta: { size: 'm' } },
101
+ vcard: { kind: 'vcard', label: 'Save my contact', url: '', icon: '👤', meta: {} },
102
+ html: { kind: 'html', label: 'Custom block', url: '', html: '' },
103
+ };
104
+
105
+ export default function LinksManager({ links: initial, settings, unsplashEnabled }: Props) {
106
+ const [links, setLinks] = useState<Link[]>(initial);
107
+ // links-page identity — its own fields, seeded from site title/tagline until set
108
+ const [profile, setProfile] = useState({
109
+ linksName: settings.linksName || settings.siteTitle,
110
+ linksBio: settings.linksBio || settings.tagline,
111
+ linksAvatar: settings.linksAvatar,
112
+ });
113
+ const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
114
+ const [pickerOpen, setPickerOpen] = useState(initial.length === 0);
115
+ const [sitesOpen, setSitesOpen] = useState(false);
116
+ const [openEditor, setOpenEditor] = useState<Record<string, boolean>>({});
117
+ const [iconPickerFor, setIconPickerFor] = useState<string | null>(null);
118
+ const [colorPickerFor, setColorPickerFor] = useState<string | null>(null);
119
+ const [saveState, setSaveState] = useState<SaveState>('saved');
120
+ const [tick, setTick] = useState(0);
121
+ const pending = useRef(0);
122
+ const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
123
+
124
+ const sensors = useSensors(
125
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
126
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
127
+ );
128
+
129
+ // every mutation runs through this so the save chip and preview stay honest
130
+ async function run(fn: () => Promise<{ error?: unknown }>) {
131
+ pending.current += 1;
132
+ setSaveState('saving');
133
+ const r = await fn();
134
+ pending.current -= 1;
135
+ if (r.error) setSaveState('error');
136
+ else if (pending.current === 0) {
137
+ setSaveState('saved');
138
+ setTick((t) => t + 1);
139
+ }
140
+ }
141
+
142
+ function debounced(key: string, fn: () => void, ms = 600) {
143
+ const prev = timers.current.get(key);
144
+ if (prev) clearTimeout(prev);
145
+ timers.current.set(key, setTimeout(() => { timers.current.delete(key); fn(); }, ms));
146
+ }
147
+
148
+ function edit(id: string, patch: Partial<Pick<Link, 'label' | 'url' | 'icon' | 'html' | 'meta'>>, instant = false) {
149
+ setLinks((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
150
+ if (instant) void run(() => actions.links.update({ id, ...patch }));
151
+ else debounced(id, () => void run(() => actions.links.update({ id, ...patch })));
152
+ }
153
+
154
+ function editProfile(patch: Partial<typeof profile>) {
155
+ setProfile((p) => ({ ...p, ...patch }));
156
+ debounced('profile', () => void run(() => actions.settings.update(patch)));
157
+ }
158
+
159
+ async function add(data: Partial<Link>, openIt = true) {
160
+ setPickerOpen(false);
161
+ setSitesOpen(false);
162
+ await run(async () => {
163
+ const r = await actions.links.create(data as Parameters<typeof actions.links.create>[0]);
164
+ if (!r.error && openIt) setOpenEditor((o) => ({ ...o, [r.data.link.id]: true }));
165
+ if (!r.error) setLinks((ls) => [...ls, r.data.link]);
166
+ return r;
167
+ });
168
+ }
169
+
170
+ function addSite(site: (typeof SITES)[number]) {
171
+ void add({ kind: 'link', label: site.name, url: site.url, icon: svgOf(site.si), meta: { color: `#${site.si.hex}` } });
172
+ }
173
+
174
+ function remove(id: string) {
175
+ if (!window.confirm('Delete this block?')) return;
176
+ setLinks((ls) => ls.filter((l) => l.id !== id));
177
+ void run(() => actions.links.delete({ id }));
178
+ }
179
+
180
+ function onDragEnd(e: DragEndEvent) {
181
+ const { active, over } = e;
182
+ if (!over || active.id === over.id) return;
183
+ setLinks((ls) => {
184
+ const next = arrayMove(ls, ls.findIndex((l) => l.id === active.id), ls.findIndex((l) => l.id === over.id));
185
+ void run(() => actions.links.reorder({ ids: next.map((l) => l.id) }));
186
+ return next;
187
+ });
188
+ }
189
+
190
+ const saveLabel = saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Save failed — edit to retry';
191
+
192
+ return (
193
+ <div className="lm-layout">
194
+ <div>
195
+ <div className="card card-pad lm-profile" style={{ marginBottom: 16 }}>
196
+ <div className="lm-avatarwrap">
197
+ <button
198
+ type="button"
199
+ className="lm-avatar"
200
+ title={profile.linksAvatar ? 'Change profile photo' : 'Add profile photo'}
201
+ onClick={() => setAvatarPickerOpen(true)}
202
+ >
203
+ {profile.linksAvatar ? (
204
+ <img src={profile.linksAvatar} alt="" />
205
+ ) : (
206
+ <span aria-hidden="true">{(profile.linksName.trim()[0] ?? '?').toUpperCase()}</span>
207
+ )}
208
+ <span className="lm-avatar-hint">Edit</span>
209
+ </button>
210
+ {profile.linksAvatar && (
211
+ <button
212
+ type="button"
213
+ className="lm-avatar-remove"
214
+ title="Remove photo"
215
+ onClick={() => { setProfile((p) => ({ ...p, linksAvatar: '' })); void run(() => actions.settings.update({ linksAvatar: '' })); }}
216
+ >
217
+ ✕
218
+ </button>
219
+ )}
220
+ </div>
221
+ <div className="lm-profile-fields">
222
+ <input
223
+ type="text"
224
+ className="lm-profile-name"
225
+ placeholder="Your name"
226
+ aria-label="Display name"
227
+ value={profile.linksName}
228
+ onChange={(e) => editProfile({ linksName: e.target.value })}
229
+ />
230
+ <textarea
231
+ className="lm-profile-bio"
232
+ rows={2}
233
+ placeholder="Write a short bio…"
234
+ aria-label="Bio"
235
+ value={profile.linksBio}
236
+ onChange={(e) => {
237
+ e.target.style.height = 'auto';
238
+ e.target.style.height = `${e.target.scrollHeight}px`;
239
+ editProfile({ linksBio: e.target.value });
240
+ }}
241
+ />
242
+ </div>
243
+ </div>
244
+
245
+ <div className="card card-pad" style={{ marginBottom: 16 }}>
246
+ <div className="lm-picker-head">
247
+ <p className="card-title">{pickerOpen && sitesOpen ? 'Choose a site' : 'Add block'}</p>
248
+ {pickerOpen && sitesOpen && (
249
+ <button type="button" className="btn sm ghost" onClick={() => setSitesOpen(false)}>‹ All blocks</button>
250
+ )}
251
+ <button type="button" className="btn sm ghost" onClick={() => { setPickerOpen((v) => !v); setSitesOpen(false); }}>
252
+ {pickerOpen ? 'Close' : '+ Add'}
253
+ </button>
254
+ </div>
255
+ {pickerOpen && !sitesOpen && TILE_GROUPS.map((g) => (
256
+ <div key={g.label}>
257
+ <p className="lm-group-label">{g.label}</p>
258
+ <div className="lm-tiles">
259
+ {g.tiles.map((t) => (
260
+ <button
261
+ key={t.key}
262
+ type="button"
263
+ className="lm-tile"
264
+ onClick={() => (t.kind === 'site' ? setSitesOpen(true) : void add(NEW_ITEM[t.kind]))}
265
+ >
266
+ <span className="lm-tile-ico" dangerouslySetInnerHTML={{ __html: TILE_ICONS[t.key] }} />
267
+ <span className="lm-tile-body">
268
+ <b>{t.name}</b>
269
+ <span>{t.desc}</span>
270
+ </span>
271
+ </button>
272
+ ))}
273
+ </div>
274
+ </div>
275
+ ))}
276
+ {pickerOpen && sitesOpen && (
277
+ <div className="lm-sites">
278
+ {SITES.map((s) => (
279
+ <button key={s.name} type="button" className="lm-site" onClick={() => addSite(s)} style={{ color: `#${s.si.hex}` }}>
280
+ <span className="lm-site-ico" dangerouslySetInnerHTML={{ __html: svgOf(s.si) }} />
281
+ <span>{s.name}</span>
282
+ </button>
283
+ ))}
284
+ </div>
285
+ )}
286
+ </div>
287
+
288
+ <div className="card rowlist">
289
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
290
+ <SortableContext items={links.map((l) => l.id)} strategy={verticalListSortingStrategy}>
291
+ {links.map((l) => (
292
+ <ItemRow
293
+ key={l.id}
294
+ link={l}
295
+ editorOpen={!!openEditor[l.id]}
296
+ onToggleEditor={() => setOpenEditor((o) => ({ ...o, [l.id]: !o[l.id] }))}
297
+ iconPickerOpen={iconPickerFor === l.id}
298
+ onToggleIconPicker={() => setIconPickerFor((cur) => (cur === l.id ? null : l.id))}
299
+ colorPickerOpen={colorPickerFor === l.id}
300
+ onToggleColorPicker={() => setColorPickerFor((cur) => (cur === l.id ? null : l.id))}
301
+ onEdit={edit}
302
+ onDelete={remove}
303
+ />
304
+ ))}
305
+ </SortableContext>
306
+ </DndContext>
307
+ {links.length === 0 && <p className="hint empty">Nothing here yet — add your first block above.</p>}
308
+ </div>
309
+ </div>
310
+
311
+ <ImagePicker
312
+ open={avatarPickerOpen}
313
+ unsplashEnabled={unsplashEnabled}
314
+ onClose={() => setAvatarPickerOpen(false)}
315
+ onSelect={(img) => {
316
+ setAvatarPickerOpen(false);
317
+ setProfile((p) => ({ ...p, linksAvatar: img.src }));
318
+ void run(() => actions.settings.update({ linksAvatar: img.src }));
319
+ }}
320
+ />
321
+
322
+ <aside className="lm-preview">
323
+ <div className="lm-preview-bar">
324
+ <span className="lm-preview-label">Live preview</span>
325
+ <span className={`lm-savestate ${saveState}`}>{saveLabel}</span>
326
+ <div style={{ display: 'flex', gap: 2 }}>
327
+ <button type="button" className="btn sm ghost" title="Refresh" onClick={() => setTick((t) => t + 1)}>↻</button>
328
+ <a className="btn sm ghost" href="/links" target="_blank" rel="noopener" title="Open in tab">↗</a>
329
+ </div>
330
+ </div>
331
+ <div className="lm-phone">
332
+ <iframe key={tick} src="/links" title="Links page preview" />
333
+ </div>
334
+ </aside>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ // ---- rows -----------------------------------------------------------------
340
+
341
+ const KIND_BADGE: Partial<Record<Kind, string>> = {
342
+ heading: 'H', text: 'T', spacer: '↕', html: 'HTML', email: '@', phone: 'TEL', vcard: 'VCF',
343
+ };
344
+
345
+ function ItemRow({
346
+ link,
347
+ editorOpen,
348
+ onToggleEditor,
349
+ iconPickerOpen,
350
+ onToggleIconPicker,
351
+ colorPickerOpen,
352
+ onToggleColorPicker,
353
+ onEdit,
354
+ onDelete,
355
+ }: {
356
+ link: Link;
357
+ editorOpen: boolean;
358
+ onToggleEditor: () => void;
359
+ iconPickerOpen: boolean;
360
+ onToggleIconPicker: () => void;
361
+ colorPickerOpen: boolean;
362
+ onToggleColorPicker: () => void;
363
+ onEdit: (id: string, patch: Partial<Pick<Link, 'label' | 'url' | 'icon' | 'html' | 'meta'>>, instant?: boolean) => void;
364
+ onDelete: (id: string) => void;
365
+ }) {
366
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: link.id });
367
+ const k = link.kind;
368
+ const hasIcon = k === 'link' || k === 'email' || k === 'phone' || k === 'vcard';
369
+ const hasEditorPane = k === 'html' || k === 'text' || k === 'vcard';
370
+ const expandLabel = k === 'html' ? 'Edit code' : k === 'text' ? 'Edit text' : 'Edit contact';
371
+
372
+ return (
373
+ <div
374
+ ref={setNodeRef}
375
+ className={`lm-item ${isDragging ? 'is-dragging' : ''}`}
376
+ style={{ transform: CSS.Transform.toString(transform), transition }}
377
+ >
378
+ <div className={`row lm-row ${hasEditorPane && editorOpen ? 'is-open' : ''}`}>
379
+ <button type="button" className="lm-handle" title="Drag to reorder" {...attributes} {...listeners}>⠿</button>
380
+
381
+ {hasIcon ? (
382
+ <IconControl link={link} open={iconPickerOpen} onToggle={onToggleIconPicker} onEdit={onEdit} />
383
+ ) : (
384
+ <span className="lm-badge">{KIND_BADGE[k]}</span>
385
+ )}
386
+
387
+ {k === 'spacer' ? (
388
+ <>
389
+ <span className="bare strong" style={{ padding: '5px 8px' }}>Spacer</span>
390
+ <select
391
+ className="lm-select"
392
+ value={String(link.meta.size ?? 'm')}
393
+ onChange={(e) => onEdit(link.id, { meta: { ...link.meta, size: e.target.value } }, true)}
394
+ >
395
+ <option value="s">Small</option>
396
+ <option value="m">Medium</option>
397
+ <option value="l">Large</option>
398
+ </select>
399
+ <span style={{ flex: 1 }} />
400
+ </>
401
+ ) : k === 'text' ? (
402
+ <>
403
+ <span className="meta dim" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
404
+ {link.html.replace(/\s+/g, ' ').slice(0, 70) || 'empty — click Edit text'}
405
+ </span>
406
+ </>
407
+ ) : (
408
+ <input
409
+ type="text"
410
+ className={`bare ${k === 'heading' ? 'strong lm-headinput' : 'strong'}`}
411
+ aria-label="Label"
412
+ placeholder={k === 'heading' ? 'Section heading' : 'Label'}
413
+ style={{ flex: k === 'link' || k === 'email' || k === 'phone' ? '0 1 200px' : 1 }}
414
+ value={link.label}
415
+ onChange={(e) => onEdit(link.id, { label: e.target.value })}
416
+ />
417
+ )}
418
+
419
+ {k === 'link' && (
420
+ <input
421
+ type="url"
422
+ className="bare dim"
423
+ aria-label="URL"
424
+ placeholder="https://…"
425
+ style={{ flex: 1 }}
426
+ value={link.url}
427
+ onChange={(e) => onEdit(link.id, { url: e.target.value })}
428
+ />
429
+ )}
430
+ {k === 'email' && (
431
+ <input
432
+ type="email"
433
+ className="bare dim"
434
+ aria-label="E-mail address"
435
+ placeholder="you@example.com"
436
+ style={{ flex: 1 }}
437
+ value={link.url}
438
+ onChange={(e) => onEdit(link.id, { url: e.target.value })}
439
+ />
440
+ )}
441
+ {k === 'phone' && (
442
+ <input
443
+ type="tel"
444
+ className="bare dim"
445
+ aria-label="Phone number"
446
+ placeholder="+1 555 000 1234"
447
+ style={{ flex: 1 }}
448
+ value={link.url}
449
+ onChange={(e) => onEdit(link.id, { url: e.target.value })}
450
+ />
451
+ )}
452
+ {k === 'html' && (
453
+ <span className="meta dim" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
454
+ {link.html.replace(/\s+/g, ' ').slice(0, 50) || 'empty — click Edit code'}
455
+ </span>
456
+ )}
457
+ {k === 'vcard' && <span style={{ flex: 1 }} />}
458
+
459
+ {(k === 'link' || k === 'email' || k === 'phone') && (
460
+ <span className="meta">{link.clicks} {link.clicks === 1 ? 'click' : 'clicks'}</span>
461
+ )}
462
+ {hasIcon && (
463
+ <ColorControl link={link} open={colorPickerOpen} onToggle={onToggleColorPicker} onEdit={onEdit} />
464
+ )}
465
+ {hasEditorPane && (
466
+ <button type="button" className={`btn sm ${editorOpen ? '' : 'ghost'}`} onClick={onToggleEditor}>
467
+ {editorOpen ? 'Done' : expandLabel}
468
+ </button>
469
+ )}
470
+ <button type="button" className="btn sm ghost danger reveal" onClick={() => onDelete(link.id)}>Delete</button>
471
+ </div>
472
+
473
+ {k === 'html' && editorOpen && (
474
+ <CodeEditor
475
+ value={link.html}
476
+ onChange={(v) => onEdit(link.id, { html: v })}
477
+ placeholder={'<!-- HTML, <style> and <script> all work — rendered inline on the links page -->'}
478
+ />
479
+ )}
480
+ {k === 'text' && editorOpen && (
481
+ <textarea
482
+ className="lm-plaintext"
483
+ rows={4}
484
+ placeholder="Static text shown on the page — line breaks are kept."
485
+ value={link.html}
486
+ onChange={(e) => onEdit(link.id, { html: e.target.value })}
487
+ />
488
+ )}
489
+ {k === 'vcard' && editorOpen && (
490
+ <div className="lm-fields">
491
+ {([
492
+ ['firstName', 'First name'], ['lastName', 'Last name'], ['org', 'Company / org'],
493
+ ['phone', 'Phone'], ['email', 'E-mail'], ['url', 'Website'],
494
+ ] as [string, string][]).map(([key, label]) => (
495
+ <label key={key} className="field">
496
+ <span>{label}</span>
497
+ <input
498
+ type="text"
499
+ value={String(link.meta[key] ?? '')}
500
+ onChange={(e) => onEdit(link.id, { meta: { ...link.meta, [key]: e.target.value } })}
501
+ />
502
+ </label>
503
+ ))}
504
+ </div>
505
+ )}
506
+ </div>
507
+ );
508
+ }
509
+
510
+ // ---- icon control ---------------------------------------------------------
511
+
512
+ function IconControl({
513
+ link,
514
+ open,
515
+ onToggle,
516
+ onEdit,
517
+ }: {
518
+ link: Link;
519
+ open: boolean;
520
+ onToggle: () => void;
521
+ onEdit: (id: string, patch: Partial<Pick<Link, 'icon'>>, instant?: boolean) => void;
522
+ }) {
523
+ const icon = link.icon.trim();
524
+ let face: React.ReactNode;
525
+ if (icon.startsWith('<svg')) {
526
+ face = <span className="lm-iconface" style={{ color: String(link.meta.color ?? 'currentColor') }} dangerouslySetInnerHTML={{ __html: icon }} />;
527
+ } else if (icon === 'favicon') {
528
+ let src = '';
529
+ try { src = `https://www.google.com/s2/favicons?domain=${new URL(link.url).hostname}&sz=64`; } catch { /* no url yet */ }
530
+ face = src ? <img src={src} alt="" /> : <span className="lm-iconface-empty">◌</span>;
531
+ } else if (icon.startsWith('http') || icon.startsWith('/')) {
532
+ face = <img src={icon} alt="" />;
533
+ } else if (icon) {
534
+ face = <span style={{ fontSize: 16 }}>{icon}</span>;
535
+ } else {
536
+ face = <span className="lm-iconface-empty">+</span>;
537
+ }
538
+
539
+ return (
540
+ <div className="lm-iconwrap">
541
+ <button type="button" className="lm-iconbtn" title="Icon: emoji, image or favicon" onClick={onToggle}>
542
+ {face}
543
+ </button>
544
+ {open && (
545
+ <div className="lm-iconpop">
546
+ <label className="field">
547
+ <span>Emoji</span>
548
+ <input
549
+ type="text"
550
+ placeholder="🔗"
551
+ value={!icon.startsWith('<svg') && icon !== 'favicon' && !icon.startsWith('http') && !icon.startsWith('/') ? icon : ''}
552
+ onChange={(e) => onEdit(link.id, { icon: e.target.value })}
553
+ />
554
+ </label>
555
+ <label className="field">
556
+ <span>Image URL</span>
557
+ <input
558
+ type="url"
559
+ placeholder="https://…/logo.png"
560
+ value={icon.startsWith('http') || icon.startsWith('/') ? icon : ''}
561
+ onChange={(e) => onEdit(link.id, { icon: e.target.value })}
562
+ />
563
+ </label>
564
+ <div style={{ display: 'flex', gap: 6 }}>
565
+ {link.kind === 'link' && (
566
+ <button
567
+ type="button"
568
+ className={`btn sm ${icon === 'favicon' ? 'primary' : ''}`}
569
+ title="Use the website's own icon"
570
+ onClick={() => onEdit(link.id, { icon: 'favicon' }, true)}
571
+ >
572
+ Use favicon
573
+ </button>
574
+ )}
575
+ <button type="button" className="btn sm ghost" onClick={() => onEdit(link.id, { icon: '' }, true)}>None</button>
576
+ <button type="button" className="btn sm ghost" style={{ marginLeft: 'auto' }} onClick={onToggle}>Done</button>
577
+ </div>
578
+ </div>
579
+ )}
580
+ </div>
581
+ );
582
+ }
583
+
584
+ // ---- color control --------------------------------------------------------
585
+
586
+ const HEX = /^#[0-9a-f]{6}$/i;
587
+
588
+ // same auto-contrast rule the public page uses when no text color is set
589
+ function contrastText(bg: string): string {
590
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(bg.slice(i, i + 2), 16));
591
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62 ? '#1e1f24' : '#ffffff';
592
+ }
593
+
594
+ function ColorControl({
595
+ link,
596
+ open,
597
+ onToggle,
598
+ onEdit,
599
+ }: {
600
+ link: Link;
601
+ open: boolean;
602
+ onToggle: () => void;
603
+ onEdit: (id: string, patch: Partial<Pick<Link, 'meta'>>, instant?: boolean) => void;
604
+ }) {
605
+ const color = typeof link.meta.color === 'string' && HEX.test(link.meta.color) ? link.meta.color : '';
606
+ const textColor = typeof link.meta.textColor === 'string' && HEX.test(link.meta.textColor) ? link.meta.textColor : '';
607
+
608
+ function setMeta(key: 'color' | 'textColor', value: string | null, instant = false) {
609
+ const meta = { ...link.meta };
610
+ if (value === null) delete meta[key];
611
+ else meta[key] = value;
612
+ onEdit(link.id, { meta }, instant);
613
+ }
614
+
615
+ return (
616
+ <div className="lm-iconwrap">
617
+ <button type="button" className="lm-iconbtn" title="Button & text color" onClick={onToggle}>
618
+ {color || textColor ? (
619
+ <span
620
+ className="lm-colorswatch"
621
+ style={{ background: color || 'transparent', color: textColor || (color ? contrastText(color) : 'var(--text)') }}
622
+ >
623
+ A
624
+ </span>
625
+ ) : (
626
+ <span className="lm-colorswatch lm-colorswatch-empty">A</span>
627
+ )}
628
+ </button>
629
+ {open && (
630
+ <div className="lm-iconpop lm-colorpop">
631
+ <div className="lm-colorrow">
632
+ <span>Button color</span>
633
+ <input
634
+ type="color"
635
+ aria-label="Button color"
636
+ value={color || '#ffffff'}
637
+ onChange={(e) => setMeta('color', e.target.value)}
638
+ />
639
+ {color && (
640
+ <button type="button" className="btn sm ghost" title="Use the theme's default background" onClick={() => setMeta('color', null, true)}>
641
+ Reset
642
+ </button>
643
+ )}
644
+ </div>
645
+ <div className="lm-colorrow">
646
+ <span>Text color</span>
647
+ <input
648
+ type="color"
649
+ aria-label="Text color"
650
+ value={textColor || (color ? contrastText(color) : '#1e1f24')}
651
+ onChange={(e) => setMeta('textColor', e.target.value)}
652
+ />
653
+ {textColor && (
654
+ <button type="button" className="btn sm ghost" title="Pick automatically to match the button color" onClick={() => setMeta('textColor', null, true)}>
655
+ Auto
656
+ </button>
657
+ )}
658
+ </div>
659
+ <div style={{ display: 'flex' }}>
660
+ <button type="button" className="btn sm ghost" style={{ marginLeft: 'auto' }} onClick={onToggle}>Done</button>
661
+ </div>
662
+ </div>
663
+ )}
664
+ </div>
665
+ );
666
+ }
667
+
668
+ // ---- code editor ----------------------------------------------------------
669
+
670
+ // Syntax-highlighted code editor: a transparent textarea typed into directly,
671
+ // with a Prism-highlighted <pre> perfectly aligned underneath (VS Code Dark+
672
+ // colors in admin.css). Prism's default bundle tokenizes HTML with inline
673
+ // <script> as JS and <style> as CSS.
674
+ function CodeEditor({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
675
+ const preRef = useRef<HTMLPreElement>(null);
676
+ const highlighted = Prism.highlight(value, Prism.languages.markup, 'markup');
677
+ return (
678
+ <div className="lm-codewrap">
679
+ <pre ref={preRef} className="lm-codehl" aria-hidden="true" dangerouslySetInnerHTML={{ __html: highlighted + '\n' }} />
680
+ <textarea
681
+ className="lm-code"
682
+ rows={8}
683
+ spellCheck={false}
684
+ placeholder={placeholder}
685
+ value={value}
686
+ onChange={(e) => onChange(e.target.value)}
687
+ onScroll={(e) => {
688
+ const p = preRef.current;
689
+ if (p) { p.scrollTop = e.currentTarget.scrollTop; p.scrollLeft = e.currentTarget.scrollLeft; }
690
+ }}
691
+ />
692
+ </div>
693
+ );
694
+ }