@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3
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 +69 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/sections.mjs +186 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +55 -1
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +4 -2
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +19 -171
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/editor/PostEditor.tsx +174 -10
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +6 -1
- package/src/layouts/Site.astro +107 -19
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/content-service.ts +198 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +19 -11
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +8 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +40 -7
- package/src/lib/repo/types.ts +9 -3
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +27 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +217 -0
- package/src/lib/theme.ts +16 -1
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +32 -0
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +69 -0
- package/src/styles/editor.css +21 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/sections.css +7 -1
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +326 -0
- package/src/theme-sdk/index.ts +10 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// "Add a section": a searchable grid grouped by category. Thumbnails are the
|
|
4
|
+
// real section rendered with its schema `example` (GET /admin/section-preview)
|
|
5
|
+
// at desktop width, scaled down; they load lazily as tiles scroll into view.
|
|
6
|
+
|
|
7
|
+
export interface PickerDef {
|
|
8
|
+
name?: string; description?: string; category?: string; limit?: number; variants?: string[];
|
|
9
|
+
presets?: { name: string; description?: string; variant?: string; data: Record<string, unknown> }[];
|
|
10
|
+
}
|
|
11
|
+
export interface PickerChoice { type: string; variant?: string; data?: Record<string, unknown>; label: string }
|
|
12
|
+
|
|
13
|
+
interface Entry extends PickerChoice { key: string; description: string; category: string; full: boolean; coded: boolean }
|
|
14
|
+
|
|
15
|
+
const FRAME_W = 1280;
|
|
16
|
+
const FRAME_H = 760;
|
|
17
|
+
|
|
18
|
+
export function SectionPicker({ open, defs, coded, counts, theme, onClose, onPick }: {
|
|
19
|
+
open: boolean;
|
|
20
|
+
defs: Record<string, PickerDef>;
|
|
21
|
+
coded: Set<string>;
|
|
22
|
+
/** how many of each type the page already has (for per-page limits) */
|
|
23
|
+
counts: Record<string, number>;
|
|
24
|
+
theme: string;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
onPick: (choice: PickerChoice) => void;
|
|
27
|
+
}) {
|
|
28
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
29
|
+
const [q, setQ] = useState('');
|
|
30
|
+
const [cat, setCat] = useState('All');
|
|
31
|
+
const [sel, setSel] = useState<string | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const d = ref.current;
|
|
34
|
+
if (!d) return;
|
|
35
|
+
if (open && !d.open) { d.showModal(); setQ(''); setSel(null); }
|
|
36
|
+
if (!open && d.open) d.close();
|
|
37
|
+
}, [open]);
|
|
38
|
+
|
|
39
|
+
const entries = useMemo<Entry[]>(() => Object.entries(defs)
|
|
40
|
+
.filter(([type]) => type !== 'page-content' || !counts[type])
|
|
41
|
+
.flatMap(([type, d]) => {
|
|
42
|
+
const category = d.category || (coded.has(type) ? 'More' : 'Basic');
|
|
43
|
+
const full = Boolean(d.limit && (counts[type] ?? 0) >= d.limit);
|
|
44
|
+
const base: Entry = { key: type, type, label: d.name || type, description: d.description || '', category, full, coded: coded.has(type) };
|
|
45
|
+
const presets = (d.presets ?? []).map((p, i): Entry => ({ ...base, key: `${type}#${i}`, label: p.name, description: p.description || `${d.name || type}, ready to fill in`, variant: p.variant, data: p.data }));
|
|
46
|
+
return [base, ...presets];
|
|
47
|
+
})
|
|
48
|
+
// the theme's own sections first; generic built-ins last
|
|
49
|
+
.sort((a, b) => Number(a.category === 'Basic') - Number(b.category === 'Basic')), [defs, coded, counts]);
|
|
50
|
+
const cats = useMemo(() => ['All', ...[...new Set(entries.map((e) => e.category))].sort((a, b) => (a === 'Basic' ? 1 : b === 'Basic' ? -1 : a.localeCompare(b)))], [entries]);
|
|
51
|
+
const shown = entries.filter((e) => (cat === 'All' || e.category === cat) && (!q || `${e.label} ${e.description} ${e.category}`.toLowerCase().includes(q.toLowerCase())));
|
|
52
|
+
const chosen = entries.find((e) => e.key === sel && !e.full);
|
|
53
|
+
const pick = (e: Entry | undefined) => { if (!e || e.full) return; onPick({ type: e.type, variant: e.variant, data: e.data, label: e.label }); onClose(); };
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<dialog ref={ref} className="modal te-picker" aria-labelledby="te-picker-title" onClose={onClose} onClick={(e) => { if (e.target === ref.current) onClose(); }}>
|
|
57
|
+
<div className="modal-head">
|
|
58
|
+
<span id="te-picker-title">Add a section</span>
|
|
59
|
+
<input className="te-picker-search" type="search" placeholder="Search sections…" aria-label="Search sections" value={q} onChange={(e) => setQ(e.target.value)} autoFocus />
|
|
60
|
+
<button className="modal-x" type="button" onClick={onClose} aria-label="Close">✕</button>
|
|
61
|
+
</div>
|
|
62
|
+
<div className="te-picker-body">
|
|
63
|
+
<nav className="te-picker-cats" aria-label="Categories">
|
|
64
|
+
{cats.map((c) => (
|
|
65
|
+
<button key={c} type="button" className={c === cat ? 'on' : ''} aria-pressed={c === cat} onClick={() => setCat(c)}>{c}</button>
|
|
66
|
+
))}
|
|
67
|
+
</nav>
|
|
68
|
+
<div className="te-picker-grid" role="listbox" aria-label="Sections">
|
|
69
|
+
{shown.length === 0 && <p className="te-muted">No sections match “{q}”.</p>}
|
|
70
|
+
{shown.map((e) => (
|
|
71
|
+
<button
|
|
72
|
+
key={e.key}
|
|
73
|
+
type="button"
|
|
74
|
+
role="option"
|
|
75
|
+
aria-selected={sel === e.key}
|
|
76
|
+
aria-disabled={e.full}
|
|
77
|
+
className={`te-tile ${sel === e.key ? 'on' : ''} ${e.full ? 'is-full' : ''}`}
|
|
78
|
+
onClick={() => setSel(e.key)}
|
|
79
|
+
onDoubleClick={() => pick(e)}
|
|
80
|
+
>
|
|
81
|
+
<Thumb type={e.type} variant={e.variant} theme={theme} />
|
|
82
|
+
<span className="te-tile-text">
|
|
83
|
+
<b>{e.label}</b>
|
|
84
|
+
<span>{e.full ? 'Already on this page' : e.description}</span>
|
|
85
|
+
{e.coded && <em>coded</em>}
|
|
86
|
+
</span>
|
|
87
|
+
</button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="modal-foot te-picker-foot">
|
|
92
|
+
<button className="btn ghost" type="button" onClick={onClose}>Cancel</button>
|
|
93
|
+
<button className="btn primary" type="button" disabled={!chosen} onClick={() => pick(chosen)}>
|
|
94
|
+
{chosen ? `Add ${chosen.label}` : 'Choose a section'}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</dialog>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Thumb({ type, variant, theme }: { type: string; variant?: string; theme: string }) {
|
|
102
|
+
const box = useRef<HTMLSpanElement>(null);
|
|
103
|
+
const [visible, setVisible] = useState(false);
|
|
104
|
+
const [scale, setScale] = useState(0.18);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const el = box.current;
|
|
107
|
+
if (!el) return;
|
|
108
|
+
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect(); } }, { rootMargin: '120px' });
|
|
109
|
+
io.observe(el);
|
|
110
|
+
const ro = new ResizeObserver(() => setScale(el.clientWidth / FRAME_W));
|
|
111
|
+
ro.observe(el);
|
|
112
|
+
return () => { io.disconnect(); ro.disconnect(); };
|
|
113
|
+
}, []);
|
|
114
|
+
const src = `/admin/section-preview?type=${encodeURIComponent(type)}&theme=${encodeURIComponent(theme)}${variant ? `&variant=${encodeURIComponent(variant)}` : ''}`;
|
|
115
|
+
return (
|
|
116
|
+
<span className="te-thumb-frame" ref={box} aria-hidden="true">
|
|
117
|
+
{visible && <iframe src={src} title="" tabIndex={-1} loading="lazy" style={{ width: FRAME_W, height: FRAME_H, transform: `scale(${scale})` }} />}
|
|
118
|
+
</span>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { translationUnits, unitStatus, translationSummary, textHash } from '../../theme-sdk/fields.mjs';
|
|
2
|
+
import { RichTextInput, type FieldDef } from './SectionFields';
|
|
3
|
+
|
|
4
|
+
// Translation mode for Design → Pages: a section's words in another language,
|
|
5
|
+
// English beside each one. Facts (times, photos, links, linked events) are
|
|
6
|
+
// shared and not shown here. Every value carries a state:
|
|
7
|
+
// AI draft — machine-written, shown to visitors, flagged for review
|
|
8
|
+
// reviewed — a person wrote or approved it; never overwritten by AI
|
|
9
|
+
// outdated — the English changed since (source hash differs)
|
|
10
|
+
|
|
11
|
+
export interface Translation { data: Record<string, string | number | boolean>; meta: Record<string, { state: 'ai' | 'reviewed'; src: string }> }
|
|
12
|
+
interface Def { name?: string; fields?: Record<string, FieldDef> }
|
|
13
|
+
interface Inst { id: string; type: string; data: Record<string, unknown>; translations?: Record<string, Translation>; locked?: boolean }
|
|
14
|
+
export interface Unit { path: string; label: string; kind: string; source: string; hash: string }
|
|
15
|
+
|
|
16
|
+
export const STATUS_LABEL: Record<string, string> = { missing: 'Not translated', ai: 'AI draft', reviewed: 'Reviewed', outdated: 'English changed' };
|
|
17
|
+
|
|
18
|
+
export const sectionUnits = (def: Def, s: Inst): Unit[] => translationUnits(def, s.data) as Unit[];
|
|
19
|
+
export const sectionSummary = (def: Def, s: Inst, lang: string) => translationSummary(def, s.data, s.translations?.[lang]) as { total: number; missing: number; ai: number; reviewed: number; outdated: number };
|
|
20
|
+
|
|
21
|
+
/** set one translated value (+ state) on a section, immutably */
|
|
22
|
+
export function withTranslation(s: Inst, lang: string, unit: Unit, value: string, state: 'ai' | 'reviewed'): Inst {
|
|
23
|
+
const t: Translation = s.translations?.[lang] ?? { data: {}, meta: {} };
|
|
24
|
+
const data = { ...t.data };
|
|
25
|
+
const meta = { ...t.meta };
|
|
26
|
+
if (value.trim()) { data[unit.path] = value; meta[unit.path] = { state, src: unit.hash }; }
|
|
27
|
+
else { delete data[unit.path]; delete meta[unit.path]; }
|
|
28
|
+
return { ...s, translations: { ...(s.translations ?? {}), [lang]: { data, meta } } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** mark a value right for the current English (reviewed) without changing it */
|
|
32
|
+
export function confirmTranslation(s: Inst, lang: string, unit: Unit): Inst {
|
|
33
|
+
const t = s.translations?.[lang];
|
|
34
|
+
const value = t?.data?.[unit.path];
|
|
35
|
+
return value === undefined ? s : withTranslation(s, lang, unit, String(value), 'reviewed');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** units a machine should (re)draft: missing, or outdated AI drafts. Reviewed text is never overwritten. */
|
|
39
|
+
export function unitsToDraft(def: Def, s: Inst, lang: string, includeOutdatedReviewed = false): Unit[] {
|
|
40
|
+
const t = s.translations?.[lang];
|
|
41
|
+
return sectionUnits(def, s).filter((u) => {
|
|
42
|
+
const st = unitStatus(u, t);
|
|
43
|
+
return st === 'missing' || (st === 'outdated' && (includeOutdatedReviewed || t?.meta?.[u.path]?.state !== 'reviewed'));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ask the server for machine drafts; returns id → text */
|
|
48
|
+
export async function requestDrafts(lang: string, items: { id: string; unit: Unit }[]): Promise<Record<string, string>> {
|
|
49
|
+
if (!items.length) return {};
|
|
50
|
+
const res = await fetch('/api/design/translate', {
|
|
51
|
+
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
52
|
+
body: JSON.stringify({ locale: lang, units: items.map(({ id, unit }) => ({ id, text: unit.source, kind: unit.kind, label: unit.label })) }),
|
|
53
|
+
});
|
|
54
|
+
const body = await res.json().catch(() => ({})) as { results?: Record<string, string>; error?: string };
|
|
55
|
+
if (!res.ok) throw new Error(body.error || `Translation failed (${res.status}).`);
|
|
56
|
+
return body.results ?? {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Guided merge: copy the words of a separately authored home page in another
|
|
61
|
+
* language (the older setup) into this page's translations. Sections match by
|
|
62
|
+
* type in order; list rows by position. Matched values are marked reviewed.
|
|
63
|
+
*/
|
|
64
|
+
export function planMerge(defs: Record<string, Def>, target: Inst[], separate: Inst[], lang: string) {
|
|
65
|
+
const used = new Set<number>();
|
|
66
|
+
let copied = 0, unmatched = 0;
|
|
67
|
+
const next = target.map((s) => {
|
|
68
|
+
const def = defs[s.type];
|
|
69
|
+
if (!def) return s;
|
|
70
|
+
const idx = separate.findIndex((x, i) => !used.has(i) && x.type === s.type);
|
|
71
|
+
if (idx < 0) { unmatched++; return s; }
|
|
72
|
+
used.add(idx);
|
|
73
|
+
const other = separate[idx];
|
|
74
|
+
let out = s;
|
|
75
|
+
// the other page's own words at the same path (its rows by position)
|
|
76
|
+
const theirs = new Map((translationUnits(def, other.data) as Unit[]).map((u) => [normalizePath(u.path, other), u.source]));
|
|
77
|
+
for (const u of sectionUnits(def, s)) {
|
|
78
|
+
const v = theirs.get(normalizePath(u.path, s));
|
|
79
|
+
if (v && v !== u.source && !s.translations?.[lang]?.data?.[u.path]) { out = withTranslation(out, lang, u, v, 'reviewed'); copied++; }
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
});
|
|
83
|
+
return { sections: next, copied, unmatched };
|
|
84
|
+
}
|
|
85
|
+
// "services.r4k2.note" → "services.<row index>.note" so rows match by position
|
|
86
|
+
function normalizePath(path: string, s?: Inst) {
|
|
87
|
+
const [key, rid, sub] = path.split('.');
|
|
88
|
+
if (!sub) return path;
|
|
89
|
+
if (rid.startsWith('#')) return `${key}.${rid.slice(1)}.${sub}`;
|
|
90
|
+
const rows = (s?.data?.[key] ?? []) as { _id?: string }[];
|
|
91
|
+
const i = rows.findIndex((r) => r?._id === rid);
|
|
92
|
+
return `${key}.${i < 0 ? rid : i}.${sub}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function TranslationFields({ def, section, lang, langName, readOnly, busyPaths, onSet, onConfirm, onDraft }: {
|
|
96
|
+
def: Def; section: Inst; lang: string; langName: string; readOnly: boolean;
|
|
97
|
+
busyPaths: Set<string>;
|
|
98
|
+
onSet: (unit: Unit, value: string) => void;
|
|
99
|
+
onConfirm: (unit: Unit) => void;
|
|
100
|
+
onDraft: ((units: Unit[]) => void) | null;
|
|
101
|
+
}) {
|
|
102
|
+
const units = sectionUnits(def, section);
|
|
103
|
+
const t = section.translations?.[lang];
|
|
104
|
+
if (!units.length) return <p className="te-muted">Nothing to translate here: this section has no words of its own (times, photos and linked content are shared).</p>;
|
|
105
|
+
return (
|
|
106
|
+
<div className="te-tr">
|
|
107
|
+
<div className="te-tr-head" aria-hidden="true"><span>English</span><span>{langName}</span></div>
|
|
108
|
+
{units.map((u) => {
|
|
109
|
+
const st = unitStatus(u, t);
|
|
110
|
+
const value = String(t?.data?.[u.path] ?? '');
|
|
111
|
+
const id = `tr-${section.id}-${u.path.replace(/[^a-z0-9]/gi, '-')}`;
|
|
112
|
+
const busy = busyPaths.has(`${section.id}::${u.path}`);
|
|
113
|
+
return (
|
|
114
|
+
<div key={u.path} className={`te-tr-row is-${st}`}>
|
|
115
|
+
<div className="te-tr-src">
|
|
116
|
+
<label htmlFor={id}>{u.label}</label>
|
|
117
|
+
{u.kind === 'richtext' ? <div className="te-tr-srctext" dangerouslySetInnerHTML={{ __html: u.source }} /> : <div className="te-tr-srctext">{u.source}</div>}
|
|
118
|
+
</div>
|
|
119
|
+
<div className="te-tr-dst">
|
|
120
|
+
<span className={`te-tr-state is-${st}`}>{busy ? 'Translating…' : STATUS_LABEL[st]}</span>
|
|
121
|
+
{u.kind === 'richtext' ? (
|
|
122
|
+
<fieldset disabled={readOnly} className="te-tr-rich"><RichTextInput id={id} value={value} onChange={(v) => onSet(u, String(v ?? ''))} /></fieldset>
|
|
123
|
+
) : u.kind === 'textarea' ? (
|
|
124
|
+
<textarea id={id} rows={3} value={value} disabled={readOnly} placeholder={st === 'missing' ? `${langName}…` : ''} onChange={(e) => onSet(u, e.target.value)} />
|
|
125
|
+
) : (
|
|
126
|
+
<input id={id} type="text" value={value} disabled={readOnly} placeholder={st === 'missing' ? `${langName}…` : ''} onChange={(e) => onSet(u, e.target.value)} />
|
|
127
|
+
)}
|
|
128
|
+
{!readOnly && (
|
|
129
|
+
<span className="te-tr-actions">
|
|
130
|
+
{(st === 'ai' || st === 'outdated') && <button type="button" onClick={() => onConfirm(u)}>{st === 'ai' ? '✓ Looks right' : '✓ Still right'}</button>}
|
|
131
|
+
{onDraft && st !== 'reviewed' && <button type="button" disabled={busy} onClick={() => onDraft([u])}>{st === 'missing' ? 'Translate with AI' : 'Re-translate'}</button>}
|
|
132
|
+
{onDraft && st === 'reviewed' && <button type="button" className="quiet" disabled={busy} onClick={() => onDraft([u])}>Re-translate</button>}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { textHash };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fieldVisible, normalizeData, normalizeValue, valueIssues, translationSummary } from '../../theme-sdk/fields.mjs';
|
|
2
|
+
import type { FieldDef, SectionTargets } from './SectionFields';
|
|
3
|
+
|
|
4
|
+
// Pre-publish checklist for section layouts. Errors (invalid values) block
|
|
5
|
+
// publishing; warnings (missing image descriptions, dead-end buttons, links
|
|
6
|
+
// to unpublished pages) can be published anyway.
|
|
7
|
+
|
|
8
|
+
export interface Check { level: 'error' | 'warn'; pageId: string; sectionId: string; section: string; message: string }
|
|
9
|
+
interface Def { name?: string; fields?: Record<string, FieldDef> }
|
|
10
|
+
interface Instance { id: string; type: string; hidden: boolean; data: Record<string, unknown>; translations?: Record<string, unknown> }
|
|
11
|
+
interface Layout { mode: string; sections: Instance[] }
|
|
12
|
+
|
|
13
|
+
// the image's description field: "<key>Alt" (e.g. photoAlt), else a sibling "alt"
|
|
14
|
+
const altFor = (key: string, fields: Record<string, FieldDef>) => [`${key}Alt`, 'alt'].find((k) => k !== key && k in fields);
|
|
15
|
+
|
|
16
|
+
export function checkLayouts(pages: Record<string, Layout>, defs: Record<string, Def>, targets: SectionTargets, only?: { pageId: string; sectionId?: string }): Check[] {
|
|
17
|
+
const out: Check[] = [];
|
|
18
|
+
const live = new Set(targets.links.map((l) => l.ref));
|
|
19
|
+
for (const [pageId, layout] of Object.entries(pages)) {
|
|
20
|
+
if (only && only.pageId !== pageId) continue;
|
|
21
|
+
if (layout.mode !== 'sections') continue;
|
|
22
|
+
for (const s of layout.sections) {
|
|
23
|
+
if (s.hidden || (only?.sectionId && only.sectionId !== s.id)) continue;
|
|
24
|
+
const def = defs[s.type];
|
|
25
|
+
if (!def) continue;
|
|
26
|
+
const name = def.name || s.type;
|
|
27
|
+
const add = (level: Check['level'], message: string) => out.push({ level, pageId, sectionId: s.id, section: name, message });
|
|
28
|
+
const fields = def.fields ?? {};
|
|
29
|
+
const data = normalizeData(def, s.data) as Record<string, unknown>;
|
|
30
|
+
const scan = (scopeFields: Record<string, FieldDef>, scope: Record<string, unknown>, where: string) => {
|
|
31
|
+
for (const [key, f] of Object.entries(scopeFields)) {
|
|
32
|
+
if (!fieldVisible(f, scope)) continue;
|
|
33
|
+
const v = normalizeValue(f, scope[key]);
|
|
34
|
+
if (f.type === 'image' && typeof v === 'string' && v.trim()) {
|
|
35
|
+
const alt = altFor(key, scopeFields);
|
|
36
|
+
if (alt && !String(scope[alt] ?? '').trim()) add('warn', `${where}${f.label} has no description for screen readers`);
|
|
37
|
+
}
|
|
38
|
+
if (f.type === 'link' && v && typeof v === 'object') {
|
|
39
|
+
const l = v as { label?: string; href?: string; ref?: string };
|
|
40
|
+
const label = String(l.label ?? '').trim(), href = String(l.href ?? '').trim();
|
|
41
|
+
if (label && !href) add('warn', `${where}“${label}” button doesn’t go anywhere`);
|
|
42
|
+
if (!label && href) add('warn', `${where}${f.label} has a destination but no text, so it won’t show`);
|
|
43
|
+
if (l.ref && !live.has(l.ref)) add('warn', `${where}“${label || f.label}” links to a page that isn’t published`);
|
|
44
|
+
}
|
|
45
|
+
if (f.type === 'list' && Array.isArray(v)) {
|
|
46
|
+
(v as Record<string, unknown>[]).forEach((row, i) => scan(f.fields, row, `${where}${f.label} ${i + 1} › `));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
for (const [key, f] of Object.entries(fields)) {
|
|
51
|
+
if (fieldVisible(f, data)) for (const m of valueIssues(f, data[key])) add('error', m);
|
|
52
|
+
}
|
|
53
|
+
scan(fields, data, '');
|
|
54
|
+
// other languages: missing or stale translations (visitors see the main language there)
|
|
55
|
+
for (const l of targets.i18n?.locales ?? []) {
|
|
56
|
+
const sum = translationSummary(def, s.data, s.translations?.[l.code]) as { total: number; missing: number; outdated: number; ai: number };
|
|
57
|
+
if (!sum.total) continue;
|
|
58
|
+
const bits = [sum.missing && `${sum.missing} not translated`, sum.outdated && `${sum.outdated} out of date`, sum.ai && `${sum.ai} AI ${sum.ai === 1 ? 'draft' : 'drafts'} not reviewed`].filter(Boolean);
|
|
59
|
+
if (bits.length) add('warn', `${l.name}: ${bits.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { roleLabels } from '../lib/roles';
|
|
2
3
|
import { getRepo } from '../lib/repo';
|
|
3
4
|
import { BRAND } from '../lib/brand';
|
|
4
5
|
import { CONTENT } from '../lib/content';
|
|
5
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
|
+
import { PUSH } from '../lib/push/config';
|
|
6
8
|
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../lib/auth';
|
|
7
9
|
import ThemeScript from '../components/ThemeScript.astro';
|
|
8
10
|
import CommandPalette from '../components/CommandPalette';
|
|
@@ -31,7 +33,7 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
|
31
33
|
const pendingBookings = bookingsEnabled() ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'] })).length : 0;
|
|
32
34
|
const me = {
|
|
33
35
|
name: sessionUser?.name || 'Admin',
|
|
34
|
-
role: sessionUserId === OWNER_ENV ? '
|
|
36
|
+
role: roleLabels[sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'],
|
|
35
37
|
avatar: sessionUser?.avatar || '',
|
|
36
38
|
};
|
|
37
39
|
const initials = me.name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase();
|
|
@@ -67,6 +69,7 @@ const icons: Record<string, string> = {
|
|
|
67
69
|
bookings: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
68
70
|
resources: 'M3 21V9l9-5 9 5v12M9 21v-6h6v6',
|
|
69
71
|
plug: 'M9 7V3M15 7V3M6 7h12v4a6 6 0 0 1-12 0zM12 17v4',
|
|
72
|
+
phone: 'M7 2.5h10a1.5 1.5 0 0 1 1.5 1.5v16a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 20V4A1.5 1.5 0 0 1 7 2.5zM10.5 18.5h3',
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
// one sidebar entry per configured collection, in config order
|
|
@@ -125,6 +128,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
125
128
|
{ key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
|
|
126
129
|
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
127
130
|
{ key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
|
|
131
|
+
// push notifications: only when acms({ mobile: { push } }) is on, owners only
|
|
132
|
+
...(PUSH.enabled && me.role === 'Owner' ? [{ key: 'mobile', label: 'Mobile app', href: '/admin/mobile', icon: icons.phone, count: null }] : []),
|
|
128
133
|
],
|
|
129
134
|
},
|
|
130
135
|
{
|
package/src/layouts/Site.astro
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveDesign } from '../lib/theme';
|
|
|
5
5
|
import { siteOrigin, absUrl, canonicalPath } from '../lib/seo';
|
|
6
6
|
import { siteLang } from '../lib/dates';
|
|
7
7
|
import { CONTENT } from '../lib/content';
|
|
8
|
-
import { I18N, localeFromPath, localeHomeUrl } from '../lib/i18n';
|
|
8
|
+
import { I18N, localeFromPath, localeHomeUrl, localePath, requestLocale, stripLocale } from '../lib/i18n';
|
|
9
9
|
import { LEGAL } from '../lib/legal';
|
|
10
10
|
import { SEARCH } from '../lib/search-config';
|
|
11
11
|
import { searchStrings } from '../lib/search-i18n';
|
|
@@ -89,7 +89,8 @@ const tokens = design.tokens;
|
|
|
89
89
|
// the visitor scheme toggle only exists when the theme follows the OS
|
|
90
90
|
const schemeToggle = tokens.scheme === 'auto';
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
// the path the visitor sees (/es/… is rewritten to the shared route by the middleware)
|
|
93
|
+
const current = (Astro.locals as { acmsPath?: string }).acmsPath ?? Astro.url.pathname;
|
|
93
94
|
const fullTitle = exactTitle && title ? title : title ? `${title} · ${settings.siteTitle}` : settings.siteTitle;
|
|
94
95
|
// the columns footer already shows the tagline beside the brand, so its
|
|
95
96
|
// default bottom line is just the copyright
|
|
@@ -113,7 +114,11 @@ const canonical = origin + canonicalPath(current);
|
|
|
113
114
|
const metaDescription = description || settings.tagline || undefined;
|
|
114
115
|
// with i18n configured, the request path decides the document language; the
|
|
115
116
|
// locale setting (a date-formatting concern) stays the fallback
|
|
116
|
-
const reqLocale = I18N.enabled ?
|
|
117
|
+
const reqLocale = I18N.enabled ? requestLocale(new URL(current, Astro.url), Astro.locals) : '';
|
|
118
|
+
// same page, other language; internal links stay in the visitor's language
|
|
119
|
+
const barePath = stripLocale(current);
|
|
120
|
+
const inLang = reqLocale && reqLocale !== I18N.defaultLocale ? reqLocale : '';
|
|
121
|
+
const lnk = (url: string) => (inLang && /^\/(?!\/|media\/|rss\.xml|sitemap|admin|api|_)/.test(url) ? localePath(url, inLang) : url);
|
|
117
122
|
const htmlLang = reqLocale || siteLang(settings);
|
|
118
123
|
// search modal chrome: strings follow the document language, the form's
|
|
119
124
|
// no-JS fallback goes to the (locale-prefixed) /search page
|
|
@@ -131,9 +136,9 @@ const readLinks = [
|
|
|
131
136
|
...(SEARCH.enabled ? [{ label: 'Search', url: searchAction }] : []),
|
|
132
137
|
{ label: 'RSS', url: '/rss.xml' },
|
|
133
138
|
];
|
|
134
|
-
// hreflang alternates
|
|
135
|
-
//
|
|
136
|
-
const isHomePath = I18N.enabled &&
|
|
139
|
+
// hreflang alternates: every public page exists in each language (translated
|
|
140
|
+
// words where available, the main language otherwise)
|
|
141
|
+
const isHomePath = I18N.enabled && !noindex && !bare;
|
|
137
142
|
// analytics tags render wherever headCode does (bare pages and previews too)
|
|
138
143
|
const analyticsHtml = analyticsHead(settings);
|
|
139
144
|
const analyticsBodyHtml = analyticsBody(settings);
|
|
@@ -165,9 +170,9 @@ const homeJsonLd =
|
|
|
165
170
|
{noindex && <meta name="robots" content="noindex" />}
|
|
166
171
|
<link rel="canonical" href={canonical} />
|
|
167
172
|
{isHomePath && I18N.locales.map((l) => (
|
|
168
|
-
<link rel="alternate" hreflang={l} href={origin +
|
|
173
|
+
<link rel="alternate" hreflang={l} href={origin + localePath(barePath, l)} />
|
|
169
174
|
))}
|
|
170
|
-
{isHomePath && <link rel="alternate" hreflang="x-default" href={origin +
|
|
175
|
+
{isHomePath && <link rel="alternate" hreflang="x-default" href={origin + barePath} />}
|
|
171
176
|
{settings.siteFavicon && <link rel="icon" href={settings.siteFavicon} type={faviconType} />}
|
|
172
177
|
<link rel="alternate" type="application/rss+xml" title={settings.siteTitle} href={`${origin}/rss.xml`} />
|
|
173
178
|
<link rel="sitemap" href={`${origin}/sitemap.xml`} />
|
|
@@ -250,8 +255,8 @@ const homeJsonLd =
|
|
|
250
255
|
<nav class="site-nav" aria-label="Main">
|
|
251
256
|
{headerLinks.map((n) => (
|
|
252
257
|
<a
|
|
253
|
-
href={n.url}
|
|
254
|
-
aria-current={isCurrent(n.url) ? 'page' : undefined}
|
|
258
|
+
href={lnk(n.url)}
|
|
259
|
+
aria-current={isCurrent(lnk(n.url)) ? 'page' : undefined}
|
|
255
260
|
target={isExternal(n.url) ? '_blank' : undefined}
|
|
256
261
|
rel={isExternal(n.url) ? 'noopener' : undefined}
|
|
257
262
|
>{n.label}</a>
|
|
@@ -270,7 +275,7 @@ const homeJsonLd =
|
|
|
270
275
|
<span class="lang-switch" role="group" aria-label="Language">
|
|
271
276
|
{I18N.locales.map((l) => (
|
|
272
277
|
<a
|
|
273
|
-
href={
|
|
278
|
+
href={localePath(barePath, l)}
|
|
274
279
|
lang={l}
|
|
275
280
|
class:list={[{ on: l === (reqLocale || I18N.defaultLocale) }]}
|
|
276
281
|
aria-current={l === (reqLocale || I18N.defaultLocale) ? 'true' : undefined}
|
|
@@ -297,8 +302,8 @@ const homeJsonLd =
|
|
|
297
302
|
<nav aria-label="Main">
|
|
298
303
|
{headerLinks.map((n) => (
|
|
299
304
|
<a
|
|
300
|
-
href={n.url}
|
|
301
|
-
aria-current={isCurrent(n.url) ? 'page' : undefined}
|
|
305
|
+
href={lnk(n.url)}
|
|
306
|
+
aria-current={isCurrent(lnk(n.url)) ? 'page' : undefined}
|
|
302
307
|
target={isExternal(n.url) ? '_blank' : undefined}
|
|
303
308
|
rel={isExternal(n.url) ? 'noopener' : undefined}
|
|
304
309
|
>{n.label}<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg></a>
|
|
@@ -308,7 +313,7 @@ const homeJsonLd =
|
|
|
308
313
|
{I18N.enabled ? (
|
|
309
314
|
<span class="lang-switch" role="group" aria-label="Language">
|
|
310
315
|
{I18N.locales.map((l) => (
|
|
311
|
-
<a href={
|
|
316
|
+
<a href={localePath(barePath, l)} lang={l} class:list={[{ on: l === (reqLocale || I18N.defaultLocale) }]}>{l.toUpperCase()}</a>
|
|
312
317
|
))}
|
|
313
318
|
</span>
|
|
314
319
|
) : <span />}
|
|
@@ -553,6 +558,89 @@ const homeJsonLd =
|
|
|
553
558
|
})();
|
|
554
559
|
</script>
|
|
555
560
|
)}
|
|
561
|
+
{preview.role === 'admin' && !bare && (
|
|
562
|
+
<script is:inline>
|
|
563
|
+
addEventListener('DOMContentLoaded', () => {
|
|
564
|
+
// Click-to-edit + live preview for coded sections (admin preview
|
|
565
|
+
// only). Inside the Design screen's iframe a click opens that
|
|
566
|
+
// section's form and unsaved edits are swapped in as you type; in
|
|
567
|
+
// a normal tab the chip links to the Design screen instead.
|
|
568
|
+
const framed = window.top !== window;
|
|
569
|
+
if (!document.querySelector('[data-acms-section]') && !framed) return;
|
|
570
|
+
const css = document.createElement('style');
|
|
571
|
+
css.textContent = `
|
|
572
|
+
[data-acms-section]{position:relative;transition:outline-color .12s}
|
|
573
|
+
[data-acms-section]:hover,[data-acms-section].acms-sel{outline:2px solid rgb(238 93 32/.55);outline-offset:-2px}
|
|
574
|
+
[data-acms-section].acms-sel{outline-color:#ee5d20}
|
|
575
|
+
${framed ? '[data-acms-section]{cursor:pointer}' : ''}
|
|
576
|
+
.acms-edit-chip{position:absolute;top:8px;right:8px;z-index:50;display:none;align-items:center;gap:6px;font:600 12px/1 system-ui,sans-serif;
|
|
577
|
+
background:#ee5d20;color:#fff;border:0;border-radius:8px;padding:7px 10px;cursor:pointer;text-decoration:none;box-shadow:0 2px 8px rgb(0 0 0/.18)}
|
|
578
|
+
[data-acms-section]:hover>.acms-edit-chip,[data-acms-section].acms-sel>.acms-edit-chip,.acms-edit-chip:focus-visible{display:inline-flex}
|
|
579
|
+
@keyframes acms-flash{from{box-shadow:inset 0 0 0 9999px rgb(238 93 32/.10)}to{box-shadow:inset 0 0 0 9999px rgb(238 93 32/0)}}
|
|
580
|
+
.acms-flash{animation:acms-flash .9s ease-out}`;
|
|
581
|
+
document.head.append(css);
|
|
582
|
+
let selected = null;
|
|
583
|
+
const open = (el) => {
|
|
584
|
+
const msg = { type: 'acms-section-edit', pageId: el.dataset.acmsPage, sectionId: el.dataset.acmsSection };
|
|
585
|
+
if (framed) { parent.postMessage(msg, location.origin); select(el.dataset.acmsSection, false); }
|
|
586
|
+
else location.href = `/admin/design?page=${encodeURIComponent(msg.pageId)}§ion=${encodeURIComponent(msg.sectionId)}#pages`;
|
|
587
|
+
};
|
|
588
|
+
const select = (id, scroll) => {
|
|
589
|
+
selected = id;
|
|
590
|
+
for (const el of document.querySelectorAll('[data-acms-section]')) {
|
|
591
|
+
const on = el.dataset.acmsSection === id;
|
|
592
|
+
el.classList.toggle('acms-sel', on);
|
|
593
|
+
if (on && scroll) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
const decorate = (root) => {
|
|
597
|
+
for (const el of root.querySelectorAll('[data-acms-section]')) {
|
|
598
|
+
if (el.querySelector(':scope > .acms-edit-chip')) continue;
|
|
599
|
+
const chip = document.createElement('button');
|
|
600
|
+
chip.type = 'button';
|
|
601
|
+
chip.className = 'acms-edit-chip';
|
|
602
|
+
chip.textContent = `Edit · ${el.dataset.acmsLabel || 'section'}`;
|
|
603
|
+
chip.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); open(el); });
|
|
604
|
+
el.append(chip);
|
|
605
|
+
if (framed) el.addEventListener('click', (e) => {
|
|
606
|
+
if (e.target.closest('a,button,input,select,textarea,summary,label,video,iframe')) return;
|
|
607
|
+
open(el);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
decorate(document);
|
|
612
|
+
// live preview: the editor sends a freshly rendered document for
|
|
613
|
+
// the page with its unsaved layout; swap the sections in place
|
|
614
|
+
const swap = (html) => {
|
|
615
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
616
|
+
const next = doc.querySelector('.acms-sections');
|
|
617
|
+
const current = document.querySelector('.acms-sections');
|
|
618
|
+
if (!next || !current) { parent.postMessage({ type: 'acms-preview-stale' }, location.origin); return; }
|
|
619
|
+
// styles for section types that weren't on the page yet
|
|
620
|
+
const have = new Set([...document.querySelectorAll('head style, head link[rel=stylesheet]')].map((n) => n.outerHTML));
|
|
621
|
+
for (const n of doc.querySelectorAll('head style, head link[rel=stylesheet]')) if (!have.has(n.outerHTML)) document.head.append(document.importNode(n, true));
|
|
622
|
+
const before = new Map([...current.querySelectorAll(':scope > [data-acms-section]')].map((el) => [el.dataset.acmsSection, el.innerHTML.replace(/<button type="button" class="acms-edit-chip">[^<]*<\/button>/, '')]));
|
|
623
|
+
const fresh = document.importNode(next, true);
|
|
624
|
+
current.replaceWith(fresh);
|
|
625
|
+
decorate(fresh);
|
|
626
|
+
for (const el of fresh.querySelectorAll(':scope > [data-acms-section]')) {
|
|
627
|
+
const id = el.dataset.acmsSection;
|
|
628
|
+
const old = before.get(id);
|
|
629
|
+
if (old !== undefined && old === el.innerHTML.replace(/<button type="button" class="acms-edit-chip">[^<]*<\/button>/, '')) continue;
|
|
630
|
+
el.classList.add('acms-flash');
|
|
631
|
+
setTimeout(() => el.classList.remove('acms-flash'), 950);
|
|
632
|
+
}
|
|
633
|
+
if (selected) select(selected, false);
|
|
634
|
+
};
|
|
635
|
+
addEventListener('message', (e) => {
|
|
636
|
+
if (e.origin !== location.origin) return;
|
|
637
|
+
if (e.data?.type === 'acms-section-focus') select(e.data.sectionId, true);
|
|
638
|
+
if (e.data?.type === 'acms-section-html' && typeof e.data.html === 'string') swap(e.data.html);
|
|
639
|
+
});
|
|
640
|
+
if (framed) parent.postMessage({ type: 'acms-sections-ready', path: location.pathname }, location.origin);
|
|
641
|
+
});
|
|
642
|
+
</script>
|
|
643
|
+
)}
|
|
556
644
|
<main class="site-main" id="main">
|
|
557
645
|
<slot />
|
|
558
646
|
</main>
|
|
@@ -567,7 +655,7 @@ const homeJsonLd =
|
|
|
567
655
|
{socialNav.length > 0 && (
|
|
568
656
|
<nav class="social-nav" aria-label="Social">
|
|
569
657
|
{socialNav.map((n) => (
|
|
570
|
-
<a href={n.url} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} title={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
|
|
658
|
+
<a href={lnk(n.url)} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} title={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
|
|
571
659
|
{n.icon ? <Fragment set:html={n.icon} /> : n.label}
|
|
572
660
|
</a>
|
|
573
661
|
))}
|
|
@@ -576,13 +664,13 @@ const homeJsonLd =
|
|
|
576
664
|
</div>
|
|
577
665
|
<nav class="footer-col" aria-label="Read">
|
|
578
666
|
<h2>Read</h2>
|
|
579
|
-
{readLinks.map((n) => <a href={n.url}>{n.label}</a>)}
|
|
667
|
+
{readLinks.map((n) => <a href={lnk(n.url)}>{n.label}</a>)}
|
|
580
668
|
</nav>
|
|
581
669
|
{footerNav.length > 0 && (
|
|
582
670
|
<nav class="footer-col" aria-label="Site">
|
|
583
671
|
<h2>Site</h2>
|
|
584
672
|
{footerNav.map((n) => (
|
|
585
|
-
<a href={n.url} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
|
|
673
|
+
<a href={lnk(n.url)} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
|
|
586
674
|
))}
|
|
587
675
|
</nav>
|
|
588
676
|
)}
|
|
@@ -592,14 +680,14 @@ const homeJsonLd =
|
|
|
592
680
|
{footerNav.length > 0 && (
|
|
593
681
|
<nav class="footer-nav">
|
|
594
682
|
{footerNav.map((n) => (
|
|
595
|
-
<a href={n.url} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
|
|
683
|
+
<a href={lnk(n.url)} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
|
|
596
684
|
))}
|
|
597
685
|
</nav>
|
|
598
686
|
)}
|
|
599
687
|
{socialNav.length > 0 && (
|
|
600
688
|
<nav class="social-nav" aria-label="Social">
|
|
601
689
|
{socialNav.map((n) => (
|
|
602
|
-
<a href={n.url} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
|
|
690
|
+
<a href={lnk(n.url)} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
|
|
603
691
|
{n.icon ? <Fragment set:html={n.icon} /> : n.label}
|
|
604
692
|
</a>
|
|
605
693
|
))}
|