@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.
Files changed (254) hide show
  1. package/CHANGELOG.md +87 -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/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -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>