@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
|
@@ -1,76 +1,275 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
-
// Full-screen draft preview
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Full-screen draft preview. Three views of the same draft:
|
|
4
|
+
// Page — live iframe of the public route at desktop / tablet / phone width
|
|
5
|
+
// Search — how the result renders on Google
|
|
6
|
+
// Social — the link card X / Facebook / iMessage build from OG tags
|
|
7
|
+
// plus a "Before you publish" checklist and a Share popover with the signed,
|
|
8
|
+
// login-free link. Search/Social read the same fields the public <head> uses
|
|
9
|
+
// (seoTitle → title, seoDesc → excerpt → first paragraph, cover image), and
|
|
10
|
+
// edit them in place under the preview so the card updates as you type.
|
|
6
11
|
|
|
7
12
|
const DEVICES = [
|
|
8
|
-
{ key: 'desktop', label: 'Desktop', width: '100%' },
|
|
9
|
-
{ key: 'tablet', label: 'Tablet', width: '768px' },
|
|
10
|
-
{ key: 'mobile', label: '
|
|
13
|
+
{ key: 'desktop', label: 'Desktop', width: '100%', d: 'M3 4h18v12H3zM8 20h8M12 16v4' },
|
|
14
|
+
{ key: 'tablet', label: 'Tablet', width: '768px', d: 'M5 3h14v18H5zM11 17h2' },
|
|
15
|
+
{ key: 'mobile', label: 'Phone', width: '390px', d: 'M7 2h10v20H7zM11 18h2' },
|
|
11
16
|
] as const;
|
|
17
|
+
type View = 'page' | 'search' | 'social';
|
|
18
|
+
|
|
19
|
+
export interface PreviewMeta {
|
|
20
|
+
title: string;
|
|
21
|
+
seoTitle: string;
|
|
22
|
+
seoDesc: string;
|
|
23
|
+
excerpt: string;
|
|
24
|
+
cover: string;
|
|
25
|
+
coverAlt: string;
|
|
26
|
+
path: string; // public path the doc will live at
|
|
27
|
+
firstParagraph: string;
|
|
28
|
+
links: number;
|
|
29
|
+
imagesMissingAlt: number;
|
|
30
|
+
}
|
|
12
31
|
|
|
13
32
|
interface Props {
|
|
14
33
|
open: boolean;
|
|
15
34
|
url: string; // admin-session preview (relative)
|
|
16
|
-
shareUrl: string; // token preview
|
|
35
|
+
shareUrl: string; // token preview, safe to send around
|
|
36
|
+
meta: PreviewMeta;
|
|
37
|
+
site: { title: string; url: string };
|
|
38
|
+
isPost: boolean;
|
|
39
|
+
startView?: View;
|
|
40
|
+
onChange: (patch: Partial<Pick<PreviewMeta, 'seoTitle' | 'seoDesc' | 'coverAlt'>>) => void;
|
|
41
|
+
onPickCover: () => void;
|
|
17
42
|
onClose: () => void;
|
|
18
43
|
}
|
|
19
44
|
|
|
20
|
-
|
|
45
|
+
// character budget bar: fills toward the limit, amber past it
|
|
46
|
+
function Meter({ n, max }: { n: number; max: number }) {
|
|
47
|
+
const state = n === 0 ? 'empty' : n > max ? 'over' : 'ok';
|
|
48
|
+
return (
|
|
49
|
+
<span className={`pe-meter ${state}`} aria-hidden="true">
|
|
50
|
+
<i style={{ width: `${Math.min(100, (n / max) * 100)}%` }} />
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startView = 'page', onChange, onPickCover, onClose }: Props) {
|
|
56
|
+
const [view, setView] = useState<View>('page');
|
|
21
57
|
const [device, setDevice] = useState<(typeof DEVICES)[number]>(DEVICES[0]);
|
|
22
58
|
const [copied, setCopied] = useState(false);
|
|
59
|
+
const [shareOpen, setShareOpen] = useState(false);
|
|
23
60
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
61
|
+
const titleRef = useRef<HTMLInputElement>(null);
|
|
62
|
+
const descRef = useRef<HTMLTextAreaElement>(null);
|
|
63
|
+
const altRef = useRef<HTMLInputElement>(null);
|
|
64
|
+
// "Fix" jumps to the view that edits the field, then focuses it
|
|
65
|
+
const [focusField, setFocusField] = useState<'title' | 'desc' | 'alt' | null>(null);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!focusField) return;
|
|
68
|
+
({ title: titleRef, desc: descRef, alt: altRef })[focusField].current?.focus();
|
|
69
|
+
setFocusField(null);
|
|
70
|
+
}, [focusField, view]);
|
|
24
71
|
|
|
25
72
|
useEffect(() => {
|
|
26
73
|
if (!open) return;
|
|
27
74
|
setRefreshKey((k) => k + 1); // fresh content each time it opens
|
|
28
|
-
|
|
75
|
+
setView(startView);
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [open]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!open) return;
|
|
81
|
+
const onKey = (e: KeyboardEvent) => {
|
|
82
|
+
if (e.key !== 'Escape') return;
|
|
83
|
+
if (document.querySelector('.pe-modal-backdrop')) return; // the image picker closes first
|
|
84
|
+
if (shareOpen) setShareOpen(false);
|
|
85
|
+
else onClose();
|
|
86
|
+
};
|
|
29
87
|
window.addEventListener('keydown', onKey);
|
|
30
88
|
return () => window.removeEventListener('keydown', onKey);
|
|
31
|
-
}, [open, onClose]);
|
|
89
|
+
}, [open, onClose, shareOpen]);
|
|
32
90
|
|
|
33
91
|
if (!open) return null;
|
|
34
92
|
|
|
93
|
+
const origin = site.url ? site.url.replace(/\/+$/, '') : location.origin;
|
|
94
|
+
const host = origin.replace(/^https?:\/\//, '');
|
|
95
|
+
const shareHref = new URL(shareUrl, location.origin).href;
|
|
96
|
+
const title = meta.seoTitle || meta.title || 'Untitled';
|
|
97
|
+
const fullTitle = site.title && !title.includes(site.title) ? `${title} | ${site.title}` : title;
|
|
98
|
+
const desc = meta.seoDesc || meta.excerpt || meta.firstParagraph;
|
|
99
|
+
const crumbs = [host, ...meta.path.split('/').filter(Boolean)].join(' › ');
|
|
100
|
+
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s);
|
|
101
|
+
|
|
102
|
+
const fixIn = (v: View, field: 'title' | 'desc' | 'alt' | null) => () => { setView(v); setFocusField(field); };
|
|
103
|
+
const checks: { ok: boolean | 'warn'; label: string; detail: string; fix?: () => void }[] = [
|
|
104
|
+
{
|
|
105
|
+
ok: title.length > 0 && title.length <= 60 ? true : 'warn',
|
|
106
|
+
label: 'Search title',
|
|
107
|
+
detail: `${title.length} characters${title.length > 60 ? ' — Google cuts titles around 60' : ', fits'}`,
|
|
108
|
+
fix: title.length > 60 ? fixIn('search', 'title') : undefined,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
ok: meta.seoDesc || meta.excerpt ? true : 'warn',
|
|
112
|
+
label: 'Meta description',
|
|
113
|
+
detail: meta.seoDesc || meta.excerpt ? `${desc.length} characters` : 'Missing — search engines will use the first paragraph',
|
|
114
|
+
fix: !(meta.seoDesc || meta.excerpt) ? fixIn('search', 'desc') : undefined,
|
|
115
|
+
},
|
|
116
|
+
...(isPost
|
|
117
|
+
? [{
|
|
118
|
+
ok: meta.cover ? (meta.coverAlt ? true : 'warn') : 'warn',
|
|
119
|
+
label: 'Feature image',
|
|
120
|
+
detail: !meta.cover ? 'None — social cards will be text-only' : meta.coverAlt ? 'Set, with alt text' : 'Missing alt text',
|
|
121
|
+
fix: !meta.cover ? () => { setView('social'); onPickCover(); } : !meta.coverAlt ? fixIn('social', 'alt') : undefined,
|
|
122
|
+
} as const]
|
|
123
|
+
: []),
|
|
124
|
+
{
|
|
125
|
+
ok: meta.imagesMissingAlt === 0 ? true : 'warn',
|
|
126
|
+
label: 'Image alt text',
|
|
127
|
+
detail: meta.imagesMissingAlt === 0 ? 'Every image is described' : `${meta.imagesMissingAlt} image${meta.imagesMissingAlt === 1 ? '' : 's'} without alt text`,
|
|
128
|
+
},
|
|
129
|
+
{ ok: true, label: 'Links', detail: meta.links ? `${meta.links} link${meta.links === 1 ? '' : 's'} in the body` : 'No links' },
|
|
130
|
+
];
|
|
131
|
+
const passed = checks.filter((c) => c.ok === true).length;
|
|
132
|
+
|
|
133
|
+
const titleLen = (meta.seoTitle || meta.title).length;
|
|
134
|
+
const descLen = (meta.seoDesc || meta.excerpt || meta.firstParagraph).length;
|
|
135
|
+
const seoInputs = (
|
|
136
|
+
<>
|
|
137
|
+
<label className="pe-seo-field">
|
|
138
|
+
<span className="pe-seo-label">Meta title <em className={titleLen > 60 ? 'over' : ''}>{titleLen} / 60</em></span>
|
|
139
|
+
<input ref={titleRef} value={meta.seoTitle} placeholder={meta.title || 'Untitled'} onChange={(e) => onChange({ seoTitle: e.target.value })} />
|
|
140
|
+
<Meter n={titleLen} max={60} />
|
|
141
|
+
<small>{meta.seoTitle ? 'Overrides the title in search and social.' : 'Empty — uses the title.'}</small>
|
|
142
|
+
</label>
|
|
143
|
+
<label className="pe-seo-field">
|
|
144
|
+
<span className="pe-seo-label">Meta description <em className={descLen > 160 ? 'over' : ''}>{descLen} / 160</em></span>
|
|
145
|
+
<textarea ref={descRef} rows={3} value={meta.seoDesc} placeholder={meta.excerpt || meta.firstParagraph || 'One or two sentences that make people click'} onChange={(e) => onChange({ seoDesc: e.target.value })} />
|
|
146
|
+
<Meter n={descLen} max={160} />
|
|
147
|
+
<small>{meta.seoDesc ? 'Overrides the excerpt in search and social.' : meta.excerpt ? 'Empty — uses the excerpt.' : 'Empty — uses the first paragraph.'}</small>
|
|
148
|
+
</label>
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
const seoFields = <section className="pe-seo-edit">{seoInputs}</section>;
|
|
152
|
+
|
|
35
153
|
return (
|
|
36
154
|
<div className="pe-preview">
|
|
37
155
|
<div className="pe-preview-bar">
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
156
|
+
<div className="pe-preview-left">
|
|
157
|
+
<button type="button" className="pe-btn ghost sm" onClick={onClose}>‹ <span className="pe-hide-sm">Back to editor</span></button>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="pe-preview-center">
|
|
160
|
+
<div className="pe-seg" role="tablist" aria-label="Preview view">
|
|
161
|
+
{(['page', 'search', 'social'] as View[]).map((v) => (
|
|
162
|
+
<button key={v} type="button" role="tab" aria-selected={view === v} onClick={() => setView(v)}>
|
|
163
|
+
{v[0].toUpperCase() + v.slice(1)}
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
{view === 'page' && (
|
|
168
|
+
<div className="pe-seg pe-hide-sm" role="group" aria-label="Device width">
|
|
169
|
+
{DEVICES.map((d) => (
|
|
170
|
+
<button key={d.key} type="button" aria-pressed={device.key === d.key} title={d.label} onClick={() => setDevice(d)}>
|
|
171
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d={d.d} /></svg>
|
|
172
|
+
</button>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
50
176
|
</div>
|
|
51
177
|
<div className="pe-preview-actions">
|
|
52
|
-
<
|
|
53
|
-
type="button"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
178
|
+
<div className="pe-share-wrap">
|
|
179
|
+
<button type="button" className="pe-btn sm" onClick={() => setShareOpen((v) => !v)} aria-expanded={shareOpen}>
|
|
180
|
+
Share <span className="pe-hide-sm">draft</span>
|
|
181
|
+
</button>
|
|
182
|
+
{shareOpen && (
|
|
183
|
+
<div className="pe-share" role="dialog" aria-label="Share preview link">
|
|
184
|
+
<b>Share a preview link</b>
|
|
185
|
+
<p>Anyone with this link can read the draft without signing in. It always shows the latest autosave.</p>
|
|
186
|
+
<div className="pe-share-row">
|
|
187
|
+
<input readOnly value={shareHref} onFocus={(e) => e.currentTarget.select()} aria-label="Preview link" />
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className="pe-btn primary sm"
|
|
191
|
+
onClick={async () => {
|
|
192
|
+
if (navigator.share && matchMedia('(pointer: coarse)').matches) {
|
|
193
|
+
try { await navigator.share({ title: meta.title, url: shareHref }); return; } catch { /* cancelled */ }
|
|
194
|
+
}
|
|
195
|
+
await navigator.clipboard.writeText(shareHref);
|
|
196
|
+
setCopied(true);
|
|
197
|
+
setTimeout(() => setCopied(false), 1600);
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{copied ? 'Copied ✓' : 'Copy'}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
<a className="pe-btn ghost sm pe-hide-sm" href={url} target="_blank" rel="noopener">Open ↗</a>
|
|
64
207
|
</div>
|
|
65
208
|
</div>
|
|
66
|
-
<div className="pe-preview-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
209
|
+
<div className="pe-preview-body">
|
|
210
|
+
<div className={`pe-preview-stage view-${view}`}>
|
|
211
|
+
{view === 'page' && (
|
|
212
|
+
<iframe
|
|
213
|
+
key={refreshKey}
|
|
214
|
+
src={url}
|
|
215
|
+
title="Preview"
|
|
216
|
+
style={{ width: device.width }}
|
|
217
|
+
className={device.key !== 'desktop' ? 'is-framed' : ''}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
{view === 'search' && (
|
|
221
|
+
<div className="pe-seo-view">
|
|
222
|
+
<div className="pe-serp">
|
|
223
|
+
<div className="pe-serp-site"><span className="pe-serp-fav"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.4 3.8 5.6 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.6-3.8-9S9.5 5.4 12 3z" /></svg></span><span><b>{site.title || host}</b><small>{crumbs}</small></span></div>
|
|
224
|
+
<div className="pe-serp-title">{clip(fullTitle, 62)}</div>
|
|
225
|
+
<div className="pe-serp-desc">{desc ? clip(desc, 158) : <i>No description yet.</i>}</div>
|
|
226
|
+
</div>
|
|
227
|
+
{seoFields}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
{view === 'social' && (
|
|
231
|
+
<div className="pe-seo-view">
|
|
232
|
+
<div className="pe-og">
|
|
233
|
+
{meta.cover ? <img src={meta.cover} alt={meta.coverAlt} /> : <div className="pe-og-empty">No feature image — this card shows text only</div>}
|
|
234
|
+
<div className="pe-og-text">
|
|
235
|
+
<small>{host}</small>
|
|
236
|
+
<b>{clip(title, 70)}</b>
|
|
237
|
+
{desc && <span>{clip(desc, 110)}</span>}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
<section className="pe-seo-edit">
|
|
241
|
+
{isPost && (
|
|
242
|
+
<div className="pe-seo-image">
|
|
243
|
+
{meta.cover ? <img src={meta.cover} alt="" /> : <span className="pe-seo-image-empty" aria-hidden="true" />}
|
|
244
|
+
<div>
|
|
245
|
+
<b>Feature image</b>
|
|
246
|
+
<small>{meta.cover ? 'Shown at 1.91 : 1 on X, Facebook, LinkedIn and iMessage' : 'Cards with an image get far more clicks'}</small>
|
|
247
|
+
</div>
|
|
248
|
+
<button type="button" className="pe-btn sm" onClick={onPickCover}>{meta.cover ? 'Change' : 'Add image'}</button>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
{isPost && meta.cover && (
|
|
252
|
+
<label className="pe-seo-field">
|
|
253
|
+
<span className="pe-seo-label">Image alt text</span>
|
|
254
|
+
<input ref={altRef} value={meta.coverAlt} placeholder="Describe the image for screen readers" onChange={(e) => onChange({ coverAlt: e.target.value })} />
|
|
255
|
+
</label>
|
|
256
|
+
)}
|
|
257
|
+
<p className="pe-seo-note">Social cards use the same title and description as search.</p>
|
|
258
|
+
{seoInputs}
|
|
259
|
+
</section>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
<aside className="pe-checks">
|
|
264
|
+
<div className="pe-checks-head"><b>Before you publish</b><span>{passed} / {checks.length}</span></div>
|
|
265
|
+
{checks.map((c) => (
|
|
266
|
+
<div className="pe-check-row" key={c.label}>
|
|
267
|
+
<i className={c.ok === true ? 'ok' : 'warn'}>{c.ok === true ? '✓' : '!'}</i>
|
|
268
|
+
<div><b>{c.label}</b><small>{c.detail}</small></div>
|
|
269
|
+
{c.fix && <button type="button" className="pe-btn sm" onClick={c.fix}>Fix</button>}
|
|
270
|
+
</div>
|
|
271
|
+
))}
|
|
272
|
+
</aside>
|
|
74
273
|
</div>
|
|
75
274
|
</div>
|
|
76
275
|
);
|
|
@@ -83,6 +83,18 @@ export const CalendarCard = Node.create({
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
interface PreviewItem { when: string; title: string; cal: number }
|
|
86
|
+
interface SiteCalendar { slug: string; name: string; color: string; visibility: string }
|
|
87
|
+
|
|
88
|
+
const isSiteSource = (s: string) => s === 'acms:all' || s.startsWith('acms:cal:');
|
|
89
|
+
|
|
90
|
+
// site calendars for the picker, fetched once per editor session
|
|
91
|
+
let siteCalendarsPromise: Promise<SiteCalendar[]> | null = null;
|
|
92
|
+
function loadSiteCalendars(): Promise<SiteCalendar[]> {
|
|
93
|
+
siteCalendarsPromise ??= actions.calendars.list({}).then((r) =>
|
|
94
|
+
r.error ? [] : r.data.calendars.filter((c) => c.visibility !== 'private' && c.kind === 'native')
|
|
95
|
+
);
|
|
96
|
+
return siteCalendarsPromise;
|
|
97
|
+
}
|
|
86
98
|
|
|
87
99
|
function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
|
88
100
|
const a = node.attrs;
|
|
@@ -92,6 +104,17 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
|
|
|
92
104
|
const [error, setError] = useState('');
|
|
93
105
|
const [adding, setAdding] = useState(false);
|
|
94
106
|
const [preview, setPreview] = useState<PreviewItem[] | null>(null);
|
|
107
|
+
const [site, setSite] = useState<SiteCalendar[]>([]);
|
|
108
|
+
useEffect(() => { void loadSiteCalendars().then(setSite); }, []);
|
|
109
|
+
const label = (s: string) =>
|
|
110
|
+
s === 'acms:all' ? 'All site events' : s.startsWith('acms:cal:') ? site.find((c) => `acms:cal:${c.slug}` === s)?.name ?? s.slice(9) : calendarSrcLabel(s);
|
|
111
|
+
const dot = (s: string, i: number) =>
|
|
112
|
+
s.startsWith('acms:cal:') ? site.find((c) => `acms:cal:${c.slug}` === s)?.color ?? CAL_DOT_COLORS[i % CAL_DOT_COLORS.length] : CAL_DOT_COLORS[i % CAL_DOT_COLORS.length];
|
|
113
|
+
const addSite = (value: string) => void addSource(value);
|
|
114
|
+
const unusedSite = [
|
|
115
|
+
...(sources.includes('acms:all') ? [] : [{ value: 'acms:all', name: 'All site events' }]),
|
|
116
|
+
...site.filter((c) => !sources.includes(`acms:cal:${c.slug}`)).map((c) => ({ value: `acms:cal:${c.slug}`, name: c.name })),
|
|
117
|
+
];
|
|
95
118
|
|
|
96
119
|
const addSource = async (raw: string) => {
|
|
97
120
|
const value = raw.trim();
|
|
@@ -140,6 +163,15 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
|
|
|
140
163
|
/>
|
|
141
164
|
<button type="submit" className="pe-btn primary sm" disabled={busy}>{busy ? 'Checking…' : 'Add calendar'}</button>
|
|
142
165
|
{error && <span className="pe-embed-error">{error}</span>}
|
|
166
|
+
<span className="pe-cal-site">
|
|
167
|
+
<span>Or show this site’s events:</span>
|
|
168
|
+
<button type="button" className="pe-btn sm" disabled={busy} onClick={() => addSite('acms:all')}>All events</button>
|
|
169
|
+
{site.map((c) => (
|
|
170
|
+
<button key={c.slug} type="button" className="pe-btn sm" disabled={busy} onClick={() => addSite(`acms:cal:${c.slug}`)}>
|
|
171
|
+
<i style={{ background: c.color }} aria-hidden="true" />{c.name}
|
|
172
|
+
</button>
|
|
173
|
+
))}
|
|
174
|
+
</span>
|
|
143
175
|
</form>
|
|
144
176
|
</NodeViewWrapper>
|
|
145
177
|
);
|
|
@@ -162,10 +194,12 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
|
|
|
162
194
|
{[3, 4, 5, 6, 8, 10, 15, 20].map((n) => <option key={n} value={n}>{n} events</option>)}
|
|
163
195
|
</select>
|
|
164
196
|
)}
|
|
165
|
-
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
197
|
+
{sources.some((x) => !isSiteSource(x)) && (
|
|
198
|
+
<label>
|
|
199
|
+
<input type="checkbox" checked={a.showOpenLink} onChange={(e) => updateAttributes({ showOpenLink: e.target.checked })} />
|
|
200
|
+
Google link
|
|
201
|
+
</label>
|
|
202
|
+
)}
|
|
169
203
|
</div>
|
|
170
204
|
<div className="pe-calendar-config" contentEditable={false}>
|
|
171
205
|
<input
|
|
@@ -184,8 +218,8 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
|
|
|
184
218
|
<div className="pe-cal-sources" contentEditable={false}>
|
|
185
219
|
{sources.map((s, i) => (
|
|
186
220
|
<span className="pe-cal-chip" key={s}>
|
|
187
|
-
<i style={{ background:
|
|
188
|
-
{
|
|
221
|
+
<i style={{ background: dot(s, i) }} />
|
|
222
|
+
{label(s)}
|
|
189
223
|
<button
|
|
190
224
|
type="button" title="Remove calendar"
|
|
191
225
|
onClick={() => updateAttributes({ sources: sources.filter((x) => x !== s) })}
|
|
@@ -203,7 +237,17 @@ function CalendarView({ node, updateAttributes, deleteNode, selected }: NodeView
|
|
|
203
237
|
<button type="submit" className="pe-btn sm" disabled={busy}>{busy ? '…' : 'Add'}</button>
|
|
204
238
|
</form>
|
|
205
239
|
) : (
|
|
206
|
-
sources.length < 6 &&
|
|
240
|
+
sources.length < 6 && (
|
|
241
|
+
<>
|
|
242
|
+
<button type="button" className="pe-btn sm" onClick={() => setAdding(true)}>+ Add feed</button>
|
|
243
|
+
{unusedSite.length > 0 && (
|
|
244
|
+
<select className="pe-cal-sitepick" value="" onChange={(e) => e.target.value && addSite(e.target.value)} aria-label="Add a site calendar">
|
|
245
|
+
<option value="">+ Site calendar…</option>
|
|
246
|
+
{unusedSite.map((o) => <option key={o.value} value={o.value}>{o.name}</option>)}
|
|
247
|
+
</select>
|
|
248
|
+
)}
|
|
249
|
+
</>
|
|
250
|
+
)
|
|
207
251
|
)}
|
|
208
252
|
{error && <span className="pe-embed-error">{error}</span>}
|
|
209
253
|
</div>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Node } from '@tiptap/core';
|
|
3
|
+
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
|
4
|
+
import type { NodeViewProps } from '@tiptap/react';
|
|
5
|
+
import { actions } from 'astro:actions';
|
|
6
|
+
|
|
7
|
+
// Event card: a dynamic block (data-dyn="events") rendered at request time by
|
|
8
|
+
// lib/calendar/cards.ts, so dates, images and "cancelled" stay current.
|
|
9
|
+
// Two modes: one chosen event (with its button), or the next few events from
|
|
10
|
+
// a calendar as image cards — the "Coming up" strip for a home page.
|
|
11
|
+
|
|
12
|
+
export const EventCard = Node.create({
|
|
13
|
+
name: 'eventCard',
|
|
14
|
+
group: 'block',
|
|
15
|
+
atom: true,
|
|
16
|
+
draggable: true,
|
|
17
|
+
|
|
18
|
+
addAttributes() {
|
|
19
|
+
return {
|
|
20
|
+
mode: { default: 'upcoming' }, // upcoming | single
|
|
21
|
+
eventId: { default: '' },
|
|
22
|
+
calendar: { default: '' }, // calendar slug; '' = all public
|
|
23
|
+
limit: { default: 3 },
|
|
24
|
+
layout: { default: 'grid' }, // grid | list
|
|
25
|
+
showImage: { default: true },
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
parseHTML() {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
tag: 'div[data-dyn="events"]',
|
|
33
|
+
getAttrs: (el) => {
|
|
34
|
+
const e = el as HTMLElement;
|
|
35
|
+
return {
|
|
36
|
+
mode: e.getAttribute('data-mode') === 'single' ? 'single' : 'upcoming',
|
|
37
|
+
eventId: e.getAttribute('data-event') ?? '',
|
|
38
|
+
calendar: e.getAttribute('data-calendar') ?? '',
|
|
39
|
+
limit: Number(e.getAttribute('data-limit') ?? 3),
|
|
40
|
+
layout: e.getAttribute('data-layout') === 'list' ? 'list' : 'grid',
|
|
41
|
+
showImage: e.getAttribute('data-show-image') !== '0',
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
renderHTML({ node }) {
|
|
49
|
+
const a = node.attrs;
|
|
50
|
+
return [
|
|
51
|
+
'div',
|
|
52
|
+
{
|
|
53
|
+
'data-dyn': 'events',
|
|
54
|
+
'data-mode': a.mode,
|
|
55
|
+
'data-event': a.eventId,
|
|
56
|
+
'data-calendar': a.calendar,
|
|
57
|
+
'data-limit': String(a.limit),
|
|
58
|
+
'data-layout': a.layout,
|
|
59
|
+
'data-show-image': a.showImage ? '1' : '0',
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
addNodeView() {
|
|
65
|
+
return ReactNodeViewRenderer(EventCardView);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
interface EventOption { id: string; title: string; when: string; status: string; cover: string }
|
|
70
|
+
interface CalOption { slug: string; name: string; color: string }
|
|
71
|
+
|
|
72
|
+
let cache: Promise<{ events: EventOption[]; calendars: CalOption[] }> | null = null;
|
|
73
|
+
function loadOptions() {
|
|
74
|
+
cache ??= Promise.all([actions.events.list({ publishedOnly: false }), actions.calendars.list({})]).then(([ev, cal]) => ({
|
|
75
|
+
events: ev.error ? [] : ev.data.events,
|
|
76
|
+
calendars: cal.error ? [] : cal.data.calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private'),
|
|
77
|
+
}));
|
|
78
|
+
return cache;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function EventCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
|
82
|
+
const a = node.attrs;
|
|
83
|
+
const [opts, setOpts] = useState<{ events: EventOption[]; calendars: CalOption[] } | null>(null);
|
|
84
|
+
useEffect(() => { void loadOptions().then(setOpts); }, []);
|
|
85
|
+
const chosen = opts?.events.find((e) => e.id === a.eventId);
|
|
86
|
+
const count = Math.min(12, Math.max(1, Number(a.limit) || 3));
|
|
87
|
+
const calName = a.calendar ? opts?.calendars.find((c) => c.slug === a.calendar)?.name ?? a.calendar : 'all calendars';
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<NodeViewWrapper className={`pe-card pe-postlist pe-evcard ${selected ? 'is-selected' : ''}`} data-drag-handle>
|
|
91
|
+
<div className="pe-card-tools" contentEditable={false}>
|
|
92
|
+
<button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="pe-postlist-config" contentEditable={false}>
|
|
95
|
+
<span className="pe-postlist-badge">Events · dynamic</span>
|
|
96
|
+
<select value={a.mode} onChange={(e) => updateAttributes({ mode: e.target.value })}>
|
|
97
|
+
<option value="upcoming">Coming up</option>
|
|
98
|
+
<option value="single">One event</option>
|
|
99
|
+
</select>
|
|
100
|
+
{a.mode === 'single' ? (
|
|
101
|
+
<select value={a.eventId} onChange={(e) => updateAttributes({ eventId: e.target.value })}>
|
|
102
|
+
<option value="">Choose an event…</option>
|
|
103
|
+
{opts?.events.map((e) => (
|
|
104
|
+
<option key={e.id} value={e.id}>{e.title}{e.when ? ` — ${e.when}` : ''}{e.status !== 'published' ? ` (${e.status})` : ''}</option>
|
|
105
|
+
))}
|
|
106
|
+
</select>
|
|
107
|
+
) : (
|
|
108
|
+
<>
|
|
109
|
+
<select value={a.calendar} onChange={(e) => updateAttributes({ calendar: e.target.value })}>
|
|
110
|
+
<option value="">All calendars</option>
|
|
111
|
+
{opts?.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
112
|
+
</select>
|
|
113
|
+
<select value={count} onChange={(e) => updateAttributes({ limit: Number(e.target.value) })}>
|
|
114
|
+
{[1, 2, 3, 4, 6, 8, 12].map((n) => <option key={n} value={n}>{n} event{n > 1 ? 's' : ''}</option>)}
|
|
115
|
+
</select>
|
|
116
|
+
<select value={a.layout} onChange={(e) => updateAttributes({ layout: e.target.value })}>
|
|
117
|
+
<option value="grid">Grid</option>
|
|
118
|
+
<option value="list">List</option>
|
|
119
|
+
</select>
|
|
120
|
+
</>
|
|
121
|
+
)}
|
|
122
|
+
<label><input type="checkbox" checked={a.showImage} onChange={(e) => updateAttributes({ showImage: e.target.checked })} /> Images</label>
|
|
123
|
+
</div>
|
|
124
|
+
<div className={`pe-postlist-ghost layout-${a.mode === 'single' ? 'feature' : a.layout}`} contentEditable={false}>
|
|
125
|
+
{a.mode === 'single' ? (
|
|
126
|
+
chosen ? (
|
|
127
|
+
<div className="pe-evcard-one">
|
|
128
|
+
{a.showImage && (chosen.cover ? <img src={chosen.cover} alt="" /> : <div className="g-img" />)}
|
|
129
|
+
<div>
|
|
130
|
+
<small>{chosen.when}</small>
|
|
131
|
+
<b>{chosen.title}</b>
|
|
132
|
+
{chosen.status !== 'published' && <em>Not published yet — hidden on the site until it is.</em>}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
) : (
|
|
136
|
+
<p className="pe-postlist-note">{opts && !opts.events.length ? 'No events yet — create one under Calendar → Events.' : 'Pick the event to feature.'}</p>
|
|
137
|
+
)
|
|
138
|
+
) : (
|
|
139
|
+
<>
|
|
140
|
+
{Array.from({ length: Math.min(count, 4) }, (_, i) => (
|
|
141
|
+
<div className="pe-postlist-ghostcard" key={i}>
|
|
142
|
+
{a.showImage && <div className="g-img" />}
|
|
143
|
+
<div className="g-line w40" />
|
|
144
|
+
<div className="g-line w90" />
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
<p className="pe-postlist-note">Next {count} event{count > 1 ? 's' : ''} from {calName} — renders live on the site (see preview)</p>
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
</NodeViewWrapper>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { Editor, Range } from '@tiptap/react';
|
|
|
3
3
|
import Suggestion from '@tiptap/suggestion';
|
|
4
4
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
|
5
5
|
import { CONTENT } from '../../../lib/content';
|
|
6
|
+
import { CALENDAR } from '../../../lib/calendar/config';
|
|
6
7
|
|
|
7
8
|
// "/" command menu, Ghost-style: type / on a line to insert cards and blocks.
|
|
8
9
|
|
|
@@ -86,10 +87,15 @@ const ITEMS: SlashItem[] = [
|
|
|
86
87
|
run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'htmlCard' }).run(),
|
|
87
88
|
},
|
|
88
89
|
{
|
|
89
|
-
title: 'Calendar', description: 'Google Calendar or ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
|
|
90
|
+
title: 'Calendar', description: 'Site calendars, Google Calendar or any ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
|
|
90
91
|
icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h2M12 13h2M7 17h2M12 17h2',
|
|
91
92
|
run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'calendarCard' }).run(),
|
|
92
93
|
},
|
|
94
|
+
...(CALENDAR.enabled ? [{
|
|
95
|
+
title: 'Events', description: 'Coming-up event cards, or one event with its button (dynamic)', keywords: 'events event upcoming coming up calendar card rsvp cta dynamic', group: 'Cards' as const,
|
|
96
|
+
icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h4v4H7z',
|
|
97
|
+
run: (editor: Editor, range: Range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'eventCard' }).run(),
|
|
98
|
+
} satisfies SlashItem] : []),
|
|
93
99
|
{
|
|
94
100
|
title: 'Info cards', description: 'Grid of cards: title, badge, details', keywords: 'info cards grid services hours schedule team features locations badge', group: 'Cards',
|
|
95
101
|
icon: 'M3 5h18v14H3zM7 9h6M15.5 9h2M7 13h7M7 16h5',
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Numbered pagination: ← Newer · 1 2 … n · Older →. `base` is the page-1 URL;
|
|
3
|
+
// later pages append ?page=N.
|
|
4
|
+
interface Props { base: string; page: number; count: number }
|
|
5
|
+
const { base, page, count } = Astro.props;
|
|
6
|
+
const href = (n: number) => (n === 1 ? base : `${base}${base.includes('?') ? '&' : '?'}page=${n}`);
|
|
7
|
+
// window of pages around the current one, with first/last always shown
|
|
8
|
+
const nums: (number | '…')[] = [];
|
|
9
|
+
for (let n = 1; n <= count; n++) {
|
|
10
|
+
if (n === 1 || n === count || Math.abs(n - page) <= 1) nums.push(n);
|
|
11
|
+
else if (nums[nums.length - 1] !== '…') nums.push('…');
|
|
12
|
+
}
|
|
13
|
+
---
|
|
14
|
+
{count > 1 && (
|
|
15
|
+
<nav class="pager" aria-label="Pagination">
|
|
16
|
+
{page > 1 ? <a class="pager-step" href={href(page - 1)} rel="prev">← Newer</a> : <span class="pager-step is-off">← Newer</span>}
|
|
17
|
+
<span class="pager-nums">
|
|
18
|
+
{nums.map((n) => n === '…'
|
|
19
|
+
? <span class="pager-gap">…</span>
|
|
20
|
+
: <a href={href(n)} aria-current={n === page ? 'page' : undefined}>{n}</a>)}
|
|
21
|
+
</span>
|
|
22
|
+
{page < count ? <a class="pager-step" href={href(page + 1)} rel="next">Older →</a> : <span class="pager-step is-off">Older →</span>}
|
|
23
|
+
</nav>
|
|
24
|
+
)}
|