@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/sections.mjs +186 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +55 -1
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +4 -2
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +19 -171
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/editor/PostEditor.tsx +174 -10
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +6 -1
- package/src/layouts/Site.astro +107 -19
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/content-service.ts +198 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +19 -11
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +8 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +40 -7
- package/src/lib/repo/types.ts +9 -3
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +27 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +217 -0
- package/src/lib/theme.ts +16 -1
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +32 -0
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +69 -0
- package/src/styles/editor.css +21 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/sections.css +7 -1
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +326 -0
- package/src/theme-sdk/index.ts +10 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +1 -0
package/src/actions/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { ActionAPIContext } from 'astro:actions';
|
|
|
7
7
|
import { getRepo } from '../lib/repo';
|
|
8
8
|
import { getMediaStore } from '../lib/media/store';
|
|
9
9
|
import { validateSession, SESSION_COOKIE } from '../lib/auth';
|
|
10
|
-
import { LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
10
|
+
import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
11
11
|
import { env } from '../lib/env';
|
|
12
12
|
import { CONTENT, collectionUrl } from '../lib/content';
|
|
13
13
|
import { invalidateRedirectCache } from '../middleware';
|
|
@@ -30,6 +30,7 @@ import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designD
|
|
|
30
30
|
import { shareEpoch } from '../lib/drafts';
|
|
31
31
|
import { createDesignToken } from '../lib/preview';
|
|
32
32
|
import { getTheme } from '../lib/theme';
|
|
33
|
+
import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
|
|
33
34
|
|
|
34
35
|
// "What changes" for the Design screen: the staged draft vs the live design
|
|
35
36
|
async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
|
|
@@ -65,79 +66,16 @@ const BlockInput = z.object({
|
|
|
65
66
|
|
|
66
67
|
// ---- calendar helpers ------------------------------------------------------
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (out.slug !== undefined) {
|
|
75
|
-
const base = slugify(out.slug || out.title || before.title);
|
|
76
|
-
let slug = base;
|
|
77
|
-
for (let n = 2; ; n++) {
|
|
78
|
-
const clash = await repo.getEventBySlug(slug);
|
|
79
|
-
if (!clash || clash.id === before.id) break;
|
|
80
|
-
slug = `${base}-${n}`;
|
|
81
|
-
}
|
|
82
|
-
out.slug = slug;
|
|
83
|
-
}
|
|
84
|
-
if (out.timezone !== undefined && out.timezone !== '' && !isValidTimeZone(out.timezone)) {
|
|
85
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: `“${out.timezone}” isn’t a timezone we recognize.` });
|
|
86
|
-
}
|
|
87
|
-
if (out.start !== undefined || out.end !== undefined || out.allDay !== undefined) {
|
|
88
|
-
const merged = before.draft ? { ...before, ...(before.draft as Partial<CalEvent>) } : before;
|
|
89
|
-
try {
|
|
90
|
-
const t = normalizeEventTimes({ start: out.start ?? merged.start, end: out.end ?? merged.end, allDay: out.allDay ?? merged.allDay });
|
|
91
|
-
out.start = t.start;
|
|
92
|
-
out.end = t.end;
|
|
93
|
-
} catch (err) {
|
|
94
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: err instanceof Error ? err.message : 'Invalid time.' });
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (out.calendarIds !== undefined) {
|
|
98
|
-
const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
|
|
99
|
-
out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
|
|
100
|
-
}
|
|
101
|
-
if (out.fields !== undefined) {
|
|
102
|
-
const allowed = new Map(CALENDAR.eventFields.map((f) => [f.key, f]));
|
|
103
|
-
out.fields = Object.fromEntries(
|
|
104
|
-
Object.entries(out.fields)
|
|
105
|
-
.filter(([k, v]) => allowed.has(k) && v !== '')
|
|
106
|
-
.filter(([k, v]) => allowed.get(k)!.type !== 'select' || allowed.get(k)!.options.includes(v))
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
if (out.cta !== undefined) {
|
|
110
|
-
const href = out.cta.href.trim();
|
|
111
|
-
if (href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(href)) {
|
|
112
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: 'The button link must start with https://, /, mailto:, or tel:.' });
|
|
113
|
-
}
|
|
114
|
-
out.cta = { ...out.cta, href };
|
|
115
|
-
}
|
|
116
|
-
if (out.location?.url) {
|
|
117
|
-
if (!/^https?:\/\//i.test(out.location.url.trim())) {
|
|
118
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: 'The map or meeting link must start with https://.' });
|
|
119
|
-
}
|
|
69
|
+
// Save/Update logic is shared with the editor-app API (lib/content-service).
|
|
70
|
+
// Its ApiErrors become ActionErrors with the same message.
|
|
71
|
+
function asActionError(error: unknown): never {
|
|
72
|
+
if (error instanceof ApiError) {
|
|
73
|
+
const code = error.status === 404 ? 'NOT_FOUND' : error.status === 403 ? 'FORBIDDEN' : error.status === 409 ? 'CONFLICT' : error.status === 401 ? 'UNAUTHORIZED' : 'BAD_REQUEST';
|
|
74
|
+
throw new ActionError({ code, message: error.message });
|
|
120
75
|
}
|
|
121
|
-
|
|
122
|
-
if (out.status === 'scheduled' && !(out.scheduledAt && out.scheduledAt > Date.now())) {
|
|
123
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick a future publish time to schedule.' });
|
|
124
|
-
}
|
|
125
|
-
if (out.status === 'published') out.scheduledAt = null;
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function eventSideEffects(
|
|
130
|
-
repo: Awaited<ReturnType<typeof getRepo>>,
|
|
131
|
-
ctx: ActionAPIContext,
|
|
132
|
-
before: CalEvent,
|
|
133
|
-
after: CalEvent,
|
|
134
|
-
applied = false
|
|
135
|
-
) {
|
|
136
|
-
const settings = await repo.getSettings();
|
|
137
|
-
await eventChanged(repo, before, after, {
|
|
138
|
-
origin: siteOrigin(settings, ctx.url), waitUntil: waitUntilFrom(ctx.locals), applied, onRedirect: invalidateRedirectCache,
|
|
139
|
-
});
|
|
76
|
+
throw error;
|
|
140
77
|
}
|
|
78
|
+
const hooksFor = (ctx: ActionAPIContext): SaveHooks => ({ url: ctx.url, locals: ctx.locals, onRedirect: invalidateRedirectCache });
|
|
141
79
|
|
|
142
80
|
async function importEvents(
|
|
143
81
|
repo: Awaited<ReturnType<typeof getRepo>>,
|
|
@@ -178,6 +116,7 @@ export const server = {
|
|
|
178
116
|
coverAlt: z.string().optional(),
|
|
179
117
|
seoTitle: z.string().optional(),
|
|
180
118
|
seoDesc: z.string().optional(),
|
|
119
|
+
translations: DocTranslations.optional(),
|
|
181
120
|
status: StatusInput.optional(),
|
|
182
121
|
featured: z.boolean().optional(),
|
|
183
122
|
authorId: z.string().nullable().optional(),
|
|
@@ -189,38 +128,7 @@ export const server = {
|
|
|
189
128
|
handler: async ({ id, ...data }, ctx) => {
|
|
190
129
|
await requireAdmin(ctx);
|
|
191
130
|
const repo = await getRepo(ctx.locals);
|
|
192
|
-
|
|
193
|
-
if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
|
|
194
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
|
|
195
|
-
}
|
|
196
|
-
const before = await repo.getPost(id);
|
|
197
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
198
|
-
// A live (published/scheduled) post never takes content edits
|
|
199
|
-
// directly: they stage into the pending draft until the user hits
|
|
200
|
-
// Update. Status transitions stay deliberate, immediate writes.
|
|
201
|
-
const transitions = data.status !== undefined && data.status !== before.status;
|
|
202
|
-
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
203
|
-
const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
|
|
204
|
-
await repo.savePostDraft(id, staged);
|
|
205
|
-
return { post: (await repo.getPost(id))! };
|
|
206
|
-
}
|
|
207
|
-
await repo.updatePost(id, data);
|
|
208
|
-
// any new tag names become registry entries with slugs for archives
|
|
209
|
-
if (data.tags !== undefined) await repo.ensureTags(data.tags);
|
|
210
|
-
const post = await repo.getPost(id);
|
|
211
|
-
if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
212
|
-
// a published post whose slug or collection changed leaves a 301
|
|
213
|
-
// behind so old links keep working
|
|
214
|
-
if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
215
|
-
await repo
|
|
216
|
-
.createRedirect({
|
|
217
|
-
fromPath: collectionUrl(before.collection, before.slug),
|
|
218
|
-
toPath: collectionUrl(post.collection, post.slug),
|
|
219
|
-
})
|
|
220
|
-
.catch(() => {});
|
|
221
|
-
invalidateRedirectCache();
|
|
222
|
-
}
|
|
223
|
-
return { post };
|
|
131
|
+
try { return { post: await savePost(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
224
132
|
},
|
|
225
133
|
}),
|
|
226
134
|
// Update button: copy the pending draft onto the live columns. The
|
|
@@ -231,21 +139,7 @@ export const server = {
|
|
|
231
139
|
handler: async ({ id }, ctx) => {
|
|
232
140
|
await requireAdmin(ctx);
|
|
233
141
|
const repo = await getRepo(ctx.locals);
|
|
234
|
-
|
|
235
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
236
|
-
await repo.applyPostDraft(id);
|
|
237
|
-
const post = (await repo.getPost(id))!;
|
|
238
|
-
if (post.tags.length) await repo.ensureTags(post.tags);
|
|
239
|
-
if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
240
|
-
await repo
|
|
241
|
-
.createRedirect({
|
|
242
|
-
fromPath: collectionUrl(before.collection, before.slug),
|
|
243
|
-
toPath: collectionUrl(post.collection, post.slug),
|
|
244
|
-
})
|
|
245
|
-
.catch(() => {});
|
|
246
|
-
invalidateRedirectCache();
|
|
247
|
-
}
|
|
248
|
-
return { post };
|
|
142
|
+
try { return { post: await applyPostUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
249
143
|
},
|
|
250
144
|
}),
|
|
251
145
|
discardChanges: defineAction({
|
|
@@ -281,6 +175,7 @@ export const server = {
|
|
|
281
175
|
locale: z.string().optional(),
|
|
282
176
|
seoTitle: z.string().optional(),
|
|
283
177
|
seoDesc: z.string().optional(),
|
|
178
|
+
translations: DocTranslations.optional(),
|
|
284
179
|
template: z.string().optional(),
|
|
285
180
|
bodyJson: z.string().optional(),
|
|
286
181
|
bodyHtml: z.string().optional(),
|
|
@@ -289,33 +184,13 @@ export const server = {
|
|
|
289
184
|
handler: async ({ id, blocks, ...data }, ctx) => {
|
|
290
185
|
await requireAdmin(ctx);
|
|
291
186
|
const repo = await getRepo(ctx.locals);
|
|
292
|
-
if (data.slug !== undefined) data.slug = slugify(data.slug);
|
|
293
|
-
// default locale stores as '', unknown codes fall back to it
|
|
294
|
-
if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
|
|
295
|
-
const before = await repo.getPage(id);
|
|
296
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
297
187
|
const cleanBlocks =
|
|
298
188
|
blocks === undefined
|
|
299
189
|
? undefined
|
|
300
190
|
: blocks
|
|
301
191
|
.filter((b) => widgetTypes.includes(b.type as WidgetType))
|
|
302
192
|
.map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
|
|
303
|
-
|
|
304
|
-
const transitions = data.status !== undefined && data.status !== before.status;
|
|
305
|
-
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
306
|
-
const { status: _status, ...staged } = data;
|
|
307
|
-
await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
|
|
308
|
-
return { page: (await repo.getPage(id))! };
|
|
309
|
-
}
|
|
310
|
-
await repo.updatePage(id, { ...data, blocks: cleanBlocks });
|
|
311
|
-
const page = await repo.getPage(id);
|
|
312
|
-
if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
313
|
-
// renaming a published slug leaves a 301 behind so old links keep working
|
|
314
|
-
if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
315
|
-
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
316
|
-
invalidateRedirectCache();
|
|
317
|
-
}
|
|
318
|
-
return { page };
|
|
193
|
+
try { return { page: await savePage(repo, id, data, cleanBlocks, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
319
194
|
},
|
|
320
195
|
}),
|
|
321
196
|
applyUpdate: defineAction({
|
|
@@ -323,15 +198,7 @@ export const server = {
|
|
|
323
198
|
handler: async ({ id }, ctx) => {
|
|
324
199
|
await requireAdmin(ctx);
|
|
325
200
|
const repo = await getRepo(ctx.locals);
|
|
326
|
-
|
|
327
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
328
|
-
await repo.applyPageDraft(id);
|
|
329
|
-
const page = (await repo.getPage(id))!;
|
|
330
|
-
if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
331
|
-
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
332
|
-
invalidateRedirectCache();
|
|
333
|
-
}
|
|
334
|
-
return { page };
|
|
201
|
+
try { return { page: await applyPageUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
335
202
|
},
|
|
336
203
|
}),
|
|
337
204
|
discardChanges: defineAction({
|
|
@@ -946,6 +813,7 @@ export const server = {
|
|
|
946
813
|
coverAlt: z.string().optional(),
|
|
947
814
|
seoTitle: z.string().optional(),
|
|
948
815
|
seoDesc: z.string().optional(),
|
|
816
|
+
translations: DocTranslations.optional(),
|
|
949
817
|
status: StatusInput.optional(),
|
|
950
818
|
featured: z.boolean().optional(),
|
|
951
819
|
template: z.string().optional(),
|
|
@@ -966,21 +834,7 @@ export const server = {
|
|
|
966
834
|
handler: async ({ id, ...data }, ctx) => {
|
|
967
835
|
await requireAdmin(ctx);
|
|
968
836
|
const repo = await getRepo(ctx.locals);
|
|
969
|
-
|
|
970
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
|
|
971
|
-
const clean = await cleanEventInput(repo, data, before);
|
|
972
|
-
// live events stage edits until Update, exactly like posts
|
|
973
|
-
const transitions = clean.status !== undefined && clean.status !== before.status;
|
|
974
|
-
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
975
|
-
const { status: _s, scheduledAt: _sa, ...staged } = clean;
|
|
976
|
-
await repo.saveEventDraft(id, staged);
|
|
977
|
-
return { event: (await repo.getEvent(id))! };
|
|
978
|
-
}
|
|
979
|
-
await repo.updateEvent(id, clean);
|
|
980
|
-
if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
|
|
981
|
-
const event = (await repo.getEvent(id))!;
|
|
982
|
-
await eventSideEffects(repo, ctx, before, event);
|
|
983
|
-
return { event };
|
|
837
|
+
try { return { event: await saveEvent(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
984
838
|
},
|
|
985
839
|
}),
|
|
986
840
|
applyUpdate: defineAction({
|
|
@@ -988,13 +842,7 @@ export const server = {
|
|
|
988
842
|
handler: async ({ id }, ctx) => {
|
|
989
843
|
await requireAdmin(ctx);
|
|
990
844
|
const repo = await getRepo(ctx.locals);
|
|
991
|
-
|
|
992
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
|
|
993
|
-
await repo.applyEventDraft(id);
|
|
994
|
-
const event = (await repo.getEvent(id))!;
|
|
995
|
-
if (event.tags.length) await repo.ensureTags(event.tags);
|
|
996
|
-
await eventSideEffects(repo, ctx, before, event, true);
|
|
997
|
-
return { event };
|
|
845
|
+
try { return { event: await applyEventUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
998
846
|
},
|
|
999
847
|
}),
|
|
1000
848
|
discardChanges: defineAction({
|
|
@@ -16,8 +16,8 @@ import { useEffect, useId, useRef, useState } from 'react';
|
|
|
16
16
|
type Provider = 'openai' | 'custom';
|
|
17
17
|
|
|
18
18
|
interface PublicSettings {
|
|
19
|
-
ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean };
|
|
20
|
-
agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean };
|
|
19
|
+
ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean; glossary?: string };
|
|
20
|
+
agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean; allowSections?: boolean };
|
|
21
21
|
agentUrl: string;
|
|
22
22
|
docsUrl: string;
|
|
23
23
|
}
|
|
@@ -58,7 +58,7 @@ const sameUrl = (a: string, b: string) => {
|
|
|
58
58
|
// only link to same-site paths or http(s) URLs from the server response
|
|
59
59
|
const safeHref = (u: string) => (u.startsWith('/') && !u.startsWith('//')) || /^https?:\/\//i.test(u) ? u : '';
|
|
60
60
|
|
|
61
|
-
type Busy = 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
|
|
61
|
+
type Busy = 'glossary' | 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
|
|
62
62
|
|
|
63
63
|
export default function AiSettings() {
|
|
64
64
|
const uid = useId();
|
|
@@ -77,6 +77,9 @@ export default function AiSettings() {
|
|
|
77
77
|
// agent form
|
|
78
78
|
const [agentEnabled, setAgentEnabled] = useState(false);
|
|
79
79
|
const [allowPublish, setAllowPublish] = useState(false);
|
|
80
|
+
const [allowSections, setAllowSections] = useState(false);
|
|
81
|
+
const [glossary, setGlossary] = useState('');
|
|
82
|
+
const [glossaryMsg, setGlossaryMsg] = useState('');
|
|
80
83
|
const [token, setToken] = useState('');
|
|
81
84
|
const [copied, setCopied] = useState('');
|
|
82
85
|
|
|
@@ -96,15 +99,27 @@ export default function AiSettings() {
|
|
|
96
99
|
setProvider(s.ai.provider);
|
|
97
100
|
setBaseUrl(s.ai.provider === 'openai' ? OPENAI_URL : s.ai.baseUrl);
|
|
98
101
|
setModel(s.ai.model);
|
|
102
|
+
setGlossary(s.ai.glossary ?? '');
|
|
99
103
|
setApiKey('');
|
|
100
104
|
setClearApiKey(false);
|
|
101
105
|
}
|
|
102
106
|
if (part !== 'ai') {
|
|
103
107
|
setAgentEnabled(s.agent.enabled);
|
|
104
108
|
setAllowPublish(s.agent.allowPublish);
|
|
109
|
+
setAllowSections(Boolean(s.agent.allowSections));
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
async function saveGlossary() {
|
|
114
|
+
setBusy('glossary');
|
|
115
|
+
setGlossaryMsg('');
|
|
116
|
+
try {
|
|
117
|
+
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveGlossary', glossary }), 'ai');
|
|
118
|
+
setGlossaryMsg('Saved. New AI translations follow it.');
|
|
119
|
+
} catch (e) { setGlossaryMsg(e instanceof Error ? e.message : 'Couldn’t save.'); }
|
|
120
|
+
finally { setBusy(null); }
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
async function load() {
|
|
109
124
|
setLoadError('');
|
|
110
125
|
try {
|
|
@@ -150,7 +165,7 @@ export default function AiSettings() {
|
|
|
150
165
|
const keyWillDrop = data.ai.hasApiKey && destinationChanged && !apiKey.trim() && !clearApiKey;
|
|
151
166
|
const formDirty =
|
|
152
167
|
enabled !== data.ai.enabled || destinationChanged || model.trim() !== data.ai.model || !!apiKey || clearApiKey;
|
|
153
|
-
const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish;
|
|
168
|
+
const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish || allowSections !== Boolean(data.agent.allowSections);
|
|
154
169
|
const isCustom = provider !== 'openai';
|
|
155
170
|
|
|
156
171
|
async function save() {
|
|
@@ -238,7 +253,7 @@ export default function AiSettings() {
|
|
|
238
253
|
setAgentMsg({ ok: '', err: '' });
|
|
239
254
|
await run('saveAgent', async () => {
|
|
240
255
|
try {
|
|
241
|
-
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish }), 'agent');
|
|
256
|
+
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish, allowSections }), 'agent');
|
|
242
257
|
setAgentMsg({ ok: 'Agent access saved.', err: '' });
|
|
243
258
|
} catch (e) {
|
|
244
259
|
setAgentMsg({ ok: '', err: errText(e) });
|
|
@@ -412,6 +427,26 @@ export default function AiSettings() {
|
|
|
412
427
|
</div>
|
|
413
428
|
</section>
|
|
414
429
|
|
|
430
|
+
{/* ---- translation glossary ---- */}
|
|
431
|
+
<section className="ais-card" aria-labelledby={id('gl-h')}>
|
|
432
|
+
<header className="ais-head">
|
|
433
|
+
<div className="ais-grow">
|
|
434
|
+
<h2 id={id('gl-h')}>Translation glossary</h2>
|
|
435
|
+
<p>When AI drafts another language, these words always come out your way. One per line: <code>Youth Night = Noche de Jóvenes</code>, or just a name to keep as is (<code>Riverside</code>).</p>
|
|
436
|
+
</div>
|
|
437
|
+
</header>
|
|
438
|
+
<label className="field" htmlFor={id('gl')}>
|
|
439
|
+
<span>Terms</span>
|
|
440
|
+
<textarea id={id('gl')} rows={6} className="mono" value={glossary} onChange={(e) => setGlossary(e.target.value)} placeholder={'Youth Night = Noche de Jóvenes\nSunday Worship = Culto dominical\nRiverside'} />
|
|
441
|
+
</label>
|
|
442
|
+
<div className="ais-actions">
|
|
443
|
+
<button type="button" className="btn primary" disabled={busy !== null || glossary.trim() === (data?.ai.glossary ?? '').trim()} onClick={() => void saveGlossary()}>
|
|
444
|
+
{busy === 'glossary' ? 'Saving…' : 'Save glossary'}
|
|
445
|
+
</button>
|
|
446
|
+
{glossaryMsg && <span className="hint" aria-live="polite">{glossaryMsg}</span>}
|
|
447
|
+
</div>
|
|
448
|
+
</section>
|
|
449
|
+
|
|
415
450
|
{/* ---- agent ---- */}
|
|
416
451
|
<section className="ais-card" aria-labelledby={id('ag-h')}>
|
|
417
452
|
<header className="ais-head">
|
|
@@ -427,6 +462,7 @@ export default function AiSettings() {
|
|
|
427
462
|
<ul className="ais-perms" aria-label="What the agent key can do">
|
|
428
463
|
<li className="yes">Read all posts and pages, including unpublished drafts</li>
|
|
429
464
|
<li className="yes">Create drafts and edit drafts of posts and pages</li>
|
|
465
|
+
<li className={allowSections ? 'yes' : 'no'}>Page sections — {allowSections ? 'can stage layout and content changes for your review' : 'read only unless you turn it on below'}</li>
|
|
430
466
|
<li className={allowPublish ? 'yes' : 'no'}>Publish — {allowPublish ? 'allowed' : 'off unless you turn it on below'}</li>
|
|
431
467
|
<li className="no">Never delete anything or change settings</li>
|
|
432
468
|
</ul>
|
|
@@ -486,6 +522,10 @@ export default function AiSettings() {
|
|
|
486
522
|
<input type="checkbox" checked={allowPublish} onChange={(e) => setAllowPublish(e.target.checked)} />
|
|
487
523
|
<span>Let the agent publish <span className="hint">(off by default, so drafts wait for you to review)</span></span>
|
|
488
524
|
</label>
|
|
525
|
+
<label className="check-row">
|
|
526
|
+
<input type="checkbox" checked={allowSections} onChange={(e) => setAllowSections(e.target.checked)} />
|
|
527
|
+
<span>Let the agent edit page sections <span className="hint">(homepage layouts and section content; changes stay in the design draft)</span></span>
|
|
528
|
+
</label>
|
|
489
529
|
<div className="ais-actions">
|
|
490
530
|
<button type="button" className="btn primary" onClick={() => void saveAgent()} disabled={busy !== null || !agentDirty}>
|
|
491
531
|
{busy === 'saveAgent' ? 'Saving…' : 'Save agent access'}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
2
2
|
import { actions } from 'astro:actions';
|
|
3
3
|
import type { Settings } from '../lib/schema';
|
|
4
4
|
import type { DesignChange } from '../lib/designDiff';
|
|
5
5
|
import { ImagePicker } from './editor/ImagePicker';
|
|
6
|
-
import ThemeSectionsEditor, { SectionsReview, useDesignApi } from './ThemeSectionsEditor';
|
|
6
|
+
import ThemeSectionsEditor, { SectionsReview, useDesignApi, type SectionFocus } from './ThemeSectionsEditor';
|
|
7
|
+
import { checkLayouts, type Check } from './sections/checks';
|
|
8
|
+
import { EMPTY_TARGETS } from './sections/SectionFields';
|
|
7
9
|
import DesignAccess from './DesignAccess';
|
|
8
10
|
import '../styles/theme-editor.css';
|
|
9
11
|
|
|
@@ -145,7 +147,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
145
147
|
const [copied, setCopied] = useState(false);
|
|
146
148
|
const [tick, setTick] = useState(0);
|
|
147
149
|
const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
|
|
148
|
-
|
|
150
|
+
// open on the device you're most likely designing for: desktop on a wide
|
|
151
|
+
// admin window (scaled to fit), phone otherwise; both are one click away
|
|
152
|
+
const [previewDevice, setPreviewDevice] = useState<DeviceKey>(() => (typeof window !== 'undefined' && window.innerWidth >= 1200 ? 'desktop' : 'mobile'));
|
|
149
153
|
const [previewScheme, setPreviewScheme] = useState<'light' | 'dark'>('light');
|
|
150
154
|
// "What changes" + preview compare / share / history
|
|
151
155
|
const [changes, setChanges] = useState<DesignChange[]>(initialChanges);
|
|
@@ -186,16 +190,45 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
186
190
|
const sectionsStagedRef = useRef(false);
|
|
187
191
|
sectionsStagedRef.current = sectionsStaged;
|
|
188
192
|
|
|
193
|
+
// pre-publish checklist (section layouts)
|
|
194
|
+
const [checks, setChecks] = useState<Check[]>([]);
|
|
195
|
+
const checksRef = useRef<HTMLDialogElement>(null);
|
|
196
|
+
const flushSections = useRef<() => Promise<boolean>>(async () => true);
|
|
197
|
+
// click-to-edit: the preview asks for a section's form; the open form
|
|
198
|
+
// highlights its section in the preview
|
|
199
|
+
const [sectionFocus, setSectionFocus] = useState<SectionFocus | null>(null);
|
|
200
|
+
const openSection = useRef<string | null>(null);
|
|
201
|
+
const frameEl = useRef<HTMLIFrameElement>(null);
|
|
202
|
+
// unsaved section edits, rendered by the server: swap them into the preview
|
|
203
|
+
// (only while it shows the draft; the Live compare stays the live site)
|
|
204
|
+
const compareRef = useRef<'draft' | 'live'>('draft');
|
|
205
|
+
compareRef.current = compare;
|
|
206
|
+
const liveHtml = useCallback((html: string) => {
|
|
207
|
+
if (compareRef.current !== 'draft') return;
|
|
208
|
+
try { frameEl.current?.contentWindow?.postMessage({ type: 'acms-section-html', html }, location.origin); } catch { /* frame navigating */ }
|
|
209
|
+
}, []);
|
|
210
|
+
const focusPreview = (sectionId: string | null) => {
|
|
211
|
+
try { frameEl.current?.contentWindow?.postMessage({ type: 'acms-section-focus', sectionId }, location.origin); } catch { /* frame navigating */ }
|
|
212
|
+
};
|
|
213
|
+
|
|
189
214
|
// the preview iframe reports where it navigated (preview-mode links keep the
|
|
190
215
|
// draft), so reloads after a change land on the same page
|
|
191
216
|
useEffect(() => {
|
|
192
217
|
const onMsg = (e: MessageEvent) => {
|
|
193
|
-
if (e.origin !== location.origin
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
if (e.origin !== location.origin) return;
|
|
219
|
+
if (e.data?.type === 'acms-preview-nav') {
|
|
220
|
+
pathRef.current = String(e.data.path || '/');
|
|
221
|
+
setPreviewPath(pathRef.current);
|
|
222
|
+
} else if (e.data?.type === 'acms-section-edit') {
|
|
223
|
+
setTab('pages');
|
|
224
|
+
setSectionFocus((f) => ({ pageId: String(e.data.pageId), sectionId: String(e.data.sectionId), n: (f?.n ?? 0) + 1 }));
|
|
225
|
+
} else if (e.data?.type === 'acms-sections-ready' && openSection.current) {
|
|
226
|
+
focusPreview(openSection.current);
|
|
227
|
+
}
|
|
196
228
|
};
|
|
197
229
|
addEventListener('message', onMsg);
|
|
198
230
|
return () => removeEventListener('message', onMsg);
|
|
231
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
199
232
|
}, []);
|
|
200
233
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
201
234
|
const [frameBox, setFrameBox] = useState({ w: 0, h: 0 });
|
|
@@ -243,13 +276,23 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
243
276
|
};
|
|
244
277
|
|
|
245
278
|
// Publish design: flush any queued save into the staged blob, then apply it
|
|
246
|
-
const publishDesign = async () => {
|
|
279
|
+
const publishDesign = async (checked = false) => {
|
|
247
280
|
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
|
248
281
|
if (!(await doSave())) return;
|
|
282
|
+
if (!(await flushSections.current())) return;
|
|
249
283
|
const n = latestChanges.current.length;
|
|
250
284
|
if (!n && !sectionsStagedRef.current) return;
|
|
285
|
+
// pre-publish checklist over the staged page layouts
|
|
286
|
+
if (!checked) {
|
|
287
|
+
const d = designApi.data;
|
|
288
|
+
const themeDefs = d?.themes.find((t) => t.slug === activeTheme)?.sections ?? {};
|
|
289
|
+
const staged = (d?.draft?.sectionPages ?? d?.live.sectionPages ?? {}) as Parameters<typeof checkLayouts>[0];
|
|
290
|
+
const found = d ? checkLayouts(staged, themeDefs as Parameters<typeof checkLayouts>[1], d.targets ?? EMPTY_TARGETS) : [];
|
|
291
|
+
if (found.length) { setChecks(found); checksRef.current?.showModal(); return; }
|
|
292
|
+
}
|
|
251
293
|
const what = n ? `${n} ${n === 1 ? 'change' : 'changes'}` : 'the staged page layouts';
|
|
252
|
-
if (!window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
|
|
294
|
+
if (!checked && !window.confirm(`Publish ${what}? Visitors see them right away. The current design is saved to History.`)) return;
|
|
295
|
+
checksRef.current?.close();
|
|
253
296
|
const { error } = await actions.settings.applyDesign({});
|
|
254
297
|
if (error) { setSaveMessage(error.message); setSaveState('error'); return; }
|
|
255
298
|
setPending(false);
|
|
@@ -370,6 +413,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
370
413
|
);
|
|
371
414
|
|
|
372
415
|
const hasDraft = (pending && changes.length > 0) || sectionsStaged;
|
|
416
|
+
// editors change section content only: Pages tab, per-section publish
|
|
417
|
+
const editorOnly = (designApi.data as { role?: string } | null)?.role === 'editor';
|
|
418
|
+
useEffect(() => { if (editorOnly && tab !== 'pages') setTab('pages'); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [editorOnly]);
|
|
373
419
|
|
|
374
420
|
return (
|
|
375
421
|
<div className="dm-layout">
|
|
@@ -384,7 +430,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
384
430
|
<span className={`lm-savestate ${saveState}`}>
|
|
385
431
|
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
|
|
386
432
|
</span>
|
|
387
|
-
<button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
|
|
433
|
+
{!editorOnly && <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>}
|
|
388
434
|
</div>
|
|
389
435
|
</div>
|
|
390
436
|
|
|
@@ -396,10 +442,10 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
396
442
|
<><span className="dm-st is-live">Live</span><span className="dm-st-sub">· no unpublished changes</span></>
|
|
397
443
|
)}
|
|
398
444
|
<span className="dm-status-actions">
|
|
399
|
-
{overrideCount > 0 && (
|
|
445
|
+
{overrideCount > 0 && !editorOnly && (
|
|
400
446
|
<button className="btn sm ghost" type="button" onClick={() => { setOverrides({}); scheduleSave(); }}>Reset all</button>
|
|
401
447
|
)}
|
|
402
|
-
{hasDraft && (
|
|
448
|
+
{hasDraft && !editorOnly && (
|
|
403
449
|
<>
|
|
404
450
|
<button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
|
|
405
451
|
<button className="btn sm primary" type="button" onClick={() => void publishDesign()}>Publish…</button>
|
|
@@ -408,22 +454,22 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
408
454
|
</span>
|
|
409
455
|
</div>
|
|
410
456
|
<div className="dm-status-links">
|
|
411
|
-
{hasDraft && tab !== 'review' && (
|
|
457
|
+
{hasDraft && tab !== 'review' && !editorOnly && (
|
|
412
458
|
<button className="btn sm" type="button" onClick={() => setTab('review')}>Review changes</button>
|
|
413
459
|
)}
|
|
414
460
|
<a className="btn sm" href="/?designPreview=1" target="_blank" rel="noopener">
|
|
415
461
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
|
|
416
462
|
Preview site
|
|
417
463
|
</a>
|
|
418
|
-
<button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
|
|
464
|
+
{!editorOnly && <button className="btn sm" type="button" onClick={openShare} disabled={!hasDraft} title={hasDraft ? undefined : 'Make a change first'}>
|
|
419
465
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5" /></svg>
|
|
420
466
|
Share preview
|
|
421
|
-
</button>
|
|
467
|
+
</button>}
|
|
422
468
|
</div>
|
|
423
469
|
</div>
|
|
424
470
|
|
|
425
471
|
<div className="seg te-tabs" role="tablist" aria-label="Design settings" onKeyDown={onTabKey}>
|
|
426
|
-
{TABS.map((t) => (
|
|
472
|
+
{TABS.filter((t) => !editorOnly || t.key === 'pages').map((t) => (
|
|
427
473
|
<button
|
|
428
474
|
key={t.key}
|
|
429
475
|
ref={(el) => { tabRefs.current[t.key] = el; }}
|
|
@@ -734,7 +780,24 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
734
780
|
</div>
|
|
735
781
|
|
|
736
782
|
<div className="te-panel" role="tabpanel" id="dm-panel-pages" aria-labelledby="dm-tab-pages" hidden={tab !== 'pages'}>
|
|
737
|
-
<ThemeSectionsEditor
|
|
783
|
+
<ThemeSectionsEditor
|
|
784
|
+
api={designApi}
|
|
785
|
+
activeTheme={activeTheme}
|
|
786
|
+
unsplashEnabled={unsplashEnabled}
|
|
787
|
+
focus={sectionFocus}
|
|
788
|
+
onOpenSection={(_pageId, sectionId, path) => {
|
|
789
|
+
openSection.current = sectionId;
|
|
790
|
+
// show the page being edited: follow it in the preview
|
|
791
|
+
if (sectionId && path && pathRef.current.split('?')[0] !== path) {
|
|
792
|
+
pathRef.current = path;
|
|
793
|
+
setPreviewPath(path);
|
|
794
|
+
setTick((t) => t + 1);
|
|
795
|
+
} else focusPreview(sectionId);
|
|
796
|
+
}}
|
|
797
|
+
onSaved={() => setTick((t) => t + 1)}
|
|
798
|
+
onLiveHtml={liveHtml}
|
|
799
|
+
flushRef={flushSections}
|
|
800
|
+
/>
|
|
738
801
|
</div>
|
|
739
802
|
|
|
740
803
|
<div className="te-panel" role="tabpanel" id="dm-panel-review" aria-labelledby="dm-tab-review" hidden={tab !== 'review'}>
|
|
@@ -840,6 +903,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
840
903
|
return (
|
|
841
904
|
<div className="dm-frame" ref={frameRef}>
|
|
842
905
|
<iframe
|
|
906
|
+
ref={frameEl}
|
|
843
907
|
key={iframeSrc}
|
|
844
908
|
src={iframeSrc}
|
|
845
909
|
title="Site preview"
|
|
@@ -854,6 +918,38 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
854
918
|
})()}
|
|
855
919
|
</div>
|
|
856
920
|
|
|
921
|
+
<dialog className="modal dm-dialog dm-checks" ref={checksRef} aria-labelledby="dm-checks-title" onClick={(e) => { if (e.target === e.currentTarget) checksRef.current?.close(); }}>
|
|
922
|
+
<div className="modal-head">
|
|
923
|
+
<span id="dm-checks-title">Before you publish</span>
|
|
924
|
+
<button className="modal-x" type="button" onClick={() => checksRef.current?.close()} aria-label="Close">✕</button>
|
|
925
|
+
</div>
|
|
926
|
+
<div className="modal-body">
|
|
927
|
+
{checks.some((c) => c.level === 'error')
|
|
928
|
+
? <p className="dm-dialog-sub">Some fields need fixing before this can go live.</p>
|
|
929
|
+
: <p className="dm-dialog-sub">A few things you may want to fix first. You can also publish as is.</p>}
|
|
930
|
+
<ul className="dm-checklist">
|
|
931
|
+
{checks.slice(0, 12).map((c, i) => (
|
|
932
|
+
<li key={i} className={`is-${c.level}`}>
|
|
933
|
+
<span className="dm-check-dot" aria-hidden="true" />
|
|
934
|
+
<span><b>{c.section}</b>: {c.message}</span>
|
|
935
|
+
<button className="btn sm ghost" type="button" onClick={() => {
|
|
936
|
+
checksRef.current?.close();
|
|
937
|
+
setTab('pages');
|
|
938
|
+
setSectionFocus((f) => ({ pageId: c.pageId, sectionId: c.sectionId, n: (f?.n ?? 0) + 1 }));
|
|
939
|
+
}}>Fix</button>
|
|
940
|
+
</li>
|
|
941
|
+
))}
|
|
942
|
+
{checks.length > 12 && <li className="te-muted">…and {checks.length - 12} more</li>}
|
|
943
|
+
</ul>
|
|
944
|
+
<div className="modal-foot">
|
|
945
|
+
<button className="btn ghost" type="button" onClick={() => checksRef.current?.close()}>Keep editing</button>
|
|
946
|
+
{!checks.some((c) => c.level === 'error') && (
|
|
947
|
+
<button className="btn primary" type="button" onClick={() => void publishDesign(true)}>Publish anyway</button>
|
|
948
|
+
)}
|
|
949
|
+
</div>
|
|
950
|
+
</div>
|
|
951
|
+
</dialog>
|
|
952
|
+
|
|
857
953
|
<dialog className="modal dm-dialog" ref={shareRef} onClick={(e) => { if (e.target === e.currentTarget) shareRef.current?.close(); }}>
|
|
858
954
|
<div className="modal-head">
|
|
859
955
|
<span>Share a preview</span>
|