@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
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
|
3
|
+
import type { DragEndEvent } from '@dnd-kit/core';
|
|
4
|
+
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
5
|
+
import { CSS as DndCSS } from '@dnd-kit/utilities';
|
|
2
6
|
import { ImagePicker } from './editor/ImagePicker';
|
|
7
|
+
import { FieldInput, EMPTY_TARGETS, type FieldDef, type SectionTargets } from './sections/SectionFields';
|
|
8
|
+
import { SectionPicker, type PickerChoice } from './sections/SectionPicker';
|
|
9
|
+
import { checkLayouts } from './sections/checks';
|
|
10
|
+
import { TranslationFields, sectionSummary, withTranslation, confirmTranslation, unitsToDraft, requestDrafts, planMerge, type Unit } from './sections/TranslationPanel';
|
|
11
|
+
import { defaultValue, fieldVisible, initialData, normalizeData, normalizeValue, orphanKeys, valueIssues } from '../theme-sdk/fields.mjs';
|
|
3
12
|
|
|
4
13
|
// Pages tab of the Design screen: lay out a page from the active theme's
|
|
5
14
|
// section registry (builtins + the theme's own sections). Edits stay local
|
|
6
|
-
//
|
|
15
|
+
// autosave into the design draft (1.2s after the last edit) through
|
|
7
16
|
// POST /api/design; nothing here publishes, and a page's own content is
|
|
8
17
|
// never touched (switching a page back to "Page content" keeps its layout).
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
interface
|
|
13
|
-
type SectionValue = string | number;
|
|
19
|
+
interface SectionDef { name?: string; label?: string; description?: string; version?: number; variants?: string[]; fields?: Record<string, FieldDef>; category?: string; limit?: number; example?: Record<string, unknown>; presets?: { name: string; description?: string; variant?: string; data: Record<string, unknown> }[] }
|
|
20
|
+
type SectionValue = unknown;
|
|
21
|
+
interface Preset { name: string; description?: string; sections: { type: string; variant?: string; hidden?: boolean; data: Record<string, SectionValue> }[] }
|
|
14
22
|
export interface SectionInstance {
|
|
15
23
|
id: string;
|
|
16
24
|
type: string;
|
|
@@ -18,6 +26,10 @@ export interface SectionInstance {
|
|
|
18
26
|
variant: string;
|
|
19
27
|
hidden: boolean;
|
|
20
28
|
data: Record<string, SectionValue>;
|
|
29
|
+
showFrom?: string;
|
|
30
|
+
showUntil?: string;
|
|
31
|
+
locked?: boolean;
|
|
32
|
+
translations?: Record<string, { data: Record<string, string | number | boolean>; meta: Record<string, { state: 'ai' | 'reviewed'; src: string }> }>;
|
|
21
33
|
}
|
|
22
34
|
export interface PageLayout { mode: 'sections' | 'content'; sections: SectionInstance[] }
|
|
23
35
|
export type SectionPages = Record<string, PageLayout>;
|
|
@@ -27,8 +39,9 @@ export interface DesignApiState {
|
|
|
27
39
|
live: DesignSide;
|
|
28
40
|
draft: DesignSide | null;
|
|
29
41
|
changes: { key: string; label: string; from: string; to: string; kind: string }[];
|
|
30
|
-
themes: { slug: string; name: string; version: string | number; sections: Record<string, SectionDef> }[];
|
|
31
|
-
|
|
42
|
+
themes: { slug: string; name: string; version: string | number; sections: Record<string, SectionDef>; coded?: string[]; migratable?: string[]; presets?: Record<string, Preset> }[];
|
|
43
|
+
targets?: SectionTargets;
|
|
44
|
+
pages: { id: string; title: string; slug: string; isHome: boolean; locale?: string }[];
|
|
32
45
|
versions: { id: string; createdAt: number; author: string; summary: string }[];
|
|
33
46
|
scopes: string[];
|
|
34
47
|
}
|
|
@@ -46,6 +59,8 @@ export function useDesignApi() {
|
|
|
46
59
|
const [data, setData] = useState<DesignApiState | null>(null);
|
|
47
60
|
const [error, setError] = useState('');
|
|
48
61
|
const [loading, setLoading] = useState(true);
|
|
62
|
+
// bumps only on a real (re)load, so local patches don't reset editors
|
|
63
|
+
const [loadCount, setLoadCount] = useState(0);
|
|
49
64
|
const load = useCallback(async () => {
|
|
50
65
|
setLoading(true);
|
|
51
66
|
setError('');
|
|
@@ -53,6 +68,7 @@ export function useDesignApi() {
|
|
|
53
68
|
const res = await fetch('/api/design', { headers: { accept: 'application/json' } });
|
|
54
69
|
if (!res.ok) { setError(await errorText(res, `Couldn't load the design (${res.status}).`)); return; }
|
|
55
70
|
setData((await res.json()) as DesignApiState);
|
|
71
|
+
setLoadCount((n) => n + 1);
|
|
56
72
|
} catch {
|
|
57
73
|
setError("Couldn't reach the server.");
|
|
58
74
|
} finally {
|
|
@@ -60,7 +76,9 @@ export function useDesignApi() {
|
|
|
60
76
|
}
|
|
61
77
|
}, []);
|
|
62
78
|
useEffect(() => { void load(); }, [load]);
|
|
63
|
-
|
|
79
|
+
/** update local state after our own write (e.g. an autosave) without a refetch */
|
|
80
|
+
const patch = useCallback((fn: (d: DesignApiState) => DesignApiState) => setData((d) => (d ? fn(d) : d)), []);
|
|
81
|
+
return { data, error, loading, loadCount, reload: load, patch };
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
const stagedPages = (d: DesignApiState): SectionPages =>
|
|
@@ -75,43 +93,150 @@ const newId = (type: string) => {
|
|
|
75
93
|
return `${type}-${rand}`;
|
|
76
94
|
};
|
|
77
95
|
|
|
78
|
-
const defaultsFor = (def: SectionDef): Record<string, SectionValue> =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
const defaultsFor = (def: SectionDef): Record<string, SectionValue> => initialData(def) as Record<string, SectionValue>;
|
|
97
|
+
const CLIP_KEY = 'acms-section-clipboard';
|
|
98
|
+
// schedules are stored as site wall-clock "YYYY-MM-DDTHH:MM"; datetime-local speaks the same format
|
|
99
|
+
const toLocalInput = (v?: string) => (v ? (v.length === 10 ? `${v}T00:00` : v) : '');
|
|
100
|
+
const fromLocalInput = (v: string) => (v ? v.slice(0, 16) : undefined);
|
|
101
|
+
const scheduleText = (s: SectionInstance) => {
|
|
102
|
+
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
|
103
|
+
if (s.showFrom && now < toLocalInput(s.showFrom)) return 'Hidden now · appears on ' + toLocalInput(s.showFrom).replace('T', ' at ');
|
|
104
|
+
if (s.showUntil && now >= toLocalInput(s.showUntil)) return 'Ended · no longer shown';
|
|
105
|
+
if (s.showUntil) return 'Showing now · until ' + toLocalInput(s.showUntil).replace('T', ' at ');
|
|
106
|
+
return s.showFrom ? 'Showing now' : 'Pick dates in your site’s timezone';
|
|
107
|
+
};
|
|
108
|
+
/** the section's other filled-in text fields, as context for AI help */
|
|
109
|
+
const sectionContext = (def: SectionDef, s: SectionInstance, except: string) =>
|
|
110
|
+
Object.entries(def.fields ?? {}).filter(([k, f]) => k !== except && ['text', 'textarea', 'richtext'].includes(f.type) && typeof s.data[k] === 'string' && String(s.data[k]).trim())
|
|
111
|
+
.map(([k, f]) => `${f.label}: ${String(s.data[k]).replace(/<[^>]+>/g, ' ').slice(0, 300)}`).join('\n').slice(0, 3000);
|
|
112
|
+
/** validation messages per field key (lists report at the list) */
|
|
113
|
+
const fieldIssues = (def: SectionDef, s: SectionInstance): Record<string, string[]> => {
|
|
114
|
+
const data = normalizeData(def, s.data) as Record<string, unknown>;
|
|
115
|
+
return Object.fromEntries(Object.entries(def.fields ?? {}).map(([k, f]) => [k, fieldVisible(f, data) ? valueIssues(f, data[k]) : []]));
|
|
84
116
|
};
|
|
117
|
+
const hasSource = (def: SectionDef | undefined) => Object.values(def?.fields ?? {}).some((f) => f.type === 'source');
|
|
85
118
|
|
|
86
119
|
const pagePath = (p: { slug: string; isHome: boolean }) => (p.isHome ? '/' : `/${p.slug}`);
|
|
87
120
|
|
|
121
|
+
export interface SectionFocus { pageId: string; sectionId: string; n: number }
|
|
122
|
+
|
|
88
123
|
interface EditorProps {
|
|
89
124
|
api: ReturnType<typeof useDesignApi>;
|
|
90
125
|
/** theme picked in the Theme tab (may be ahead of the staged draft) */
|
|
91
126
|
activeTheme: string;
|
|
92
127
|
unsplashEnabled: boolean;
|
|
128
|
+
/** open a section (click-to-edit in the preview, or ?page=§ion= links) */
|
|
129
|
+
focus?: SectionFocus | null;
|
|
130
|
+
/** the open section changed: the preview highlights it */
|
|
131
|
+
onOpenSection?: (pageId: string, sectionId: string | null, path: string) => void;
|
|
132
|
+
/** a save or publish landed: refresh the preview */
|
|
133
|
+
onSaved?: () => void;
|
|
134
|
+
/** freshly rendered sections for the unsaved layout: swap them into the preview */
|
|
135
|
+
onLiveHtml?: (html: string) => void;
|
|
136
|
+
/** the Design screen's Publish flushes pending section edits through this */
|
|
137
|
+
flushRef?: { current: () => Promise<boolean> };
|
|
93
138
|
}
|
|
94
139
|
|
|
95
|
-
export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled }: EditorProps) {
|
|
96
|
-
const { data, error: loadError, loading, reload } = api;
|
|
140
|
+
export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled, focus, onOpenSection, onSaved, onLiveHtml, flushRef }: EditorProps) {
|
|
141
|
+
const { data, error: loadError, loading, reload, loadCount, patch } = api;
|
|
97
142
|
const [pages, setPages] = useState<SectionPages>({});
|
|
98
143
|
const [pageId, setPageId] = useState('');
|
|
99
144
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
100
|
-
const [
|
|
145
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
146
|
+
// translation mode: '' = the site's main language, else a locale code
|
|
147
|
+
const [lang, setLang] = useState('');
|
|
148
|
+
const [busyPaths, setBusyPaths] = useState<Set<string>>(new Set());
|
|
149
|
+
const [trError, setTrError] = useState('');
|
|
150
|
+
const loadedPages = useRef<SectionPages>({});
|
|
151
|
+
const isOwnerRef = useRef(true);
|
|
152
|
+
const [schedFor, setSchedFor] = useState<string | null>(null);
|
|
153
|
+
const [clip, setClip] = useState<SectionInstance | null>(() => {
|
|
154
|
+
try { const v = JSON.parse(localStorage.getItem(CLIP_KEY) ?? 'null'); return v?.acmsSection ? v.section : null; } catch { return null; }
|
|
155
|
+
});
|
|
101
156
|
const [saving, setSaving] = useState(false);
|
|
102
157
|
const [saveError, setSaveError] = useState('');
|
|
158
|
+
const [flash, setFlash] = useState('');
|
|
103
159
|
const [conflict, setConflict] = useState(false);
|
|
104
|
-
const [
|
|
160
|
+
const [imageApply, setImageApply] = useState<((src: string) => void) | null>(null);
|
|
161
|
+
const listRef = useRef<HTMLOListElement>(null);
|
|
162
|
+
// undo/redo: whole-layout snapshots; a burst of typing is one step
|
|
163
|
+
const past = useRef<SectionPages[]>([]);
|
|
164
|
+
const future = useRef<SectionPages[]>([]);
|
|
165
|
+
const lastEdit = useRef(0);
|
|
166
|
+
const [, setHistoryTick] = useState(0);
|
|
167
|
+
/** every user edit goes through here so it can be undone */
|
|
168
|
+
const edit = (fn: (all: SectionPages) => SectionPages) => {
|
|
169
|
+
setPages((prev) => {
|
|
170
|
+
const next = fn(prev);
|
|
171
|
+
if (next === prev) return prev;
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
if (now - lastEdit.current > 800) {
|
|
174
|
+
past.current = [...past.current.slice(-49), prev];
|
|
175
|
+
future.current = [];
|
|
176
|
+
setHistoryTick((n) => n + 1);
|
|
177
|
+
}
|
|
178
|
+
lastEdit.current = now;
|
|
179
|
+
return next;
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
const undo = () => setPages((cur) => {
|
|
183
|
+
const prev = past.current.pop();
|
|
184
|
+
if (!prev) return cur;
|
|
185
|
+
future.current.push(cur);
|
|
186
|
+
lastEdit.current = 0;
|
|
187
|
+
setHistoryTick((n) => n + 1);
|
|
188
|
+
return prev;
|
|
189
|
+
});
|
|
190
|
+
const redo = () => setPages((cur) => {
|
|
191
|
+
const next = future.current.pop();
|
|
192
|
+
if (!next) return cur;
|
|
193
|
+
past.current.push(cur);
|
|
194
|
+
lastEdit.current = 0;
|
|
195
|
+
setHistoryTick((n) => n + 1);
|
|
196
|
+
return next;
|
|
197
|
+
});
|
|
105
198
|
|
|
106
|
-
//
|
|
107
|
-
|
|
199
|
+
// `base` is what the server holds (the staged draft); edits autosave into
|
|
200
|
+
// it. A real reload rehydrates everything; our own autosaves only move
|
|
201
|
+
// `base` and the revision forward, so typing is never overwritten.
|
|
202
|
+
const [base, setBase] = useState<SectionPages>({});
|
|
203
|
+
const revRef = useRef(0);
|
|
108
204
|
useEffect(() => {
|
|
109
205
|
if (!data) return;
|
|
110
|
-
|
|
206
|
+
const fresh = stagedPages(data);
|
|
207
|
+
loadedPages.current = structuredClone(fresh);
|
|
208
|
+
revRef.current = data.revision;
|
|
209
|
+
setBase(fresh);
|
|
210
|
+
setPages(structuredClone(fresh));
|
|
211
|
+
past.current = [];
|
|
212
|
+
future.current = [];
|
|
111
213
|
setConflict(false);
|
|
112
214
|
setSaveError('');
|
|
113
|
-
setPageId((cur) =>
|
|
114
|
-
|
|
215
|
+
setPageId((cur) => {
|
|
216
|
+
if (cur && data.pages.some((p) => p.id === cur)) return cur;
|
|
217
|
+
const wanted = new URLSearchParams(location.search).get('page');
|
|
218
|
+
return (data.pages.find((p) => p.id === wanted) ?? data.pages.find((p) => p.isHome) ?? data.pages[0])?.id ?? '';
|
|
219
|
+
});
|
|
220
|
+
const wantedSection = new URLSearchParams(location.search).get('section');
|
|
221
|
+
if (wantedSection) setOpenId((cur) => cur ?? wantedSection);
|
|
222
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
223
|
+
}, [loadCount]);
|
|
224
|
+
|
|
225
|
+
// click-to-edit from the preview
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!focus || !data) return;
|
|
228
|
+
if (data.pages.some((p) => p.id === focus.pageId)) setPageId(focus.pageId);
|
|
229
|
+
setOpenId(focus.sectionId);
|
|
230
|
+
}, [focus, data]);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const p = data?.pages.find((x) => x.id === pageId);
|
|
233
|
+
const base0 = p ? pagePath(p) : '/';
|
|
234
|
+
const path = lang ? (base0 === '/' ? `/${lang}` : `/${lang}${base0}`) : base0;
|
|
235
|
+
if (!openId) { onOpenSection?.(pageId, null, path); return; }
|
|
236
|
+
onOpenSection?.(pageId, openId, path);
|
|
237
|
+
requestAnimationFrame(() => listRef.current?.querySelector(`[data-sec="${CSS.escape(openId)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
238
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
239
|
+
}, [openId, pageId, lang]);
|
|
115
240
|
|
|
116
241
|
const dirty = useMemo(() => JSON.stringify(pages) !== JSON.stringify(base), [pages, base]);
|
|
117
242
|
const dirtyRef = useRef(false);
|
|
@@ -122,15 +247,188 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
|
|
|
122
247
|
return () => removeEventListener('beforeunload', warn);
|
|
123
248
|
}, []);
|
|
124
249
|
|
|
250
|
+
const baseRef = useRef(base);
|
|
251
|
+
baseRef.current = base;
|
|
252
|
+
const post = async (body: Record<string, unknown>, retried = false): Promise<{ revision: number } | null> => {
|
|
253
|
+
const res = await fetch('/api/design', {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
256
|
+
body: JSON.stringify(body),
|
|
257
|
+
});
|
|
258
|
+
if (res.status === 409 && !retried && typeof body.revision === 'number') {
|
|
259
|
+
// the revision moved: if only other design settings changed (theme,
|
|
260
|
+
// colors autosave), our layouts are still current, so retry once
|
|
261
|
+
const fresh = await fetch('/api/design', { headers: { accept: 'application/json' } }).then((r) => (r.ok ? (r.json() as Promise<DesignApiState>) : null)).catch(() => null);
|
|
262
|
+
if (fresh && JSON.stringify(stagedPages(fresh)) === JSON.stringify(baseRef.current)) return post({ ...body, revision: fresh.revision }, true);
|
|
263
|
+
if (body.operation === 'stage') { setConflict(true); return null; }
|
|
264
|
+
}
|
|
265
|
+
if (!res.ok) { setSaveError(await errorText(res, `Couldn't save (${res.status}).`)); return null; }
|
|
266
|
+
return (await res.json()) as { revision: number };
|
|
267
|
+
};
|
|
268
|
+
// autosave: stage into the design draft 1.2s after the last edit
|
|
269
|
+
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
270
|
+
const saving$ = useRef(false);
|
|
271
|
+
const staleRef = useRef(false);
|
|
272
|
+
const autosave = async () => {
|
|
273
|
+
if (saving$.current) return;
|
|
274
|
+
saving$.current = true;
|
|
275
|
+
setSaveState('saving');
|
|
276
|
+
const snapshot = pagesRef.current;
|
|
277
|
+
try {
|
|
278
|
+
const out = await post({ operation: 'stage', revision: revRef.current, patch: { sectionPages: snapshot } });
|
|
279
|
+
if (!out) { setSaveState('error'); return; }
|
|
280
|
+
revRef.current = out.revision;
|
|
281
|
+
setBase(snapshot);
|
|
282
|
+
setSaveState('saved');
|
|
283
|
+
patch((d) => ({ ...d, revision: out.revision, draft: { ...(d.draft ?? {}), sectionPages: snapshot } }));
|
|
284
|
+
// the preview couldn't swap (new layout / mode change): reload it now
|
|
285
|
+
if (staleRef.current) { staleRef.current = false; onSaved?.(); }
|
|
286
|
+
} catch {
|
|
287
|
+
setSaveState('error');
|
|
288
|
+
setSaveError("Couldn't reach the server. Your edits are kept here and will save when you're back online.");
|
|
289
|
+
} finally {
|
|
290
|
+
saving$.current = false;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const autosaveRef = useRef(autosave);
|
|
294
|
+
autosaveRef.current = autosave;
|
|
295
|
+
const pagesRef = useRef(pages);
|
|
296
|
+
pagesRef.current = pages;
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!data || !dirty || conflict) return;
|
|
299
|
+
const t = setTimeout(() => void autosaveRef.current(), 1200);
|
|
300
|
+
return () => clearTimeout(t);
|
|
301
|
+
}, [pages, dirty, conflict, data, saveState]);
|
|
302
|
+
|
|
303
|
+
// live preview: render the unsaved layout ~0.35s after you pause typing
|
|
304
|
+
const liveSent = useRef('');
|
|
305
|
+
const [liveNonce, setLiveNonce] = useState(0);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
const onMsg = (e: MessageEvent) => {
|
|
308
|
+
if (e.origin !== location.origin) return;
|
|
309
|
+
if (e.data?.type === 'acms-sections-ready') { liveSent.current = ''; setLiveNonce((n) => n + 1); }
|
|
310
|
+
if (e.data?.type === 'acms-preview-stale') staleRef.current = true;
|
|
311
|
+
};
|
|
312
|
+
addEventListener('message', onMsg);
|
|
313
|
+
return () => removeEventListener('message', onMsg);
|
|
314
|
+
}, []);
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (!onLiveHtml || !pageId) return;
|
|
317
|
+
const layout = pages[pageId];
|
|
318
|
+
if (!layout || layout.mode !== 'sections') return;
|
|
319
|
+
const key = JSON.stringify([pageId, activeTheme, layout, lang]);
|
|
320
|
+
if (key === liveSent.current) return;
|
|
321
|
+
const ctrl = new AbortController();
|
|
322
|
+
const t = setTimeout(async () => {
|
|
323
|
+
try {
|
|
324
|
+
const res = await fetch('/admin/section-preview', { method: 'POST', signal: ctrl.signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pageId, layout, theme: activeTheme, locale: lang || undefined }) });
|
|
325
|
+
if (!res.ok) return;
|
|
326
|
+
const html = await res.text();
|
|
327
|
+
liveSent.current = key;
|
|
328
|
+
onLiveHtml(html);
|
|
329
|
+
} catch { /* aborted or offline: the next edit tries again */ }
|
|
330
|
+
}, 350);
|
|
331
|
+
return () => { clearTimeout(t); ctrl.abort(); };
|
|
332
|
+
}, [pages, pageId, activeTheme, onLiveHtml, liveNonce, lang]);
|
|
333
|
+
|
|
334
|
+
// machine drafts for translations: fills values, marks them "AI draft"
|
|
335
|
+
const draftUnits = async (targetLang: string, items: { sectionId: string; unit: Unit }[], quiet = false) => {
|
|
336
|
+
if (!items.length) return 0;
|
|
337
|
+
const keyOf = (x: { sectionId: string; unit: Unit }) => `${x.sectionId}::${x.unit.path}`;
|
|
338
|
+
setBusyPaths((b) => new Set([...b, ...items.map(keyOf)]));
|
|
339
|
+
setTrError('');
|
|
340
|
+
try {
|
|
341
|
+
const results = await requestDrafts(targetLang, items.map((x) => ({ id: keyOf(x), unit: x.unit })));
|
|
342
|
+
let applied = 0;
|
|
343
|
+
edit((all) => {
|
|
344
|
+
const next = { ...all };
|
|
345
|
+
for (const [pid, layout] of Object.entries(all)) {
|
|
346
|
+
const sections = layout.sections.map((sec) => {
|
|
347
|
+
let out = sec;
|
|
348
|
+
for (const x of items) if (x.sectionId === sec.id && results[keyOf(x)] !== undefined) {
|
|
349
|
+
const cur = out.translations?.[targetLang];
|
|
350
|
+
// never overwrite something a person reviewed meanwhile
|
|
351
|
+
if (cur?.meta?.[x.unit.path]?.state === 'reviewed' && cur.meta[x.unit.path].src === x.unit.hash) continue;
|
|
352
|
+
out = withTranslation(out, targetLang, x.unit, results[keyOf(x)], 'ai') as SectionInstance;
|
|
353
|
+
applied++;
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
});
|
|
357
|
+
next[pid] = { ...layout, sections };
|
|
358
|
+
}
|
|
359
|
+
return next;
|
|
360
|
+
});
|
|
361
|
+
return applied;
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (!quiet) setTrError(e instanceof Error ? e.message : 'Translation failed.');
|
|
364
|
+
return 0;
|
|
365
|
+
} finally {
|
|
366
|
+
setBusyPaths((b) => { const n = new Set(b); for (const x of items) n.delete(keyOf(x)); return n; });
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
// auto-draft: when English changes in sections you edited this session,
|
|
370
|
+
// the other languages redraft (missing, or outdated AI drafts) a few seconds later
|
|
371
|
+
const i18nInfo = data?.targets?.i18n;
|
|
372
|
+
const drafting = useRef(false);
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (lang || !i18nInfo?.drafts || !i18nInfo.locales.length || !data) return;
|
|
375
|
+
const t = setTimeout(async () => {
|
|
376
|
+
if (drafting.current) return;
|
|
377
|
+
const defsNow = (data.themes.find((th) => th.slug === activeTheme) ?? data.themes[0])?.sections ?? {};
|
|
378
|
+
const work: { l: string; items: { sectionId: string; unit: Unit }[] }[] = [];
|
|
379
|
+
for (const { code } of i18nInfo.locales) {
|
|
380
|
+
const items: { sectionId: string; unit: Unit }[] = [];
|
|
381
|
+
for (const [pid, layout] of Object.entries(pagesRef.current)) {
|
|
382
|
+
for (const sec of layout.sections) {
|
|
383
|
+
const before = loadedPages.current[pid]?.sections.find((x) => x.id === sec.id);
|
|
384
|
+
if (before && JSON.stringify(before.data) === JSON.stringify(sec.data)) continue; // untouched this session
|
|
385
|
+
const def = defsNow[sec.type];
|
|
386
|
+
if (!def || sec.locked && !isOwnerRef.current) continue;
|
|
387
|
+
for (const unit of unitsToDraft(def, sec, code)) items.push({ sectionId: sec.id, unit });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (items.length) work.push({ l: code, items: items.slice(0, 120) });
|
|
391
|
+
}
|
|
392
|
+
if (!work.length) return;
|
|
393
|
+
drafting.current = true;
|
|
394
|
+
try { for (const w of work) await draftUnits(w.l, w.items, true); } finally { drafting.current = false; }
|
|
395
|
+
}, 4000);
|
|
396
|
+
return () => clearTimeout(t);
|
|
397
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
398
|
+
}, [pages, lang, i18nInfo?.drafts]);
|
|
399
|
+
|
|
400
|
+
// ⌘Z / ⌘⇧Z (Ctrl+Y) while editing sections; rich text keeps its own undo
|
|
401
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
const onKey = (e: KeyboardEvent) => {
|
|
404
|
+
if (!(e.metaKey || e.ctrlKey) || !rootRef.current || rootRef.current.offsetParent === null) return;
|
|
405
|
+
const t = e.target as HTMLElement | null;
|
|
406
|
+
if (t?.isContentEditable) return;
|
|
407
|
+
if (t && t !== document.body && !rootRef.current.contains(t)) return;
|
|
408
|
+
const k = e.key.toLowerCase();
|
|
409
|
+
if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
|
|
410
|
+
else if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); }
|
|
411
|
+
};
|
|
412
|
+
addEventListener('keydown', onKey);
|
|
413
|
+
return () => removeEventListener('keydown', onKey);
|
|
414
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
415
|
+
}, []);
|
|
416
|
+
|
|
417
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
418
|
+
|
|
125
419
|
const theme = data?.themes.find((t) => t.slug === activeTheme)
|
|
126
420
|
?? data?.themes.find((t) => t.slug === (data.draft?.activeTheme ?? data.live.activeTheme))
|
|
127
421
|
?? data?.themes[0];
|
|
128
422
|
const defs: Record<string, SectionDef> = theme?.sections ?? {};
|
|
129
423
|
const defTypes = Object.keys(defs);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
424
|
+
const coded = new Set(theme?.coded ?? []);
|
|
425
|
+
const migratable = new Set(theme?.migratable ?? []);
|
|
426
|
+
const presets = Object.entries(theme?.presets ?? {});
|
|
427
|
+
const targets = data?.targets ?? EMPTY_TARGETS;
|
|
428
|
+
const i18n = targets.i18n ?? EMPTY_TARGETS.i18n!;
|
|
429
|
+
// editors change content only; owners arrange layouts and can lock sections
|
|
430
|
+
const isOwner = (data as { role?: string } | null)?.role !== 'editor';
|
|
431
|
+
isOwnerRef.current = isOwner;
|
|
134
432
|
|
|
135
433
|
if (loading && !data) return <p className="te-muted">Loading page layouts…</p>;
|
|
136
434
|
if (!data) {
|
|
@@ -153,36 +451,76 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
|
|
|
153
451
|
|
|
154
452
|
const page = data.pages.find((p) => p.id === pageId) ?? data.pages[0];
|
|
155
453
|
const layout: PageLayout | undefined = pages[page.id];
|
|
454
|
+
const liveLayout = (data.live.sectionPages ?? {})[page.id] as PageLayout | undefined;
|
|
156
455
|
|
|
157
456
|
const setLayout = (fn: (l: PageLayout) => PageLayout) =>
|
|
158
|
-
|
|
457
|
+
edit((all) => ({ ...all, [page.id]: fn(all[page.id] ?? { mode: 'sections', sections: [] }) }));
|
|
159
458
|
const setSections = (fn: (s: SectionInstance[]) => SectionInstance[]) =>
|
|
160
459
|
setLayout((l) => ({ ...l, sections: fn(l.sections) }));
|
|
161
460
|
const patchSection = (id: string, fn: (s: SectionInstance) => SectionInstance) =>
|
|
162
461
|
setSections((list) => list.map((s) => (s.id === id ? fn(s) : s)));
|
|
163
462
|
|
|
164
|
-
const
|
|
165
|
-
const def = defs[
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
data: defaultsFor(def),
|
|
463
|
+
const instanceFor = (type: string, variant?: string, data?: Record<string, SectionValue>, hidden = false): SectionInstance => {
|
|
464
|
+
const def = defs[type];
|
|
465
|
+
return {
|
|
466
|
+
id: newId(type),
|
|
467
|
+
type,
|
|
468
|
+
version: def?.version ?? 1,
|
|
469
|
+
variant: variant && def?.variants?.includes(variant) ? variant : def?.variants?.[0] ?? 'default',
|
|
470
|
+
hidden,
|
|
471
|
+
data: { ...defaultsFor(def ?? {}), ...(data ?? {}) },
|
|
174
472
|
};
|
|
473
|
+
};
|
|
474
|
+
const addSection = (choice: PickerChoice) => {
|
|
475
|
+
if (!defs[choice.type]) return;
|
|
476
|
+
const s = instanceFor(choice.type, choice.variant, choice.data ? structuredClone(choice.data) as Record<string, SectionValue> : undefined);
|
|
175
477
|
setLayout((l) => ({ ...l, mode: 'sections', sections: [...l.sections, s] }));
|
|
176
478
|
setOpenId(s.id);
|
|
177
479
|
};
|
|
480
|
+
// copy / paste sections (also across tabs, via localStorage)
|
|
481
|
+
const copySection = (s: SectionInstance) => {
|
|
482
|
+
const json = JSON.stringify({ acmsSection: 1, section: s });
|
|
483
|
+
try { localStorage.setItem(CLIP_KEY, json); } catch { /* private mode */ }
|
|
484
|
+
void navigator.clipboard?.writeText(json).catch(() => {});
|
|
485
|
+
setClip(s);
|
|
486
|
+
setFlash(`Copied “${sectionName(defs[s.type], s.type)}”. Paste it on any page.`);
|
|
487
|
+
setTimeout(() => setFlash(''), 2600);
|
|
488
|
+
};
|
|
489
|
+
const pasteSection = () => {
|
|
490
|
+
if (!clip || !defs[clip.type]) return;
|
|
491
|
+
const s = { ...structuredClone(clip), id: newId(clip.type), locked: undefined };
|
|
492
|
+
setLayout((l) => ({ ...l, mode: 'sections', sections: [...l.sections, s] }));
|
|
493
|
+
setOpenId(s.id);
|
|
494
|
+
};
|
|
495
|
+
const copyToPage = (s: SectionInstance, targetId: string) => {
|
|
496
|
+
const target = data.pages.find((p) => p.id === targetId);
|
|
497
|
+
if (!target) return;
|
|
498
|
+
const copy = { ...structuredClone(s), id: newId(s.type) };
|
|
499
|
+
edit((all) => ({ ...all, [targetId]: { ...(all[targetId] ?? { mode: 'sections', sections: [] }), sections: [...(all[targetId]?.sections ?? []), copy] } }));
|
|
500
|
+
setFlash(`Copied to ${target.title || target.slug}. It saves with your draft.`);
|
|
501
|
+
setTimeout(() => setFlash(''), 2600);
|
|
502
|
+
};
|
|
503
|
+
const applyPreset = (key: string) => {
|
|
504
|
+
const preset = theme?.presets?.[key];
|
|
505
|
+
if (!preset) return;
|
|
506
|
+
if (layout?.sections.length && !window.confirm(`Replace the ${layout.sections.length} sections on ${page.title} with “${preset.name}”? This happens when you save the section draft.`)) return;
|
|
507
|
+
setLayout(() => ({ mode: 'sections', sections: preset.sections.map((p) => instanceFor(p.type, p.variant, structuredClone(p.data), p.hidden)) }));
|
|
508
|
+
setOpenId(null);
|
|
509
|
+
};
|
|
178
510
|
const move = (i: number, dir: -1 | 1) =>
|
|
179
511
|
setSections((list) => {
|
|
180
512
|
const j = i + dir;
|
|
181
513
|
if (j < 0 || j >= list.length) return list;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
514
|
+
return arrayMove(list, i, j);
|
|
515
|
+
});
|
|
516
|
+
const onDragEnd = (e: DragEndEvent) => {
|
|
517
|
+
if (!e.over || e.active.id === e.over.id) return;
|
|
518
|
+
setSections((list) => {
|
|
519
|
+
const from = list.findIndex((s) => s.id === e.active.id);
|
|
520
|
+
const to = list.findIndex((s) => s.id === e.over!.id);
|
|
521
|
+
return from < 0 || to < 0 ? list : arrayMove(list, from, to);
|
|
185
522
|
});
|
|
523
|
+
};
|
|
186
524
|
const duplicate = (i: number) =>
|
|
187
525
|
setSections((list) => {
|
|
188
526
|
const copy = { ...structuredClone(list[i]), id: newId(list[i].type) };
|
|
@@ -199,43 +537,115 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
|
|
|
199
537
|
return { ...s, data: d };
|
|
200
538
|
});
|
|
201
539
|
|
|
202
|
-
const
|
|
540
|
+
const finish = async (message: string) => {
|
|
541
|
+
setFlash(message);
|
|
542
|
+
setTimeout(() => setFlash(''), 2600);
|
|
543
|
+
await reload();
|
|
544
|
+
onSaved?.();
|
|
545
|
+
};
|
|
546
|
+
/** save now (the Save button, and before publishing/upgrading) */
|
|
547
|
+
const flush = async (): Promise<boolean> => {
|
|
548
|
+
if (!dirty) return true;
|
|
549
|
+
await autosaveRef.current();
|
|
550
|
+
return JSON.stringify(pagesRef.current) === JSON.stringify(baseRef.current);
|
|
551
|
+
};
|
|
552
|
+
if (flushRef) flushRef.current = flush;
|
|
553
|
+
const publishSection = async (s: SectionInstance) => {
|
|
554
|
+
const name = sectionName(defs[s.type], s.type);
|
|
555
|
+
const warns = checkLayouts({ [page.id]: { mode: 'sections', sections: [s] } }, defs as never, targets, { pageId: page.id, sectionId: s.id });
|
|
556
|
+
const note = warns.length ? `\n\nWorth a look first:\n${warns.slice(0, 5).map((w) => `• ${w.message}`).join('\n')}` : '';
|
|
557
|
+
if (!window.confirm(`Publish your changes to “${name}” now? Visitors see them right away. Everything else stays in the draft.${note}`)) return;
|
|
203
558
|
setSaving(true);
|
|
204
559
|
setSaveError('');
|
|
205
560
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
body: JSON.stringify({ operation: 'stage', revision: data.revision, patch: { sectionPages: { ...base, ...pages } } }),
|
|
210
|
-
});
|
|
211
|
-
if (res.status === 409) { setConflict(true); return; }
|
|
212
|
-
if (!res.ok) { setSaveError(await errorText(res, `Couldn't save the section draft (${res.status}).`)); return; }
|
|
213
|
-
dirtyRef.current = false;
|
|
214
|
-
location.reload(); // refresh the draft status, change list and preview
|
|
561
|
+
if (!(await flush())) return;
|
|
562
|
+
if (await post({ operation: 'publishSection', revision: revRef.current, pageId: page.id, sectionId: s.id })) await finish(`“${name}” is live.`);
|
|
563
|
+
else await reload();
|
|
215
564
|
} catch {
|
|
216
565
|
setSaveError("Couldn't reach the server. Your edits are still here.");
|
|
217
566
|
} finally {
|
|
218
567
|
setSaving(false);
|
|
219
568
|
}
|
|
220
569
|
};
|
|
570
|
+
const upgrade = async () => {
|
|
571
|
+
setSaving(true);
|
|
572
|
+
setSaveError('');
|
|
573
|
+
try {
|
|
574
|
+
if (!(await flush())) return;
|
|
575
|
+
if (await post({ operation: 'migrate', revision: revRef.current })) await finish('Sections upgraded to the theme’s current version. Review and publish.');
|
|
576
|
+
} finally {
|
|
577
|
+
setSaving(false);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
221
580
|
const reloadLatest = () => {
|
|
222
581
|
if (dirty && !window.confirm('Load the latest draft? Your unsaved section edits will be lost.')) return;
|
|
223
582
|
void reload();
|
|
224
583
|
};
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
584
|
+
|
|
585
|
+
// ---- translation helpers (page level) ----
|
|
586
|
+
const mainLangName = i18n.defaultLocale ? ({ en: 'English', es: 'Español', fr: 'Français', pt: 'Português' } as Record<string, string>)[i18n.defaultLocale.split('-')[0]] ?? i18n.defaultLocale.toUpperCase() : 'English';
|
|
587
|
+
const pageSummary = (code: string) => {
|
|
588
|
+
const total = { missing: 0, ai: 0, reviewed: 0, outdated: 0 };
|
|
589
|
+
for (const x of layout?.mode === 'sections' ? layout.sections : []) {
|
|
590
|
+
const d = defs[x.type];
|
|
591
|
+
if (!d || x.hidden) continue;
|
|
592
|
+
const sm = sectionSummary(d, x, code);
|
|
593
|
+
total.missing += sm.missing; total.ai += sm.ai; total.reviewed += sm.reviewed; total.outdated += sm.outdated;
|
|
594
|
+
}
|
|
595
|
+
return total;
|
|
596
|
+
};
|
|
597
|
+
const draftAllMissing = async () => {
|
|
598
|
+
if (!lang || !layout) return;
|
|
599
|
+
const items = layout.sections.flatMap((x) => (defs[x.type] && !x.hidden && !(x.locked && !isOwner) ? unitsToDraft(defs[x.type], x, lang).map((unit) => ({ sectionId: x.id, unit })) : []));
|
|
600
|
+
await draftUnits(lang, items.slice(0, 200));
|
|
601
|
+
};
|
|
602
|
+
const jumpToStatus = (status: 'ai' | 'missing' | 'outdated') => {
|
|
603
|
+
const hit = layout?.sections.find((x) => defs[x.type] && sectionSummary(defs[x.type], x, lang)[status] > 0);
|
|
604
|
+
if (hit) setOpenId(hit.id);
|
|
605
|
+
};
|
|
606
|
+
// the older setup: a separately authored home page for this language
|
|
607
|
+
const mergeSource = lang && page.isHome
|
|
608
|
+
? (() => {
|
|
609
|
+
const other = data.pages.find((p) => p.isHome && p.locale === lang && p.id !== page.id);
|
|
610
|
+
const otherLayout = other ? pages[other.id] : undefined;
|
|
611
|
+
const alreadyMerged = layout?.sections.some((x) => x.translations?.[lang]);
|
|
612
|
+
return other && otherLayout?.mode === 'sections' && otherLayout.sections.length && !alreadyMerged ? { ...other, layout: otherLayout } : null;
|
|
613
|
+
})()
|
|
614
|
+
: null;
|
|
615
|
+
const runMerge = () => {
|
|
616
|
+
if (!mergeSource || !layout) return;
|
|
617
|
+
const plan = planMerge(defs as never, layout.sections as never, mergeSource.layout.sections as never, lang);
|
|
618
|
+
if (!window.confirm(`Copy ${plan.copied} ${plan.copied === 1 ? 'text' : 'texts'} from “${mergeSource.title}” into this page's ${i18n.locales.find((l) => l.code === lang)?.name ?? lang} version?${plan.unmatched ? ` ${plan.unmatched} ${plan.unmatched === 1 ? 'section has' : 'sections have'} no match there; translate ${plan.unmatched === 1 ? 'it' : 'them'} with AI afterwards.` : ''}\n\nThe separate page isn't deleted; once this page has translations, /${lang} shows this one.`)) return;
|
|
619
|
+
setLayout((l) => ({ ...l, sections: plan.sections as SectionInstance[] }));
|
|
228
620
|
};
|
|
229
621
|
|
|
622
|
+
const allIssues = (layout?.mode === 'sections' ? layout.sections : []).map((x) => {
|
|
623
|
+
const d = defs[x.type];
|
|
624
|
+
return { id: x.id, n: d && !x.hidden ? Object.values(fieldIssues(d, x)).reduce((n, l) => n + l.length, 0) : 0 };
|
|
625
|
+
});
|
|
626
|
+
const totalIssues = allIssues.reduce((n, x) => n + x.n, 0);
|
|
627
|
+
const firstIssueId = allIssues.find((x) => x.n > 0)?.id ?? null;
|
|
230
628
|
const pageDirty = (id: string) => JSON.stringify(pages[id]) !== JSON.stringify(base[id]);
|
|
231
|
-
const
|
|
629
|
+
const liveSection = (id: string) => liveLayout?.mode === 'sections' ? liveLayout.sections.find((x) => x.id === id) : undefined;
|
|
630
|
+
const canPublishAlone = data.scopes.includes('design:publish');
|
|
232
631
|
|
|
233
632
|
return (
|
|
234
|
-
<div className="te">
|
|
633
|
+
<div className="te" ref={rootRef}>
|
|
235
634
|
<p className="te-intro">
|
|
236
|
-
Build a page from your theme's sections.
|
|
237
|
-
draft; visitors see them
|
|
635
|
+
Build a page from your theme's sections. Sections marked <b>coded</b> come from your theme's code: their look
|
|
636
|
+
changes in code, their content changes here. Edits save to a private draft as you type; visitors see them after you <b>Publish</b>.
|
|
238
637
|
</p>
|
|
638
|
+
<details className="te-help-panel">
|
|
639
|
+
<summary>How editing works</summary>
|
|
640
|
+
<ul>
|
|
641
|
+
<li><b>Click anything in the preview</b> to open its section here. The preview updates as you type.</li>
|
|
642
|
+
<li><b>Your changes save themselves</b> to a draft. Undo with ⌘Z (Ctrl+Z), redo with ⌘⇧Z.</li>
|
|
643
|
+
<li><b>Publish</b> (top of this screen) makes every change live. <b>Publish this section</b> sends just one section and leaves the rest in the draft.</li>
|
|
644
|
+
<li><b>Linked</b> sections (like upcoming events) fill themselves from your calendar or posts, so there's nothing to update here.</li>
|
|
645
|
+
<li>Fields marked in red need fixing before that section can be published.</li>
|
|
646
|
+
<li>Something look wrong? <b>History</b> (top right) restores any earlier published design as a draft.</li>
|
|
647
|
+
</ul>
|
|
648
|
+
</details>
|
|
239
649
|
|
|
240
650
|
<div className="te-pagebar">
|
|
241
651
|
<label className="field te-pagepick">
|
|
@@ -253,15 +663,75 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
|
|
|
253
663
|
</a>
|
|
254
664
|
</div>
|
|
255
665
|
|
|
256
|
-
{
|
|
666
|
+
{layout?.mode === 'sections' && i18n.locales.length > 0 && (
|
|
667
|
+
<div className="te-langbar">
|
|
668
|
+
<div className="seg" role="radiogroup" aria-label="Language">
|
|
669
|
+
<button type="button" role="radio" aria-checked={!lang} className={!lang ? 'on' : ''} onClick={() => setLang('')}>{mainLangName}</button>
|
|
670
|
+
{i18n.locales.map((l) => {
|
|
671
|
+
const need = pageSummary(l.code);
|
|
672
|
+
return (
|
|
673
|
+
<button key={l.code} type="button" role="radio" aria-checked={lang === l.code} className={lang === l.code ? 'on' : ''} onClick={() => setLang(l.code)}>
|
|
674
|
+
{l.name}{need.missing + need.outdated + need.ai > 0 && <span className="te-lang-count" aria-label={`${need.missing + need.outdated + need.ai} to check`}>{need.missing + need.outdated + need.ai}</span>}
|
|
675
|
+
</button>
|
|
676
|
+
);
|
|
677
|
+
})}
|
|
678
|
+
</div>
|
|
679
|
+
{lang ? (() => {
|
|
680
|
+
const sum = pageSummary(lang);
|
|
681
|
+
const name = i18n.locales.find((l) => l.code === lang)?.name ?? lang;
|
|
682
|
+
return (
|
|
683
|
+
<div className="te-lang-sum">
|
|
684
|
+
<span>
|
|
685
|
+
<b>{sum.reviewed}</b> reviewed · <span className="st-ai"><b>{sum.ai}</b> AI {sum.ai === 1 ? 'draft' : 'drafts'}</span> · <span className="st-old"><b>{sum.outdated}</b> English changed</span> · <span className="st-miss"><b>{sum.missing}</b> not translated</span>
|
|
686
|
+
</span>
|
|
687
|
+
<span className="te-lang-actions">
|
|
688
|
+
{i18n.drafts && (sum.missing + sum.outdated > 0) && (
|
|
689
|
+
<button className="btn sm" type="button" disabled={busyPaths.size > 0} onClick={() => void draftAllMissing()}>{busyPaths.size ? 'Translating…' : `Translate ${sum.missing + sum.outdated} with AI`}</button>
|
|
690
|
+
)}
|
|
691
|
+
{sum.ai > 0 && <button className="btn sm ghost" type="button" onClick={() => jumpToStatus('ai')}>Review AI drafts</button>}
|
|
692
|
+
</span>
|
|
693
|
+
<span className="te-muted te-lang-note">Times, photos, links and linked events are shared with {mainLangName}; only the words change. {name} visitors see AI drafts right away, and {mainLangName} wherever nothing is translated yet.</span>
|
|
694
|
+
{!i18n.drafts && <span className="te-muted te-lang-note">Turn on AI in Settings → AI & agents and {name} drafts itself whenever the {mainLangName} changes.</span>}
|
|
695
|
+
{mergeSource && (
|
|
696
|
+
<div className="notice te-note te-merge">
|
|
697
|
+
<span>You also have a separate {name} home page, “{mergeSource.title}”. Bring its text in here? Matching sections keep your {name} words (marked reviewed); the rest can be drafted with AI. Nothing is deleted.</span>
|
|
698
|
+
<button className="btn sm" type="button" onClick={runMerge}>Bring in its text</button>
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
);
|
|
703
|
+
})() : null}
|
|
704
|
+
{trError && <p className="flash error te-alert" role="alert">{trError}</p>}
|
|
705
|
+
</div>
|
|
706
|
+
)}
|
|
707
|
+
|
|
708
|
+
{!layout && !isOwner ? (
|
|
709
|
+
<div className="te-empty"><p><b>{page.title || page.slug}</b> shows its page content. Edit it in the page editor.</p></div>
|
|
710
|
+
) : !layout ? (
|
|
257
711
|
<div className="te-empty">
|
|
258
712
|
<p><b>{page.title || page.slug}</b> shows its page content.</p>
|
|
259
|
-
|
|
260
|
-
|
|
713
|
+
{presets.length > 0 ? (
|
|
714
|
+
<>
|
|
715
|
+
<p className="te-muted">{theme?.name} comes with ready-made layouts. Start from one, then change any text, list or image.</p>
|
|
716
|
+
<div className="te-presets">
|
|
717
|
+
{presets.map(([key, p]) => (
|
|
718
|
+
<button key={key} type="button" className="te-preset" onClick={() => applyPreset(key)}>
|
|
719
|
+
<b>{p.name}</b>
|
|
720
|
+
<span>{p.description || `${p.sections.length} sections`}</span>
|
|
721
|
+
</button>
|
|
722
|
+
))}
|
|
723
|
+
</div>
|
|
724
|
+
<p className="te-muted">Or keep your current text and widgets and add sections around them:</p>
|
|
725
|
+
</>
|
|
726
|
+
) : (
|
|
727
|
+
<p className="te-muted">Start a layout, then add Existing page content to keep your current text or widgets alongside new sections. You can switch back at any time.</p>
|
|
728
|
+
)}
|
|
729
|
+
<button className="btn" type="button" onClick={() => setLayout(() => ({ mode: 'sections', sections: [instanceFor('page-content')] }))}>Keep content and add sections</button>
|
|
261
730
|
</div>
|
|
262
731
|
) : (
|
|
263
732
|
<>
|
|
264
|
-
<
|
|
733
|
+
{!isOwner && <p className="notice te-note">You can change the text, lists and pictures in these sections. Adding, moving or restyling sections is up to an owner.</p>}
|
|
734
|
+
<div className="te-mode" hidden={!isOwner}>
|
|
265
735
|
<span className="te-mode-label" id="te-mode-label">Page shows</span>
|
|
266
736
|
<div className="seg" role="radiogroup" aria-labelledby="te-mode-label">
|
|
267
737
|
{(['sections', 'content'] as const).map((m) => (
|
|
@@ -284,98 +754,217 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
|
|
|
284
754
|
</p>
|
|
285
755
|
)}
|
|
286
756
|
|
|
287
|
-
<
|
|
288
|
-
{layout.sections.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<span className="te-sec-meta">
|
|
307
|
-
{s.variant}
|
|
308
|
-
{s.hidden && ' · hidden'}
|
|
309
|
-
{!def && ' · unavailable'}
|
|
310
|
-
</span>
|
|
311
|
-
</button>
|
|
312
|
-
<div className="te-sec-actions">
|
|
313
|
-
<button className="icon-btn" type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label={`Move ${name} up`} title="Move up">
|
|
314
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
|
|
315
|
-
</button>
|
|
316
|
-
<button className="icon-btn" type="button" onClick={() => move(i, 1)} disabled={i === layout.sections.length - 1} aria-label={`Move ${name} down`} title="Move down">
|
|
317
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
318
|
-
</button>
|
|
319
|
-
</div>
|
|
320
|
-
</div>
|
|
321
|
-
|
|
322
|
-
{open && (
|
|
323
|
-
<div className="te-sec-body" id={panelId}>
|
|
324
|
-
{!def ? (
|
|
325
|
-
<p className="notice warn te-note">
|
|
326
|
-
{theme?.name ?? 'This theme'} has no “{s.type}” section, so it won't render. Its {Object.keys(s.data).length} saved {Object.keys(s.data).length === 1 ? 'value is' : 'values are'} preserved and come back if you switch to a theme that has it.
|
|
327
|
-
</p>
|
|
328
|
-
) : (
|
|
757
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
758
|
+
<SortableContext items={layout.sections.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
|
759
|
+
<ol ref={listRef} className={`te-list ${layout.mode === 'content' ? 'is-inactive' : ''}`} aria-label={`Sections on ${page.title || page.slug}`}>
|
|
760
|
+
{layout.sections.length === 0 && <li className="te-muted te-none">No sections yet. Add one below.</li>}
|
|
761
|
+
{layout.sections.map((s, i) => {
|
|
762
|
+
const def = defs[s.type];
|
|
763
|
+
const name = sectionName(def, s.type);
|
|
764
|
+
const open = openId === s.id;
|
|
765
|
+
const panelId = `te-sec-${s.id}`;
|
|
766
|
+
const orphans = def ? orphanKeys(def, s.data) : [];
|
|
767
|
+
const live = liveSection(s.id);
|
|
768
|
+
const savedCopy = (base[page.id]?.sections ?? []).find((x) => x.id === s.id);
|
|
769
|
+
const changedFromLive = Boolean(live) && JSON.stringify(live) !== JSON.stringify(s);
|
|
770
|
+
const unsaved = JSON.stringify(savedCopy) !== JSON.stringify(s);
|
|
771
|
+
const issues = def && !s.hidden ? fieldIssues(def, s) : {};
|
|
772
|
+
const issueCount = Object.values(issues).reduce((n, list) => n + list.length, 0);
|
|
773
|
+
return (
|
|
774
|
+
<SortableSection key={s.id} id={s.id} className={`te-sec ${s.hidden ? 'is-hidden' : ''} ${open ? 'is-open' : ''}`}>
|
|
775
|
+
{(handle) => (
|
|
329
776
|
<>
|
|
330
|
-
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
777
|
+
<div className="te-sec-head">
|
|
778
|
+
{isOwner && !lang ? <button className="te-grip" type="button" aria-label={`Reorder ${name}`} {...handle}>⋮⋮</button> : <span className="te-grip-space" />}
|
|
779
|
+
<button
|
|
780
|
+
type="button"
|
|
781
|
+
className="te-sec-toggle"
|
|
782
|
+
aria-expanded={open}
|
|
783
|
+
aria-controls={panelId}
|
|
784
|
+
onClick={() => setOpenId(open ? null : s.id)}
|
|
785
|
+
>
|
|
786
|
+
<span className="te-sec-name">{name}</span>
|
|
787
|
+
<span className="te-sec-meta">
|
|
788
|
+
{s.variant !== 'default' && s.variant}
|
|
789
|
+
{s.hidden && ' · hidden'}
|
|
790
|
+
{!def && ' · unavailable'}
|
|
791
|
+
{unsaved && ' · saving'}
|
|
792
|
+
{(s.showFrom || s.showUntil) && ' · scheduled'}
|
|
793
|
+
</span>
|
|
794
|
+
{issueCount > 0 && <span className="te-issue-mark">{issueCount} to fix</span>}
|
|
795
|
+
</button>
|
|
796
|
+
{lang && def ? (() => {
|
|
797
|
+
const sum = sectionSummary(def, s, lang);
|
|
798
|
+
return sum.total === 0 ? <span className="te-mark">shared</span>
|
|
799
|
+
: sum.missing ? <span className="te-mark st-miss">{sum.missing} to translate</span>
|
|
800
|
+
: sum.outdated ? <span className="te-mark st-old">{sum.outdated} changed</span>
|
|
801
|
+
: sum.ai ? <span className="te-mark st-ai">{sum.ai} AI {sum.ai === 1 ? 'draft' : 'drafts'}</span>
|
|
802
|
+
: <span className="te-mark is-linked">✓ translated</span>;
|
|
803
|
+
})() : def && hasSource(def) ? <span className="te-mark is-linked">linked</span> : coded.has(s.type) ? <span className="te-mark is-coded">coded</span> : null}
|
|
804
|
+
{s.locked && <span className="te-mark is-locked" title={isOwner ? 'Editors can’t change this section' : 'Locked by an owner'}>locked</span>}
|
|
805
|
+
<div className="te-sec-actions" hidden={!isOwner || Boolean(lang)}>
|
|
806
|
+
<button className="icon-btn" type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label={`Move ${name} up`} title="Move up">
|
|
807
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 15 6-6 6 6" /></svg>
|
|
808
|
+
</button>
|
|
809
|
+
<button className="icon-btn" type="button" onClick={() => move(i, 1)} disabled={i === layout.sections.length - 1} aria-label={`Move ${name} down`} title="Move down">
|
|
810
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
811
|
+
</button>
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
|
|
815
|
+
{open && lang && def ? (
|
|
816
|
+
<div className="te-sec-body" id={panelId}>
|
|
817
|
+
<TranslationFields
|
|
818
|
+
def={def}
|
|
819
|
+
section={s}
|
|
820
|
+
lang={lang}
|
|
821
|
+
langName={i18n.locales.find((l) => l.code === lang)?.name ?? lang}
|
|
822
|
+
readOnly={Boolean(s.locked && !isOwner)}
|
|
823
|
+
busyPaths={busyPaths}
|
|
824
|
+
onSet={(u, v) => patchSection(s.id, (x) => withTranslation(x, lang, u, v, 'reviewed') as SectionInstance)}
|
|
825
|
+
onConfirm={(u) => patchSection(s.id, (x) => confirmTranslation(x, lang, u) as SectionInstance)}
|
|
826
|
+
onDraft={i18n.drafts ? (units) => void draftUnits(lang, units.map((unit) => ({ sectionId: s.id, unit }))) : null}
|
|
827
|
+
/>
|
|
828
|
+
</div>
|
|
829
|
+
) : open && (
|
|
830
|
+
<div className="te-sec-body" id={panelId}>
|
|
831
|
+
{!def ? (
|
|
832
|
+
<p className="notice warn te-note">
|
|
833
|
+
{theme?.name ?? 'This theme'} has no “{s.type}” section, so it won't render. Its {Object.keys(s.data).length} saved {Object.keys(s.data).length === 1 ? 'value is' : 'values are'} preserved and come back if you switch to a theme that has it.
|
|
834
|
+
</p>
|
|
835
|
+
) : (
|
|
836
|
+
<>
|
|
837
|
+
{def.version !== undefined && s.version !== def.version && (
|
|
838
|
+
<div className="notice warn te-note">
|
|
839
|
+
<span>Saved with version {s.version} of this section; the theme ships version {def.version}.{migratable.has(s.type) ? ' The theme can upgrade it.' : ' The theme has no upgrade for it, so it stays hidden until a developer adds one.'}</span>
|
|
840
|
+
{migratable.has(s.type) && <button className="btn sm" type="button" onClick={() => void upgrade()} disabled={saving}>Upgrade sections</button>}
|
|
841
|
+
</div>
|
|
842
|
+
)}
|
|
843
|
+
{def.description && <p className="te-muted te-desc">{def.description}</p>}
|
|
844
|
+
{s.type === 'page-content' && <p className="te-muted">This section uses the page's published text and widgets. Edit them in the page editor; any pending content draft stays separate.</p>}
|
|
845
|
+
{s.locked && !isOwner && <p className="notice te-note">An owner locked this section, so it can’t be changed here.</p>}
|
|
846
|
+
{(def.variants?.length ?? 0) > 1 && isOwner && (
|
|
847
|
+
<div className="field te-variants">
|
|
848
|
+
<span>Style</span>
|
|
849
|
+
<div className="seg" role="radiogroup" aria-label="Style">
|
|
850
|
+
{!def.variants!.includes(s.variant) && <button type="button" role="radio" aria-checked className="on">{s.variant} (not in this theme)</button>}
|
|
851
|
+
{def.variants!.map((v) => (
|
|
852
|
+
<button key={v} type="button" role="radio" aria-checked={s.variant === v} className={s.variant === v ? 'on' : ''} onClick={() => patchSection(s.id, (x) => ({ ...x, variant: v }))}>
|
|
853
|
+
{v.replace(/-/g, ' ')}
|
|
854
|
+
</button>
|
|
855
|
+
))}
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
)}
|
|
859
|
+
{Object.entries(def.fields ?? {}).filter(([, f]) => fieldVisible(f, normalizeData(def, s.data))).map(([key, f]) => (
|
|
860
|
+
<fieldset key={key} disabled={!isOwner && (s.locked || Boolean((f as { locked?: boolean }).locked))} className={`te-field ${issues[key]?.length ? 'has-issue' : ''} ${!isOwner && (f as { locked?: boolean }).locked ? 'is-locked' : ''}`}>
|
|
861
|
+
{!isOwner && (f as { locked?: boolean }).locked && !s.locked && <small className="te-lock-note">Locked by an owner</small>}
|
|
862
|
+
{(isOwner || !(s.locked || (f as { locked?: boolean }).locked)) && JSON.stringify(normalizeValue(f, s.data[key])) !== JSON.stringify(defaultValue(f)) && f.type !== 'list' && f.type !== 'source' && (
|
|
863
|
+
<button type="button" className="te-reset" onClick={() => setField(s.id, key, defaultValue(f) as SectionValue)} title="Back to the theme’s default">Reset</button>
|
|
864
|
+
)}
|
|
865
|
+
<FieldInput
|
|
866
|
+
id={`${panelId}-${key}`}
|
|
867
|
+
field={f}
|
|
868
|
+
value={normalizeValue(f, s.data[key])}
|
|
869
|
+
onChange={(v) => setField(s.id, key, v)}
|
|
870
|
+
targets={targets}
|
|
871
|
+
pickImage={(apply) => setImageApply(() => apply)}
|
|
872
|
+
context={sectionContext(def, s, key)}
|
|
873
|
+
focal={f.type === 'image' ? String(s.data[`${key}__focus`] ?? '') : undefined}
|
|
874
|
+
onFocal={f.type === 'image' ? (v) => setField(s.id, `${key}__focus`, v) : undefined}
|
|
875
|
+
/>
|
|
876
|
+
{issues[key]?.map((m) => <small key={m} className="te-error" role="alert">{m}</small>)}
|
|
877
|
+
</fieldset>
|
|
878
|
+
))}
|
|
879
|
+
{orphans.length > 0 && isOwner && (
|
|
880
|
+
<div className="notice te-note te-orphans">
|
|
881
|
+
<span>{orphans.length} saved {orphans.length === 1 ? 'value' : 'values'} ({orphans.join(', ')}) no longer {orphans.length === 1 ? 'has' : 'have'} a field in this section. Kept in case the theme brings {orphans.length === 1 ? 'it' : 'them'} back.</span>
|
|
882
|
+
<button className="btn sm ghost" type="button" onClick={() => patchSection(s.id, (x) => {
|
|
883
|
+
const d = { ...x.data };
|
|
884
|
+
for (const k of orphans) delete d[k];
|
|
885
|
+
return { ...x, data: d };
|
|
886
|
+
})}>Remove</button>
|
|
887
|
+
</div>
|
|
888
|
+
)}
|
|
889
|
+
</>
|
|
890
|
+
)}
|
|
891
|
+
{(schedFor === s.id || s.showFrom || s.showUntil) && (
|
|
892
|
+
<div className="te-schedule">
|
|
893
|
+
<span className="te-schedule-title">Show on the site</span>
|
|
894
|
+
<label>From <input type="datetime-local" value={toLocalInput(s.showFrom)} onChange={(e) => patchSection(s.id, (x) => ({ ...x, showFrom: fromLocalInput(e.target.value) }))} /></label>
|
|
895
|
+
<label>Until <input type="datetime-local" value={toLocalInput(s.showUntil)} onChange={(e) => patchSection(s.id, (x) => ({ ...x, showUntil: fromLocalInput(e.target.value) }))} /></label>
|
|
896
|
+
<span className="te-muted te-schedule-state">{scheduleText(s)}</span>
|
|
897
|
+
{(s.showFrom || s.showUntil) && <button className="btn sm ghost" type="button" onClick={() => { patchSection(s.id, (x) => ({ ...x, showFrom: undefined, showUntil: undefined })); setSchedFor(null); }}>Always show</button>}
|
|
898
|
+
</div>
|
|
899
|
+
)}
|
|
900
|
+
<div className="te-sec-foot">
|
|
901
|
+
{isOwner && <>
|
|
902
|
+
<button className="btn sm" type="button" aria-pressed={s.hidden} onClick={() => patchSection(s.id, (x) => ({ ...x, hidden: !x.hidden }))}>
|
|
903
|
+
{s.hidden ? 'Show section' : 'Hide section'}
|
|
904
|
+
</button>
|
|
905
|
+
{!s.showFrom && !s.showUntil && schedFor !== s.id && <button className="btn sm" type="button" onClick={() => setSchedFor(s.id)}>Schedule…</button>}
|
|
906
|
+
<details className="te-more">
|
|
907
|
+
<summary className="btn sm">More</summary>
|
|
908
|
+
<div className="te-more-menu">
|
|
909
|
+
<button type="button" onClick={() => duplicate(i)}>Duplicate</button>
|
|
910
|
+
<button type="button" onClick={() => copySection(s)}>Copy section</button>
|
|
911
|
+
{data.pages.length > 1 && (
|
|
912
|
+
<label>Copy to page
|
|
913
|
+
<select value="" onChange={(e) => { if (e.target.value) copyToPage(s, e.target.value); }}>
|
|
914
|
+
<option value="">Choose…</option>
|
|
915
|
+
{data.pages.filter((p) => p.id !== page.id).map((p) => <option key={p.id} value={p.id}>{p.title || p.slug}</option>)}
|
|
916
|
+
</select>
|
|
917
|
+
</label>
|
|
918
|
+
)}
|
|
919
|
+
{def && Object.keys(def.fields ?? {}).length > 0 && <button type="button" onClick={() => { if (window.confirm(`Reset every field in “${name}” to its default?`)) patchSection(s.id, (x) => ({ ...x, data: defaultsFor(def) })); }}>Reset all fields</button>}
|
|
920
|
+
<button type="button" onClick={() => patchSection(s.id, (x) => ({ ...x, locked: x.locked ? undefined : true }))}>{s.locked ? 'Unlock for editors' : 'Lock for editors'}</button>
|
|
921
|
+
</div>
|
|
922
|
+
</details>
|
|
923
|
+
<button className="btn sm ghost danger te-delete" type="button" onClick={() => remove(s)}>Delete section</button>
|
|
924
|
+
</>}
|
|
925
|
+
{canPublishAlone && changedFromLive && layout.mode === 'sections' && liveLayout?.mode === 'sections' && (
|
|
926
|
+
<button className="btn sm primary te-publish-one" type="button" onClick={() => void publishSection(s)} disabled={saving || conflict || issueCount > 0} title={issueCount ? 'Fix the fields marked below first' : undefined}>
|
|
927
|
+
{unsaved ? 'Save & publish this section' : 'Publish this section'}
|
|
928
|
+
</button>
|
|
929
|
+
)}
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
342
932
|
)}
|
|
343
|
-
{Object.entries(def.fields ?? {}).map(([key, f]) => (
|
|
344
|
-
<FieldInput
|
|
345
|
-
key={key}
|
|
346
|
-
id={`${panelId}-${key}`}
|
|
347
|
-
field={f}
|
|
348
|
-
value={s.data[key]}
|
|
349
|
-
onChange={(v) => setField(s.id, key, v)}
|
|
350
|
-
onPickImage={() => setImageFor({ sectionId: s.id, key })}
|
|
351
|
-
/>
|
|
352
|
-
))}
|
|
353
933
|
</>
|
|
354
934
|
)}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
);
|
|
366
|
-
})}
|
|
367
|
-
</ol>
|
|
368
|
-
|
|
369
|
-
<div className="te-add">
|
|
370
|
-
<label className="field">
|
|
371
|
-
<span>Add a section</span>
|
|
372
|
-
<select aria-label="Add a section" value={addType} onChange={(e) => setAddType(e.target.value)} disabled={defTypes.length === 0}>
|
|
373
|
-
{defTypes.length === 0 && <option value="">This theme has no sections</option>}
|
|
374
|
-
{defTypes.map((t) => <option key={t} value={t}>{sectionName(defs[t], t)}</option>)}
|
|
375
|
-
</select>
|
|
376
|
-
</label>
|
|
377
|
-
<button className="btn" type="button" onClick={addSection} disabled={!defs[addType]}>Add</button>
|
|
935
|
+
</SortableSection>
|
|
936
|
+
);
|
|
937
|
+
})}
|
|
938
|
+
</ol>
|
|
939
|
+
</SortableContext>
|
|
940
|
+
</DndContext>
|
|
941
|
+
|
|
942
|
+
<div className="te-add" hidden={!isOwner || Boolean(lang)}>
|
|
943
|
+
<button className="btn" type="button" onClick={() => setPickerOpen(true)} disabled={defTypes.length === 0}>+ Add section</button>
|
|
944
|
+
{clip && defs[clip.type] && <button className="btn ghost" type="button" onClick={pasteSection}>Paste “{sectionName(defs[clip.type], clip.type)}”</button>}
|
|
378
945
|
</div>
|
|
946
|
+
<SectionPicker
|
|
947
|
+
open={pickerOpen}
|
|
948
|
+
defs={defs}
|
|
949
|
+
coded={coded}
|
|
950
|
+
counts={Object.fromEntries(defTypes.map((t) => [t, layout.sections.filter((x) => x.type === t).length]))}
|
|
951
|
+
theme={theme?.slug ?? activeTheme}
|
|
952
|
+
onClose={() => setPickerOpen(false)}
|
|
953
|
+
onPick={addSection}
|
|
954
|
+
/>
|
|
955
|
+
{presets.length > 0 && (
|
|
956
|
+
<details className="te-preset-more" hidden={!isOwner}>
|
|
957
|
+
<summary>Start over from a {theme?.name} layout</summary>
|
|
958
|
+
<div className="te-presets">
|
|
959
|
+
{presets.map(([key, p]) => (
|
|
960
|
+
<button key={key} type="button" className="te-preset" onClick={() => applyPreset(key)}>
|
|
961
|
+
<b>{p.name}</b>
|
|
962
|
+
<span>{p.description || `${p.sections.length} sections`}</span>
|
|
963
|
+
</button>
|
|
964
|
+
))}
|
|
965
|
+
</div>
|
|
966
|
+
</details>
|
|
967
|
+
)}
|
|
379
968
|
</>
|
|
380
969
|
)}
|
|
381
970
|
|
|
@@ -388,97 +977,36 @@ export default function ThemeSectionsEditor({ api, activeTheme, unsplashEnabled
|
|
|
388
977
|
{saveError && <p className="flash error te-alert" role="alert">{saveError}</p>}
|
|
389
978
|
|
|
390
979
|
<div className="te-savebar">
|
|
391
|
-
<span className={`te-savestate ${
|
|
392
|
-
{
|
|
980
|
+
<span className={`te-savestate ${saveState === 'error' ? 'is-dirty' : ''}`} aria-live="polite">
|
|
981
|
+
{flash || (saveState === 'error' ? 'Not saved yet' : dirty || saveState === 'saving' ? 'Saving draft…' : saveState === 'saved' ? 'Draft saved · publish when ready' : 'All changes saved')}
|
|
982
|
+
{totalIssues > 0 && <> · <button type="button" className="te-issue-link" onClick={() => setOpenId(firstIssueId)}>{totalIssues} {totalIssues === 1 ? 'field needs' : 'fields need'} fixing before publishing</button></>}
|
|
393
983
|
</span>
|
|
394
|
-
|
|
395
|
-
<button className="btn sm" type="button" onClick={
|
|
396
|
-
|
|
397
|
-
</button>
|
|
984
|
+
<button className="btn sm ghost" type="button" onClick={undo} disabled={!past.current.length} title="Undo (⌘Z)">Undo</button>
|
|
985
|
+
<button className="btn sm ghost" type="button" onClick={redo} disabled={!future.current.length} title="Redo (⌘⇧Z)">Redo</button>
|
|
986
|
+
{saveState === 'error' && <button className="btn sm" type="button" onClick={() => void autosaveRef.current()} disabled={conflict}>Try saving again</button>}
|
|
398
987
|
</div>
|
|
399
988
|
|
|
400
989
|
<ImagePicker
|
|
401
|
-
open={
|
|
990
|
+
open={imageApply !== null}
|
|
402
991
|
unsplashEnabled={unsplashEnabled}
|
|
403
|
-
onClose={() =>
|
|
992
|
+
onClose={() => setImageApply(null)}
|
|
404
993
|
onSelect={(img) => {
|
|
405
|
-
|
|
406
|
-
|
|
994
|
+
imageApply?.(img.src);
|
|
995
|
+
setImageApply(null);
|
|
407
996
|
}}
|
|
408
997
|
/>
|
|
409
998
|
</div>
|
|
410
999
|
);
|
|
411
1000
|
}
|
|
412
1001
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
switch (field.type) {
|
|
422
|
-
case 'textarea':
|
|
423
|
-
return (
|
|
424
|
-
<label className="field" htmlFor={id}>
|
|
425
|
-
<span>{field.label}</span>
|
|
426
|
-
<textarea id={id} rows={4} value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
427
|
-
</label>
|
|
428
|
-
);
|
|
429
|
-
case 'number':
|
|
430
|
-
return (
|
|
431
|
-
<label className="field" htmlFor={id}>
|
|
432
|
-
<span>{field.label}</span>
|
|
433
|
-
<input
|
|
434
|
-
id={id}
|
|
435
|
-
type="number"
|
|
436
|
-
value={shown === '' ? '' : String(shown)}
|
|
437
|
-
onChange={(e) => {
|
|
438
|
-
const n = e.target.valueAsNumber;
|
|
439
|
-
onChange(e.target.value === '' || !Number.isFinite(n) ? undefined : n);
|
|
440
|
-
}}
|
|
441
|
-
/>
|
|
442
|
-
</label>
|
|
443
|
-
);
|
|
444
|
-
case 'select': {
|
|
445
|
-
const opts = field.options ?? [];
|
|
446
|
-
return (
|
|
447
|
-
<label className="field" htmlFor={id}>
|
|
448
|
-
<span>{field.label}</span>
|
|
449
|
-
<select id={id} value={String(shown)} onChange={(e) => onChange(e.target.value)}>
|
|
450
|
-
{!opts.includes(String(shown)) && <option value={String(shown)}>{shown === '' ? 'Choose…' : `${shown} (not an option)`}</option>}
|
|
451
|
-
{opts.map((o) => <option key={o} value={o}>{o}</option>)}
|
|
452
|
-
</select>
|
|
453
|
-
</label>
|
|
454
|
-
);
|
|
455
|
-
}
|
|
456
|
-
case 'image':
|
|
457
|
-
return (
|
|
458
|
-
<div className="field te-image">
|
|
459
|
-
<label htmlFor={id}><span>{field.label}</span></label>
|
|
460
|
-
<div className="te-image-row">
|
|
461
|
-
{shown !== '' && <img className="te-thumb" src={String(shown)} alt="" />}
|
|
462
|
-
<input id={id} className="mono" type="url" placeholder="https://" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
463
|
-
<button className="btn sm" type="button" onClick={onPickImage}>Choose…</button>
|
|
464
|
-
</div>
|
|
465
|
-
</div>
|
|
466
|
-
);
|
|
467
|
-
case 'url':
|
|
468
|
-
return (
|
|
469
|
-
<label className="field" htmlFor={id}>
|
|
470
|
-
<span>{field.label}</span>
|
|
471
|
-
<input id={id} className="mono" type="url" placeholder="https:// or /path" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
472
|
-
</label>
|
|
473
|
-
);
|
|
474
|
-
default:
|
|
475
|
-
return (
|
|
476
|
-
<label className="field" htmlFor={id}>
|
|
477
|
-
<span>{field.label}</span>
|
|
478
|
-
<input id={id} type="text" value={String(shown)} onChange={(e) => onChange(e.target.value)} />
|
|
479
|
-
</label>
|
|
480
|
-
);
|
|
481
|
-
}
|
|
1002
|
+
type DragHandle = Record<string, unknown>;
|
|
1003
|
+
function SortableSection({ id, className, children }: { id: string; className: string; children: (handle: DragHandle) => ReactNode }) {
|
|
1004
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
|
1005
|
+
return (
|
|
1006
|
+
<li ref={setNodeRef} data-sec={id} className={`${className} ${isDragging ? 'is-dragging' : ''}`} style={{ transform: DndCSS.Transform.toString(transform), transition }}>
|
|
1007
|
+
{children({ ...attributes, ...listeners })}
|
|
1008
|
+
</li>
|
|
1009
|
+
);
|
|
482
1010
|
}
|
|
483
1011
|
|
|
484
1012
|
/** Review tab: which pages the staged draft re-lays out, vs the live site */
|