@acms-subir/core 0.2.0 → 0.3.1
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 +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
|
|
4
|
+
// ⌘K palette: create, jump, run commands, find content. Opened by ⌘K / Ctrl+K
|
|
5
|
+
// or any [data-cmdk] element (sidebar search, mobile Search tab). The content
|
|
6
|
+
// index is fetched once per page load on first open and filtered client-side.
|
|
7
|
+
|
|
8
|
+
interface Collection { key: string; singular: string; plural: string }
|
|
9
|
+
interface Props { collections: Collection[] }
|
|
10
|
+
interface IndexItem { kind: string; title: string; sub: string; href: string; status: string; image: string; at: number }
|
|
11
|
+
type Row =
|
|
12
|
+
| { type: 'link'; id: string; group: string; label: string; hint?: string; icon: string; href: string; newTab?: boolean }
|
|
13
|
+
| { type: 'run'; id: string; group: string; label: string; hint?: string; icon: string; run: () => void }
|
|
14
|
+
| { type: 'content'; id: string; group: string; item: IndexItem };
|
|
15
|
+
|
|
16
|
+
const I: Record<string, string> = {
|
|
17
|
+
plus: 'M12 5v14M5 12h14',
|
|
18
|
+
go: 'M5 12h14M13 6l6 6-6 6',
|
|
19
|
+
upload: 'M12 16V4M7 9l5-5 5 5M4 17v3h16v-3',
|
|
20
|
+
links: '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',
|
|
21
|
+
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4',
|
|
22
|
+
moon: 'M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z',
|
|
23
|
+
monitor: 'M3 4h18v12H3zM8 20h8M12 16v4',
|
|
24
|
+
globe: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18',
|
|
25
|
+
page: 'M7 3h8l4 4v14H7zM15 3v4h4',
|
|
26
|
+
tag: 'M3 12V3h9l9 9-9 9zM7.5 7.5h0',
|
|
27
|
+
search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4',
|
|
28
|
+
};
|
|
29
|
+
const Icon = ({ d }: { d: string }) => (
|
|
30
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const setTheme = (t: string) => (window as unknown as { acmsTheme?: { set: (p: string) => void } }).acmsTheme?.set(t);
|
|
34
|
+
|
|
35
|
+
export default function CommandPalette({ collections }: Props) {
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [q, setQ] = useState('');
|
|
38
|
+
const [sel, setSel] = useState(0);
|
|
39
|
+
const [index, setIndex] = useState<IndexItem[] | null>(null);
|
|
40
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
41
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const onKey = (e: KeyboardEvent) => {
|
|
45
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setOpen((o) => !o);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const onClick = (e: MouseEvent) => {
|
|
51
|
+
if (e.target instanceof Element && e.target.closest('[data-cmdk]')) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setOpen(true);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener('keydown', onKey);
|
|
57
|
+
document.addEventListener('click', onClick);
|
|
58
|
+
return () => {
|
|
59
|
+
window.removeEventListener('keydown', onKey);
|
|
60
|
+
document.removeEventListener('click', onClick);
|
|
61
|
+
};
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open) return;
|
|
66
|
+
setQ('');
|
|
67
|
+
setSel(0);
|
|
68
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
69
|
+
if (!index) {
|
|
70
|
+
actions.admin.index({}).then(({ data }) => setIndex(data?.items ?? [])).catch(() => setIndex([]));
|
|
71
|
+
}
|
|
72
|
+
}, [open]);
|
|
73
|
+
|
|
74
|
+
const staticRows = useMemo<Row[]>(() => {
|
|
75
|
+
const create: Row[] = [
|
|
76
|
+
...collections.map((c): Row => ({ type: 'link', id: `new-${c.key}`, group: 'Create', label: `New ${c.singular.toLowerCase()}`, icon: I.plus, href: `/admin/c/${c.key}?new=1` })),
|
|
77
|
+
{ type: 'link', id: 'new-page', group: 'Create', label: 'New page', icon: I.plus, href: '/admin/pages?new=1' },
|
|
78
|
+
{ type: 'link', id: 'upload', group: 'Create', label: 'Upload media', icon: I.upload, href: '/admin/media' },
|
|
79
|
+
{ type: 'link', id: 'add-link', group: 'Create', label: 'Add link block', icon: I.links, href: '/admin/links' },
|
|
80
|
+
];
|
|
81
|
+
const jump: Row[] = [
|
|
82
|
+
['Overview', '/admin'],
|
|
83
|
+
...collections.map((c) => [c.plural, `/admin/c/${c.key}`]),
|
|
84
|
+
['Pages', '/admin/pages'], ['Tags', '/admin/tags'], ['Media', '/admin/media'],
|
|
85
|
+
['Design', '/admin/design'], ['Navigation', '/admin/navigation'], ['Links page', '/admin/links'],
|
|
86
|
+
['Users', '/admin/users'], ['Settings', '/admin/settings'],
|
|
87
|
+
['Settings › Analytics', '/admin/settings#analytics'], ['Settings › Code injection', '/admin/settings#code'],
|
|
88
|
+
['Settings › Redirects', '/admin/settings#redirects'], ['Settings › Announcement bar', '/admin/settings#announcement'],
|
|
89
|
+
].map(([label, href]): Row => ({ type: 'link', id: `go-${href}`, group: 'Jump to', label, icon: I.go, href }));
|
|
90
|
+
const cmds: Row[] = [
|
|
91
|
+
{ type: 'run', id: 'theme-light', group: 'Commands', label: 'Switch to light mode', icon: I.sun, hint: '⌘⇧L', run: () => setTheme('light') },
|
|
92
|
+
{ type: 'run', id: 'theme-dark', group: 'Commands', label: 'Switch to night mode', icon: I.moon, hint: '⌘⇧L', run: () => setTheme('dark') },
|
|
93
|
+
{ type: 'run', id: 'theme-system', group: 'Commands', label: 'Match system theme', icon: I.monitor, run: () => setTheme('system') },
|
|
94
|
+
{ type: 'link', id: 'view-site', group: 'Commands', label: 'View site', icon: I.globe, href: '/', newTab: true },
|
|
95
|
+
];
|
|
96
|
+
return [...create, ...jump, ...cmds];
|
|
97
|
+
}, [collections]);
|
|
98
|
+
|
|
99
|
+
const rows = useMemo<Row[]>(() => {
|
|
100
|
+
const needle = q.trim().toLowerCase();
|
|
101
|
+
const match = (s: string) => s.toLowerCase().includes(needle);
|
|
102
|
+
if (!needle) {
|
|
103
|
+
const recent = (index ?? []).filter((i) => i.at).sort((a, b) => b.at - a.at).slice(0, 5)
|
|
104
|
+
.map((item): Row => ({ type: 'content', id: `c-${item.href}`, group: 'Recent', item }));
|
|
105
|
+
return [...staticRows.filter((r) => r.group === 'Create'), ...recent, ...staticRows.filter((r) => r.group === 'Jump to').slice(0, 6)];
|
|
106
|
+
}
|
|
107
|
+
const content = (index ?? []).filter((i) => match(i.title) || match(i.sub))
|
|
108
|
+
.slice(0, 8)
|
|
109
|
+
.map((item): Row => ({ type: 'content', id: `c-${item.href}-${item.title}`, group: 'Content', item }));
|
|
110
|
+
const statics = staticRows.filter((r) => r.type !== 'content' && match(r.label));
|
|
111
|
+
return [...statics.filter((r) => r.group === 'Create'), ...content, ...statics.filter((r) => r.group !== 'Create')];
|
|
112
|
+
}, [q, index, staticRows]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => { setSel(0); }, [q]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
|
|
117
|
+
}, [sel]);
|
|
118
|
+
|
|
119
|
+
if (!open) return null;
|
|
120
|
+
|
|
121
|
+
const close = () => setOpen(false);
|
|
122
|
+
const activate = (r: Row, newTab = false) => {
|
|
123
|
+
if (r.type === 'run') { r.run(); close(); return; }
|
|
124
|
+
const href = r.type === 'content' ? r.item.href : r.href;
|
|
125
|
+
if (newTab || (r.type === 'link' && r.newTab)) window.open(href, '_blank', 'noopener');
|
|
126
|
+
else location.href = href;
|
|
127
|
+
close();
|
|
128
|
+
};
|
|
129
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
130
|
+
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
131
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(rows.length - 1, s + 1)); }
|
|
132
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); }
|
|
133
|
+
else if (e.key === 'Enter' && rows[sel]) { e.preventDefault(); activate(rows[sel], e.metaKey || e.ctrlKey); }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
let lastGroup = '';
|
|
137
|
+
return (
|
|
138
|
+
<div className="cmdk-root" onKeyDown={onKeyDown}>
|
|
139
|
+
<div className="cmdk-dim" onClick={close} />
|
|
140
|
+
<div className="cmdk" role="dialog" aria-modal="true" aria-label="Command palette">
|
|
141
|
+
<div className="cmdk-in">
|
|
142
|
+
<Icon d={I.search} />
|
|
143
|
+
<input
|
|
144
|
+
ref={inputRef}
|
|
145
|
+
value={q}
|
|
146
|
+
onChange={(e) => setQ(e.target.value)}
|
|
147
|
+
placeholder="Search or type a command…"
|
|
148
|
+
role="combobox"
|
|
149
|
+
aria-expanded="true"
|
|
150
|
+
aria-controls="cmdk-list"
|
|
151
|
+
aria-activedescendant={rows[sel] ? `cmdk-${sel}` : undefined}
|
|
152
|
+
/>
|
|
153
|
+
<button type="button" className="cmdk-esc" onClick={close}>esc</button>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="cmdk-list" id="cmdk-list" role="listbox" ref={listRef}>
|
|
156
|
+
{rows.length === 0 && <div className="cmdk-empty">{index ? `Nothing matches “${q}”` : 'Loading…'}</div>}
|
|
157
|
+
{rows.map((r, i) => {
|
|
158
|
+
const head = r.group !== lastGroup ? <div className="cmdk-g">{r.group}</div> : null;
|
|
159
|
+
lastGroup = r.group;
|
|
160
|
+
return (
|
|
161
|
+
<div key={r.id}>
|
|
162
|
+
{head}
|
|
163
|
+
<div
|
|
164
|
+
id={`cmdk-${i}`}
|
|
165
|
+
role="option"
|
|
166
|
+
aria-selected={i === sel}
|
|
167
|
+
className="cmdk-it"
|
|
168
|
+
onMouseMove={() => sel !== i && setSel(i)}
|
|
169
|
+
onClick={(e) => activate(r, e.metaKey || e.ctrlKey)}
|
|
170
|
+
>
|
|
171
|
+
{r.type === 'content' ? (
|
|
172
|
+
<>
|
|
173
|
+
{r.item.image ? <img className="cmdk-thumb" src={r.item.image} alt="" /> : (
|
|
174
|
+
<span className="cmdk-ico"><Icon d={r.item.kind === 'Tag' ? I.tag : I.page} /></span>
|
|
175
|
+
)}
|
|
176
|
+
<span className="cmdk-label">{r.item.title}<small>{r.item.sub}</small></span>
|
|
177
|
+
{r.item.status && <span className={`status ${r.item.status}`}>{r.item.status}</span>}
|
|
178
|
+
<span className="cmdk-kind">{r.item.kind}</span>
|
|
179
|
+
</>
|
|
180
|
+
) : (
|
|
181
|
+
<>
|
|
182
|
+
<span className="cmdk-ico"><Icon d={r.icon} /></span>
|
|
183
|
+
<span className="cmdk-label">{r.label}</span>
|
|
184
|
+
{r.hint && <kbd>{r.hint}</kbd>}
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
})}
|
|
191
|
+
</div>
|
|
192
|
+
<div className="cmdk-foot">
|
|
193
|
+
<span><kbd>↑</kbd> <kbd>↓</kbd> move</span>
|
|
194
|
+
<span><kbd>↵</kbd> open</span>
|
|
195
|
+
<span><kbd>⌘ ↵</kbd> new tab</span>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Ghost-style content rows grouped by month, shared by every collection list
|
|
3
|
+
// and Pages. Rows link to the editor; hover (desktop) or swipe/long-press
|
|
4
|
+
// (touch) reveals actions. Bulk + row actions POST back to the page as
|
|
5
|
+
// op=bulk with `ids` and `to` (published | draft | archived | delete).
|
|
6
|
+
|
|
7
|
+
export interface ContentItem {
|
|
8
|
+
id: string;
|
|
9
|
+
href: string;
|
|
10
|
+
title: string;
|
|
11
|
+
sub: string; // public path
|
|
12
|
+
meta: string; // "By Ana in design"
|
|
13
|
+
at: number; // sort/group timestamp
|
|
14
|
+
status: string;
|
|
15
|
+
edited?: boolean;
|
|
16
|
+
scheduledAt?: number | null;
|
|
17
|
+
image?: string;
|
|
18
|
+
viewHref?: string; // public URL when live
|
|
19
|
+
badges?: string[];
|
|
20
|
+
featured?: boolean;
|
|
21
|
+
// read-only rows (e.g. events mirrored from an external feed): no select
|
|
22
|
+
// box, swipe or status menu — just the row and an optional single action
|
|
23
|
+
readonly?: boolean;
|
|
24
|
+
// one extra POST action shown in the row's action area
|
|
25
|
+
action?: { label: string; fields: Record<string, string> };
|
|
26
|
+
// override the relative "3d ago" time with fixed text (event dates)
|
|
27
|
+
when?: string;
|
|
28
|
+
// extra class on the status line (e.g. 'cancelled')
|
|
29
|
+
statusLabel?: string;
|
|
30
|
+
}
|
|
31
|
+
interface Props {
|
|
32
|
+
items: ContentItem[];
|
|
33
|
+
noun: string; // "post"
|
|
34
|
+
timeZone?: string;
|
|
35
|
+
empty?: string; // slot-less empty message when filters hide everything
|
|
36
|
+
groupByMonth?: boolean;
|
|
37
|
+
}
|
|
38
|
+
const { items, noun, timeZone, empty = `No ${noun}s match the current filters.`, groupByMonth = true } = Astro.props;
|
|
39
|
+
const tz = timeZone ? { timeZone } : {};
|
|
40
|
+
const month = (ts: number) => {
|
|
41
|
+
try { return new Date(ts).toLocaleDateString('en-US', { month: 'long', year: 'numeric', ...tz }); }
|
|
42
|
+
catch { return new Date(ts).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); }
|
|
43
|
+
};
|
|
44
|
+
const ago = (ts: number) => {
|
|
45
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
46
|
+
if (s < 60) return 'just now';
|
|
47
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
48
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
49
|
+
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d ago`;
|
|
50
|
+
try { return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...tz }); }
|
|
51
|
+
catch { return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
|
|
52
|
+
};
|
|
53
|
+
const when = (ts: number) => {
|
|
54
|
+
try { return new Date(ts).toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', ...tz }); }
|
|
55
|
+
catch { return new Date(ts).toLocaleString('en-US'); }
|
|
56
|
+
};
|
|
57
|
+
const label = (s: string) => s[0].toUpperCase() + s.slice(1);
|
|
58
|
+
|
|
59
|
+
const groups: { title: string; rows: ContentItem[] }[] = [];
|
|
60
|
+
for (const it of items) {
|
|
61
|
+
const t = groupByMonth ? month(it.at) : '';
|
|
62
|
+
if (!groups.length || groups[groups.length - 1].title !== t) groups.push({ title: t, rows: [] });
|
|
63
|
+
groups[groups.length - 1].rows.push(it);
|
|
64
|
+
}
|
|
65
|
+
const I = {
|
|
66
|
+
ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
|
67
|
+
dots: 'M5 12h.01M12 12h.01M19 12h.01',
|
|
68
|
+
archive: 'M3 4h18v4H3zM5 8v12h14V8M10 12h4',
|
|
69
|
+
trash: 'M4 7h16M10 11v6M14 11v6M5 7l1 13h12l1-13M9 7V4h6v3',
|
|
70
|
+
image: 'M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6',
|
|
71
|
+
x: 'M6 6l12 12M18 6 6 18',
|
|
72
|
+
};
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
<form id="bulk-form" method="post" hidden>
|
|
76
|
+
<input type="hidden" name="op" value="bulk" />
|
|
77
|
+
</form>
|
|
78
|
+
|
|
79
|
+
<div class="clist" data-noun={noun}>
|
|
80
|
+
{groups.map((g) => (
|
|
81
|
+
<section class="cgroup">
|
|
82
|
+
{g.title && <div class="cgroup-title">{g.title}</div>}
|
|
83
|
+
{g.rows.map((it) => (
|
|
84
|
+
<div class={`crow${it.readonly ? ' is-readonly' : ''}`} data-q={`${it.title} ${it.sub} ${it.meta}`.toLowerCase()} data-id={it.id}>
|
|
85
|
+
{it.readonly ? <span class="crow-check" aria-hidden="true" /> : (
|
|
86
|
+
<label class="crow-check" title="Select">
|
|
87
|
+
<input type="checkbox" name="ids" value={it.id} form="bulk-form" aria-label={`Select ${it.title}`} />
|
|
88
|
+
</label>
|
|
89
|
+
)}
|
|
90
|
+
<a class="crow-main" href={it.href}>
|
|
91
|
+
<span class="crow-thumb">
|
|
92
|
+
{it.image ? <img src={it.image} alt="" loading="lazy" /> : (
|
|
93
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d={I.image} /></svg>
|
|
94
|
+
)}
|
|
95
|
+
</span>
|
|
96
|
+
<span class="crow-text">
|
|
97
|
+
<b>{it.featured && <span class="crow-star" title="Featured">★ </span>}{it.title || 'Untitled'}</b>
|
|
98
|
+
<small>{it.meta ? `${it.meta} · ` : ''}{it.when ?? ago(it.at)}{it.sub && <span class="crow-path"> · {it.sub}</span>}</small>
|
|
99
|
+
<span class={`status ${it.status}`}>
|
|
100
|
+
{it.statusLabel ?? (it.status === 'scheduled' && it.scheduledAt ? `Scheduled · ${when(it.scheduledAt)}` : label(it.status))}
|
|
101
|
+
{it.edited && <span class="edited"> · edited</span>}
|
|
102
|
+
{it.badges?.map((b) => <span class="pill lang">{b}</span>)}
|
|
103
|
+
</span>
|
|
104
|
+
</span>
|
|
105
|
+
</a>
|
|
106
|
+
<div class="crow-actions">
|
|
107
|
+
{it.action && (
|
|
108
|
+
<form method="post" class="crow-action">
|
|
109
|
+
{Object.entries(it.action.fields).map(([k, v]) => <input type="hidden" name={k} value={v} />)}
|
|
110
|
+
<button class="btn sm" type="submit">{it.action.label}</button>
|
|
111
|
+
</form>
|
|
112
|
+
)}
|
|
113
|
+
{it.viewHref && (
|
|
114
|
+
<a class="icon-btn" href={it.viewHref} target="_blank" rel="noopener" title="View live" aria-label="View live">
|
|
115
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={I.ext} /></svg>
|
|
116
|
+
</a>
|
|
117
|
+
)}
|
|
118
|
+
{!it.readonly && <details class="rmenu">
|
|
119
|
+
<summary class="icon-btn" title="More actions" aria-label="More actions">
|
|
120
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d={I.dots} /></svg>
|
|
121
|
+
</summary>
|
|
122
|
+
<form method="post" class="rmenu-pop">
|
|
123
|
+
<input type="hidden" name="op" value="bulk" />
|
|
124
|
+
<input type="hidden" name="ids" value={it.id} />
|
|
125
|
+
{it.status !== 'published' && <button class="menu-item" name="to" value="published">Publish now</button>}
|
|
126
|
+
{it.status === 'published' && <button class="menu-item" name="to" value="draft">Unpublish</button>}
|
|
127
|
+
{it.status !== 'archived' && <button class="menu-item" name="to" value="archived">Archive</button>}
|
|
128
|
+
{it.status === 'archived' && <button class="menu-item" name="to" value="draft">Restore as draft</button>}
|
|
129
|
+
<div class="menu-sep" />
|
|
130
|
+
<button class="menu-item danger" name="to" value="delete" data-confirm={`Delete “${it.title || 'Untitled'}” permanently?`}>Delete</button>
|
|
131
|
+
</form>
|
|
132
|
+
</details>}
|
|
133
|
+
</div>
|
|
134
|
+
{!it.readonly && <form method="post" class="crow-swipe" aria-hidden="true">
|
|
135
|
+
<input type="hidden" name="op" value="bulk" />
|
|
136
|
+
<input type="hidden" name="ids" value={it.id} />
|
|
137
|
+
<button name="to" value="archived" class="sw-archive" tabindex="-1">
|
|
138
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={I.archive} /></svg>Archive
|
|
139
|
+
</button>
|
|
140
|
+
<button name="to" value="delete" class="sw-delete" tabindex="-1" data-confirm={`Delete “${it.title || 'Untitled'}” permanently?`}>
|
|
141
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={I.trash} /></svg>Delete
|
|
142
|
+
</button>
|
|
143
|
+
</form>}
|
|
144
|
+
</div>
|
|
145
|
+
))}
|
|
146
|
+
</section>
|
|
147
|
+
))}
|
|
148
|
+
<p class="clist-none hint" hidden>{empty}</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="bulkbar" hidden>
|
|
152
|
+
<b><span data-count>0</span> selected</b>
|
|
153
|
+
<button class="btn sm" name="to" value="published" form="bulk-form">Publish</button>
|
|
154
|
+
<button class="btn sm" name="to" value="archived" form="bulk-form">Archive</button>
|
|
155
|
+
<button class="btn sm danger" name="to" value="delete" form="bulk-form" data-confirm="Delete the selected items permanently?">Delete</button>
|
|
156
|
+
<button class="icon-btn" type="button" data-clear aria-label="Clear selection">
|
|
157
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d={I.x} /></svg>
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<script>
|
|
162
|
+
const list = document.querySelector<HTMLElement>('.clist');
|
|
163
|
+
const bar = document.querySelector<HTMLElement>('.bulkbar');
|
|
164
|
+
const boxes = () => [...document.querySelectorAll<HTMLInputElement>('.crow-check input')];
|
|
165
|
+
const sync = () => {
|
|
166
|
+
const n = boxes().filter((b) => b.checked).length;
|
|
167
|
+
if (bar) { bar.hidden = n === 0; bar.querySelector('[data-count]')!.textContent = String(n); }
|
|
168
|
+
list?.classList.toggle('selecting', n > 0);
|
|
169
|
+
boxes().forEach((b) => b.closest('.crow')?.classList.toggle('is-selected', b.checked));
|
|
170
|
+
};
|
|
171
|
+
list?.addEventListener('change', sync);
|
|
172
|
+
bar?.querySelector('[data-clear]')?.addEventListener('click', () => { boxes().forEach((b) => (b.checked = false)); sync(); });
|
|
173
|
+
|
|
174
|
+
// destructive buttons confirm first
|
|
175
|
+
document.addEventListener('click', (e) => {
|
|
176
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
177
|
+
if (b && !confirm(b.dataset.confirm)) e.preventDefault();
|
|
178
|
+
});
|
|
179
|
+
// one row menu open at a time; outside click closes
|
|
180
|
+
document.addEventListener('click', (e) => {
|
|
181
|
+
document.querySelectorAll<HTMLDetailsElement>('details.rmenu[open]').forEach((d) => {
|
|
182
|
+
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// live filter from the page's search box
|
|
187
|
+
const q = document.querySelector<HTMLInputElement>('[data-live-filter]');
|
|
188
|
+
q?.addEventListener('input', () => {
|
|
189
|
+
const needle = q.value.trim().toLowerCase();
|
|
190
|
+
let shown = 0;
|
|
191
|
+
document.querySelectorAll<HTMLElement>('.cgroup').forEach((g) => {
|
|
192
|
+
let any = false;
|
|
193
|
+
g.querySelectorAll<HTMLElement>('.crow').forEach((r) => {
|
|
194
|
+
const hit = !needle || (r.dataset.q ?? '').includes(needle);
|
|
195
|
+
r.hidden = !hit;
|
|
196
|
+
if (hit) { any = true; shown++; }
|
|
197
|
+
});
|
|
198
|
+
g.hidden = !any;
|
|
199
|
+
});
|
|
200
|
+
const none = document.querySelector<HTMLElement>('.clist-none');
|
|
201
|
+
if (none) none.hidden = shown > 0;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// touch: swipe left for Archive/Delete, long-press to start selecting
|
|
205
|
+
if (matchMedia('(pointer: coarse)').matches) {
|
|
206
|
+
document.querySelectorAll<HTMLElement>('.crow:not(.is-readonly)').forEach((row) => {
|
|
207
|
+
const main = row.querySelector<HTMLElement>('.crow-main')!;
|
|
208
|
+
let x0 = 0, y0 = 0, dx = 0, swiping = false, timer = 0;
|
|
209
|
+
row.addEventListener('touchstart', (e) => {
|
|
210
|
+
x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; dx = 0; swiping = false;
|
|
211
|
+
timer = window.setTimeout(() => {
|
|
212
|
+
const cb = row.querySelector<HTMLInputElement>('.crow-check input');
|
|
213
|
+
if (cb) { cb.checked = !cb.checked; sync(); navigator.vibrate?.(10); }
|
|
214
|
+
timer = -1;
|
|
215
|
+
}, 480);
|
|
216
|
+
}, { passive: true });
|
|
217
|
+
row.addEventListener('touchmove', (e) => {
|
|
218
|
+
const mx = e.touches[0].clientX - x0, my = e.touches[0].clientY - y0;
|
|
219
|
+
if (Math.abs(mx) > 8 || Math.abs(my) > 8) { clearTimeout(timer); if (timer !== -1) timer = 0; }
|
|
220
|
+
if (!swiping && Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my)) swiping = true;
|
|
221
|
+
if (swiping) {
|
|
222
|
+
dx = Math.max(-148, Math.min(0, mx + (row.classList.contains('swiped') ? -148 : 0)));
|
|
223
|
+
main.style.transition = 'none';
|
|
224
|
+
main.style.transform = `translateX(${dx}px)`;
|
|
225
|
+
}
|
|
226
|
+
}, { passive: true });
|
|
227
|
+
row.addEventListener('touchend', (e) => {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
if (timer === -1) { e.preventDefault(); timer = 0; return; }
|
|
230
|
+
if (!swiping) return;
|
|
231
|
+
main.style.transition = '';
|
|
232
|
+
main.style.transform = '';
|
|
233
|
+
document.querySelectorAll('.crow.swiped').forEach((r) => r !== row && r.classList.remove('swiped'));
|
|
234
|
+
row.classList.toggle('swiped', dx < -60);
|
|
235
|
+
});
|
|
236
|
+
main.addEventListener('click', (e) => {
|
|
237
|
+
if (row.classList.contains('swiped')) { e.preventDefault(); row.classList.remove('swiped'); }
|
|
238
|
+
else if (list?.classList.contains('selecting')) {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
const cb = row.querySelector<HTMLInputElement>('.crow-check input');
|
|
241
|
+
if (cb) { cb.checked = !cb.checked; sync(); }
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
</script>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
// Owner-only scoped keys for design tools (agents, scripts, a theme CLI).
|
|
4
|
+
// A key's secret is shown exactly once after creation and only ever lives in
|
|
5
|
+
// component state; it's never written to storage.
|
|
6
|
+
|
|
7
|
+
interface KeyRow { id: string; name: string; prefix: string; scopes: string[]; createdAt: number | string }
|
|
8
|
+
|
|
9
|
+
const SCOPES: { key: string; label: string; hint: string }[] = [
|
|
10
|
+
{ key: 'design:read', label: 'Read design', hint: 'Themes, the live design and the staged draft' },
|
|
11
|
+
{ key: 'design:write-draft', label: 'Stage draft changes', hint: 'Edits land in the private draft only' },
|
|
12
|
+
{ key: 'design:publish', label: 'Publish design', hint: 'Makes the draft live for visitors. Only grant this to tools you trust' },
|
|
13
|
+
{ key: 'media:read', label: 'Read media', hint: 'Browse the media library' },
|
|
14
|
+
{ key: 'media:write', label: 'Upload media', hint: 'Add images to the media library' },
|
|
15
|
+
];
|
|
16
|
+
const DEFAULT_SCOPES = ['design:read', 'design:write-draft'];
|
|
17
|
+
|
|
18
|
+
const created = (v: number | string) => {
|
|
19
|
+
const d = new Date(v);
|
|
20
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const errorText = async (res: Response, fallback: string) => {
|
|
24
|
+
try {
|
|
25
|
+
const body = (await res.json()) as { error?: unknown };
|
|
26
|
+
if (typeof body?.error === 'string' && body.error) return body.error;
|
|
27
|
+
} catch { /* not JSON */ }
|
|
28
|
+
return fallback;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default function DesignAccess() {
|
|
32
|
+
const [keys, setKeys] = useState<KeyRow[] | null>(null);
|
|
33
|
+
const [forbidden, setForbidden] = useState(false);
|
|
34
|
+
const [error, setError] = useState('');
|
|
35
|
+
const [name, setName] = useState('');
|
|
36
|
+
const [scopes, setScopes] = useState<string[]>(DEFAULT_SCOPES);
|
|
37
|
+
const [busy, setBusy] = useState(false);
|
|
38
|
+
const [token, setToken] = useState<{ name: string; value: string } | null>(null);
|
|
39
|
+
const [copied, setCopied] = useState(false);
|
|
40
|
+
|
|
41
|
+
const load = useCallback(async () => {
|
|
42
|
+
setError('');
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch('/api/design/keys', { headers: { accept: 'application/json' } });
|
|
45
|
+
if (res.status === 403) { setForbidden(true); return; }
|
|
46
|
+
if (!res.ok) { setError(await errorText(res, `Couldn't load keys (${res.status}).`)); return; }
|
|
47
|
+
const body = (await res.json()) as { keys?: KeyRow[] };
|
|
48
|
+
setKeys(body.keys ?? []);
|
|
49
|
+
} catch {
|
|
50
|
+
setError("Couldn't reach the server.");
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
useEffect(() => { void load(); }, [load]);
|
|
54
|
+
|
|
55
|
+
const toggleScope = (key: string, on: boolean) =>
|
|
56
|
+
setScopes((s) => (on ? [...new Set([...s, key])] : s.filter((x) => x !== key)));
|
|
57
|
+
|
|
58
|
+
const create = async (e: FormEvent) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
if (!name.trim() || scopes.length === 0) return;
|
|
61
|
+
setBusy(true);
|
|
62
|
+
setError('');
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/design/keys', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
67
|
+
body: JSON.stringify({ name: name.trim(), scopes }),
|
|
68
|
+
});
|
|
69
|
+
if (res.status === 403) { setForbidden(true); return; }
|
|
70
|
+
if (!res.ok) { setError(await errorText(res, `Couldn't create the key (${res.status}).`)); return; }
|
|
71
|
+
const body = (await res.json()) as { key: KeyRow; token: string };
|
|
72
|
+
setKeys((k) => [body.key, ...(k ?? [])]);
|
|
73
|
+
setToken({ name: body.key.name, value: body.token });
|
|
74
|
+
setCopied(false);
|
|
75
|
+
setName('');
|
|
76
|
+
setScopes(DEFAULT_SCOPES);
|
|
77
|
+
} catch {
|
|
78
|
+
setError("Couldn't reach the server.");
|
|
79
|
+
} finally {
|
|
80
|
+
setBusy(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const revoke = async (k: KeyRow) => {
|
|
85
|
+
if (!window.confirm(`Revoke “${k.name}”? Anything using it loses access right away.`)) return;
|
|
86
|
+
setError('');
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/design/keys', {
|
|
89
|
+
method: 'DELETE',
|
|
90
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
91
|
+
body: JSON.stringify({ id: k.id }),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) { setError(await errorText(res, `Couldn't revoke the key (${res.status}).`)); return; }
|
|
94
|
+
setKeys((list) => (list ?? []).filter((x) => x.id !== k.id));
|
|
95
|
+
} catch {
|
|
96
|
+
setError("Couldn't reach the server.");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const copyToken = async () => {
|
|
101
|
+
if (!token) return;
|
|
102
|
+
try { await navigator.clipboard.writeText(token.value); setCopied(true); } catch { setCopied(false); }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (forbidden) {
|
|
106
|
+
return (
|
|
107
|
+
<p className="notice te-note">
|
|
108
|
+
Only the site owner can manage design access keys.
|
|
109
|
+
</p>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="da">
|
|
115
|
+
<p className="te-intro">
|
|
116
|
+
Scoped keys let a tool, script or agent work on the design without your login. Keys can only do what their
|
|
117
|
+
scopes allow. Older agent keys don't have design access.
|
|
118
|
+
</p>
|
|
119
|
+
|
|
120
|
+
{token && (
|
|
121
|
+
<div className="notice da-token" role="status">
|
|
122
|
+
<p><b>Copy the key for “{token.name}” now.</b> It won't be shown again.</p>
|
|
123
|
+
<div className="da-token-row">
|
|
124
|
+
<input readOnly className="mono" value={token.value} aria-label="New access key" onFocus={(e) => e.currentTarget.select()} />
|
|
125
|
+
<button className="btn sm" type="button" onClick={() => void copyToken()}>{copied ? 'Copied' : 'Copy'}</button>
|
|
126
|
+
<button className="btn sm ghost" type="button" onClick={() => setToken(null)}>Done</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
{error && <p className="flash error te-alert" role="alert">{error}</p>}
|
|
131
|
+
|
|
132
|
+
{keys === null ? (
|
|
133
|
+
!error && <p className="te-muted">Loading keys…</p>
|
|
134
|
+
) : keys.length === 0 ? (
|
|
135
|
+
<p className="te-muted">No design keys yet.</p>
|
|
136
|
+
) : (
|
|
137
|
+
<ul className="da-keys" aria-label="Design access keys">
|
|
138
|
+
{keys.map((k) => (
|
|
139
|
+
<li key={k.id}>
|
|
140
|
+
<div className="da-key">
|
|
141
|
+
<b>{k.name}</b>
|
|
142
|
+
<small>
|
|
143
|
+
<code>{k.prefix}…</code>
|
|
144
|
+
{created(k.createdAt) && ` · created ${created(k.createdAt)}`}
|
|
145
|
+
</small>
|
|
146
|
+
<small className="da-scopes">
|
|
147
|
+
{k.scopes.map((s) => SCOPES.find((x) => x.key === s)?.label ?? s).join(' · ')}
|
|
148
|
+
</small>
|
|
149
|
+
</div>
|
|
150
|
+
<button className="btn sm ghost te-delete" type="button" onClick={() => void revoke(k)} aria-label={`Revoke key ${k.name}`}>
|
|
151
|
+
Revoke
|
|
152
|
+
</button>
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
<form className="da-form" onSubmit={(e) => void create(e)}>
|
|
159
|
+
<p className="card-title da-title">New key</p>
|
|
160
|
+
<label className="field">
|
|
161
|
+
<span>Name</span>
|
|
162
|
+
<input type="text" required maxLength={100} placeholder="e.g. Theme CLI on my laptop" value={name} onChange={(e) => setName(e.target.value)} />
|
|
163
|
+
</label>
|
|
164
|
+
<fieldset className="da-scopes-set">
|
|
165
|
+
<legend>Scopes</legend>
|
|
166
|
+
{SCOPES.map((s) => (
|
|
167
|
+
<label key={s.key} className="check-row">
|
|
168
|
+
<input type="checkbox" checked={scopes.includes(s.key)} onChange={(e) => toggleScope(s.key, e.target.checked)} />
|
|
169
|
+
<span>
|
|
170
|
+
{s.label} <code>{s.key}</code>
|
|
171
|
+
<small>{s.hint}</small>
|
|
172
|
+
</span>
|
|
173
|
+
</label>
|
|
174
|
+
))}
|
|
175
|
+
</fieldset>
|
|
176
|
+
<button className="btn" type="submit" disabled={busy || !name.trim() || scopes.length === 0}>
|
|
177
|
+
{busy ? 'Creating…' : 'Create key'}
|
|
178
|
+
</button>
|
|
179
|
+
</form>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|