@acms-subir/core 0.1.0 → 0.3.0

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 (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -0,0 +1,178 @@
1
+ ---
2
+ // The built-in event page (feature image, date block, place, button, add to
3
+ // calendar, share, related events). Themes replace it with
4
+ // templates/event.astro, which receives the same props. Every element
5
+ // carries a stable .pc-ev-* class for CSS overrides.
6
+ import type { CalEvent, Calendar, Settings } from '../lib/schema';
7
+ import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
8
+ import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
9
+ import { describeRepeat } from '../lib/calendar/rrule';
10
+ import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
+ import { siteLocale } from '../lib/dates';
12
+
13
+ interface Props {
14
+ event: CalEvent;
15
+ occurrence: Occurrence;
16
+ settings: Settings;
17
+ calendars: Calendar[];
18
+ html: string;
19
+ upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[];
20
+ origin: string;
21
+ preview?: boolean;
22
+ // RSVP state when the event takes RSVPs (null otherwise)
23
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean } | null;
24
+ }
25
+ const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
26
+ const tz = eventTimeZone(e, settings);
27
+ const locale = siteLocale(settings);
28
+ const f = formatOccurrence(o, tz, locale);
29
+ const pageUrl = origin + eventUrl(e.slug);
30
+ const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
31
+ const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
32
+ const [y, m, d] = ymd.split('-').map(Number);
33
+ return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
34
+ });
35
+ const ctaHref = safeHref(e.cta.href);
36
+ const mapHref = e.location.url
37
+ ? safeHref(e.location.url)
38
+ : e.location.address
39
+ ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([e.location.name, e.location.address].filter(Boolean).join(', '))}`
40
+ : '';
41
+ const fieldRows = CALENDAR.eventFields.filter((fd) => e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
42
+ const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
43
+ const I = {
44
+ pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
45
+ clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
46
+ repeat: 'M17 2l4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4M21 13v2a3 3 0 0 1-3 3H3',
47
+ cal: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
48
+ share: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5',
49
+ info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 8h.01',
50
+ video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
51
+ };
52
+ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
53
+ ---
54
+
55
+ <article class={`pc-ev-page${e.cancelled ? ' is-cancelled' : ''}`}>
56
+ {preview && <p class="pc-ev-preview">Preview — only you can see this {e.status === 'published' ? 'version' : 'draft'}.</p>}
57
+ {e.cover && (
58
+ <figure class="pc-ev-hero"><img src={e.cover} alt={e.coverAlt} /></figure>
59
+ )}
60
+ <div class="pc-ev-layout">
61
+ <div class="pc-ev-main">
62
+ {(calendars.length > 0 || visibleTags.length > 0) && (
63
+ <div class="pc-ev-chips">
64
+ {calendars.map((c) => (
65
+ <a class="pc-ev-chip" href={calendarPageUrl(c.slug)}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
66
+ ))}
67
+ {visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
68
+ </div>
69
+ )}
70
+ <h1 class="pc-ev-title">{e.title}</h1>
71
+ {e.cancelled && <p class="pc-ev-cancelled"><b>This event is cancelled.</b> It stays listed so nobody is left wondering.</p>}
72
+ {e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
73
+ <div class="pc-ev-body post-content" set:html={html} />
74
+ </div>
75
+
76
+ <aside class="pc-ev-side" aria-label="Event details">
77
+ <div class="pc-ev-date">
78
+ <span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
79
+ <div>
80
+ <b>{f.date}</b>
81
+ <span>{f.time}</span>
82
+ </div>
83
+ </div>
84
+ {(e.location.name || e.location.address || e.location.url) && (
85
+ <div class="pc-ev-fact">
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={online && !e.location.address ? I.video : I.pin} /></svg>
87
+ <div>
88
+ {e.location.name && <b>{e.location.name}</b>}
89
+ {(e.location.address || mapHref) && (
90
+ <span>
91
+ {e.location.address}
92
+ {mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? 'Join online' : 'Map'}</a></>}
93
+ </span>
94
+ )}
95
+ </div>
96
+ </div>
97
+ )}
98
+ {repeats && (
99
+ <div class="pc-ev-fact">
100
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
101
+ <div><b>{repeats}</b><span>Showing {f.date}</span></div>
102
+ </div>
103
+ )}
104
+ {fieldRows.map((r) => (
105
+ <div class="pc-ev-fact">
106
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.info} /></svg>
107
+ <div><span>{r.label}</span><b>{r.type === 'url' && safeHref(r.value) ? <a href={safeHref(r.value)} target="_blank" rel="noopener">{r.value.replace(/^https?:\/\//, '')}</a> : r.value}</b></div>
108
+ </div>
109
+ ))}
110
+ {rsvp ? (
111
+ <div class="pc-ev-rsvp">
112
+ <p class="pc-ev-going"><b>{rsvp.going}</b> going{rsvp.left !== null && <> · <b>{rsvp.left}</b> {rsvp.left === 1 ? 'spot' : 'spots'} left</>}</p>
113
+ {rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
114
+ <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
115
+ <summary class="pc-ev-cta">Save my seat</summary>
116
+ <form method="post">
117
+ {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
118
+ <label class="pc-bk-field"><span>Your name</span><input name="name" required autocomplete="name" /></label>
119
+ <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required autocomplete="email" /></label>
120
+ {rsvp.maxSeats > 1 && (
121
+ <label class="pc-bk-field"><span>Seats</span>
122
+ <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1}>{i + 1}</option>)}</select>
123
+ </label>
124
+ )}
125
+ <button class="pc-bk-submit" type="submit">{e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat'}</button>
126
+ </form>
127
+ </details>
128
+ ) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
129
+ </div>
130
+ ) : e.cta.label && ctaHref && !e.cancelled && (
131
+ <a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
132
+ )}
133
+ <details class="pc-ev-add">
134
+ <summary>
135
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.cal} /></svg>
136
+ Add to calendar
137
+ </summary>
138
+ <div class="pc-ev-add-menu">
139
+ <a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
140
+ <a href={links.ics}>Apple Calendar</a>
141
+ <a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
142
+ <a href={links.ics} download={`${e.slug}.ics`}>Download .ics</a>
143
+ </div>
144
+ </details>
145
+ <button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title}>
146
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.share} /></svg>
147
+ <span>Share</span>
148
+ </button>
149
+ </aside>
150
+ </div>
151
+
152
+ {upcoming.length > 0 && (
153
+ <section class="pc-ev-more">
154
+ <h2>More events</h2>
155
+ <div class="pc-ev-cards" data-layout="grid" set:html={upcoming.map((u) => eventCardHtml(u, settings, { showImage: true })).join('')} />
156
+ <a class="pc-ev-all" href={CALENDAR.base}>See the full calendar →</a>
157
+ </section>
158
+ )}
159
+ </article>
160
+
161
+ <script>
162
+ // share sheet where available, else copy the link
163
+ document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
164
+ const url = b.dataset.url ?? location.href;
165
+ if (navigator.share) {
166
+ try { await navigator.share({ title: b.dataset.title, url }); return; } catch { /* dismissed */ }
167
+ }
168
+ await navigator.clipboard.writeText(url);
169
+ const label = b.querySelector('span');
170
+ if (label) { label.textContent = 'Link copied'; setTimeout(() => (label.textContent = 'Share'), 1400); }
171
+ }));
172
+ // one open menu; outside click closes
173
+ document.addEventListener('click', (ev) => {
174
+ document.querySelectorAll<HTMLDetailsElement>('details.pc-ev-add[open]').forEach((d) => {
175
+ if (!(ev.target instanceof Node && d.contains(ev.target))) d.open = false;
176
+ });
177
+ });
178
+ </script>
@@ -0,0 +1,27 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import WidgetRenderer from './WidgetRenderer.astro';
4
+ import { getRepo } from '../lib/repo';
5
+ import { defaultPolicy, LEGAL } from '../lib/legal';
6
+ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
+ interface Props { policy: 'privacy' | 'terms' }
8
+ const { policy } = Astro.props;
9
+ const repo = await getRepo(Astro.locals);
10
+ const [settings, page] = await Promise.all([repo.getSettings(), repo.getPageBySlug(policy)]);
11
+ const custom = page?.status === 'published' ? page : null;
12
+ const standard = defaultPolicy(policy, settings);
13
+ const html = custom?.bodyHtml ? await expandDynamicBlocks(custom.bodyHtml, repo) : '';
14
+ ---
15
+ <Site title={custom?.title || standard.title} noindex={!custom && !standard.complete}>
16
+ {custom ? (
17
+ html ? <article class="page-body" set:html={html} /> : <WidgetRenderer pageId={custom.id} blocks={custom.blocks} />
18
+ ) : (
19
+ <article class="page-body">
20
+ <h1>{standard.title}</h1>
21
+ {LEGAL.updatedAt && <p>Last updated: <time datetime={LEGAL.updatedAt}>{LEGAL.updatedAt}</time></p>}
22
+ {!standard.complete && <p><strong>Standard notice:</strong> The site operator has not yet completed its identity and contact details. This page describes the standard site features and needs the operator’s site-specific information.</p>}
23
+ {standard.sections.map(section => <section><h2>{section.title}</h2><p>{section.text}</p></section>)}
24
+ {LEGAL.contactEmail && <p><a href={`mailto:${LEGAL.contactEmail}`}>Contact the site operator</a></p>}
25
+ </article>
26
+ )}
27
+ </Site>
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
3
  import type { DragEndEvent } from '@dnd-kit/core';
4
4
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
@@ -17,6 +17,10 @@ import { ImagePicker } from './editor/ImagePicker';
17
17
  // (brand-preset links, custom links, email, phone, heading, spacer, text,
18
18
  // vcard, raw HTML), drag-to-reorder, inline editing with an icon picker
19
19
  // (emoji / image / favicon), and a phone-framed live preview of /links.
20
+ //
21
+ // Edits stay local until "Save changes" commits them all at once — the live
22
+ // /links page never sees a half-arranged state. Discard restores the saved
23
+ // state.
20
24
 
21
25
  type SaveState = 'saved' | 'saving' | 'error';
22
26
  type Kind = Link['kind'];
@@ -102,14 +106,20 @@ const NEW_ITEM: Record<Kind, Partial<Link>> = {
102
106
  html: { kind: 'html', label: 'Custom block', url: '', html: '' },
103
107
  };
104
108
 
109
+ // unsaved additions carry a temp id until links.create returns the real one
110
+ const isTemp = (id: string) => id.startsWith('tmp-');
111
+
105
112
  export default function LinksManager({ links: initial, settings, unsplashEnabled }: Props) {
106
113
  const [links, setLinks] = useState<Link[]>(initial);
107
114
  // links-page identity — its own fields, seeded from site title/tagline until set
108
- const [profile, setProfile] = useState({
115
+ const initialProfile = {
109
116
  linksName: settings.linksName || settings.siteTitle,
110
117
  linksBio: settings.linksBio || settings.tagline,
111
118
  linksAvatar: settings.linksAvatar,
112
- });
119
+ };
120
+ const [profile, setProfile] = useState(initialProfile);
121
+ // last state the server has — dirty compares against this
122
+ const [saved, setSaved] = useState({ links: initial, profile: initialProfile });
113
123
  const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
114
124
  const [pickerOpen, setPickerOpen] = useState(initial.length === 0);
115
125
  const [sitesOpen, setSitesOpen] = useState(false);
@@ -118,81 +128,141 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
118
128
  const [colorPickerFor, setColorPickerFor] = useState<string | null>(null);
119
129
  const [saveState, setSaveState] = useState<SaveState>('saved');
120
130
  const [tick, setTick] = useState(0);
121
- const pending = useRef(0);
122
- const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
131
+ // narrow screens show one pane at a time
132
+ const [view, setView] = useState<'edit' | 'preview' | 'insights'>('edit');
133
+
134
+ const linkShape = (l: Link) => ({ id: l.id, kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
135
+ const dirty = useMemo(
136
+ () =>
137
+ JSON.stringify({ links: links.map(linkShape), profile }) !==
138
+ JSON.stringify({ links: saved.links.map(linkShape), profile: saved.profile }),
139
+ [links, profile, saved]
140
+ );
141
+
142
+ // unsaved edits live only in this tab — warn before losing them
143
+ useEffect(() => {
144
+ if (!dirty) return;
145
+ const onLeave = (e: BeforeUnloadEvent) => e.preventDefault();
146
+ window.addEventListener('beforeunload', onLeave);
147
+ return () => window.removeEventListener('beforeunload', onLeave);
148
+ }, [dirty]);
123
149
 
124
150
  const sensors = useSensors(
125
151
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
126
152
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
127
153
  );
128
154
 
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) {
155
+ function edit(id: string, patch: Partial<Pick<Link, 'label' | 'url' | 'icon' | 'html' | 'meta'>>) {
149
156
  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
157
  }
153
158
 
154
159
  function editProfile(patch: Partial<typeof profile>) {
155
160
  setProfile((p) => ({ ...p, ...patch }));
156
- debounced('profile', () => void run(() => actions.settings.update(patch)));
157
161
  }
158
162
 
159
- async function add(data: Partial<Link>, openIt = true) {
163
+ function add(data: Partial<Link>, openIt = true) {
160
164
  setPickerOpen(false);
161
165
  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
- });
166
+ const id = `tmp-${crypto.randomUUID()}`;
167
+ setLinks((ls) => [
168
+ ...ls,
169
+ { id, kind: 'link', label: '', url: '', icon: '', html: '', meta: {}, clicks: 0, order: ls.length, ...data } as Link,
170
+ ]);
171
+ if (openIt) setOpenEditor((o) => ({ ...o, [id]: true }));
168
172
  }
169
173
 
170
174
  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}` } });
