@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,55 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Linking, Text, View } from 'react-native';
|
|
3
|
+
import { router } from 'expo-router';
|
|
4
|
+
import type { DocKind } from '@acms-subir/client';
|
|
5
|
+
import { Card, EditorButton, htmlToText, ListRow, Notice, StatusText, errorMessage, isConflict, resolveUrl, useDocument, useEditorContext, useEditorTheme, usePublish } from '@acms-subir/native/editor';
|
|
6
|
+
import { Screen } from './Screen';
|
|
7
|
+
import { hasTool } from '../lib/tools';
|
|
8
|
+
import { dateTime } from '../lib/format';
|
|
9
|
+
|
|
10
|
+
/** a document's status, words and publish actions (the content recipe's detail) */
|
|
11
|
+
export function DocumentDetail({ kind, id }: { kind: DocKind; id: string }) {
|
|
12
|
+
const t = useEditorTheme();
|
|
13
|
+
const { baseUrl } = useEditorContext();
|
|
14
|
+
const doc = useDocument(kind, id);
|
|
15
|
+
const pub = usePublish(kind, id);
|
|
16
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
17
|
+
const d = doc.document;
|
|
18
|
+
if (doc.isPending) return <Screen><ActivityIndicator color={t.colors.muted} /></Screen>;
|
|
19
|
+
if (!d) return <Screen><Notice tone="error">{errorMessage(doc.error) || 'Not found.'}</Notice></Screen>;
|
|
20
|
+
const live = d.status === 'published' || d.status === 'scheduled';
|
|
21
|
+
const run = async (what: () => Promise<unknown>, done: string) => {
|
|
22
|
+
setMessage(null);
|
|
23
|
+
try { await what(); setMessage(done); } catch (e) { setMessage(isConflict(e) ? 'This changed on the site. It has been reloaded; try again.' : errorMessage(e)); }
|
|
24
|
+
};
|
|
25
|
+
const preview = htmlToText(d.bodyHtml).slice(0, 600);
|
|
26
|
+
return (
|
|
27
|
+
<Screen refreshing={doc.isRefetching} onRefresh={() => doc.refetch()}>
|
|
28
|
+
<View style={{ gap: 6 }}>
|
|
29
|
+
<Text accessibilityRole="header" style={t.text.screenTitle}>{d.title || 'Untitled'}</Text>
|
|
30
|
+
<Text style={t.text.meta}>
|
|
31
|
+
<StatusText status={d.status} suffix={d.status === 'scheduled' && d.scheduledAt ? dateTime(d.scheduledAt) : d.hasChanges ? 'unpublished edits' : undefined} />
|
|
32
|
+
{` Updated ${dateTime(d.updatedAt)}`}
|
|
33
|
+
</Text>
|
|
34
|
+
</View>
|
|
35
|
+
{!!message && <Notice tone={/couldn|changed|error|failed|isn/i.test(message) ? 'warn' : 'success'}>{message}</Notice>}
|
|
36
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
37
|
+
{hasTool('writer') && <EditorButton label="Edit" variant="primary" onPress={() => router.push({ pathname: '/write/[kind]/[id]', params: { kind, id } })} />}
|
|
38
|
+
{(!live || d.hasChanges) && d.status !== 'scheduled' && (
|
|
39
|
+
<EditorButton label={live ? 'Publish edits' : 'Publish'} busy={pub.action === 'publish'} disabled={pub.pending} variant={hasTool('writer') ? 'secondary' : 'primary'}
|
|
40
|
+
onPress={() => run(pub.publish, live ? 'Your edits are live.' : 'Published.')} />
|
|
41
|
+
)}
|
|
42
|
+
{d.hasChanges && live && <EditorButton label="Discard edits" busy={pub.action === 'discard'} disabled={pub.pending} onPress={() => run(pub.discard, 'Edits discarded.')} />}
|
|
43
|
+
{live && <EditorButton label="Unpublish" variant="danger" busy={pub.action === 'unpublish'} disabled={pub.pending} onPress={() => run(pub.unpublish, 'Unpublished. It is a draft again.')} />}
|
|
44
|
+
</View>
|
|
45
|
+
<Card>
|
|
46
|
+
<ListRow title="Address" meta={d.url} onPress={d.status === 'published' ? () => { const u = resolveUrl(d.url, baseUrl); if (u) void Linking.openURL(u); } : undefined} />
|
|
47
|
+
{!!d.excerpt && <ListRow title="Summary" meta={d.excerpt} />}
|
|
48
|
+
{kind === 'event' && d.event && <ListRow title="When" meta={`${d.event.allDay ? d.event.start.slice(0, 10) : dateTime(d.event.start)}${d.event.location.name ? ` · ${d.event.location.name}` : ''}`} />}
|
|
49
|
+
{d.tags.length > 0 && <ListRow title="Tags" meta={d.tags.join(', ')} />}
|
|
50
|
+
{d.languages.length > 0 && <ListRow title="Languages" meta={d.languages.join(', ')} />}
|
|
51
|
+
</Card>
|
|
52
|
+
{!!preview && <Text style={t.text.body} numberOfLines={14}>{preview}</Text>}
|
|
53
|
+
</Screen>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Content', description: 'Posts, pages and events', href: '/content', order: 10 };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "editor",
|
|
3
|
+
"title": "Content",
|
|
4
|
+
"description": "Posts, pages and events: kind and status filters, search, infinite scroll; publish, unpublish and discard staged edits. Tablets show list + detail side by side.",
|
|
5
|
+
"packages": [],
|
|
6
|
+
"routes": ["/content", "/content/[kind]/[id]"],
|
|
7
|
+
"next": ["It appears on Home (tools/content.ts). Add editor-writer to edit text from the detail screen."]
|
|
8
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, FlatList, Image, Platform, Pressable, RefreshControl, Text, View } from 'react-native';
|
|
3
|
+
import { Stack } from 'expo-router';
|
|
4
|
+
import type { EditorMedia } from '@acms-subir/client';
|
|
5
|
+
import { EditorButton, Notice, Sheet, errorMessage, resolveUrl, useEditorContext, useEditorTheme, useMedia } from '@acms-subir/native/editor';
|
|
6
|
+
import { HeaderActions, useWide } from '../../components/Screen';
|
|
7
|
+
import { TextTabs } from '../../components/TextTabs';
|
|
8
|
+
import { shortDate } from '../../lib/format';
|
|
9
|
+
|
|
10
|
+
type Kind = 'image' | 'document' | 'all';
|
|
11
|
+
const size = (bytes: number) => (bytes > 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1000))} KB`);
|
|
12
|
+
|
|
13
|
+
export default function MediaScreen() {
|
|
14
|
+
const t = useEditorTheme();
|
|
15
|
+
const { baseUrl } = useEditorContext();
|
|
16
|
+
const { width, gutter } = useWide();
|
|
17
|
+
const [kind, setKind] = useState<Kind>('image');
|
|
18
|
+
const media = useMedia({ type: kind === 'all' ? undefined : kind });
|
|
19
|
+
const [open, setOpen] = useState<EditorMedia | null>(null);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const cols = Math.max(3, Math.min(8, Math.floor((width - gutter * 2) / 150)));
|
|
22
|
+
const cell = (Math.min(width, 1400) - gutter * 2 - (cols - 1) * 8) / cols;
|
|
23
|
+
const upload = async (source: 'library' | 'camera') => {
|
|
24
|
+
setError(null);
|
|
25
|
+
try { const m = await media.pickAndUpload({ source }); if (m) setOpen(m); }
|
|
26
|
+
catch (e) { setError(errorMessage(e)); }
|
|
27
|
+
};
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<Stack.Screen options={{ title: 'Media', headerRight: () => <HeaderActions><EditorButton compact variant="primary" label={media.uploading ? 'Uploading…' : 'Upload'} busy={media.uploading} onPress={() => { void upload('library'); }} /></HeaderActions> }} />
|
|
31
|
+
<FlatList
|
|
32
|
+
key={cols}
|
|
33
|
+
numColumns={cols}
|
|
34
|
+
data={media.items}
|
|
35
|
+
keyExtractor={(m) => m.id}
|
|
36
|
+
style={{ backgroundColor: t.colors.panel }}
|
|
37
|
+
contentContainerStyle={{ padding: gutter, gap: 8, maxWidth: 1400, width: '100%', alignSelf: 'center' }}
|
|
38
|
+
columnWrapperStyle={{ gap: 8 }}
|
|
39
|
+
refreshControl={<RefreshControl refreshing={media.isRefetching && !media.isFetchingNextPage} onRefresh={() => media.refetch()} tintColor={t.colors.muted} />}
|
|
40
|
+
onEndReachedThreshold={0.5}
|
|
41
|
+
onEndReached={() => { if (media.hasNextPage && !media.isFetchingNextPage) void media.fetchNextPage(); }}
|
|
42
|
+
ListHeaderComponent={
|
|
43
|
+
<View style={{ gap: 8, marginBottom: 8 }}>
|
|
44
|
+
<TextTabs<Kind> accessibilityLabel="Type" value={kind} onChange={setKind} options={[{ value: 'image', label: 'Images' }, { value: 'document', label: 'Documents' }, { value: 'all', label: 'All' }]} />
|
|
45
|
+
{Platform.OS !== 'web' && <EditorButton label="Take a photo" onPress={() => { void upload('camera'); }} style={{ alignSelf: 'flex-start' }} compact />}
|
|
46
|
+
{!!error && <Notice tone="error">{error}</Notice>}
|
|
47
|
+
</View>
|
|
48
|
+
}
|
|
49
|
+
ListEmptyComponent={media.isPending ? <ActivityIndicator color={t.colors.muted} /> : media.error ? <Notice tone="error">{errorMessage(media.error)}</Notice> : <Notice>No files yet. Upload the first one.</Notice>}
|
|
50
|
+
ListFooterComponent={media.isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} color={t.colors.muted} /> : null}
|
|
51
|
+
renderItem={({ item }) => {
|
|
52
|
+
const thumb = item.variants.find((v) => v.width >= 300)?.url ?? item.url;
|
|
53
|
+
const image = item.mime.startsWith('image/');
|
|
54
|
+
return (
|
|
55
|
+
<Pressable accessibilityRole="button" accessibilityLabel={item.alt || item.filename} onPress={() => setOpen(item)}
|
|
56
|
+
style={({ pressed }) => ({ width: cell, height: cell, borderRadius: t.radius.control, overflow: 'hidden', backgroundColor: t.colors.sel, opacity: pressed ? 0.85 : 1, alignItems: 'center', justifyContent: 'center' })}>
|
|
57
|
+
{image ? <Image source={{ uri: resolveUrl(thumb, baseUrl) }} style={{ width: '100%', height: '100%', opacity: t.scheme === 'dark' ? 0.85 : 1 }} resizeMode="cover" />
|
|
58
|
+
: <Text style={[t.text.meta, { padding: 8, textAlign: 'center' }]} numberOfLines={3}>{item.filename}</Text>}
|
|
59
|
+
</Pressable>
|
|
60
|
+
);
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
{open && (
|
|
64
|
+
<Sheet visible onClose={() => setOpen(null)} title={open.filename}>
|
|
65
|
+
{open.mime.startsWith('image/') && <Image source={{ uri: resolveUrl(open.url, baseUrl) }} style={{ width: '100%', aspectRatio: open.width && open.height ? open.width / open.height : 4 / 3, borderRadius: t.radius.card, backgroundColor: t.colors.sel }} resizeMode="contain" />}
|
|
66
|
+
<Text style={t.text.meta}>{[open.width && open.height ? `${open.width} × ${open.height}` : '', size(open.size), shortDate(open.createdAt)].filter(Boolean).join(' · ')}</Text>
|
|
67
|
+
{!!open.alt && <Text style={t.text.body}>{open.alt}</Text>}
|
|
68
|
+
<Text selectable style={[t.text.body, { fontSize: 13 }]}>{resolveUrl(open.url, baseUrl)}</Text>
|
|
69
|
+
</Sheet>
|
|
70
|
+
)}
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Media', description: 'Photos and files', href: '/media', order: 50 };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "editor",
|
|
3
|
+
"title": "Media",
|
|
4
|
+
"description": "The site's media library as a grid that uses the width on tablets, with photo upload from the library or camera (compressed on the device) and a details sheet.",
|
|
5
|
+
"packages": ["expo-image-picker"],
|
|
6
|
+
"routes": ["/media"],
|
|
7
|
+
"next": ["It appears on Home (tools/media.ts).", "Add \"expo-image-picker\" to app.json plugins with photosPermission and cameraPermission messages for iOS."]
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
2
|
+
import { SectionEditorPane } from '../../../components/SectionEditorPane';
|
|
3
|
+
|
|
4
|
+
export default function SectionScreen() {
|
|
5
|
+
const { pageId, sectionId } = useLocalSearchParams<{ pageId: string; sectionId: string }>();
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
<Stack.Screen options={{ title: 'Section' }} />
|
|
9
|
+
<SectionEditorPane pageId={pageId} sectionId={sectionId} />
|
|
10
|
+
</>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, ScrollView, Text, View } from 'react-native';
|
|
3
|
+
import { Stack, router } from 'expo-router';
|
|
4
|
+
import { Card, ListRow, Notice, SectionLabel, StatusText, errorMessage, useEditorTheme, useSectionLayouts } from '@acms-subir/native/editor';
|
|
5
|
+
import { SplitView, useWide } from '../../components/Screen';
|
|
6
|
+
import { SectionEditorPane } from '../../components/SectionEditorPane';
|
|
7
|
+
|
|
8
|
+
export default function SectionsScreen() {
|
|
9
|
+
const t = useEditorTheme();
|
|
10
|
+
const { wide, gutter } = useWide();
|
|
11
|
+
const layouts = useSectionLayouts();
|
|
12
|
+
const [selected, setSelected] = useState<{ pageId: string; sectionId: string } | null>(null);
|
|
13
|
+
const pages = (layouts.data?.pages ?? []).filter((p) => p.mode === 'sections' && p.sections.length);
|
|
14
|
+
const open = (pageId: string, sectionId: string) => (wide ? setSelected({ pageId, sectionId }) : router.push({ pathname: '/sections/[pageId]/[sectionId]', params: { pageId, sectionId } }));
|
|
15
|
+
const list = (
|
|
16
|
+
<ScrollView contentContainerStyle={{ padding: gutter, gap: 8 }}>
|
|
17
|
+
{layouts.isPending && <ActivityIndicator color={t.colors.muted} />}
|
|
18
|
+
{layouts.error && <Notice tone="error">{errorMessage(layouts.error)}</Notice>}
|
|
19
|
+
{layouts.isSuccess && !pages.length && <Notice>No page uses sections yet. An owner sets them up on the website (Design → Pages).</Notice>}
|
|
20
|
+
{pages.map((p) => (
|
|
21
|
+
<View key={p.pageId} style={{ gap: 6 }}>
|
|
22
|
+
<SectionLabel>{p.isHome && p.title.trim().toLowerCase() !== 'home' ? `${p.title} · Home page` : p.title}</SectionLabel>
|
|
23
|
+
<Card>
|
|
24
|
+
{p.sections.map((s) => (
|
|
25
|
+
<ListRow key={s.id} title={s.name} selected={wide && selected?.sectionId === s.id && selected.pageId === p.pageId}
|
|
26
|
+
accessibilityLabel={`${s.name}${s.locked ? ', locked' : ''}`}
|
|
27
|
+
onPress={() => open(p.pageId, s.id)}
|
|
28
|
+
meta={
|
|
29
|
+
<Text numberOfLines={1} style={t.text.meta}>
|
|
30
|
+
{s.isNew ? <StatusText status="draft" label="New" /> : s.changed ? <StatusText status="draft" label="Not live yet" /> : <StatusText status="published" label="Live" />}
|
|
31
|
+
{s.hidden ? ' · Hidden' : ''}{s.locked ? ' · Locked' : ''}{!s.editable ? ' · View only' : ''}
|
|
32
|
+
</Text>
|
|
33
|
+
} />
|
|
34
|
+
))}
|
|
35
|
+
</Card>
|
|
36
|
+
</View>
|
|
37
|
+
))}
|
|
38
|
+
</ScrollView>
|
|
39
|
+
);
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<Stack.Screen options={{ title: 'Sections' }} />
|
|
43
|
+
<SplitView list={list} detail={selected ? <SectionEditorPane key={`${selected.pageId}/${selected.sectionId}`} {...selected} /> : (
|
|
44
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}><Text style={t.text.meta}>Choose a section on the left.</Text></View>
|
|
45
|
+
)} />
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Dimensions, Keyboard, Platform, Text, View } from 'react-native';
|
|
3
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
4
|
+
import { EditorButton, Notice, SectionFields, StatusText, errorMessage, isForbidden, useAuth, useEditorTheme, useSectionEditor } from '@acms-subir/native/editor';
|
|
5
|
+
import { Screen } from './Screen';
|
|
6
|
+
|
|
7
|
+
/** one section's form: save stages it, Publish makes just this section live */
|
|
8
|
+
export function SectionEditorPane({ pageId, sectionId }: { pageId: string; sectionId: string }) {
|
|
9
|
+
const t = useEditorTheme();
|
|
10
|
+
const insets = useSafeAreaInsets();
|
|
11
|
+
const keyboard = useKeyboardOverlap();
|
|
12
|
+
const { role } = useAuth();
|
|
13
|
+
const ed = useSectionEditor(pageId, sectionId);
|
|
14
|
+
const [done, setDone] = useState<string | null>(null);
|
|
15
|
+
const [tried, setTried] = useState(false);
|
|
16
|
+
if (ed.loading) return <Screen><ActivityIndicator color={t.colors.muted} /></Screen>;
|
|
17
|
+
if (ed.error || !ed.section) return <Screen><Notice tone="error">{errorMessage(ed.error) || 'This section was removed.'}</Notice></Screen>;
|
|
18
|
+
if (!ed.definition) return <Screen><Notice tone="warn">This section type isn’t in the site’s theme any more. Its content is kept; an owner can fix it on the website.</Notice></Screen>;
|
|
19
|
+
const invalid = Object.keys(ed.issues).length > 0;
|
|
20
|
+
const act = async (what: 'save' | 'publish') => {
|
|
21
|
+
setDone(null); setTried(true);
|
|
22
|
+
if (invalid) return;
|
|
23
|
+
try {
|
|
24
|
+
if (what === 'save') { await ed.save(); setDone('Saved. It shows on the website after you publish it.'); }
|
|
25
|
+
else { await ed.publish(); setDone('This section is live.'); }
|
|
26
|
+
} catch { /* shown below */ }
|
|
27
|
+
};
|
|
28
|
+
const readOnly = !ed.permissions.canEditSection;
|
|
29
|
+
return (
|
|
30
|
+
<View style={{ flex: 1, paddingBottom: keyboard }}>
|
|
31
|
+
<Screen>
|
|
32
|
+
<View style={{ gap: 4 }}>
|
|
33
|
+
<Text style={t.text.meta}>{ed.page?.title}</Text>
|
|
34
|
+
<Text accessibilityRole="header" style={t.text.screenTitle}>{ed.section.name}</Text>
|
|
35
|
+
<Text style={t.text.meta}>
|
|
36
|
+
{ed.dirty ? <StatusText status="draft" label="Unsaved changes" /> : ed.changed ? <StatusText status="draft" label="Saved · not live yet" /> : <StatusText status="published" label="Live" />}
|
|
37
|
+
{ed.section.hidden ? ' · Hidden on the website' : ''}
|
|
38
|
+
</Text>
|
|
39
|
+
</View>
|
|
40
|
+
{readOnly && <Notice title="View only">{role === 'editor' ? 'An owner locked this section. You can read it here; ask an owner to change it.' : 'You can’t edit this section.'}</Notice>}
|
|
41
|
+
{!readOnly && ed.section.isNew && <Notice>This section is new on the page. It goes live when an owner publishes the page design on the website.</Notice>}
|
|
42
|
+
{ed.conflict && (
|
|
43
|
+
<Notice tone="warn" title="Someone changed the same fields"
|
|
44
|
+
action={<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
45
|
+
<EditorButton compact variant="primary" label="Keep mine" busy={ed.saving} onPress={() => { void ed.keepMine().then(() => setDone('Saved your version.'), () => {}); }} />
|
|
46
|
+
<EditorButton compact label="Use theirs" onPress={ed.keepTheirs} />
|
|
47
|
+
</View>}>
|
|
48
|
+
{`They changed: ${ed.conflict.keys.join(', ')}. Keep mine saves your values over theirs; Use theirs drops your unsaved changes.`}
|
|
49
|
+
</Notice>
|
|
50
|
+
)}
|
|
51
|
+
{!!ed.saveError && !ed.conflict && <Notice tone="error">{isForbidden(ed.saveError) ? `${errorMessage(ed.saveError)} Your role can’t make that change.` : errorMessage(ed.saveError)}</Notice>}
|
|
52
|
+
{tried && invalid && <Notice tone="error" title="Check these fields">{Object.values(ed.issues).flat().join('\n')}</Notice>}
|
|
53
|
+
{!!done && <Notice tone="success">{done}</Notice>}
|
|
54
|
+
<SectionFields definition={ed.definition} data={ed.data} onChange={(k, v) => { setDone(null); ed.setField(k, v); }} permissions={ed.permissions} role={role ?? undefined} showIssues={tried} />
|
|
55
|
+
</Screen>
|
|
56
|
+
{!readOnly && (
|
|
57
|
+
<View style={{ flexDirection: 'row', gap: 10, padding: 12, paddingBottom: 12 + (keyboard ? 0 : insets.bottom), borderTopWidth: 1, borderTopColor: t.colors.line, backgroundColor: t.colors.panel, justifyContent: 'flex-end' }}>
|
|
58
|
+
<EditorButton label="Reset" variant="ghost" disabled={!ed.dirty || ed.saving} onPress={ed.reset} />
|
|
59
|
+
<EditorButton label={ed.saving ? 'Saving…' : 'Save'} disabled={!ed.dirty || ed.saving} onPress={() => { void act('save'); }} />
|
|
60
|
+
<EditorButton label="Publish section" variant="primary" busy={ed.publishing} disabled={ed.publishing || ed.saving || ed.section.isNew || (!ed.dirty && !ed.changed)} onPress={() => { void act('publish'); }} />
|
|
61
|
+
</View>
|
|
62
|
+
)}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** iOS keyboard overlap, so the action bar stays above it (Android resizes the window itself) */
|
|
68
|
+
function useKeyboardOverlap() {
|
|
69
|
+
const [height, setHeight] = useState(0);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (Platform.OS !== 'ios') return;
|
|
72
|
+
const change = Keyboard.addListener('keyboardWillChangeFrame', (e) => setHeight(Math.max(0, Dimensions.get('window').height - e.endCoordinates.screenY)));
|
|
73
|
+
const hide = Keyboard.addListener('keyboardWillHide', () => setHeight(0));
|
|
74
|
+
return () => { change.remove(); hide.remove(); };
|
|
75
|
+
}, []);
|
|
76
|
+
return height;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Sections', description: 'Edit what the pages say', href: '/sections', order: 30 };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "editor",
|
|
3
|
+
"title": "Sections",
|
|
4
|
+
"description": "Pick a page, then a section, and edit its fields with the right input for each type (lists, images with focal points, links, linked calendars); save, then publish that one section. Locked sections and fields are read-only for editors.",
|
|
5
|
+
"packages": ["expo-image-picker"],
|
|
6
|
+
"routes": ["/sections", "/sections/[pageId]/[sectionId]"],
|
|
7
|
+
"next": ["It appears on Home (tools/sections.ts).", "Image fields upload through expo-image-picker; add \"expo-image-picker\" to app.json plugins with a photosPermission message for iOS."]
|
|
8
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, FlatList, RefreshControl, Text, View } from 'react-native';
|
|
3
|
+
import { Stack, router } from 'expo-router';
|
|
4
|
+
import { ListRow, Notice, errorMessage, useEditorTheme, useMe, useTranslationQueue, type TranslationQueueItem } from '@acms-subir/native/editor';
|
|
5
|
+
import { SplitView, useWide } from '../../components/Screen';
|
|
6
|
+
import { TextTabs } from '../../components/TextTabs';
|
|
7
|
+
import { ReviewPane, targetParams } from '../../components/ReviewPane';
|
|
8
|
+
|
|
9
|
+
type Filter = 'open' | 'missing' | 'ai' | 'outdated' | 'all';
|
|
10
|
+
|
|
11
|
+
export default function TranslationsScreen() {
|
|
12
|
+
const t = useEditorTheme();
|
|
13
|
+
const { wide, gutter } = useWide();
|
|
14
|
+
const me = useMe();
|
|
15
|
+
const languages = me.data?.features.languages.extra ?? [];
|
|
16
|
+
const [locale, setLocale] = useState<string | undefined>();
|
|
17
|
+
useEffect(() => { if (!locale && languages[0]) setLocale(languages[0].code); }, [languages, locale]);
|
|
18
|
+
const [filter, setFilter] = useState<Filter>('open');
|
|
19
|
+
const queue = useTranslationQueue(locale, { all: true });
|
|
20
|
+
const [selected, setSelected] = useState<TranslationQueueItem | null>(null);
|
|
21
|
+
const items = useMemo(() => (queue.data ?? []).filter((i) => filter === 'all' ? true : filter === 'open' ? i.open > 0 : i.summary[filter] > 0), [queue.data, filter]);
|
|
22
|
+
const count = (f: Filter) => (queue.data ?? []).filter((i) => f === 'all' ? true : f === 'open' ? i.open > 0 : i.summary[f] > 0).length;
|
|
23
|
+
const name = languages.find((l) => l.code === locale)?.name ?? locale ?? '';
|
|
24
|
+
if (me.isSuccess && !languages.length) return <View style={{ flex: 1, padding: 16, backgroundColor: t.colors.panel }}><Stack.Screen options={{ title: 'Translations' }} /><Notice>This site has one language. Add another in the site’s astro.config.mjs (i18n) to translate here.</Notice></View>;
|
|
25
|
+
const open = (i: TranslationQueueItem) => (wide ? setSelected(i) : router.push({ pathname: '/translations/review', params: { locale: locale!, ...targetParams(i.target) } }));
|
|
26
|
+
const list = (
|
|
27
|
+
<FlatList
|
|
28
|
+
data={items}
|
|
29
|
+
keyExtractor={(i) => JSON.stringify(i.target)}
|
|
30
|
+
contentContainerStyle={{ padding: gutter, gap: 2 }}
|
|
31
|
+
refreshControl={<RefreshControl refreshing={queue.isRefetching} onRefresh={() => queue.refetch()} tintColor={t.colors.muted} />}
|
|
32
|
+
ListHeaderComponent={
|
|
33
|
+
<View style={{ gap: 8, marginBottom: 8 }}>
|
|
34
|
+
{languages.length > 1 && <TextTabs accessibilityLabel="Language" value={locale ?? ''} onChange={(l) => { setLocale(l); setSelected(null); }} options={languages.map((l) => ({ value: l.code, label: l.name }))} />}
|
|
35
|
+
<TextTabs<Filter> accessibilityLabel="Show" value={filter} onChange={setFilter} options={[
|
|
36
|
+
{ value: 'open', label: 'To review', count: count('open') }, { value: 'missing', label: 'Missing', count: count('missing') },
|
|
37
|
+
{ value: 'ai', label: 'AI drafts', count: count('ai') }, { value: 'outdated', label: 'Outdated', count: count('outdated') }, { value: 'all', label: 'All', count: count('all') },
|
|
38
|
+
]} />
|
|
39
|
+
{me.data && !me.data.features.aiTranslation && <Text style={t.text.meta}>AI drafts are off on this site; you can still write {name} by hand.</Text>}
|
|
40
|
+
</View>
|
|
41
|
+
}
|
|
42
|
+
ListEmptyComponent={queue.isPending ? <ActivityIndicator color={t.colors.muted} /> : queue.error ? <Notice tone="error">{errorMessage(queue.error)}</Notice> : <Notice tone="success">{`Nothing ${filter === 'all' ? 'to translate' : 'left here'} in ${name}.`}</Notice>}
|
|
43
|
+
renderItem={({ item }) => (
|
|
44
|
+
<ListRow title={item.title} selected={wide && JSON.stringify(selected?.target) === JSON.stringify(item.target)} onPress={() => open(item)}
|
|
45
|
+
meta={<Text numberOfLines={1} style={t.text.meta}>
|
|
46
|
+
{item.context}
|
|
47
|
+
{item.summary.missing ? <Text style={{ color: t.colors.red }}>{` · ${item.summary.missing} missing`}</Text> : null}
|
|
48
|
+
{item.summary.outdated ? <Text style={{ color: t.colors.amber }}>{` · ${item.summary.outdated} outdated`}</Text> : null}
|
|
49
|
+
{item.summary.ai ? <Text style={{ color: t.colors.accentText }}>{` · ${item.summary.ai} AI`}</Text> : null}
|
|
50
|
+
{!item.open ? <Text style={{ color: t.colors.green }}>{' · Done'}</Text> : null}
|
|
51
|
+
</Text>} />
|
|
52
|
+
)}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<Stack.Screen options={{ title: name ? `Review ${name}` : 'Translations' }} />
|
|
58
|
+
<SplitView list={list} listWidth={400} detail={selected && locale ? <ReviewPane key={JSON.stringify(selected.target) + locale} target={selected.target} locale={locale} title={selected.title} /> : (
|
|
59
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}><Text style={t.text.meta}>Choose something to review.</Text></View>
|
|
60
|
+
)} />
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
2
|
+
import type { DocKind } from '@acms-subir/client';
|
|
3
|
+
import { ReviewPane } from '../../components/ReviewPane';
|
|
4
|
+
|
|
5
|
+
export default function ReviewScreen() {
|
|
6
|
+
const p = useLocalSearchParams<{ locale: string; kind?: DocKind; id?: string; pageId?: string; sectionId?: string }>();
|
|
7
|
+
const target = p.kind && p.id ? { kind: p.kind, id: p.id } : { pageId: p.pageId!, sectionId: p.sectionId! };
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<Stack.Screen options={{ title: 'Review' }} />
|
|
11
|
+
<ReviewPane target={target} locale={p.locale} />
|
|
12
|
+
</>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Text, View } from 'react-native';
|
|
3
|
+
import { EditorButton, FieldInput, Notice, StatusText, errorMessage, useEditorTheme, useMe, useTranslations, htmlToText, type TranslationTarget } from '@acms-subir/native/editor';
|
|
4
|
+
import { Screen, useWide } from './Screen';
|
|
5
|
+
|
|
6
|
+
export const targetParams = (t: TranslationTarget): Record<string, string> => ('kind' in t ? { kind: t.kind, id: t.id } : { pageId: t.pageId, sectionId: t.sectionId });
|
|
7
|
+
const LABEL: Record<string, string> = { missing: 'Missing', ai: 'AI draft', reviewed: 'Reviewed', outdated: 'Original changed' };
|
|
8
|
+
|
|
9
|
+
/** every text of one document or section: the original beside the translation */
|
|
10
|
+
export function ReviewPane({ target, locale, title }: { target: TranslationTarget; locale: string; title?: string }) {
|
|
11
|
+
const t = useEditorTheme();
|
|
12
|
+
const { wide } = useWide();
|
|
13
|
+
const me = useMe();
|
|
14
|
+
const tr = useTranslations(target, locale);
|
|
15
|
+
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
16
|
+
const [message, setMessage] = useState<{ tone: 'success' | 'error'; text: string } | null>(null);
|
|
17
|
+
useEffect(() => { setDrafts({}); }, [tr.translation?.revision]);
|
|
18
|
+
const language = me.data?.features.languages.extra.find((l) => l.code === locale)?.name ?? locale;
|
|
19
|
+
if (tr.isPending) return <Screen><ActivityIndicator color={t.colors.muted} /></Screen>;
|
|
20
|
+
if (tr.error && !tr.translation) return <Screen><Notice tone="error">{errorMessage(tr.error)}</Notice></Screen>;
|
|
21
|
+
const s = tr.summary;
|
|
22
|
+
const run = async (what: () => Promise<{ changed?: number }>, text: (n: number) => string) => {
|
|
23
|
+
setMessage(null);
|
|
24
|
+
try { const r = await what(); setMessage({ tone: 'success', text: text(r.changed ?? 0) }); }
|
|
25
|
+
catch (e) { setMessage({ tone: 'error', text: errorMessage(e) }); }
|
|
26
|
+
};
|
|
27
|
+
const dirty = Object.keys(drafts).length > 0;
|
|
28
|
+
return (
|
|
29
|
+
<Screen maxWidth={wide ? 900 : 760} refreshing={tr.isRefetching} onRefresh={() => tr.refetch()}>
|
|
30
|
+
<View style={{ gap: 4 }}>
|
|
31
|
+
{!!title && <Text accessibilityRole="header" style={t.text.screenTitle}>{title}</Text>}
|
|
32
|
+
{s && <Text style={t.text.meta}>{language}: {s.reviewed}/{s.total} reviewed{s.missing ? ` · ${s.missing} missing` : ''}{s.ai ? ` · ${s.ai} AI drafts` : ''}{s.outdated ? ` · ${s.outdated} outdated` : ''}</Text>}
|
|
33
|
+
</View>
|
|
34
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
35
|
+
{dirty && <EditorButton variant="primary" label="Save changes" busy={tr.pendingOp === 'set'} onPress={() => run(() => tr.set(drafts), (n) => `Saved ${n} ${n === 1 ? 'text' : 'texts'}.`)} />}
|
|
36
|
+
{me.data?.features.aiTranslation && !!s && (s.missing > 0 || s.outdated > 0) && (
|
|
37
|
+
<EditorButton label="AI draft what’s missing" busy={tr.pendingOp === 'ai'} disabled={tr.pending} onPress={() => run(() => tr.aiDraft(), (n) => n ? `Drafted ${n} ${n === 1 ? 'text' : 'texts'}. Review them below.` : 'Nothing to draft.')} />
|
|
38
|
+
)}
|
|
39
|
+
{!!s && s.ai + s.outdated > 0 && !dirty && (
|
|
40
|
+
<EditorButton label="Approve all" busy={tr.pendingOp === 'review'} disabled={tr.pending} onPress={() => run(() => tr.markReviewed(), (n) => `Approved ${n} ${n === 1 ? 'text' : 'texts'}.`)} />
|
|
41
|
+
)}
|
|
42
|
+
</View>
|
|
43
|
+
{!!message && <Notice tone={message.tone}>{message.text}</Notice>}
|
|
44
|
+
{tr.units.length === 0 && <Notice>Nothing to translate here yet.</Notice>}
|
|
45
|
+
{tr.units.map((u) => {
|
|
46
|
+
const value = drafts[u.path] ?? u.value;
|
|
47
|
+
const rich = u.kind === 'richtext';
|
|
48
|
+
return (
|
|
49
|
+
<View key={u.path} style={{ gap: 8, paddingVertical: 12, borderTopWidth: 1, borderTopColor: t.colors.line }}>
|
|
50
|
+
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
|
51
|
+
<Text style={[t.text.label, { flex: 1 }]} numberOfLines={2}>{u.label}</Text>
|
|
52
|
+
<StatusText status={drafts[u.path] !== undefined ? 'draft' : u.status} label={drafts[u.path] !== undefined ? 'Edited' : LABEL[u.status]} />
|
|
53
|
+
</View>
|
|
54
|
+
<View style={{ flexDirection: wide ? 'row' : 'column', gap: 10 }}>
|
|
55
|
+
<View style={{ flex: 1, backgroundColor: t.colors.sel, borderRadius: t.radius.input, padding: 10 }}>
|
|
56
|
+
<Text style={[t.text.body, { fontSize: 14 }]} selectable>{rich ? htmlToText(u.source) : u.source}</Text>
|
|
57
|
+
</View>
|
|
58
|
+
<View style={{ flex: 1 }}>
|
|
59
|
+
<FieldInput field={{ label: `${language}`, type: rich ? 'richtext' : u.kind === 'textarea' ? 'textarea' : 'text' }} value={value}
|
|
60
|
+
onChange={(v) => setDrafts((d) => ({ ...d, [u.path]: String(v ?? '') }))} />
|
|
61
|
+
</View>
|
|
62
|
+
</View>
|
|
63
|
+
{(u.status === 'ai' || u.status === 'outdated') && drafts[u.path] === undefined && (
|
|
64
|
+
<EditorButton compact variant="ghost" label="Approve" style={{ alignSelf: 'flex-start' }} disabled={tr.pending}
|
|
65
|
+
onPress={() => run(() => tr.markReviewed([u.path]), () => 'Approved.')} />
|
|
66
|
+
)}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</Screen>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Translations', description: 'Review other languages', href: '/translations', order: 40 };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "editor",
|
|
3
|
+
"title": "Translations",
|
|
4
|
+
"description": "A review queue per language (missing, AI drafts, outdated) across sections, pages, posts and events; edit each text beside the original, approve, or ask for AI drafts when the site has AI translation on.",
|
|
5
|
+
"packages": [],
|
|
6
|
+
"routes": ["/translations", "/translations/review"],
|
|
7
|
+
"next": ["It appears on Home (tools/translations.ts). The site needs a second language: acms({ i18n: { locales: ['en', 'es'], defaultLocale: 'en' } })."]
|
|
8
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Text, View } from 'react-native';
|
|
3
|
+
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
4
|
+
import type { DocKind, EditorDocument } from '@acms-subir/client';
|
|
5
|
+
import {
|
|
6
|
+
EditorButton, EditorInput, FieldInput, Notice, Sheet, StatusText, errorMessage, isConflict, useDocument, useEditorTheme, usePublish,
|
|
7
|
+
} from '@acms-subir/native/editor';
|
|
8
|
+
import { HeaderActions, Screen, useWide } from '../../../components/Screen';
|
|
9
|
+
import { dateTime } from '../../../lib/format';
|
|
10
|
+
|
|
11
|
+
type Form = { title: string; excerpt: string; bodyHtml: string; tags: string; slug: string };
|
|
12
|
+
const toForm = (d: EditorDocument): Form => ({ title: d.title, excerpt: d.excerpt, bodyHtml: d.bodyHtml, tags: d.tags.join(', '), slug: d.slug });
|
|
13
|
+
const BODY = { label: 'Body', type: 'richtext' as const };
|
|
14
|
+
|
|
15
|
+
export default function WriteScreen() {
|
|
16
|
+
const { kind, id } = useLocalSearchParams<{ kind: DocKind; id: string }>();
|
|
17
|
+
const t = useEditorTheme();
|
|
18
|
+
const { wide } = useWide();
|
|
19
|
+
const doc = useDocument(kind, id);
|
|
20
|
+
const pub = usePublish(kind, id);
|
|
21
|
+
const [form, setForm] = useState<Form | null>(null);
|
|
22
|
+
const [base, setBase] = useState<Form | null>(null);
|
|
23
|
+
const [message, setMessage] = useState<{ tone: 'success' | 'error' | 'warn'; text: string } | null>(null);
|
|
24
|
+
const [scheduling, setScheduling] = useState(false);
|
|
25
|
+
const d = doc.document;
|
|
26
|
+
// start from the loaded copy (and again after "Use theirs")
|
|
27
|
+
useEffect(() => { if (d && (!base || !form)) { setForm(toForm(d)); setBase(toForm(d)); } }, [d]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
28
|
+
const changes = useMemo(() => {
|
|
29
|
+
if (!form || !base || !d) return {};
|
|
30
|
+
const out: Record<string, unknown> = {};
|
|
31
|
+
if (form.title !== base.title) out.title = form.title;
|
|
32
|
+
if (kind !== 'page' && form.excerpt !== base.excerpt) out.excerpt = form.excerpt;
|
|
33
|
+
if (form.bodyHtml !== base.bodyHtml) out.bodyHtml = form.bodyHtml;
|
|
34
|
+
if (kind !== 'page' && form.tags !== base.tags) out.tags = form.tags.split(',').map((x) => x.trim()).filter(Boolean);
|
|
35
|
+
if (form.slug !== base.slug) out.slug = form.slug;
|
|
36
|
+
return out;
|
|
37
|
+
}, [form, base, d, kind]);
|
|
38
|
+
const dirty = Object.keys(changes).length > 0;
|
|
39
|
+
if (doc.isPending || !form) return <Screen><ActivityIndicator color={t.colors.muted} /></Screen>;
|
|
40
|
+
if (!d) return <Screen><Notice tone="error">{errorMessage(doc.error) || 'Not found.'}</Notice></Screen>;
|
|
41
|
+
const live = d.status === 'published' || d.status === 'scheduled';
|
|
42
|
+
const set = (k: keyof Form) => (v: unknown) => setForm((f) => (f ? { ...f, [k]: String(v ?? '') } : f));
|
|
43
|
+
|
|
44
|
+
const save = async () => {
|
|
45
|
+
setMessage(null);
|
|
46
|
+
try {
|
|
47
|
+
const saved = await doc.save(changes as never);
|
|
48
|
+
setBase(toForm(saved)); setForm(toForm(saved));
|
|
49
|
+
setMessage({ tone: 'success', text: live ? 'Saved. Publish to make your edits live.' : 'Draft saved.' });
|
|
50
|
+
return true;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
setMessage(isConflict(e) ? null : { tone: 'error', text: errorMessage(e) });
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const publish = async () => {
|
|
57
|
+
if (dirty && !(await save())) return;
|
|
58
|
+
try { await pub.publish(); setMessage({ tone: 'success', text: live ? 'Your edits are live.' : 'Published.' }); await doc.refetch(); }
|
|
59
|
+
catch (e) { setMessage({ tone: 'error', text: errorMessage(e) }); }
|
|
60
|
+
};
|
|
61
|
+
const keepMine = async () => {
|
|
62
|
+
try { const saved = await doc.keepMine(); if (saved) { setBase(toForm(saved)); setForm(toForm(saved)); setMessage({ tone: 'success', text: 'Saved your version.' }); } }
|
|
63
|
+
catch (e) { setMessage({ tone: 'error', text: errorMessage(e) }); }
|
|
64
|
+
};
|
|
65
|
+
const keepTheirs = () => { const server = doc.conflict?.server; doc.keepTheirs(); if (server) { setBase(toForm(server)); setForm(toForm(server)); } setMessage(null); };
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<Stack.Screen options={{
|
|
70
|
+
title: '',
|
|
71
|
+
headerRight: () => (
|
|
72
|
+
<HeaderActions>
|
|
73
|
+
<EditorButton compact label={doc.saving ? 'Saving…' : 'Save'} disabled={!dirty || doc.saving} onPress={() => { void save(); }} />
|
|
74
|
+
<EditorButton compact variant="primary" label={live ? (d.hasChanges || dirty ? 'Update' : 'Live') : 'Publish'} busy={pub.action === 'publish'}
|
|
75
|
+
disabled={pub.pending || (live && !d.hasChanges && !dirty)} onPress={() => { void publish(); }} />
|
|
76
|
+
</HeaderActions>
|
|
77
|
+
),
|
|
78
|
+
}} />
|
|
79
|
+
<Screen maxWidth={wide ? 820 : 760}>
|
|
80
|
+
<Text style={t.text.meta}>
|
|
81
|
+
<StatusText status={d.status} suffix={d.status === 'scheduled' && d.scheduledAt ? dateTime(d.scheduledAt) : undefined} />
|
|
82
|
+
{` ${dirty ? 'Unsaved changes' : d.hasChanges ? 'Saved · not live yet' : 'Saved'}`}
|
|
83
|
+
</Text>
|
|
84
|
+
{doc.conflict && (
|
|
85
|
+
<Notice tone="warn" title="Someone saved this while you were editing"
|
|
86
|
+
action={<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
|
87
|
+
<EditorButton compact variant="primary" label="Keep mine" busy={doc.saving} onPress={() => { void keepMine(); }} />
|
|
88
|
+
<EditorButton compact label="Use theirs" onPress={keepTheirs} />
|
|
89
|
+
</View>}>
|
|
90
|
+
{`Their version (saved ${dateTime(doc.conflict.server.updatedAt)}) is titled “${doc.conflict.server.title}”. Keep mine saves your changes over it; Use theirs drops your unsaved changes.`}
|
|
91
|
+
</Notice>
|
|
92
|
+
)}
|
|
93
|
+
{!!message && <Notice tone={message.tone}>{message.text}</Notice>}
|
|
94
|
+
<EditorInput value={form.title} onChangeText={set('title')} placeholder="Title" accessibilityLabel="Title"
|
|
95
|
+
style={{ fontSize: 22, lineHeight: 28, fontWeight: '700', borderWidth: 0, backgroundColor: 'transparent', paddingHorizontal: 0 }} />
|
|
96
|
+
{kind !== 'page' && (
|
|
97
|
+
<FieldInput field={{ label: kind === 'event' ? 'Summary' : 'Excerpt', type: 'textarea', maxLength: 300, help: 'Shown in lists and when shared.' }} value={form.excerpt} onChange={set('excerpt')} />
|
|
98
|
+
)}
|
|
99
|
+
<FieldInput field={BODY} value={form.bodyHtml} onChange={set('bodyHtml')} />
|
|
100
|
+
{kind !== 'page' && <FieldInput field={{ label: 'Tags', type: 'text', help: 'Separate with commas.' }} value={form.tags} onChange={set('tags')} />}
|
|
101
|
+
<FieldInput field={{ label: 'Address', type: 'text', help: `Part of the link: ${d.url}` }} value={form.slug} onChange={set('slug')} />
|
|
102
|
+
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
|
103
|
+
{kind !== 'page' && !live && <EditorButton label="Schedule…" onPress={() => setScheduling(true)} />}
|
|
104
|
+
{live && <EditorButton label="Unpublish" variant="danger" busy={pub.action === 'unpublish'} onPress={() => { pub.unpublish().then(() => setMessage({ tone: 'success', text: 'Unpublished. It is a draft again.' }), (e) => setMessage({ tone: 'error', text: errorMessage(e) })); }} />}
|
|
105
|
+
{d.hasChanges && live && <EditorButton label="Discard edits" busy={pub.action === 'discard'} onPress={() => { pub.discard().then((x) => { setBase(toForm(x)); setForm(toForm(x)); setMessage({ tone: 'success', text: 'Edits discarded.' }); }, (e) => setMessage({ tone: 'error', text: errorMessage(e) })); }} />}
|
|
106
|
+
</View>
|
|
107
|
+
</Screen>
|
|
108
|
+
{scheduling && <ScheduleSheet onClose={() => setScheduling(false)} onSchedule={async (at) => {
|
|
109
|
+
if (dirty && !(await save())) return;
|
|
110
|
+
await pub.schedule(at).then(() => { setScheduling(false); setMessage({ tone: 'success', text: `Scheduled for ${dateTime(at)}.` }); }, (e) => setMessage({ tone: 'error', text: errorMessage(e) }));
|
|
111
|
+
}} busy={pub.action === 'schedule'} />}
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ScheduleSheet({ onClose, onSchedule, busy }: { onClose: () => void; onSchedule: (at: string) => void; busy: boolean }) {
|
|
117
|
+
const t = useEditorTheme();
|
|
118
|
+
const tomorrow = new Date(Date.now() + 86_400_000);
|
|
119
|
+
const [date, setDate] = useState(`${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`);
|
|
120
|
+
const [time, setTime] = useState('09:00');
|
|
121
|
+
const at = /^\d{4}-\d{2}-\d{2}$/.test(date) && /^([01]?\d|2[0-3]):[0-5]\d$/.test(time) ? new Date(`${date}T${time.padStart(5, '0')}`) : null;
|
|
122
|
+
const ok = !!at && at.getTime() > Date.now();
|
|
123
|
+
return (
|
|
124
|
+
<Sheet visible onClose={onClose} title="Schedule" footer={<EditorButton variant="primary" label="Schedule" busy={busy} disabled={!ok} onPress={() => at && onSchedule(at.toISOString())} />}>
|
|
125
|
+
<View style={{ flexDirection: 'row', gap: 10 }}>
|
|
126
|
+
<View style={{ flex: 1, gap: 6 }}><Text style={t.text.label}>Date</Text><EditorInput value={date} onChangeText={setDate} accessibilityLabel="Date" keyboardType="numbers-and-punctuation" /></View>
|
|
127
|
+
<View style={{ flex: 1, gap: 6 }}><Text style={t.text.label}>Time</Text><EditorInput value={time} onChangeText={setTime} accessibilityLabel="Time" keyboardType="numbers-and-punctuation" /></View>
|
|
128
|
+
</View>
|
|
129
|
+
<Text style={t.text.meta}>{ok ? `Goes live ${at!.toLocaleString()} (this device’s time zone).` : 'Pick a future date and time.'}</Text>
|
|
130
|
+
</Sheet>
|
|
131
|
+
);
|
|
132
|
+
}
|