@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.
- package/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- 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
|
+
}
|