175
+ add({ kind: 'link', label: site.name, url: site.url, icon: svgOf(site.si), meta: { color: `#${site.si.hex}` } }, false);
172
176
  }
173
177
 
174
178
  function remove(id: string) {
175
- if (!window.confirm('Delete this block?')) return;
179
+ // local until Save — Discard brings it back
176
180
  setLinks((ls) => ls.filter((l) => l.id !== id));
177
- void run(() => actions.links.delete({ id }));
178
181
  }
179
182
 
180
183
  function onDragEnd(e: DragEndEvent) {
181
184
  const { active, over } = e;
182
185
  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
- });
186
+ setLinks((ls) => arrayMove(ls, ls.findIndex((l) => l.id === active.id), ls.findIndex((l) => l.id === over.id)));
187
+ }
188
+
189
+ // one commit for everything: deletes, creates (temp ids become real),
190
+ // updates, the display order, then the profile fields
191
+ async function saveAll() {
192
+ setSaveState('saving');
193
+ try {
194
+ const savedById = new Map(saved.links.map((l) => [l.id, l]));
195
+ const keptIds = new Set(links.map((l) => l.id));
196
+ for (const l of saved.links) {
197
+ if (!keptIds.has(l.id)) {
198
+ const r = await actions.links.delete({ id: l.id });
199
+ if (r.error) throw r.error;
200
+ }
201
+ }
202
+ const next: Link[] = [];
203
+ for (const l of links) {
204
+ if (isTemp(l.id)) {
205
+ const r = await actions.links.create({ kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
206
+ if (r.error) throw r.error;
207
+ next.push({ ...l, id: r.data.link.id, order: r.data.link.order });
208
+ } else {
209
+ const prev = savedById.get(l.id);
210
+ if (prev && JSON.stringify(linkShape(prev)) !== JSON.stringify(linkShape(l))) {
211
+ const r = await actions.links.update({ id: l.id, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
212
+ if (r.error) throw r.error;
213
+ }
214
+ next.push(l);
215
+ }
216
+ }
217
+ if (next.length) {
218
+ const r = await actions.links.reorder({ ids: next.map((l) => l.id) });
219
+ if (r.error) throw r.error;
220
+ }
221
+ if (JSON.stringify(profile) !== JSON.stringify(saved.profile)) {
222
+ const r = await actions.settings.update(profile);
223
+ if (r.error) throw r.error;
224
+ }
225
+ // reopen editors under the real ids so panes don't collapse mid-edit
226
+ setOpenEditor((o) => {
227
+ const remap: Record<string, boolean> = {};
228
+ links.forEach((l, i) => { if (o[l.id]) remap[next[i]?.id ?? l.id] = true; });
229
+ return remap;
230
+ });
231
+ setLinks(next);
232
+ setSaved({ links: next, profile });
233
+ setSaveState('saved');
234
+ setTick((t) => t + 1);
235
+ } catch {
236
+ setSaveState('error');
237
+ }
188
238
  }
189
239
 
190
- const saveLabel = saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Save failed — edit to retry';
240
+ function discard() {
241
+ setLinks(saved.links);
242
+ setProfile(saved.profile);
243
+ setSaveState('saved');
244
+ }
245
+
246
+ const saveLabel =
247
+ saveState === 'saving' ? 'Saving…' :
248
+ saveState === 'error' ? 'Save failed' :
249
+ dirty ? 'Unsaved changes — not live yet' : 'Live';
250
+
251
+ const clickable = links.filter((l) => l.kind === 'link' || l.kind === 'email' || l.kind === 'phone' || l.kind === 'vcard');
252
+ const totalClicks = clickable.reduce((n, l) => n + l.clicks, 0);
253
+ const topClicks = [...clickable].sort((x, y) => y.clicks - x.clicks);
191
254
 
192
255
  return (
193
- <div className="lm-layout">
194
- <div>
195
- <div className="card card-pad lm-profile" style={{ marginBottom: 16 }}>
256
+ <div className={`lm-layout view-${view}`}>
257
+ <div className="lm-views seg" role="tablist" aria-label="Links page view">
258
+ {(['edit', 'preview', 'insights'] as const).map((v) => (
259
+ <button key={v} type="button" role="tab" aria-selected={view === v} onClick={() => { setView(v); if (v === 'preview') setTick((t) => t + 1); }}>
260
+ {v[0].toUpperCase() + v.slice(1)}
261
+ </button>
262
+ ))}
263
+ </div>
264
+ <div className="lm-editor">
265
+ <div className="lm-profile">
196
266
  <div className="lm-avatarwrap">
197
267
  <button
198
268
  type="button"
@@ -203,7 +273,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
203
273
  {profile.linksAvatar ? (
204
274
  <img src={profile.linksAvatar} alt="" />
205
275
  ) : (
206
- <span aria-hidden="true">{(profile.linksName.trim()[0] ?? '?').toUpperCase()}</span>
276
+ <svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4zM12 11a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
207
277
  )}
208
278
  <span className="lm-avatar-hint">Edit</span>
209
279
  </button>
@@ -212,7 +282,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
212
282
  type="button"
213
283
  className="lm-avatar-remove"
214
284
  title="Remove photo"
215
- onClick={() => { setProfile((p) => ({ ...p, linksAvatar: '' })); void run(() => actions.settings.update({ linksAvatar: '' })); }}
285
+ onClick={() => setProfile((p) => ({ ...p, linksAvatar: '' }))}
216
286
  >
217
287
  ✕
218
288
  </button>
@@ -242,50 +312,60 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
242
312
  </div>
243
313
  </div>
244
314
 
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>
315
+ <div className="lm-addwrap">
316
+ <button
317
+ type="button"
318
+ className={`lm-addbtn ${pickerOpen ? 'is-open' : ''}`}
319
+ aria-expanded={pickerOpen}
320
+ onClick={() => { setPickerOpen((v) => !v); setSitesOpen(false); }}
321
+ >
322
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
323
+ Add block
324
+ </button>
325
+ {pickerOpen && (
326
+ <div className="lm-picker">
327
+ {sitesOpen && (
328
+ <div className="lm-picker-head">
329
+ <button type="button" className="btn sm ghost" onClick={() => setSitesOpen(false)}>‹ All blocks</button>
330
+ <b>Choose a site</b>
331
+ </div>
332
+ )}
333
+ {!sitesOpen && TILE_GROUPS.map((g) => (
334
+ <div key={g.label}>
335
+ <p className="lm-group-label">{g.label}</p>
336
+ <div className="lm-tiles">
337
+ {g.tiles.map((t) => (
338
+ <button
339
+ key={t.key}
340
+ type="button"
341
+ className="lm-tile"
342
+ onClick={() => (t.kind === 'site' ? setSitesOpen(true) : add(NEW_ITEM[t.kind]))}
343
+ >
344
+ <span className="lm-tile-ico" dangerouslySetInnerHTML={{ __html: TILE_ICONS[t.key] }} />
345
+ <span className="lm-tile-body">
346
+ <b>{t.name}</b>
347
+ <span>{t.desc}</span>
348
+ </span>
349
+ </button>
350
+ ))}
351
+ </div>
352
+ </div>
283
353
  ))}
354
+ {sitesOpen && (
355
+ <div className="lm-sites">
356
+ {SITES.map((s) => (
357
+ <button key={s.name} type="button" className="lm-site" onClick={() => addSite(s)} style={{ color: `#${s.si.hex}` }}>
358
+ <span className="lm-site-ico" dangerouslySetInnerHTML={{ __html: svgOf(s.si) }} />
359
+ <span>{s.name}</span>
360
+ </button>
361
+ ))}
362
+ </div>
363
+ )}
284
364
  </div>
285
365
  )}
286
366
  </div>
287
367
 
288
- <div className="card rowlist">
368
+ <div className="lm-list rowlist">
289
369
  <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
290
370
  <SortableContext items={links.map((l) => l.id)} strategy={verticalListSortingStrategy}>
291
371
  {links.map((l) => (
@@ -304,7 +384,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
304
384
  ))}
305
385
  </SortableContext>
306
386
  </DndContext>
307
- {links.length === 0 && <p className="hint empty">Nothing here yet — add your first block above.</p>}
387
+ {links.length === 0 && <p className="hint lm-empty">Nothing here yet — add your first block above.</p>}
308
388
  </div>
309
389
  </div>
310
390
 
@@ -315,23 +395,52 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
315
395
  onSelect={(img) => {
316
396
  setAvatarPickerOpen(false);
317
397
  setProfile((p) => ({ ...p, linksAvatar: img.src }));
318
- void run(() => actions.settings.update({ linksAvatar: img.src }));
319
398
  }}
320
399
  />
321
400
 
322
401
  <aside className="lm-preview">
323
402
  <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>
403
+ <span className="lm-url">{location.host}/links</span>
404
+ <button
405
+ type="button"
406
+ className="icon-btn"
407
+ title="Copy link"
408
+ aria-label="Copy link"
409
+ onClick={() => void navigator.clipboard.writeText(`${location.origin}/links`)}
410
+ >
411
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 9h11v11H9zM5 15H4V4h11v1" /></svg>
412
+ </button>
413
+ <a className="icon-btn" href="/links" target="_blank" rel="noopener" title="Open in tab" aria-label="Open in tab">
414
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
415
+ </a>
330
416
  </div>
331
417
  <div className="lm-phone">
332
418
  <iframe key={tick} src="/links" title="Links page preview" />
333
419
  </div>
420
+ <p className="lm-stat"><b>{totalClicks.toLocaleString()}</b> total clicks · <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>{saveLabel}</span></p>
334
421
  </aside>
422
+
423
+ <section className="lm-insights" aria-label="Insights">
424
+ <div className="card-head"><b>Clicks by block</b><span className="hint">{totalClicks.toLocaleString()} total</span></div>
425
+ {topClicks.length === 0 && <p className="hint">No buttons yet.</p>}
426
+ {topClicks.map((l) => (
427
+ <div key={l.id} className="mini-row bar-row">
428
+ <i style={{ width: `${topClicks[0].clicks ? (l.clicks / topClicks[0].clicks) * 100 : 0}%` }} />
429
+ <span className="grow">{l.label || l.url || 'Untitled'}{l.meta?.hidden ? ' · hidden' : ''}</span>
430
+ <b>{l.clicks.toLocaleString()}</b>
431
+ </div>
432
+ ))}
433
+ </section>
434
+
435
+ {dirty && (
436
+ <div className="lm-savebar" role="status">
437
+ <span>{saveState === 'error' ? 'Save failed — try again' : 'Unsaved changes'}</span>
438
+ <button type="button" className="btn sm" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
439
+ <button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={saveState === 'saving'}>
440
+ {saveState === 'saving' ? 'Saving…' : 'Save changes'}
441
+ </button>
442
+ </div>
443
+ )}
335
444
  </div>
336
445
  );
337
446
  }
@@ -372,7 +481,7 @@ function ItemRow({
372
481
  return (
373
482
  <div
374
483
  ref={setNodeRef}
375
- className={`lm-item ${isDragging ? 'is-dragging' : ''}`}
484
+ className={`lm-item ${isDragging ? 'is-dragging' : ''} ${link.meta?.hidden ? 'is-hidden' : ''}`}
376
485
  style={{ transform: CSS.Transform.toString(transform), transition }}
377
486
  >
378
487
  <div className={`row lm-row ${hasEditorPane && editorOpen ? 'is-open' : ''}`}>
@@ -467,6 +576,16 @@ function ItemRow({
467
576
  {editorOpen ? 'Done' : expandLabel}
468
577
  </button>
469
578
  )}
579
+ <button
580
+ type="button"
581
+ role="switch"
582
+ aria-checked={!link.meta?.hidden}
583
+ className={`lm-switch ${link.meta?.hidden ? '' : 'on'}`}
584
+ title={link.meta?.hidden ? 'Hidden on the page — click to show' : 'Visible — click to hide'}
585
+ onClick={() => onEdit(link.id, { meta: { ...link.meta, hidden: !link.meta?.hidden } }, true)}
586
+ >
587
+ <i />
588
+ </button>
470
589
  <button type="button" className="btn sm ghost danger reveal" onClick={() => onDelete(link.id)}>Delete</button>
471
590
  </div>
472
591