@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,560 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } 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 } from '@dnd-kit/utilities';
|
|
6
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
7
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
8
|
+
import { defaultValue, fieldVisible, itemTitle, newRowId, normalizeValue, SOURCE_LIMIT_MAX } from '../../theme-sdk/fields.mjs';
|
|
9
|
+
|
|
10
|
+
// Inputs for every section field type. Values follow theme-sdk/fields.mjs;
|
|
11
|
+
// each input receives the normalized value and reports the whole new value.
|
|
12
|
+
|
|
13
|
+
export type ScalarType = 'text' | 'textarea' | 'richtext' | 'image' | 'url' | 'link' | 'select' | 'number' | 'toggle' | 'date' | 'time';
|
|
14
|
+
export type Scalar = string | number | boolean;
|
|
15
|
+
export interface LinkValue { label: string; href: string; ref?: string }
|
|
16
|
+
export interface VisibleIf { field: string; equals?: unknown; notEquals?: unknown; in?: unknown[] }
|
|
17
|
+
export interface ScalarFieldDef { label: string; type: ScalarType; default?: unknown; options?: string[]; help?: string; placeholder?: string; visibleIf?: VisibleIf; required?: boolean; maxLength?: number; aiHint?: string; locked?: boolean }
|
|
18
|
+
export interface ListFieldDef { label: string; type: 'list'; fields: Record<string, ScalarFieldDef>; itemLabel?: string; min?: number; max?: number; help?: string; default?: unknown; visibleIf?: VisibleIf; locked?: boolean }
|
|
19
|
+
export interface SourceFieldDef { label: string; type: 'source'; source: 'events' | 'posts' | 'bookings'; help?: string; default?: unknown; visibleIf?: VisibleIf; locked?: boolean }
|
|
20
|
+
export type FieldDef = ScalarFieldDef | ListFieldDef | SourceFieldDef;
|
|
21
|
+
export type Row = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
export interface SectionTargets {
|
|
24
|
+
links: { ref: string; label: string; url: string; group: string }[];
|
|
25
|
+
calendars: { slug: string; name: string }[];
|
|
26
|
+
collections: { key: string; label: string }[];
|
|
27
|
+
tags: string[];
|
|
28
|
+
resources: { slug: string; name: string }[];
|
|
29
|
+
features: { events: boolean; bookings: boolean };
|
|
30
|
+
ai?: { enabled: boolean; languages: string[] };
|
|
31
|
+
i18n?: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const EMPTY_TARGETS: SectionTargets = { links: [], calendars: [], collections: [], tags: [], resources: [], features: { events: false, bookings: false }, ai: { enabled: false, languages: [] }, i18n: { defaultLocale: '', locales: [], drafts: false } };
|
|
35
|
+
|
|
36
|
+
interface InputProps<F, V> {
|
|
37
|
+
id: string;
|
|
38
|
+
field: F;
|
|
39
|
+
value: V;
|
|
40
|
+
onChange: (v: unknown) => void;
|
|
41
|
+
targets: SectionTargets;
|
|
42
|
+
/** open the media picker; `apply` receives the chosen image URL */
|
|
43
|
+
pickImage: (apply: (src: string) => void) => void;
|
|
44
|
+
/** the section's other filled-in text, for AI help ("Heading: Join us") */
|
|
45
|
+
context?: string;
|
|
46
|
+
/** image fields: stored focal point ("30% 20%") and its setter */
|
|
47
|
+
focal?: string;
|
|
48
|
+
onFocal?: (v: string | undefined) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const Help = ({ text }: { text?: string }) => (text ? <small className="hint te-help">{text}</small> : null);
|
|
52
|
+
/** live character counter for fields with a maxLength */
|
|
53
|
+
const Count = ({ max, value }: { max?: number; value: string }) =>
|
|
54
|
+
max ? <b className={`te-count-chars ${value.length > max ? 'is-over' : ''}`}>{value.length} / {max}</b> : null;
|
|
55
|
+
|
|
56
|
+
export function FieldInput(props: InputProps<FieldDef, unknown>) {
|
|
57
|
+
const { field } = props;
|
|
58
|
+
if (field.type === 'list') return <ListInput {...props} field={field} value={props.value as Row[]} />;
|
|
59
|
+
if (field.type === 'source') return <SourceInput {...props} field={field} value={props.value as Row} />;
|
|
60
|
+
return <ScalarInput {...props} field={field} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ScalarInput({ id, field, value, onChange, targets, pickImage, context, focal, onFocal }: InputProps<ScalarFieldDef, unknown>) {
|
|
64
|
+
const str = typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
|
|
65
|
+
switch (field.type) {
|
|
66
|
+
case 'textarea':
|
|
67
|
+
return (
|
|
68
|
+
<label className="field" htmlFor={id}>
|
|
69
|
+
<span className="te-label">{field.label}{field.required && <i className="te-req"> required</i>}<Count max={field.maxLength} value={str} /></span>
|
|
70
|
+
<textarea id={id} rows={3} value={str} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)} />
|
|
71
|
+
<Help text={field.help} />
|
|
72
|
+
<AiAssist kind="textarea" field={field} value={str} onChange={onChange} targets={targets} context={context} />
|
|
73
|
+
</label>
|
|
74
|
+
);
|
|
75
|
+
case 'richtext':
|
|
76
|
+
return (
|
|
77
|
+
<div className="field te-rich-field">
|
|
78
|
+
<label htmlFor={id}><span>{field.label}</span></label>
|
|
79
|
+
<RichTextInput id={id} value={str} onChange={onChange} />
|
|
80
|
+
<Help text={field.help} />
|
|
81
|
+
<AiAssist kind="richtext" field={field} value={str} onChange={onChange} targets={targets} context={context} />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
case 'number':
|
|
85
|
+
return (
|
|
86
|
+
<label className="field" htmlFor={id}>
|
|
87
|
+
<span>{field.label}</span>
|
|
88
|
+
<input id={id} type="number" value={typeof value === 'number' ? String(value) : ''} onChange={(e) => {
|
|
89
|
+
const n = e.target.valueAsNumber;
|
|
90
|
+
onChange(e.target.value === '' || !Number.isFinite(n) ? 0 : n);
|
|
91
|
+
}} />
|
|
92
|
+
<Help text={field.help} />
|
|
93
|
+
</label>
|
|
94
|
+
);
|
|
95
|
+
case 'toggle':
|
|
96
|
+
return (
|
|
97
|
+
<div className="field te-toggle-field">
|
|
98
|
+
<button id={id} type="button" role="switch" aria-checked={value === true} className={`te-switch ${value === true ? 'on' : ''}`} onClick={() => onChange(value !== true)}>
|
|
99
|
+
<i aria-hidden="true" />
|
|
100
|
+
</button>
|
|
101
|
+
<label htmlFor={id}>{field.label}</label>
|
|
102
|
+
<Help text={field.help} />
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
case 'date':
|
|
106
|
+
case 'time':
|
|
107
|
+
return (
|
|
108
|
+
<label className="field" htmlFor={id}>
|
|
109
|
+
<span>{field.label}</span>
|
|
110
|
+
<input id={id} type={field.type} value={str} onChange={(e) => onChange(e.target.value)} />
|
|
111
|
+
<Help text={field.help} />
|
|
112
|
+
</label>
|
|
113
|
+
);
|
|
114
|
+
case 'select': {
|
|
115
|
+
const opts = field.options ?? [];
|
|
116
|
+
return (
|
|
117
|
+
<label className="field" htmlFor={id}>
|
|
118
|
+
<span>{field.label}</span>
|
|
119
|
+
<select id={id} value={str} onChange={(e) => onChange(e.target.value)}>
|
|
120
|
+
{!opts.includes(str) && <option value={str}>{str === '' ? 'Choose…' : `${str} (not an option)`}</option>}
|
|
121
|
+
{opts.map((o) => <option key={o} value={o}>{o}</option>)}
|
|
122
|
+
</select>
|
|
123
|
+
<Help text={field.help} />
|
|
124
|
+
</label>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
case 'image':
|
|
128
|
+
return (
|
|
129
|
+
<div className="field te-image">
|
|
130
|
+
<label htmlFor={id}><span>{field.label}</span></label>
|
|
131
|
+
<div className="te-image-row">
|
|
132
|
+
{str !== '' && <img className="te-thumb" src={str} alt="" />}
|
|
133
|
+
<input id={id} className="mono" type="url" placeholder="https://" value={str} onChange={(e) => onChange(e.target.value)} />
|
|
134
|
+
<button className="btn sm" type="button" onClick={() => pickImage((src) => onChange(src))}>Choose…</button>
|
|
135
|
+
</div>
|
|
136
|
+
{str !== '' && onFocal && <FocalPoint src={str} value={focal} onChange={onFocal} />}
|
|
137
|
+
<Help text={field.help} />
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
case 'url':
|
|
141
|
+
return (
|
|
142
|
+
<label className="field" htmlFor={id}>
|
|
143
|
+
<span>{field.label}</span>
|
|
144
|
+
<input id={id} className="mono" type="url" placeholder={field.placeholder ?? 'https:// or /path'} value={str} onChange={(e) => onChange(e.target.value)} />
|
|
145
|
+
<Help text={field.help} />
|
|
146
|
+
</label>
|
|
147
|
+
);
|
|
148
|
+
case 'link':
|
|
149
|
+
return <LinkInput id={id} label={field.label} help={field.help} value={value as LinkValue} onChange={onChange} targets={targets} />;
|
|
150
|
+
default:
|
|
151
|
+
return (
|
|
152
|
+
<label className="field" htmlFor={id}>
|
|
153
|
+
<span className="te-label">{field.label}{field.required && <i className="te-req"> required</i>}<Count max={field.maxLength} value={str} /></span>
|
|
154
|
+
<input id={id} type="text" value={str} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)} />
|
|
155
|
+
<Help text={field.help} />
|
|
156
|
+
<AiAssist kind="text" field={field} value={str} onChange={onChange} targets={targets} context={context} />
|
|
157
|
+
</label>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- image focal point -----------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function FocalPoint({ src, value, onChange }: { src: string; value?: string; onChange: (v: string | undefined) => void }) {
|
|
165
|
+
const [open, setOpen] = useState(false);
|
|
166
|
+
const m = /^(\d+)% (\d+)%$/.exec(value ?? '');
|
|
167
|
+
const x = m ? Number(m[1]) : 50, y = m ? Number(m[2]) : 50;
|
|
168
|
+
const label = !m || (x === 50 && y === 50) ? 'center' : `${x < 34 ? 'left' : x > 66 ? 'right' : 'middle'} · ${y < 34 ? 'top' : y > 66 ? 'bottom' : 'middle'}`;
|
|
169
|
+
const set = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
170
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
171
|
+
const nx = Math.round(((e.clientX - r.left) / r.width) * 100), ny = Math.round(((e.clientY - r.top) / r.height) * 100);
|
|
172
|
+
onChange(`${Math.max(0, Math.min(100, nx))}% ${Math.max(0, Math.min(100, ny))}%`);
|
|
173
|
+
};
|
|
174
|
+
return (
|
|
175
|
+
<div className="te-focal">
|
|
176
|
+
<button type="button" className="te-focal-toggle" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
177
|
+
Focus: {label} · {open ? 'Done' : 'Set focus'}
|
|
178
|
+
</button>
|
|
179
|
+
{open && (
|
|
180
|
+
<>
|
|
181
|
+
<div className="te-focal-img" onPointerDown={set} role="application" aria-label="Click the most important part of the photo">
|
|
182
|
+
<img src={src} alt="" draggable={false} />
|
|
183
|
+
<span className="te-focal-dot" style={{ left: `${x}%`, top: `${y}%` }} aria-hidden="true" />
|
|
184
|
+
</div>
|
|
185
|
+
<div className="te-focal-preview" aria-hidden="true">
|
|
186
|
+
{[['Wide', '16 / 7'], ['Card', '4 / 3'], ['Phone', '3 / 4']].map(([n, r]) => (
|
|
187
|
+
<span key={n}><img src={src} alt="" style={{ aspectRatio: r, objectPosition: `${x}% ${y}%` }} /><small>{n}</small></span>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
<small className="hint te-help">Click the part that must never be cropped, like a face. {m && <button type="button" className="te-reset-inline" onClick={() => onChange(undefined)}>Reset to center</button>}</small>
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- AI help ("Help me write") ---------------------------------------------
|
|
198
|
+
|
|
199
|
+
type AiAction = 'shorter' | 'friendlier' | 'clearer' | 'translate' | 'draft';
|
|
200
|
+
function AiAssist({ kind, field, value, onChange, targets, context }: {
|
|
201
|
+
kind: 'text' | 'textarea' | 'richtext'; field: ScalarFieldDef; value: string; onChange: (v: unknown) => void; targets: SectionTargets; context?: string;
|
|
202
|
+
}) {
|
|
203
|
+
const [menu, setMenu] = useState(false);
|
|
204
|
+
const [busy, setBusy] = useState(false);
|
|
205
|
+
const [err, setErr] = useState('');
|
|
206
|
+
const [lang, setLang] = useState('');
|
|
207
|
+
const [suggestion, setSuggestion] = useState<{ text: string; action: AiAction } | null>(null);
|
|
208
|
+
const box = useRef<HTMLSpanElement>(null);
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!menu) return;
|
|
211
|
+
const close = (e: MouseEvent) => { if (!box.current?.contains(e.target as Node)) setMenu(false); };
|
|
212
|
+
addEventListener('mousedown', close);
|
|
213
|
+
return () => removeEventListener('mousedown', close);
|
|
214
|
+
}, [menu]);
|
|
215
|
+
if (!targets.ai?.enabled) return null;
|
|
216
|
+
const empty = !value.replace(/<[^>]+>/g, '').trim();
|
|
217
|
+
const run = async (action: AiAction, language = lang) => {
|
|
218
|
+
setMenu(false); setBusy(true); setErr('');
|
|
219
|
+
try {
|
|
220
|
+
const res = await fetch('/api/design/ai', {
|
|
221
|
+
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
222
|
+
body: JSON.stringify({ action, text: value, label: field.label, kind, hint: field.aiHint, maxLength: field.maxLength, language: action === 'translate' ? language : undefined, context }),
|
|
223
|
+
});
|
|
224
|
+
const body = await res.json().catch(() => ({})) as { text?: string; error?: string };
|
|
225
|
+
if (!res.ok || !body.text) { setErr(body.error || `AI help failed (${res.status}).`); return; }
|
|
226
|
+
setSuggestion({ text: body.text, action });
|
|
227
|
+
} catch { setErr('Couldn’t reach the server.'); } finally { setBusy(false); }
|
|
228
|
+
};
|
|
229
|
+
const item = (label: string, action: AiAction, disabled = false) => (
|
|
230
|
+
<button type="button" role="menuitem" disabled={disabled} onClick={(e) => { e.preventDefault(); void run(action); }}>{label}</button>
|
|
231
|
+
);
|
|
232
|
+
return (
|
|
233
|
+
<span className={`te-ai ${menu || busy || suggestion || err ? 'is-active' : ''}`} ref={box}>
|
|
234
|
+
<button type="button" className="te-ai-btn" aria-haspopup="menu" aria-expanded={menu} disabled={busy} onClick={(e) => { e.preventDefault(); setMenu(!menu); }}>
|
|
235
|
+
{busy ? 'Writing…' : '✦ Help me write'}
|
|
236
|
+
</button>
|
|
237
|
+
{menu && (
|
|
238
|
+
<span className="te-ai-menu" role="menu">
|
|
239
|
+
{empty ? item('Write a first draft', 'draft') : (
|
|
240
|
+
<>
|
|
241
|
+
{item('Make it shorter', 'shorter')}
|
|
242
|
+
{item('Make it friendlier', 'friendlier')}
|
|
243
|
+
{item('Fix spelling and clarity', 'clearer')}
|
|
244
|
+
<span className="te-ai-translate">
|
|
245
|
+
<select aria-label="Translate to" value={lang} onChange={(e) => { setLang(e.target.value); if (e.target.value) void run('translate', e.target.value); }}>
|
|
246
|
+
<option value="">Translate to…</option>
|
|
247
|
+
{targets.ai.languages.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
248
|
+
</select>
|
|
249
|
+
</span>
|
|
250
|
+
{item('Rewrite from scratch', 'draft')}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
<small>Uses your AI provider. You review it before anything changes.</small>
|
|
254
|
+
</span>
|
|
255
|
+
)}
|
|
256
|
+
{err && <small className="te-error">{err}</small>}
|
|
257
|
+
{suggestion && (
|
|
258
|
+
<span className="te-ai-suggest" role="status">
|
|
259
|
+
{kind === 'richtext'
|
|
260
|
+
? <span className="te-ai-text" dangerouslySetInnerHTML={{ __html: suggestion.text }} />
|
|
261
|
+
: <span className="te-ai-text">{suggestion.text}</span>}
|
|
262
|
+
<span className="te-ai-actions">
|
|
263
|
+
<button type="button" className="btn sm primary" onClick={(e) => { e.preventDefault(); onChange(suggestion.text); setSuggestion(null); }}>Use this</button>
|
|
264
|
+
<button type="button" className="btn sm" onClick={(e) => { e.preventDefault(); void run(suggestion.action); }}>Try again</button>
|
|
265
|
+
<button type="button" className="btn sm ghost" onClick={(e) => { e.preventDefault(); setSuggestion(null); }}>Dismiss</button>
|
|
266
|
+
</span>
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
</span>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- link -----------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
const CUSTOM = '__custom';
|
|
276
|
+
|
|
277
|
+
function LinkInput({ id, label, help, value, onChange, targets }: { id: string; label: string; help?: string; value: LinkValue; onChange: (v: unknown) => void; targets: SectionTargets }) {
|
|
278
|
+
const v = value ?? { label: '', href: '' };
|
|
279
|
+
const target = v.ref ? targets.links.find((t) => t.ref === v.ref) : undefined;
|
|
280
|
+
// a ref whose target vanished (unpublished/deleted) keeps its stored href
|
|
281
|
+
const choice = target ? target.ref : v.ref ? `${v.ref}` : CUSTOM;
|
|
282
|
+
const groups = [...new Set(targets.links.map((t) => t.group))];
|
|
283
|
+
return (
|
|
284
|
+
<fieldset className="field te-link">
|
|
285
|
+
<legend>{label}</legend>
|
|
286
|
+
<div className="te-link-row">
|
|
287
|
+
<input id={id} type="text" aria-label={`${label} text`} placeholder="Button text" value={v.label} onChange={(e) => onChange({ ...v, label: e.target.value })} />
|
|
288
|
+
<select aria-label={`${label} goes to`} value={choice} onChange={(e) => {
|
|
289
|
+
const t = targets.links.find((x) => x.ref === e.target.value);
|
|
290
|
+
onChange(t ? { label: v.label || t.label, href: t.url, ref: t.ref } : { label: v.label, href: v.href });
|
|
291
|
+
}}>
|
|
292
|
+
<option value={CUSTOM}>Custom URL…</option>
|
|
293
|
+
{v.ref && !target && <option value={v.ref}>Missing: {v.href || v.ref}</option>}
|
|
294
|
+
{groups.map((g) => (
|
|
295
|
+
<optgroup key={g} label={g}>
|
|
296
|
+
{targets.links.filter((t) => t.group === g).map((t) => <option key={t.ref} value={t.ref}>{t.label}</option>)}
|
|
297
|
+
</optgroup>
|
|
298
|
+
))}
|
|
299
|
+
</select>
|
|
300
|
+
</div>
|
|
301
|
+
{choice === CUSTOM ? (
|
|
302
|
+
<input className="mono te-link-url" type="url" aria-label={`${label} URL`} placeholder="https:// or /path" value={v.href} onChange={(e) => onChange({ label: v.label, href: e.target.value })} />
|
|
303
|
+
) : (
|
|
304
|
+
<small className="hint te-help">{target ? `Goes to ${target.url} · follows the page if its address changes` : 'That page is no longer published; the saved address is used.'}</small>
|
|
305
|
+
)}
|
|
306
|
+
<Help text={help} />
|
|
307
|
+
</fieldset>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- source (linked data) -------------------------------------------------
|
|
312
|
+
|
|
313
|
+
function SourceInput({ id, field, value, onChange, targets }: InputProps<SourceFieldDef, Row>) {
|
|
314
|
+
const v = value ?? {};
|
|
315
|
+
const set = (patch: Row) => {
|
|
316
|
+
const next: Row = { ...v, ...patch };
|
|
317
|
+
for (const k of Object.keys(next)) if (next[k] === '' || next[k] === undefined) delete next[k];
|
|
318
|
+
onChange(next);
|
|
319
|
+
};
|
|
320
|
+
const limit = typeof v.limit === 'number' ? v.limit : 3;
|
|
321
|
+
const off = (field.source === 'events' && !targets.features.events) || (field.source === 'bookings' && !targets.features.bookings);
|
|
322
|
+
let from = '';
|
|
323
|
+
let picker: ReactNode = null;
|
|
324
|
+
if (field.source === 'events') {
|
|
325
|
+
const cal = targets.calendars.find((c) => c.slug === v.calendar);
|
|
326
|
+
from = `Calendar${cal ? ` → ${cal.name}` : ' · all public calendars'}`;
|
|
327
|
+
picker = (
|
|
328
|
+
<select id={id} aria-label="Calendar" value={String(v.calendar ?? '')} onChange={(e) => set({ calendar: e.target.value })}>
|
|
329
|
+
<option value="">All public calendars</option>
|
|
330
|
+
{targets.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
331
|
+
</select>
|
|
332
|
+
);
|
|
333
|
+
} else if (field.source === 'posts') {
|
|
334
|
+
const col = targets.collections.find((c) => c.key === (v.collection ?? 'posts'));
|
|
335
|
+
from = `${col?.label ?? 'Posts'}${v.tag ? ` tagged “${v.tag}”` : ''}`;
|
|
336
|
+
picker = (
|
|
337
|
+
<>
|
|
338
|
+
<select id={id} aria-label="Collection" value={String(v.collection ?? 'posts')} onChange={(e) => set({ collection: e.target.value === 'posts' ? '' : e.target.value })}>
|
|
339
|
+
{targets.collections.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
|
340
|
+
</select>
|
|
341
|
+
<select aria-label="Tag" value={String(v.tag ?? '')} onChange={(e) => set({ tag: e.target.value })}>
|
|
342
|
+
<option value="">Any tag</option>
|
|
343
|
+
{targets.tags.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
344
|
+
</select>
|
|
345
|
+
</>
|
|
346
|
+
);
|
|
347
|
+
} else {
|
|
348
|
+
const res = targets.resources.find((r) => r.slug === v.resource);
|
|
349
|
+
from = `Booking${res ? ` → ${res.name}` : ' · every public resource'}`;
|
|
350
|
+
picker = (
|
|
351
|
+
<select id={id} aria-label="Resource" value={String(v.resource ?? '')} onChange={(e) => set({ resource: e.target.value })}>
|
|
352
|
+
<option value="">Every public resource</option>
|
|
353
|
+
{targets.resources.map((r) => <option key={r.slug} value={r.slug}>{r.name}</option>)}
|
|
354
|
+
</select>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return (
|
|
358
|
+
<fieldset className="field te-source">
|
|
359
|
+
<legend>{field.label}</legend>
|
|
360
|
+
{off ? (
|
|
361
|
+
<p className="notice warn te-note">{field.source === 'events' ? 'The calendar is turned off in this site’s config, so this shows nothing.' : 'Bookings are turned off in this site’s config, so this shows nothing.'}</p>
|
|
362
|
+
) : (
|
|
363
|
+
<>
|
|
364
|
+
<div className="te-source-row">
|
|
365
|
+
{picker}
|
|
366
|
+
<label className="te-source-limit">
|
|
367
|
+
<span>Show</span>
|
|
368
|
+
<input type="number" min={1} max={SOURCE_LIMIT_MAX} value={limit} onChange={(e) => {
|
|
369
|
+
const n = Math.round(e.target.valueAsNumber);
|
|
370
|
+
set({ limit: Number.isFinite(n) ? Math.max(1, Math.min(SOURCE_LIMIT_MAX, n)) : 3 });
|
|
371
|
+
}} />
|
|
372
|
+
</label>
|
|
373
|
+
</div>
|
|
374
|
+
<p className="te-linked"><b>Linked</b> · {from}. Updates on its own when that content changes.</p>
|
|
375
|
+
</>
|
|
376
|
+
)}
|
|
377
|
+
<Help text={field.help} />
|
|
378
|
+
</fieldset>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---- rich text ------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
export function RichTextInput({ id, value, onChange }: { id: string; value: string; onChange: (v: unknown) => void }) {
|
|
385
|
+
const last = useRef(value);
|
|
386
|
+
const editor = useEditor({
|
|
387
|
+
extensions: [StarterKit.configure({ heading: { levels: [3, 4] }, codeBlock: false, horizontalRule: false, link: { openOnClick: false, autolink: true } })],
|
|
388
|
+
content: value || '',
|
|
389
|
+
immediatelyRender: false,
|
|
390
|
+
editorProps: { attributes: { id, class: 'te-rich-body', role: 'textbox', 'aria-multiline': 'true' } },
|
|
391
|
+
onUpdate: ({ editor }) => {
|
|
392
|
+
const html = editor.isEmpty ? '' : editor.getHTML();
|
|
393
|
+
last.current = html;
|
|
394
|
+
onChange(html);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
// inside a disabled (locked) fieldset the editor is read-only too
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (!editor) return;
|
|
400
|
+
const locked = Boolean(editor.view.dom.closest('fieldset:disabled'));
|
|
401
|
+
if (editor.isEditable === locked) editor.setEditable(!locked);
|
|
402
|
+
});
|
|
403
|
+
// outside changes (discard, reload) replace the document
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (editor && value !== last.current) {
|
|
406
|
+
last.current = value;
|
|
407
|
+
editor.commands.setContent(value || '', { emitUpdate: false });
|
|
408
|
+
}
|
|
409
|
+
}, [editor, value]);
|
|
410
|
+
const btn = (label: string, title: string, active: boolean, run: () => void) => (
|
|
411
|
+
<button type="button" className={`te-rich-btn ${active ? 'on' : ''}`} title={title} aria-label={title} aria-pressed={active} onMouseDown={(e) => e.preventDefault()} onClick={run}>{label}</button>
|
|
412
|
+
);
|
|
413
|
+
return (
|
|
414
|
+
<div className="te-rich">
|
|
415
|
+
{editor && (
|
|
416
|
+
<div className="te-rich-bar" role="toolbar" aria-label="Formatting">
|
|
417
|
+
{btn('B', 'Bold', editor.isActive('bold'), () => editor.chain().focus().toggleBold().run())}
|
|
418
|
+
{btn('I', 'Italic', editor.isActive('italic'), () => editor.chain().focus().toggleItalic().run())}
|
|
419
|
+
{btn('•', 'Bulleted list', editor.isActive('bulletList'), () => editor.chain().focus().toggleBulletList().run())}
|
|
420
|
+
{btn('1.', 'Numbered list', editor.isActive('orderedList'), () => editor.chain().focus().toggleOrderedList().run())}
|
|
421
|
+
{btn('↗', editor.isActive('link') ? 'Remove link' : 'Link', editor.isActive('link'), () => {
|
|
422
|
+
if (editor.isActive('link')) { editor.chain().focus().unsetLink().run(); return; }
|
|
423
|
+
const href = window.prompt('Link to (https://… or /path)');
|
|
424
|
+
if (href && /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(href.trim())) editor.chain().focus().setLink({ href: href.trim() }).run();
|
|
425
|
+
})}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
<EditorContent editor={editor} />
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- list (repeater) ------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
/** "Services" → "service", "People" → "person", "Photos" → "photo" */
|
|
436
|
+
const singular = (label: string) => {
|
|
437
|
+
const w = label.trim().toLowerCase();
|
|
438
|
+
if (w === 'people') return 'person';
|
|
439
|
+
if (/(ies)$/.test(w)) return w.replace(/ies$/, 'y');
|
|
440
|
+
if (/(ches|shes|sses|xes)$/.test(w)) return w.replace(/es$/, '');
|
|
441
|
+
return w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w || 'item';
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
let rowSeq = 0;
|
|
445
|
+
const rowId = () => `row-${++rowSeq}`;
|
|
446
|
+
|
|
447
|
+
function ListInput({ id, field, value, onChange, targets, pickImage }: InputProps<ListFieldDef, Row[]>) {
|
|
448
|
+
const rows = Array.isArray(value) ? value : [];
|
|
449
|
+
const max = field.max ?? 50;
|
|
450
|
+
// dnd-kit needs stable ids; rows are plain data, so ids live beside them
|
|
451
|
+
const [ids, setIds] = useState<string[]>(() => rows.map(rowId));
|
|
452
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (ids.length !== rows.length) setIds(rows.map(rowId));
|
|
455
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
456
|
+
}, [rows.length]);
|
|
457
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
458
|
+
const commit = (next: Row[], nextIds: string[]) => { setIds(nextIds); onChange(next); };
|
|
459
|
+
// rows carry a stable hidden id so their translations follow them when reordered
|
|
460
|
+
const newRow = (): Row => ({ _id: newRowId(), ...Object.fromEntries(Object.entries(field.fields).map(([k, f]) => [k, defaultValue(f)])) });
|
|
461
|
+
const add = () => {
|
|
462
|
+
const nid = rowId();
|
|
463
|
+
commit([...rows, newRow()], [...ids, nid]);
|
|
464
|
+
setOpen(nid);
|
|
465
|
+
};
|
|
466
|
+
const remove = (i: number) => {
|
|
467
|
+
if (!window.confirm(`Remove “${itemTitle(field, rows[i], i)}”?`)) return;
|
|
468
|
+
commit(rows.filter((_, j) => j !== i), ids.filter((_, j) => j !== i));
|
|
469
|
+
};
|
|
470
|
+
const duplicate = (i: number) => {
|
|
471
|
+
const nid = rowId();
|
|
472
|
+
commit([...rows.slice(0, i + 1), { ...structuredClone(rows[i]), _id: newRowId() }, ...rows.slice(i + 1)], [...ids.slice(0, i + 1), nid, ...ids.slice(i + 1)]);
|
|
473
|
+
setOpen(nid);
|
|
474
|
+
};
|
|
475
|
+
const setCell = (i: number, key: string, v: unknown) => onChange(rows.map((r, j) => {
|
|
476
|
+
if (j !== i) return r;
|
|
477
|
+
const next = { ...r, [key]: v };
|
|
478
|
+
if (v === undefined) delete next[key];
|
|
479
|
+
return next;
|
|
480
|
+
}));
|
|
481
|
+
const onDragEnd = (e: DragEndEvent) => {
|
|
482
|
+
if (!e.over || e.active.id === e.over.id) return;
|
|
483
|
+
const from = ids.indexOf(String(e.active.id));
|
|
484
|
+
const to = ids.indexOf(String(e.over.id));
|
|
485
|
+
if (from < 0 || to < 0) return;
|
|
486
|
+
commit(arrayMove(rows, from, to), arrayMove(ids, from, to));
|
|
487
|
+
};
|
|
488
|
+
const safeIds = ids.length === rows.length ? ids : rows.map((_, i) => `pending-${i}`);
|
|
489
|
+
return (
|
|
490
|
+
<fieldset className="field te-list-field">
|
|
491
|
+
<legend>{field.label} <span className="te-count">{rows.length} of {max}</span></legend>
|
|
492
|
+
<div className="te-rows">
|
|
493
|
+
{rows.length === 0 && <p className="te-muted te-rows-empty">Nothing here yet.</p>}
|
|
494
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
495
|
+
<SortableContext items={safeIds} strategy={verticalListSortingStrategy}>
|
|
496
|
+
{rows.map((row, i) => (
|
|
497
|
+
<ListRow
|
|
498
|
+
key={safeIds[i]}
|
|
499
|
+
sortId={safeIds[i]}
|
|
500
|
+
title={itemTitle(field, row, i)}
|
|
501
|
+
open={open === safeIds[i]}
|
|
502
|
+
onToggle={() => setOpen(open === safeIds[i] ? null : safeIds[i])}
|
|
503
|
+
onRemove={() => remove(i)}
|
|
504
|
+
onDuplicate={rows.length < max ? () => duplicate(i) : undefined}
|
|
505
|
+
>
|
|
506
|
+
{Object.entries(field.fields).filter(([, sub]) => fieldVisible(sub, row)).map(([key, sub]) => (
|
|
507
|
+
<FieldInput
|
|
508
|
+
key={key}
|
|
509
|
+
id={`${id}-${safeIds[i]}-${key}`}
|
|
510
|
+
field={sub}
|
|
511
|
+
value={normalizeValue(sub, row[key])}
|
|
512
|
+
onChange={(v) => setCell(i, key, v)}
|
|
513
|
+
targets={targets}
|
|
514
|
+
pickImage={pickImage}
|
|
515
|
+
focal={sub.type === 'image' ? String(row[`${key}__focus`] ?? '') : undefined}
|
|
516
|
+
onFocal={sub.type === 'image' ? (v) => setCell(i, `${key}__focus`, v) : undefined}
|
|
517
|
+
/>
|
|
518
|
+
))}
|
|
519
|
+
</ListRow>
|
|
520
|
+
))}
|
|
521
|
+
</SortableContext>
|
|
522
|
+
</DndContext>
|
|
523
|
+
<button className="te-row-add" type="button" onClick={add} disabled={rows.length >= max}>
|
|
524
|
+
+ Add {singular(field.label)}
|
|
525
|
+
</button>
|
|
526
|
+
</div>
|
|
527
|
+
{field.min !== undefined && rows.length < field.min && <small className="hint te-help te-warn">Needs at least {field.min}.</small>}
|
|
528
|
+
<Help text={field.help} />
|
|
529
|
+
</fieldset>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function ListRow({ sortId, title, open, onToggle, onRemove, onDuplicate, children }: {
|
|
534
|
+
sortId: string; title: string; open: boolean; onToggle: () => void; onRemove: () => void; onDuplicate?: () => void; children: ReactNode;
|
|
535
|
+
}) {
|
|
536
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortId });
|
|
537
|
+
return (
|
|
538
|
+
<div ref={setNodeRef} className={`te-row ${open ? 'is-open' : ''} ${isDragging ? 'is-dragging' : ''}`} style={{ transform: CSS.Transform.toString(transform), transition }}>
|
|
539
|
+
<div className="te-row-head">
|
|
540
|
+
<button className="te-grip" type="button" aria-label={`Reorder ${title}`} {...attributes} {...listeners}>⋮⋮</button>
|
|
541
|
+
<button className="te-row-title" type="button" aria-expanded={open} onClick={onToggle}>{title}</button>
|
|
542
|
+
<svg className="te-row-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
|
543
|
+
</div>
|
|
544
|
+
{open && (
|
|
545
|
+
<>
|
|
546
|
+
<div className="te-sheet-backdrop" onClick={onToggle} aria-hidden="true" />
|
|
547
|
+
<div className="te-row-body" role="group" aria-label={title}>
|
|
548
|
+
<div className="te-sheet-head"><b>{title}</b></div>
|
|
549
|
+
{children}
|
|
550
|
+
<div className="te-row-foot">
|
|
551
|
+
<button className="btn sm ghost te-delete" type="button" onClick={onRemove}>Remove</button>
|
|
552
|
+
{onDuplicate && <button className="btn sm ghost" type="button" onClick={onDuplicate}>Duplicate</button>}
|
|
553
|
+
<button className="btn sm primary te-sheet-done" type="button" onClick={onToggle}>Done</button>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
</>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
}
|