@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
package/src/middleware.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
- import { verifySessionToken, SESSION_COOKIE } from './lib/auth';
2
+ import { validateSession, SESSION_COOKIE } from './lib/auth';
3
3
  import { getRepo } from './lib/repo';
4
4
  import { CONTENT } from './lib/content';
5
+ import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
6
+ import { dispatchEmail } from './lib/email/service';
7
+ import { sendDueReminders } from './lib/booking/service';
8
+ import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
5
9
 
6
10
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
7
11
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -56,19 +60,38 @@ export const onRequest = defineMiddleware(async (context, next) => {
56
60
  return context.redirect(pathname.replace(/\/+$/, '') + context.url.search, 301);
57
61
  }
58
62
 
63
+ // design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
64
+ // ?preview=off) become a cookie, then the clean URL — so every link clicked
65
+ // afterwards stays in preview mode
66
+ const previewTo = await previewParamRedirect(context.url, context.request.method, context.cookies, async () =>
67
+ Boolean(await validateSession(context.cookies.get(SESSION_COOKIE)?.value, context.locals)));
68
+ if (previewTo) return context.redirect(previewTo, 302);
69
+
59
70
  if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
60
71
  const token = context.cookies.get(SESSION_COOKIE)?.value;
61
- if (!verifySessionToken(token)) {
72
+ if (!(await validateSession(token, context.locals))) {
62
73
  return context.redirect('/admin/login');
63
74
  }
64
75
  }
65
76
 
66
77
  const now = Date.now();
67
- if (now - lastPublishCheck > 60_000 && !pathname.startsWith('/media/')) {
78
+ if (now - lastPublishCheck > 60_000 && !pathname.startsWith('/media/') && !pathname.startsWith('/account/') && !pathname.startsWith('/api/email/')) {
68
79
  lastPublishCheck = now;
69
80
  try {
70
81
  const repo = await getRepo(context.locals);
71
82
  await repo.publishDuePosts(now);
83
+ // webhook retries ride the same once-a-minute sweep (Node has no
84
+ // cron) — in the background, never holding up this request
85
+ const emails = dispatchEmail(repo, context.locals, 2).catch(() => 0);
86
+ const wait = waitUntilFrom(context.locals);
87
+ if (wait) wait(emails); else await emails;
88
+ const retries = processDueDeliveries(repo, { now }).catch(() => {});
89
+ if (wait) wait(retries); else await retries;
90
+ // booking reminders (Node has no cron): site URL, else this request's origin
91
+ const reminders = repo.getSettings()
92
+ .then((s) => sendDueReminders(repo, { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now }))
93
+ .catch(() => {});
94
+ if (wait) wait(reminders); else await reminders;
72
95
  } catch {
73
96
  // never let the publish sweep break a request
74
97
  }
@@ -76,6 +99,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
76
99
 
77
100
  const response = await next();
78
101
 
102
+ // a preview render must never be cached where a visitor could get it
103
+ if (hasPreviewCookie(context.cookies)) {
104
+ try {
105
+ response.headers.set('Cache-Control', 'private, no-store');
106
+ response.headers.append('Vary', 'Cookie');
107
+ } catch {
108
+ // immutable headers (e.g. a proxied response): nothing to cache-bust
109
+ }
110
+ }
111
+
79
112
  // Redirects fire only when the request would otherwise 404, so a rule can
80
113
  // never shadow real content. The table is cached per isolate for a minute.
81
114
  if (response.status === 404 && !pathname.startsWith('/admin') && !pathname.startsWith('/api')) {
@@ -0,0 +1,23 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import { SEARCH } from '../lib/search-config';
4
+ // Rendered for every unmatched URL and for routes that return an empty 404.
5
+ Astro.response.status = 404;
6
+ ---
7
+
8
+ <Site title="Page not found" noindex>
9
+ <div class="not-found">
10
+ <div class="empty-state">
11
+ <span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6M8.5 11h5" /></svg></span>
12
+ <h1>Page not found</h1>
13
+ <span>It moved, or it never existed.</span>
14
+ {SEARCH.enabled && (
15
+ <form action="/search" method="get" role="search">
16
+ <input type="search" name="q" placeholder="Search the site" aria-label="Search the site" />
17
+ <button class="btn primary sm" type="submit">Search</button>
18
+ </form>
19
+ )}
20
+ <a class="btn secondary sm" href="/">Go home</a>
21
+ </div>
22
+ </div>
23
+ </Site>
@@ -1,8 +1,9 @@
1
1
  ---
2
+ import ThemeSections from '../components/ThemeSections.astro';
2
3
  import Site from '../layouts/Site.astro';
3
4
  import WidgetRenderer from '../components/WidgetRenderer.astro';
4
5
  import { getRepo } from '../lib/repo';
5
- import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
6
+ import { validateSession, SESSION_COOKIE } from '../lib/auth';
6
7
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
8
  import { highlightCodeHtml } from '../lib/highlightCode';
8
9
  import { resolveDesign } from '../lib/theme';
@@ -13,15 +14,15 @@ const repo = await getRepo(Astro.locals);
13
14
  const page = slug ? await repo.getPageBySlug(slug) : null;
14
15
 
15
16
  // Drafts are visible only to a logged-in admin (doubles as draft preview).
16
- const isAdmin = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
17
+ const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
17
18
  if (!page || (page.status !== 'published' && !isAdmin)) {
18
19
  return new Response(null, { status: 404, statusText: 'Not found' });
19
20
  }
20
21
  const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
21
22
  // ?designPreview=1 (admin-only) renders the staged Design-screen changes
22
- const settings = effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies);
23
+ const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
23
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
- const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
25
+ const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !== 'content' ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
25
26
  ---
26
27
 
27
28
  <Site
@@ -35,7 +36,9 @@ const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplate
35
36
  Draft preview — only you can see this page.
36
37
  </p>
37
38
  )}
38
- {PageTemplate ? (
39
+ {settings.sectionPages?.[page.id]?.mode === 'sections' ? (
40
+ <ThemeSections page={page} settings={settings} />
41
+ ) : PageTemplate ? (
39
42
  <PageTemplate page={page} html={bodyHtml} settings={settings} />
40
43
  ) : page.bodyHtml ? (
41
44
  <article class="page-body" set:html={bodyHtml} />
@@ -0,0 +1,53 @@
1
+ ---
2
+ import { getRepo } from '../../lib/repo';
3
+ import { accountForm, issueAccountToken, redeemAccountToken } from '../../lib/email/accounts';
4
+ import { sha256 } from '../../lib/email/config';
5
+ import { dispatchEmail, kickEmail } from '../../lib/email/service';
6
+ import { waitUntilFrom } from '../../lib/webhooks';
7
+ import { SESSION_COOKIE } from '../../lib/auth';
8
+ import '../../styles/admin.css';
9
+ const action=Astro.params.action||'';
10
+ if(!['invite','reset','forgot'].includes(action))return new Response('Not found',{status:404});
11
+ Astro.response.headers.set('Cache-Control','no-store');
12
+ Astro.response.headers.set('Referrer-Policy','no-referrer');
13
+ Astro.response.headers.set('X-Robots-Tag','noindex');
14
+ const repo=await getRepo(Astro.locals);
15
+ const token=Astro.url.searchParams.get('token')||'';
16
+ let error='',success='';
17
+ if(Astro.request.method==='POST'){
18
+ try{
19
+ if(Astro.request.headers.get('origin')!==Astro.url.origin)throw new Error('Please submit this form from this site.');
20
+ if(Number(Astro.request.headers.get('content-length'))>10000)throw new Error('Form too large.');
21
+ const form=await accountForm(Astro.request);
22
+ const address=String(form.get('email')||'').trim().toLowerCase();
23
+ const ip=Astro.clientAddress||'unknown';
24
+ const allowed=await repo.email.rateLimit(`account-ip:${sha256(ip)}`,20,15*60000);
25
+ if(action==='forgot'){
26
+ const accountAllowed=await repo.email.rateLimit(`reset:${sha256(address)}`,3,15*60000);
27
+ if(allowed&&accountAllowed){
28
+ try { await issueAccountToken(repo,{purpose:'reset',email:address},Astro.locals); const wait=waitUntilFrom(Astro.locals); if(wait)wait(dispatchEmail(repo,Astro.locals,1).catch(()=>0)); }
29
+ catch { /* Generic response also covers unavailable email configuration. */ }
30
+ }
31
+ success='If an account exists for that address, a password reset email will be sent. Check your inbox.';
32
+ }else{
33
+ if(!allowed)throw new Error('Too many attempts. Please try again later.');
34
+ const password=String(form.get('password')||'');
35
+ if(password!==String(form.get('confirm')||''))throw new Error('The passwords do not match.');
36
+ if(!await redeemAccountToken(repo,token,password,action as 'invite'|'reset'))throw new Error('This link has expired, was used, or was revoked. Request a new link.');
37
+ Astro.cookies.delete(SESSION_COOKIE,{path:'/'});
38
+ success=action==='invite'?'Your account is ready. Sign in with your email and new password.':'Your password has changed. Sign in again with your new password.';
39
+ await kickEmail(repo,Astro.locals,waitUntilFrom(Astro.locals));
40
+ }
41
+ }catch(e){error=e instanceof Error?e.message:'Unable to process this request.';}
42
+ }
43
+ const liveToken=action==='forgot'?true:/^[a-zA-Z0-9_-]{43}$/.test(token)&&await repo.email.tokenByHash(sha256(token));
44
+ const heading=action==='forgot'?'Forgot your password?':action==='invite'?'Accept your invitation':'Choose a new password';
45
+ ---
46
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="robots" content="noindex"/><meta name="referrer" content="no-referrer"/><title>{heading}</title></head>
47
+ <body><main class="login-wrap"><div class="card card-pad login-card"><h1>{heading}</h1>
48
+ {error&&<p class="flash error" role="alert">{error}</p>}
49
+ {success?<p class="flash" role="status">{success}</p>:!liveToken?<p>This link has expired or is no longer available. Ask the owner for a new invitation or request a password reset.</p>:<form method="post">
50
+ {action==='forgot'?<label class="field"><span>Email address</span><input name="email" type="email" autocomplete="email" required maxlength="254"/></label>:<><label class="field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required/></label><label class="field"><span>Confirm password</span><input name="confirm" type="password" autocomplete="new-password" minlength="12" maxlength="128" required/></label><p class="hint">Use at least 12 characters. This link can only be used once.</p></>}
51
+ <button class="btn primary block" type="submit">{action==='forgot'?'Request password reset':action==='invite'?'Accept invitation':'Change password'}</button>
52
+ </form>}
53
+ <p><a href="/admin/login">Back to sign in</a></p></div></main></body></html>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import AiCleaner from '../../components/AiCleaner';
4
+ import { CONTENT } from '../../lib/content';
5
+ const initialKind = Astro.url.searchParams.get('kind') === 'pages' ? 'pages' : 'posts';
6
+ const requestedCollection = Astro.url.searchParams.get('collection') ?? 'posts';
7
+ const initialCollection = CONTENT.collections[requestedCollection] ? requestedCollection : 'posts';
8
+ const collections = Object.entries(CONTENT.collections).map(([key, value]) => ({ key, label: value.plural }));
9
+ ---
10
+ <Admin title="Create from document" section="ai" wide>
11
+ <AiCleaner client:load initialKind={initialKind} initialCollection={initialCollection} collections={collections} />
12
+ </Admin>
@@ -7,6 +7,7 @@ import { env } from '../../../../lib/env';
7
7
  import { BRAND } from '../../../../lib/brand';
8
8
  import { CONTENT, collectionUrl } from '../../../../lib/content';
9
9
  import { mergePostDraft } from '../../../../lib/drafts';
10
+ import ThemeScript from '../../../../components/ThemeScript.astro';
10
11
  import PostEditor from '../../../../components/editor/PostEditor';
11
12
  import '../../../../styles/admin.css';
12
13
  import '../../../../styles/editor.css';
@@ -42,7 +43,8 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
42
43
  <html lang="en">
43
44
  <head>
44
45
  <meta charset="utf-8" />
45
- <meta name="viewport" content="width=device-width, initial-scale=1" />
46
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
47
+ <ThemeScript />
46
48
  <meta name="robots" content="noindex" />
47
49
  <title>{post.title || 'Untitled'} · {BRAND.name} editor</title>
48
50
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -58,6 +60,7 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
58
60
  sharePath={sharePath}
59
61
  livePath={livePath}
60
62
  templates={templates}
63
+ site={{ title: settings.siteTitle, url: settings.siteUrl }}
61
64
  />
62
65
  </body>
63
66
  </html>
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  import Admin from '../../../../layouts/Admin.astro';
3
3
  import Modal from '../../../../components/Modal.astro';
4
+ import ContentList, { type ContentItem } from '../../../../components/ContentList.astro';
4
5
  import { getRepo } from '../../../../lib/repo';
5
6
  import { slugify, type Status } from '../../../../lib/schema';
6
- import { CONTENT } from '../../../../lib/content';
7
+ import { CONTENT, collectionUrl } from '../../../../lib/content';
7
8
 
8
9
  // One list page for every collection; [collection] is the config key from
9
10
  // acms({ content.collections }). Unknown keys 404 — content in removed
@@ -14,8 +15,6 @@ if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
14
15
 
15
16
  const { singular, plural, base } = col;
16
17
  const lower = singular.toLowerCase();
17
- // lower goes inside an inline confirm('…') — a quote in the name must not break it
18
- const lowerJs = lower.replace(/['\\]/g, '\\$&');
19
18
  const repo = await getRepo(Astro.locals);
20
19
 
21
20
  if (Astro.request.method === 'POST') {
@@ -29,43 +28,88 @@ if (Astro.request.method === 'POST') {
29
28
  const post = await repo.createPost({ title, slug, collection: key });
30
29
  return Astro.redirect(`/admin/c/${key}/${post.id}`);
31
30
  }
32
- if (op === 'delete') {
33
- await repo.deletePost(String(form.get('id')));
34
- return Astro.redirect(`/admin/c/${key}`);
31
+ // row menu, swipe actions and the bulk bar all post here
32
+ if (op === 'bulk') {
33
+ const to = String(form.get('to'));
34
+ const ids = form.getAll('ids').map(String).filter(Boolean);
35
+ for (const id of ids) {
36
+ const p = await repo.getPost(id);
37
+ if (!p || p.collection !== key) continue;
38
+ if (to === 'delete') await repo.deletePost(id);
39
+ else if (to === 'published' || to === 'draft' || to === 'archived') {
40
+ await repo.updatePost(id, { status: to as Status, ...(to === 'published' ? { scheduledAt: null } : {}) });
41
+ }
42
+ }
43
+ return Astro.redirect(`/admin/c/${key}${Astro.url.search}`);
35
44
  }
36
45
  }
37
46
 
38
- const all = await repo.listPosts(key);
47
+ const [all, users, settings] = await Promise.all([repo.listPosts(key), repo.listUsers(), repo.getSettings()]);
48
+ const userById = new Map(users.map((u) => [u.id, u]));
39
49
  const q = (Astro.url.searchParams.get('q') ?? '').toLowerCase();
40
50
  const statusFilter = Astro.url.searchParams.get('status') ?? 'all';
41
51
  const tagFilter = Astro.url.searchParams.get('tag') ?? '';
52
+ const authorFilter = Astro.url.searchParams.get('author') ?? '';
53
+ const sort = Astro.url.searchParams.get('sort') ?? 'updated';
42
54
 
43
- const posts = all.filter((p) => {
44
- if (statusFilter !== 'all' && p.status !== statusFilter) return false;
45
- if (tagFilter && !p.tags.includes(tagFilter)) return false;
46
- if (q && !p.title.toLowerCase().includes(q) && !p.slug.toLowerCase().includes(q)) return false;
47
- return true;
48
- });
55
+ const authorsOf = (p: (typeof all)[number]) => (p.authorIds.length ? p.authorIds : p.authorId ? [p.authorId] : []);
56
+ const posts = all
57
+ .filter((p) => {
58
+ if (statusFilter !== 'all' && p.status !== statusFilter) return false;
59
+ if (tagFilter && !p.tags.includes(tagFilter)) return false;
60
+ if (authorFilter && !authorsOf(p).includes(authorFilter)) return false;
61
+ if (q && !p.title.toLowerCase().includes(q) && !p.slug.toLowerCase().includes(q)) return false;
62
+ return true;
63
+ })
64
+ .sort((a, b) => (sort === 'oldest' ? a.updatedAt - b.updatedAt : sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt));
49
65
 
50
66
  const allTags = [...new Set(all.flatMap((p) => p.tags))].sort();
67
+ const authorIds = [...new Set(all.flatMap(authorsOf))].filter((id) => userById.has(id));
51
68
  const statusCounts = { all: all.length } as Record<string, number>;
52
69
  for (const p of all) statusCounts[p.status] = (statusCounts[p.status] ?? 0) + 1;
53
70
  const STATUSES: ('all' | Status)[] = ['all', 'draft', 'scheduled', 'published', 'archived'];
71
+ const statusLabel = (s: string) => (s === 'all' ? `All ${plural.toLowerCase()}` : s[0].toUpperCase() + s.slice(1));
72
+ const SORTS = [['updated', 'Recently updated'], ['oldest', 'Oldest first'], ['title', 'Title A–Z']];
73
+
74
+ const items: ContentItem[] = posts.map((p) => {
75
+ const names = authorsOf(p).map((id) => userById.get(id)?.name).filter(Boolean);
76
+ const meta = [names.length ? `By ${names.join(', ')}` : '', p.tags[0] ? `in ${p.tags[0]}` : ''].filter(Boolean).join(' ');
77
+ return {
78
+ id: p.id,
79
+ href: `/admin/c/${key}/${p.id}`,
80
+ title: p.title,
81
+ sub: collectionUrl(key, p.slug),
82
+ meta,
83
+ at: p.updatedAt,
84
+ status: p.status,
85
+ edited: Boolean(p.draft),
86
+ scheduledAt: p.scheduledAt,
87
+ image: p.cover,
88
+ viewHref: p.status === 'published' ? collectionUrl(key, p.slug) : undefined,
89
+ featured: p.featured,
90
+ };
91
+ });
54
92
 
55
93
  const showNew = Astro.url.searchParams.has('new');
56
- const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
57
94
  const filterHref = (patch: Record<string, string>) => {
58
95
  const p = new URLSearchParams(Astro.url.search);
59
96
  for (const [k, v] of Object.entries(patch)) v ? p.set(k, v) : p.delete(k);
60
97
  const s = p.toString();
61
98
  return `/admin/c/${key}${s ? `?${s}` : ''}`;
62
99
  };
100
+ const chev = 'M6 9l6 6 6-6';
63
101
  ---
64
102
 
65
103
  <Admin title={plural} section={`c:${key}`}>
66
104
  <div class="page-head">
67
- <h1>{plural}</h1>
68
- <button class="btn primary" type="button" data-modal="new-post">New {lower}</button>
105
+ <div>
106
+ <h1>{plural}</h1>
107
+ <p class="sub">{all.length} {all.length === 1 ? lower : plural.toLowerCase()} · published under {base}</p>
108
+ </div>
109
+ <div class="page-actions">
110
+ <a class="btn" href={`/admin/ai?kind=posts&collection=${encodeURIComponent(key)}`}>Create from document</a>
111
+ <button class="btn primary" type="button" data-modal="new-post">New {lower}</button>
112
+ </div>
69
113
  </div>
70
114
 
71
115
  <Modal id="new-post" title={`New ${lower}`} open={showNew}>
@@ -79,86 +123,77 @@ const filterHref = (patch: Record<string, string>) => {
79
123
  <button class="btn ghost" type="button" data-close>Cancel</button>
80
124
  <button class="btn primary" type="submit">Create {lower}</button>
81
125
  </div>
82
- <p class="hint" style="margin:12px 0 0">Creates a draft at {base}/&lt;slug-from-title&gt; and opens the editor — the slug is editable there.</p>
126
+ <p class="hint modal-note">Creates a draft at {base}/&lt;slug-from-title&gt; and opens the editor — the slug is editable there.</p>
83
127
  </form>
84
128
  </Modal>
85
129
 
86
- <div class="filter-bar">
87
- <div class="filter-tabs">
88
- {STATUSES.map((s) => (
89
- <a class={`filter-tab ${statusFilter === s ? 'is-active' : ''}`} href={filterHref({ status: s === 'all' ? '' : s })}>
90
- {s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}
91
- <span class="filter-count">{statusCounts[s] ?? 0}</span>
92
- </a>
93
- ))}
130
+ {all.length === 0 ? (
131
+ <div class="empty-state">
132
+ <b>No {plural.toLowerCase()} yet</b>
133
+ <p>Write your first {lower} — it stays a draft until you publish it.</p>
134
+ <button class="btn primary" type="button" data-modal="new-post">New {lower}</button>
94
135
  </div>
95
- <form method="get" style="display:flex;gap:8px;align-items:center">
96
- {statusFilter !== 'all' && <input type="hidden" name="status" value={statusFilter} />}
97
- {allTags.length > 0 && (
98
- <select name="tag" onchange="this.form.submit()" style="width:auto">
99
- <option value="">All tags</option>
100
- {allTags.map((t) => <option value={t} selected={t === tagFilter}>{t}</option>)}
101
- </select>
102
- )}
103
- <input type="search" name="q" placeholder={`Search ${plural.toLowerCase()}…`} value={q} style="min-width:180px" />
104
- </form>
105
- </div>
106
-
107
- <div class="card">
108
- <table class="list">
109
- <thead><tr><th>Title</th><th>Status</th><th>Tags</th><th>Updated</th><th></th></tr></thead>
110
- <tbody>
111
- {posts.map((p) => (
112
- <tr>
113
- <td>
114
- <a class="t-title" href={`/admin/c/${key}/${p.id}`}>{p.featured && <span title="Featured" style="color:#e8a13a">★ </span>}{p.title}</a>
115
- <div class="t-sub">{base}/{p.slug}{p.excerpt ? ` — ${p.excerpt.slice(0, 60)}${p.excerpt.length > 60 ? '…' : ''}` : ''}</div>
116
- </td>
117
- <td>
118
- <span class={`pill ${p.status}`}>{p.status}</span>
119
- {p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
120
- {p.status === 'scheduled' && p.scheduledAt && (
121
- <div class="t-sub" style="margin-top:2px">
122
- {new Date(p.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
123
- </div>
124
- )}
125
- </td>
126
- <td class="t-sub">{p.tags.join(', ')}</td>
127
- <td class="t-sub">{fmt(p.updatedAt)}</td>
128
- <td style="text-align:right">
129
- <form class="inline-form" method="post" onsubmit={`return confirm('Delete this ${lowerJs} permanently? Consider archiving instead (from the editor).')`}>
130
- <input type="hidden" name="op" value="delete" />
131
- <input type="hidden" name="id" value={p.id} />
132
- <button class="btn sm ghost danger" type="submit">Delete</button>
133
- </form>
134
- </td>
135
- </tr>
136
+ ) : (
137
+ <>
138
+ <nav class="chips" aria-label="Status">
139
+ {STATUSES.map((s) => (
140
+ <a class={statusFilter === s ? 'on' : ''} href={filterHref({ status: s === 'all' ? '' : s })}>
141
+ {s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}<small>{statusCounts[s] ?? 0}</small>
142
+ </a>
136
143
  ))}
137
- {posts.length === 0 && (
138
- <tr>
139
- <td colspan="5">
140
- {all.length === 0 ? (
141
- <div class="empty-state">
142
- <b>No {plural.toLowerCase()} yet</b>
143
- <p>Write your first {lower} — it stays a draft until you publish it.</p>
144
- <button class="btn primary" type="button" data-modal="new-post">New {lower}</button>
145
- </div>
146
- ) : (
147
- <p class="hint" style="margin:0;padding:12px 0">No {plural.toLowerCase()} match the current filters.</p>
148
- )}
149
- </td>
150
- </tr>
151
- )}
152
- </tbody>
153
- </table>
154
- </div>
144
+ </nav>
145
+ <div class="list-tools">
146
+ <form method="get" class="list-search" role="search">
147
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
148
+ {statusFilter !== 'all' && <input type="hidden" name="status" value={statusFilter} />}
149
+ <input type="search" name="q" value={q} placeholder={`Filter ${plural.toLowerCase()}…`} data-live-filter aria-label={`Filter ${plural.toLowerCase()}`} />
150
+ </form>
151
+ <div class="fmenus">
152
+ <details class={`fmenu ${statusFilter !== 'all' ? 'is-set' : ''}`} data-kind="status">
153
+ <summary>{statusLabel(statusFilter)} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
154
+ <div class="fmenu-pop">
155
+ {STATUSES.map((s) => (
156
+ <a class={statusFilter === s ? 'on' : ''} href={filterHref({ status: s === 'all' ? '' : s })}>{statusLabel(s)}<small>{statusCounts[s] ?? 0}</small></a>
157
+ ))}
158
+ </div>
159
+ </details>
160
+ {authorIds.length > 1 && (
161
+ <details class={`fmenu ${authorFilter ? 'is-set' : ''}`}>
162
+ <summary>{authorFilter ? userById.get(authorFilter)?.name ?? 'Author' : 'All authors'} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
163
+ <div class="fmenu-pop">
164
+ <a class={!authorFilter ? 'on' : ''} href={filterHref({ author: '' })}>All authors</a>
165
+ {authorIds.map((id) => <a class={authorFilter === id ? 'on' : ''} href={filterHref({ author: id })}>{userById.get(id)?.name}</a>)}
166
+ </div>
167
+ </details>
168
+ )}
169
+ {allTags.length > 0 && (
170
+ <details class={`fmenu ${tagFilter ? 'is-set' : ''}`}>
171
+ <summary>{tagFilter ? `#${tagFilter}` : 'All tags'} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
172
+ <div class="fmenu-pop">
173
+ <a class={!tagFilter ? 'on' : ''} href={filterHref({ tag: '' })}>All tags</a>
174
+ {allTags.map((t) => <a class={tagFilter === t ? 'on' : ''} href={filterHref({ tag: t })}>#{t}</a>)}
175
+ </div>
176
+ </details>
177
+ )}
178
+ <details class="fmenu">
179
+ <summary>{SORTS.find(([k]) => k === sort)?.[1] ?? 'Recently updated'} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
180
+ <div class="fmenu-pop">
181
+ {SORTS.map(([k, l]) => <a class={sort === k ? 'on' : ''} href={filterHref({ sort: k === 'updated' ? '' : k })}>{l}</a>)}
182
+ </div>
183
+ </details>
184
+ </div>
185
+ </div>
186
+ <ContentList items={items} noun={lower} timeZone={settings.timezone || undefined} groupByMonth={sort !== 'title'} empty={`No ${plural.toLowerCase()} match the current filters.`} />
187
+ {items.length === 0 && <p class="hint" style="padding:24px 8px">No {plural.toLowerCase()} match the current filters. <a href={`/admin/c/${key}`}>Clear filters</a></p>}
188
+ </>
189
+ )}
155
190
  </Admin>
156
191
 
157
- <style>
158
- .filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
159
- .filter-tabs { display: flex; gap: 2px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 3px; }
160
- .filter-tab { color: var(--muted); font-size: 12.5px; font-weight: 600; padding: 4px 11px; border-radius: 6px; }
161
- .filter-tab:hover { color: var(--text); }
162
- .filter-tab.is-active { background: var(--accent-soft); color: var(--text); }
163
- .filter-count { color: var(--faint); font-size: 11px; margin-left: 5px; }
164
- </style>
192
+ <script>
193
+ // one filter menu open at a time; outside click closes
194
+ document.addEventListener('click', (e) => {
195
+ document.querySelectorAll<HTMLDetailsElement>('details.fmenu[open]').forEach((d) => {
196
+ if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
197
+ });
198
+ });
199
+ </script>