@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.
- package/CHANGELOG.md +106 -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 -315
- 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 +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- 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 +455 -47
- 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 +216 -97
- package/src/components/NavManager.tsx +83 -38
- 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 +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- 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 +331 -57
- 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/analytics.ts +121 -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 +112 -0
- 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 +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- 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 +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -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 +10 -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 +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- 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 +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- 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 +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- 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 +140 -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 +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- 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>
|
|
@@ -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
|
|
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
|
-
|
|
122
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
function add(data: Partial<Link>, openIt = true) {
|
|
160
164
|
setPickerOpen(false);
|
|
161
165
|
setSitesOpen(false);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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=
|
|
194
|
-
<div>
|
|
195
|
-
|
|
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
|
-
<
|
|
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={() =>
|
|
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="
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
{pickerOpen
|
|
249
|
-
|
|
250
|
-
)}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
</
|
|
255
|
-
{pickerOpen &&
|
|
256
|
-
<div
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
</
|
|
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="
|
|
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-
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|