@acms-subir/core 0.2.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.
- package/CHANGELOG.md +78 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +324 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- 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>
|
|
@@ -128,6 +128,8 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
128
128
|
const [colorPickerFor, setColorPickerFor] = useState<string | null>(null);
|
|
129
129
|
const [saveState, setSaveState] = useState<SaveState>('saved');
|
|
130
130
|
const [tick, setTick] = useState(0);
|
|
131
|
+
// narrow screens show one pane at a time
|
|
132
|
+
const [view, setView] = useState<'edit' | 'preview' | 'insights'>('edit');
|
|
131
133
|
|
|
132
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 });
|
|
133
135
|
const dirty = useMemo(
|
|
@@ -246,10 +248,21 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
246
248
|
saveState === 'error' ? 'Save failed' :
|
|
247
249
|
dirty ? 'Unsaved changes — not live yet' : 'Live';
|
|
248
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);
|
|
254
|
+
|
|
249
255
|
return (
|
|
250
|
-
<div className=
|
|
251
|
-
<div>
|
|
252
|
-
|
|
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">
|
|
253
266
|
<div className="lm-avatarwrap">
|
|
254
267
|
<button
|
|
255
268
|
type="button"
|
|
@@ -260,7 +273,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
260
273
|
{profile.linksAvatar ? (
|
|
261
274
|
<img src={profile.linksAvatar} alt="" />
|
|
262
275
|
) : (
|
|
263
|
-
<
|
|
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>
|
|
264
277
|
)}
|
|
265
278
|
<span className="lm-avatar-hint">Edit</span>
|
|
266
279
|
</button>
|
|
@@ -299,50 +312,60 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
299
312
|
</div>
|
|
300
313
|
</div>
|
|
301
314
|
|
|
302
|
-
<div className="
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
{pickerOpen
|
|
306
|
-
|
|
307
|
-
)}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
</
|
|
312
|
-
{pickerOpen &&
|
|
313
|
-
<div
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
>
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
</
|
|
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>
|
|
340
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
|
+
)}
|
|
341
364
|
</div>
|
|
342
365
|
)}
|
|
343
366
|
</div>
|
|
344
367
|
|
|
345
|
-
<div className="
|
|
368
|
+
<div className="lm-list rowlist">
|
|
346
369
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
347
370
|
<SortableContext items={links.map((l) => l.id)} strategy={verticalListSortingStrategy}>
|
|
348
371
|
{links.map((l) => (
|
|
@@ -361,7 +384,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
361
384
|
))}
|
|
362
385
|
</SortableContext>
|
|
363
386
|
</DndContext>
|
|
364
|
-
{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>}
|
|
365
388
|
</div>
|
|
366
389
|
</div>
|
|
367
390
|
|
|
@@ -377,23 +400,47 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
|
|
|
377
400
|
|
|
378
401
|
<aside className="lm-preview">
|
|
379
402
|
<div className="lm-preview-bar">
|
|
380
|
-
<span className="lm-
|
|
381
|
-
<
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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>
|
|
392
416
|
</div>
|
|
393
417
|
<div className="lm-phone">
|
|
394
418
|
<iframe key={tick} src="/links" title="Links page preview" />
|
|
395
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>
|
|
396
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
|
+
)}
|
|
397
444
|
</div>
|
|
398
445
|
);
|
|
399
446
|
}
|
|
@@ -434,7 +481,7 @@ function ItemRow({
|
|
|
434
481
|
return (
|
|
435
482
|
<div
|
|
436
483
|
ref={setNodeRef}
|
|
437
|
-
className={`lm-item ${isDragging ? 'is-dragging' : ''}`}
|
|
484
|
+
className={`lm-item ${isDragging ? 'is-dragging' : ''} ${link.meta?.hidden ? 'is-hidden' : ''}`}
|
|
438
485
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
|
439
486
|
>
|
|
440
487
|
<div className={`row lm-row ${hasEditorPane && editorOpen ? 'is-open' : ''}`}>
|
|
@@ -529,6 +576,16 @@ function ItemRow({
|
|
|
529
576
|
{editorOpen ? 'Done' : expandLabel}
|
|
530
577
|
</button>
|
|
531
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>
|
|
532
589
|
<button type="button" className="btn sm ghost danger reveal" onClick={() => onDelete(link.id)}>Delete</button>
|
|
533
590
|
</div>
|
|
534
591
|
|
|
@@ -11,8 +11,10 @@ import type { NavItem, NavArea } from '../lib/schema';
|
|
|
11
11
|
// area is empty); footer renders above the footer text; social feeds the
|
|
12
12
|
// footer social row and share UIs.
|
|
13
13
|
//
|
|
14
|
-
// Edits stay local until "Save changes"
|
|
15
|
-
//
|
|
14
|
+
// Edits stay local until "Save changes", which STAGES the whole list in the
|
|
15
|
+
// design draft: menus go live together with the design on "Publish design"
|
|
16
|
+
// (here or on the Design screen), and preview mode shows them first.
|
|
17
|
+
// Discard restores the last saved state.
|
|
16
18
|
|
|
17
19
|
type SaveState = 'saved' | 'saving' | 'error';
|
|
18
20
|
|
|
@@ -22,8 +24,7 @@ const AREAS: { key: NavArea; title: string; hint: string }[] = [
|
|
|
22
24
|
{ key: 'social', title: 'Social', hint: 'Footer social row — also used for share links on posts.' },
|
|
23
25
|
];
|
|
24
26
|
|
|
25
|
-
//
|
|
26
|
-
const isTemp = (id: string) => id.startsWith('tmp-');
|
|
27
|
+
// new items carry a temp id; Publish design gives them real ids
|
|
27
28
|
|
|
28
29
|
function Row({
|
|
29
30
|
item, onEdit, onArea, onDelete,
|
|
@@ -67,8 +68,10 @@ function Row({
|
|
|
67
68
|
);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
71
|
+
export default function NavManager({ items: initial, staged: stagedInitially }: { items: NavItem[]; staged: boolean }) {
|
|
71
72
|
const [items, setItems] = useState<NavItem[]>(initial);
|
|
73
|
+
// the saved list is staged (differs from the live menus) until Publish
|
|
74
|
+
const [staged, setStaged] = useState(stagedInitially);
|
|
72
75
|
// last state the server has — dirty compares against this
|
|
73
76
|
const [saved, setSaved] = useState<NavItem[]>(initial);
|
|
74
77
|
const [saveState, setSaveState] = useState<SaveState>('saved');
|
|
@@ -122,45 +125,34 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
|
122
125
|
});
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
// one commit for everything:
|
|
126
|
-
// updates, then the full display order
|
|
128
|
+
// one commit for everything: the full ordered list stages in the draft
|
|
127
129
|
async function saveAll() {
|
|
128
130
|
setSaveState('saving');
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (ordered.length) {
|
|
155
|
-
const r = await actions.nav.reorder({ ids: ordered });
|
|
156
|
-
if (r.error) throw r.error;
|
|
157
|
-
}
|
|
158
|
-
setItems(next);
|
|
159
|
-
setSaved(next);
|
|
160
|
-
setSaveState('saved');
|
|
161
|
-
} catch {
|
|
162
|
-
setSaveState('error');
|
|
163
|
-
}
|
|
131
|
+
const ordered = AREAS.flatMap((a) => items.filter((n) => n.area === a.key));
|
|
132
|
+
const r = await actions.nav.saveDraft({
|
|
133
|
+
items: ordered.map(({ id, label, url, area }) => ({ id, label, url, area })),
|
|
134
|
+
});
|
|
135
|
+
if (r.error) { setSaveState('error'); return; }
|
|
136
|
+
setItems(ordered);
|
|
137
|
+
setSaved(ordered);
|
|
138
|
+
setStaged(r.data.changes.some((c) => c.key === 'nav'));
|
|
139
|
+
setSaveState('saved');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function publish() {
|
|
143
|
+
if (!window.confirm('Publish now? Staged menus and any staged design changes go live together. The current design is saved to History.')) return;
|
|
144
|
+
setSaveState('saving');
|
|
145
|
+
const r = await actions.settings.applyDesign({});
|
|
146
|
+
if (r.error) { setSaveState('error'); return; }
|
|
147
|
+
setStaged(false);
|
|
148
|
+
setSaveState('saved');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function unstage() {
|
|
152
|
+
setSaveState('saving');
|
|
153
|
+
const r = await actions.settings.revertDesignChange({ key: 'nav' });
|
|
154
|
+
if (r.error) { setSaveState('error'); return; }
|
|
155
|
+
location.reload(); // back to the live menus
|
|
164
156
|
}
|
|
165
157
|
|
|
166
158
|
function discard() {
|
|
@@ -172,7 +164,7 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
|
172
164
|
<div className="nm">
|
|
173
165
|
<div className="nm-head">
|
|
174
166
|
<span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>
|
|
175
|
-
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : dirty ? 'Unsaved changes
|
|
167
|
+
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : dirty ? 'Unsaved changes' : staged ? 'Staged, not live yet' : 'Live'}
|
|
176
168
|
</span>
|
|
177
169
|
{dirty && (
|
|
178
170
|
<>
|
|
@@ -180,6 +172,13 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
|
180
172
|
<button className="btn sm primary" type="button" onClick={() => void saveAll()} disabled={saveState === 'saving'}>Save changes</button>
|
|
181
173
|
</>
|
|
182
174
|
)}
|
|
175
|
+
{!dirty && staged && (
|
|
176
|
+
<>
|
|
177
|
+
<button className="btn sm ghost" type="button" onClick={() => void unstage()} disabled={saveState === 'saving'}>Discard staged menus</button>
|
|
178
|
+
<a className="btn sm" href="/?designPreview=1" target="_blank" rel="noopener">Preview site ↗</a>
|
|
179
|
+
<button className="btn sm primary" type="button" onClick={() => void publish()} disabled={saveState === 'saving'}>Publish…</button>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
183
182
|
</div>
|
|
184
183
|
{AREAS.map((a) => {
|
|
185
184
|
const group = items.filter((n) => n.area === a.key);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Admin theme (Light / Dark / System). Inlined in <head> of every admin
|
|
3
|
+
// document so data-theme is set before first paint — no light flash on a
|
|
4
|
+
// dark-mode machine. The preference lives in localStorage per browser;
|
|
5
|
+
// "system" follows the OS live. Public-site previews (iframes) never read it.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<meta name="color-scheme" content="light dark" />
|
|
9
|
+
<meta name="theme-color" content="#e8e6e1" />
|
|
10
|
+
<script is:inline>
|
|
11
|
+
(() => {
|
|
12
|
+
const KEY = 'acms-theme';
|
|
13
|
+
const COLORS = { light: '#e8e6e1', dark: '#141311' };
|
|
14
|
+
const mq = matchMedia('(prefers-color-scheme: dark)');
|
|
15
|
+
const root = document.documentElement;
|
|
16
|
+
const pref = () => {
|
|
17
|
+
try { return localStorage.getItem(KEY) || 'system'; } catch { return 'system'; }
|
|
18
|
+
};
|
|
19
|
+
const apply = (p = pref()) => {
|
|
20
|
+
const t = p === 'dark' || (p === 'system' && mq.matches) ? 'dark' : 'light';
|
|
21
|
+
root.dataset.theme = t;
|
|
22
|
+
root.dataset.themePref = p;
|
|
23
|
+
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', COLORS[t]);
|
|
24
|
+
document.querySelectorAll('[data-set-theme]').forEach((el) => {
|
|
25
|
+
el.setAttribute('aria-pressed', String(el.getAttribute('data-set-theme') === p));
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const set = (p) => {
|
|
29
|
+
try { localStorage.setItem(KEY, p); } catch {}
|
|
30
|
+
apply(p);
|
|
31
|
+
window.dispatchEvent(new CustomEvent('acms:theme', { detail: root.dataset.theme }));
|
|
32
|
+
};
|
|
33
|
+
window.acmsTheme = { set, pref };
|
|
34
|
+
apply();
|
|
35
|
+
mq.addEventListener('change', () => pref() === 'system' && apply());
|
|
36
|
+
document.addEventListener('DOMContentLoaded', () => apply());
|
|
37
|
+
document.addEventListener('click', (e) => {
|
|
38
|
+
const el = e.target instanceof Element ? e.target.closest('[data-set-theme]') : null;
|
|
39
|
+
if (el) set(el.getAttribute('data-set-theme'));
|
|
40
|
+
});
|
|
41
|
+
// ⌘⇧L / Ctrl+Shift+L cycles light → dark → system
|
|
42
|
+
document.addEventListener('keydown', (e) => {
|
|
43
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
const order = ['light', 'dark', 'system'];
|
|
46
|
+
set(order[(order.indexOf(pref()) + 1) % order.length]);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
})();
|
|
50
|
+
</script>
|