@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4
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 +105 -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/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- 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 +17 -13
- 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 +205 -174
- 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/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- 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 +11 -7
- package/src/layouts/Site.astro +136 -21
- 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/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -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 +26 -14
- 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 +14 -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 +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -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 +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- 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/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- 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/book/[resource].ics.ts +7 -1
- 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 +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- 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 +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -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
|
|
@@ -106,15 +109,14 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
106
109
|
? [{
|
|
107
110
|
id: 'calendar',
|
|
108
111
|
label: 'Calendar',
|
|
112
|
+
// two homes: Schedule (anything on a date — grid, list, calendar
|
|
113
|
+
// settings) and Bookings (anything people reserve — requests, rooms &
|
|
114
|
+
// people, booking links). Sub-pages highlight their home.
|
|
109
115
|
items: [
|
|
110
|
-
{ key: 'calendar:
|
|
116
|
+
{ key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
|
|
111
117
|
...(bookingsEnabled()
|
|
112
|
-
? [
|
|
113
|
-
{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 },
|
|
114
|
-
{ key: 'calendar:resources', label: 'Resources', href: '/admin/calendar/resources', icon: icons.resources, count: null },
|
|
115
|
-
]
|
|
118
|
+
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
116
119
|
: []),
|
|
117
|
-
{ key: 'calendar:calendars', label: 'Calendars', href: '/admin/calendar/calendars', icon: icons.calendars, count: null },
|
|
118
120
|
],
|
|
119
121
|
}]
|
|
120
122
|
: []),
|
|
@@ -125,6 +127,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
125
127
|
{ key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
|
|
126
128
|
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
127
129
|
{ key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
|
|
130
|
+
// push notifications: only when acms({ mobile: { push } }) is on, owners only
|
|
131
|
+
...(PUSH.enabled && me.role === 'Owner' ? [{ key: 'mobile', label: 'Mobile app', href: '/admin/mobile', icon: icons.phone, count: null }] : []),
|
|
128
132
|
],
|
|
129
133
|
},
|
|
130
134
|
{
|