@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.
- package/CHANGELOG.md +78 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +324 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
package/src/middleware.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { defineMiddleware } from 'astro:middleware';
|
|
2
|
-
import {
|
|
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 (!
|
|
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 {
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
<
|
|
68
|
-
|
|
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
|
|
126
|
+
<p class="hint modal-note">Creates a draft at {base}/<slug-from-title> and opens the editor — the slug is editable there.</p>
|
|
83
127
|
</form>
|
|
84
128
|
</Modal>
|
|
85
129
|
|
|
86
|
-
|
|
87
|
-
<div class="
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
<
|
|
158
|
-
|
|
159
|
-
.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
</
|
|
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>
|