@acms-subir/core 0.2.0 → 0.3.0

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.
Files changed (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -0,0 +1,64 @@
1
+ ---
2
+ // One post teaser in three shapes, shared by the blog index, tag and author
3
+ // archives and "Keep reading":
4
+ // card — 16:10 cover (or a generated tile), meta line, title, excerpt
5
+ // row — text-first list row with a small thumbnail on the right
6
+ // feature — the big split card for the featured post
7
+ // Posts without a cover get a quiet generated tile (soft fill + first tag),
8
+ // so a grid never goes ragged.
9
+ import TagLabel from './TagLabel.astro';
10
+ import { tagColor } from '../../lib/tagColor';
11
+
12
+ interface Props {
13
+ variant?: 'card' | 'row' | 'feature';
14
+ href: string;
15
+ title: string;
16
+ excerpt?: string;
17
+ cover?: string;
18
+ coverAlt?: string;
19
+ date?: string;
20
+ datetime?: string;
21
+ readMins?: number;
22
+ tags?: { name: string; href: string }[];
23
+ featured?: boolean;
24
+ author?: { name: string; avatar?: string; href: string } | null;
25
+ headingLevel?: 2 | 3;
26
+ }
27
+ const {
28
+ variant = 'card', href, title, excerpt, cover, coverAlt = '', date, datetime, readMins,
29
+ tags = [], featured = false, author = null, headingLevel = 3,
30
+ } = Astro.props;
31
+ const H = `h${headingLevel}` as 'h2' | 'h3';
32
+ const firstTag = tags[0]?.name;
33
+ const shownTags = tags.slice(0, variant === 'feature' ? 2 : 1);
34
+ ---
35
+ <article class:list={['post-card', `is-${variant}`]}>
36
+ {variant !== 'row' && (
37
+ <a class="post-card-media" href={href} tabindex="-1" aria-hidden="true">
38
+ {cover
39
+ ? <img src={cover} alt={coverAlt} loading={variant === 'feature' ? 'eager' : 'lazy'} />
40
+ : <span class="post-card-tile" style={firstTag ? `--tc:${tagColor(firstTag)}` : undefined}><span>{firstTag ?? title}</span></span>}
41
+ </a>
42
+ )}
43
+ <div class="post-card-body">
44
+ <div class="post-card-meta">
45
+ {featured && <TagLabel name="Featured" accent />}
46
+ {shownTags.map((t) => <TagLabel name={t.name} href={t.href} />)}
47
+ {date && <time datetime={datetime}>{date}</time>}
48
+ {readMins && <span class="post-card-read">{readMins} min read</span>}
49
+ </div>
50
+ <H class="post-card-title"><a href={href}>{title}</a></H>
51
+ {excerpt && <p class="post-card-excerpt">{excerpt}</p>}
52
+ {variant === 'feature' && author && (
53
+ <a class="post-card-author" href={author.href}>
54
+ {author.avatar
55
+ ? <img class="avatar" src={author.avatar} alt="" />
56
+ : <span class="avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>}
57
+ <span>{author.name}</span>
58
+ </a>
59
+ )}
60
+ </div>
61
+ {variant === 'row' && cover && (
62
+ <a class="post-card-thumb" href={href} tabindex="-1" aria-hidden="true"><img src={cover} alt="" loading="lazy" /></a>
63
+ )}
64
+ </article>
@@ -0,0 +1,11 @@
1
+ ---
2
+ // A tag as marker + text. `href` makes it a link; `accent` swaps the hashed
3
+ // color for the theme accent (used for "Featured").
4
+ import { tagColor } from '../../lib/tagColor';
5
+ interface Props { name: string; href?: string; accent?: boolean }
6
+ const { name, href, accent = false } = Astro.props;
7
+ const style = accent ? undefined : `--tc:${tagColor(name)}`;
8
+ ---
9
+ {href
10
+ ? <a class:list={['tag', { 'is-accent': accent }]} href={href} style={style}>{name}</a>
11
+ : <span class:list={['tag', { 'is-accent': accent }]} style={style}>{name}</span>}
@@ -2,6 +2,10 @@
2
2
  import { getRepo } from '../lib/repo';
3
3
  import { BRAND } from '../lib/brand';
4
4
  import { CONTENT } from '../lib/content';
5
+ import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
6
+ import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../lib/auth';
7
+ import ThemeScript from '../components/ThemeScript.astro';
8
+ import CommandPalette from '../components/CommandPalette';
5
9
  import '../styles/admin.css';
6
10
 
7
11
  interface Props {
@@ -14,12 +18,24 @@ interface Props {
14
18
  const { title, section, wide = false } = Astro.props;
15
19
 
16
20
  const repo = await getRepo(Astro.locals);
17
- const [pages, posts, links, home] = await Promise.all([
21
+ const sessionUserId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
22
+ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
18
23
  repo.listPages(),
19
24
  repo.listPosts(),
20
25
  repo.listLinks(),
21
26
  repo.getHomePage(),
27
+ repo.getSettings(),
28
+ sessionUserId && sessionUserId !== OWNER_ENV ? repo.getUser(sessionUserId) : Promise.resolve(null),
22
29
  ]);
30
+ // pending booking requests, shown as a count on the Bookings nav item
31
+ const pendingBookings = bookingsEnabled() ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'] })).length : 0;
32
+ const me = {
33
+ name: sessionUser?.name || 'Admin',
34
+ role: sessionUserId === OWNER_ENV ? 'Owner' : sessionUser?.role === 'owner' ? 'Owner' : 'Editor',
35
+ avatar: sessionUser?.avatar || '',
36
+ };
37
+ const initials = me.name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase();
38
+ const siteName = settings.siteTitle || BRAND.name;
23
39
 
24
40
  const icons: Record<string, string> = {
25
41
  dashboard: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
@@ -30,34 +46,107 @@ const icons: Record<string, string> = {
30
46
  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',
31
47
  tags: 'M3 12V3h9l9 9-9 9zM7.5 7.5h0',
32
48
  users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M22 21v-2a4 4 0 0 0-3-3.87M15 3.13a4 4 0 0 1 0 7.75',
33
- settings: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12a7 7 0 0 0 .1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6A7 7 0 0 0 19 12z',
49
+ settings: 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4',
34
50
  design: 'M12 3c3.2 3.7 6 7 6 10.2a6 6 0 1 1-12 0C6 10 8.8 6.7 12 3zM9.5 14.5a2.5 2.5 0 0 0 5 0',
35
51
  navigation: 'M4 6h16M4 12h10M4 18h13M19 11l3 3-3 3',
52
+ search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4',
53
+ write: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
54
+ chev: 'M6 9l6 6 6-6',
55
+ chevr: 'M9 6l6 6-6 6',
56
+ updown: 'M8 9l4-4 4 4M8 15l4 4 4-4',
57
+ side: 'M4 4h16v16H4zM9 4v16',
58
+ 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',
59
+ moon: 'M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z',
60
+ monitor: 'M3 4h18v12H3zM8 20h8M12 16v4',
61
+ globe: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.4 3.8 5.6 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.6-3.8-9S9.5 5.4 12 3z',
62
+ out: 'M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3',
63
+ more: 'M5 12h.01M12 12h.01M19 12h.01',
64
+ user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
65
+ calendar: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
66
+ calendars: 'M4 6h.01M4 12h.01M4 18h.01M8 6h12M8 12h12M8 18h12',
67
+ bookings: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
68
+ resources: 'M3 21V9l9-5 9 5v12M9 21v-6h6v6',
69
+ plug: 'M9 7V3M15 7V3M6 7h12v4a6 6 0 0 1-12 0zM12 17v4',
36
70
  };
37
71
 
38
72
  // one sidebar entry per configured collection, in config order
39
73
  const byCollection = new Map<string, number>();
40
74
  for (const p of posts) byCollection.set(p.collection, (byCollection.get(p.collection) ?? 0) + 1);
41
- const collectionItems = Object.entries(CONTENT.collections).map(([key, c]) => ({
75
+ const collections = Object.entries(CONTENT.collections);
76
+ const firstCollection = collections[0]?.[0] ?? 'posts';
77
+ const collectionItems = collections.map(([key, c]) => ({
42
78
  key: `c:${key}`,
43
79
  label: c.plural,
44
80
  href: `/admin/c/${key}`,
45
81
  icon: icons.posts,
46
- count: byCollection.get(key) ?? 0,
82
+ count: byCollection.get(key) ?? 0 as number | null,
47
83
  }));
48
84
 
49
- const items = [
50
- { key: 'dashboard', label: 'Dashboard', href: '/admin', icon: icons.dashboard, count: null },
51
- { key: 'home', label: 'Home Page', href: home ? `/admin/pages/${home.id}` : '/admin/pages', icon: icons.home, count: null },
52
- { key: 'pages', label: 'Pages', href: '/admin/pages', icon: icons.pages, count: pages.length },
53
- ...collectionItems,
54
- { key: 'tags', label: 'Tags', href: '/admin/tags', icon: icons.tags, count: null },
55
- { key: 'media', label: 'Media', href: '/admin/media', icon: icons.media, count: null },
56
- { key: 'links', label: 'Links', href: '/admin/links', icon: icons.links, count: links.length },
57
- { key: 'users', label: 'Users', href: '/admin/users', icon: icons.users, count: null },
58
- { key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
59
- { key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
60
- { key: 'settings', label: 'Settings', href: '/admin/settings', icon: icons.settings, count: null },
85
+ type Item = { key: string; label: string; href: string; icon: string; count: number | null; attention?: boolean };
86
+ const groups: { id: string; label: string; items: Item[] }[] = [
87
+ {
88
+ id: 'main',
89
+ label: '',
90
+ items: [
91
+ { key: 'dashboard', label: 'Overview', href: '/admin', icon: icons.dashboard, count: null },
92
+ { key: 'home', label: 'Home page', href: home ? `/admin/pages/${home.id}` : '/admin/pages', icon: icons.home, count: null },
93
+ ],
94
+ },
95
+ {
96
+ id: 'content',
97
+ label: 'Content',
98
+ items: [
99
+ ...collectionItems,
100
+ { key: 'pages', label: 'Pages', href: '/admin/pages', icon: icons.pages, count: pages.length },
101
+ { key: 'tags', label: 'Tags', href: '/admin/tags', icon: icons.tags, count: null },
102
+ { key: 'media', label: 'Media', href: '/admin/media', icon: icons.media, count: null },
103
+ ],
104
+ },
105
+ ...(CALENDAR.enabled
106
+ ? [{
107
+ id: 'calendar',
108
+ label: 'Calendar',
109
+ items: [
110
+ { key: 'calendar:events', label: 'Events', href: '/admin/calendar/events', icon: icons.calendar, count: null },
111
+ ...(bookingsEnabled()
112
+ ? [
113
+ { key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 },
114
+ { key: 'calendar:resources', label: 'Resources', href: '/admin/calendar/resources', icon: icons.resources, count: null },
115
+ ]
116
+ : []),
117
+ { key: 'calendar:calendars', label: 'Calendars', href: '/admin/calendar/calendars', icon: icons.calendars, count: null },
118
+ { key: 'calendar:integrations', label: 'API & webhooks', href: '/admin/calendar/integrations', icon: icons.plug, count: null },
119
+ ],
120
+ }]
121
+ : []),
122
+ {
123
+ id: 'site',
124
+ label: 'Site',
125
+ items: [
126
+ { key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
127
+ { key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
128
+ { key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
129
+ ],
130
+ },
131
+ {
132
+ id: 'team',
133
+ label: 'Team',
134
+ items: [
135
+ { key: 'users', label: 'Users', href: '/admin/users', icon: icons.users, count: null },
136
+ { key: 'ai', label: 'Document import', href: '/admin/ai', icon: icons.posts, count: null },
137
+ { key: 'settings', label: 'Settings', href: '/admin/settings', icon: icons.settings, count: null },
138
+ ],
139
+ },
140
+ ];
141
+ const activeGroup = groups.find((g) => g.items.some((i) => i.key === section));
142
+ const crumbGroup = activeGroup?.label ?? '';
143
+
144
+ // bottom tab bar (phones): Home · Content · Links · Search · More
145
+ const tabActive = section === 'dashboard' ? 'home' : section.startsWith('c:') || section === 'pages' ? 'content' : section === 'links' ? 'links' : 'more';
146
+ const themeOpts = [
147
+ { key: 'light', label: 'Light', icon: icons.sun },
148
+ { key: 'dark', label: 'Dark', icon: icons.moon },
149
+ { key: 'system', label: 'Auto', icon: icons.monitor },
61
150
  ];
62
151
  ---
63
152
 
@@ -65,55 +154,206 @@ const items = [
65
154
  <html lang="en">
66
155
  <head>
67
156
  <meta charset="utf-8" />
68
- <meta name="viewport" content="width=device-width, initial-scale=1" />
157
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
69
158
  <meta name="robots" content="noindex" />
70
159
  <title>{title} · {BRAND.name} admin</title>
160
+ <ThemeScript />
161
+ <script is:inline>
162
+ // restore the collapsed rail before paint
163
+ try { if (localStorage.getItem('acms-rail') === '1') document.documentElement.classList.add('rail-pending'); } catch {}
164
+ </script>
71
165
  <link rel="preconnect" href="https://fonts.googleapis.com" />
72
166
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
73
167
  </head>
74
168
  <body>
75
- <div class="shell">
76
- <aside class="sidebar">
169
+ <div class="shell" id="acms-shell">
170
+ <aside class="sidebar" aria-label="Admin navigation">
77
171
  <div class="brand">
78
- <svg width="22" height="22" viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
79
- <rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="#e7e8ec" />
80
- <rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="#ff8a4c" />
81
- <rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="#e7e8ec" opacity="0.48" />
172
+ <svg width="20" height="20" viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
173
+ <rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="currentColor" opacity="0.85" />
174
+ <rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="var(--accent)" />
175
+ <rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="currentColor" opacity="0.35" />
82
176
  </svg>
83
- <div>
84
- <div class="brand-name">{BRAND.name}</div>
85
- <div class="brand-sub">v{BRAND.version} · {BRAND.stack}</div>
86
- </div>
177
+ <span class="brand-name" title={`v${BRAND.version} · ${BRAND.stack}`}>{siteName}</span>
178
+ <button class="icon-btn" type="button" id="acms-rail-toggle" aria-label="Collapse sidebar" title="Collapse sidebar">
179
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.side} /></svg>
180
+ </button>
87
181
  </div>
88
- {items.map((it) => (
89
- <a class={`nav-item ${section === it.key ? 'active' : ''}`} href={it.href}>
90
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
91
- <path d={it.icon} />
92
- </svg>
93
- {it.label}
94
- {it.count !== null && <span class="count">{it.count}</span>}
95
- </a>
96
- ))}
97
- <div class="nav-sep" />
98
- <a class="nav-item" href="/" target="_blank" rel="noopener">
99
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
100
- <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.4 3.8 5.6 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.6-3.8-9S9.5 5.4 12 3z" />
101
- </svg>
102
- View site
182
+ <button class="side-search" type="button" data-cmdk title="Search or jump to (⌘K)">
183
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d={icons.search} /></svg>
184
+ <span>Search</span><kbd>⌘K</kbd>
185
+ </button>
186
+ <a class="nav-item" href={`/admin/c/${firstCollection}?new=1`} title={`Write a ${(CONTENT.collections[firstCollection] ?? CONTENT.posts).singular.toLowerCase()}`}>
187
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.write} /></svg>
188
+ <span>Write a {(CONTENT.collections[firstCollection] ?? CONTENT.posts).singular.toLowerCase()}</span>
103
189
  </a>
190
+ <nav class="nav-scroll">
191
+ {groups.map((g) => {
192
+ const links = g.items.map((it) => (
193
+ <a class={`nav-item ${section === it.key ? 'active' : ''}`} href={it.href} title={it.label} aria-current={section === it.key ? 'page' : undefined}>
194
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={it.icon} /></svg>
195
+ <span>{it.label}</span>
196
+ {it.count !== null && <span class={`count${it.attention ? ' is-attention' : ''}`}>{it.count}</span>}
197
+ </a>
198
+ ));
199
+ return g.label ? (
200
+ <details class="nav-group" data-group={g.id} open>
201
+ <summary>
202
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={icons.chev} /></svg>
203
+ {g.label}
204
+ </summary>
205
+ {links}
206
+ </details>
207
+ ) : <div>{links}</div>;
208
+ })}
209
+ </nav>
104
210
  <div class="sidebar-foot">
105
- Signed in · <a href="/admin/logout">Sign out</a>
211
+ <button class="account" type="button" id="acms-account" aria-haspopup="menu" aria-expanded="false">
212
+ <span class="avatar">{me.avatar ? <img src={me.avatar} alt="" /> : initials}</span>
213
+ <span class="who"><b>{me.name}</b><small>{me.role}</small></span>
214
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.updown} /></svg>
215
+ </button>
216
+ <div class="menu-pop" id="acms-account-menu" role="menu" hidden>
217
+ <div class="menu-label">Appearance</div>
218
+ <div class="seg block" role="group" aria-label="Theme">
219
+ {themeOpts.map((t) => (
220
+ <button type="button" data-set-theme={t.key}>
221
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={t.icon} /></svg>{t.label}
222
+ </button>
223
+ ))}
224
+ </div>
225
+ <div class="menu-sep" />
226
+ <a class="menu-item" href="/" target="_blank" rel="noopener" role="menuitem">
227
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.globe} /></svg>View site
228
+ </a>
229
+ <a class="menu-item" href="/admin/logout" role="menuitem">
230
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.out} /></svg>Sign out
231
+ </a>
232
+ </div>
106
233
  </div>
107
234
  </aside>
235
+
108
236
  <div class="main">
109
- <div class="topbar">
110
- <div class="crumb">{BRAND.name} / <b>{title}</b></div>
111
- <a class="btn sm ghost" href="/" target="_blank" rel="noopener">Open site ↗</a>
237
+ <main class="panel" id="acms-panel">
238
+ <div class="topbar">
239
+ <div class="crumb">
240
+ {crumbGroup && <span>{crumbGroup}</span>}
241
+ {crumbGroup && <span class="sl">/</span>}
242
+ <b>{title}</b>
243
+ </div>
244
+ <div class="topbar-actions">
245
+ <slot name="topbar" />
246
+ <a class="btn sm ghost" href="/" target="_blank" rel="noopener">View site ↗</a>
247
+ </div>
248
+ </div>
249
+ <div class={`content ${wide ? 'content-wide' : ''}`}>
250
+ <slot />
251
+ </div>
252
+ </main>
253
+ </div>
254
+ </div>
255
+
256
+ <nav class="tabbar" aria-label="Sections">
257
+ <a href="/admin" class={tabActive === 'home' ? 'active' : ''}>
258
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.home} /></svg>Home
259
+ </a>
260
+ <a href={`/admin/c/${firstCollection}`} class={tabActive === 'content' ? 'active' : ''}>
261
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.posts} /></svg>Content
262
+ </a>
263
+ <a href="/admin/links" class={tabActive === 'links' ? 'active' : ''}>
264
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.links} /></svg>Links
265
+ </a>
266
+ <button type="button" data-cmdk>
267
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.search} /></svg>Search
268
+ </button>
269
+ <button type="button" class={tabActive === 'more' ? 'active' : ''} id="acms-more-open">
270
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d={icons.more} /></svg>More
271
+ </button>
272
+ </nav>
273
+
274
+ <dialog class="sheet" id="acms-more" aria-label="More">
275
+ <div class="sheet-grab" />
276
+ <div class="sheet-body">
277
+ <div class="sheet-title">More</div>
278
+ <div class="glist">
279
+ <div class="glist-row">
280
+ <span class="avatar" style="width:34px;height:34px;font-size:12px">{me.avatar ? <img src={me.avatar} alt="" /> : initials}</span>
281
+ <span class="grow"><b>{me.name}</b><br /><small class="hint">{me.role} · {siteName}</small></span>
282
+ </div>
112
283
  </div>
113
- <div class={`content ${wide ? 'content-wide' : ''}`}>
114
- <slot />
284
+ <div class="sheet-label">Appearance</div>
285
+ <div class="seg" role="group" aria-label="Theme" style="display:flex;margin-bottom:12px">
286
+ {themeOpts.map((t) => (
287
+ <button type="button" data-set-theme={t.key} style="flex:1;padding:8px 4px">
288
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={t.icon} /></svg>{t.label}
289
+ </button>
290
+ ))}
291
+ </div>
292
+ {groups.map((g) => (
293
+ <div class="glist">
294
+ {g.items
295
+ .filter((it) => !['dashboard', `c:${firstCollection}`, 'links'].includes(it.key))
296
+ .map((it) => (
297
+ <a href={it.href} aria-current={section === it.key ? 'page' : undefined}>
298
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={it.icon} /></svg>
299
+ <span class="grow">{it.label}</span>
300
+ {it.count !== null && <span class="count">{it.count}</span>}
301
+ <svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={icons.chevr} /></svg>
302
+ </a>
303
+ ))}
304
+ </div>
305
+ ))}
306
+ <div class="glist">
307
+ <a href="/" target="_blank" rel="noopener">
308
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.globe} /></svg>
309
+ <span class="grow">View site</span>
310
+ </a>
311
+ <a href="/admin/logout" class="danger">
312
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={icons.out} /></svg>
313
+ <span class="grow">Sign out</span>
314
+ </a>
115
315
  </div>
116
316
  </div>
117
- </div>
317
+ </dialog>
318
+
319
+ <CommandPalette client:idle collections={collections.map(([key, c]) => ({ key, singular: c.singular, plural: c.plural }))} />
320
+
321
+ <script>
322
+ const shell = document.getElementById('acms-shell');
323
+ // collapsed rail, remembered per browser
324
+ if (document.documentElement.classList.contains('rail-pending')) shell?.classList.add('is-rail');
325
+ document.getElementById('acms-rail-toggle')?.addEventListener('click', () => {
326
+ const on = !shell?.classList.contains('is-rail');
327
+ shell?.classList.toggle('is-rail', on);
328
+ try { localStorage.setItem('acms-rail', on ? '1' : '0'); } catch {}
329
+ });
330
+
331
+ // nav groups remember open/closed
332
+ document.querySelectorAll<HTMLDetailsElement>('details.nav-group').forEach((d) => {
333
+ const k = `acms-nav-${d.dataset.group}`;
334
+ try { if (localStorage.getItem(k) === '0' && !d.querySelector('.active')) d.open = false; } catch {}
335
+ d.addEventListener('toggle', () => { try { localStorage.setItem(k, d.open ? '1' : '0'); } catch {} });
336
+ });
337
+
338
+ // account menu
339
+ const acct = document.getElementById('acms-account');
340
+ const menu = document.getElementById('acms-account-menu');
341
+ const close = () => { if (menu) menu.hidden = true; acct?.setAttribute('aria-expanded', 'false'); };
342
+ acct?.addEventListener('click', (e) => {
343
+ e.stopPropagation();
344
+ if (!menu) return;
345
+ menu.hidden = !menu.hidden;
346
+ acct.setAttribute('aria-expanded', String(!menu.hidden));
347
+ });
348
+ document.addEventListener('click', (e) => {
349
+ if (menu && !menu.hidden && !(e.target instanceof Node && menu.contains(e.target))) close();
350
+ });
351
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
352
+
353
+ // More sheet (phones)
354
+ const sheet = document.getElementById('acms-more') as HTMLDialogElement | null;
355
+ document.getElementById('acms-more-open')?.addEventListener('click', () => sheet?.showModal());
356
+ sheet?.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
357
+ </script>
118
358
  </body>
119
359
  </html>