@django-stack/cli 0.3.0 → 0.4.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/dist/bin.js +3 -3
- package/dist/index.d.ts +52 -40
- package/dist/index.js +1 -1
- package/dist/{stack-CTyqYWCV.js → scaffold-DbgGYEsc.js} +216 -183
- package/package.json +1 -1
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/AGENTS.md +7 -11
- package/templates/base/README.md +3 -3
- package/templates/base/SECURITY.md +1 -1
- package/templates/base/gitignore +6 -0
- package/templates/base/package.json +1 -1
- package/templates/frontend-astro/AGENTS.md +43 -0
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/template.json +5 -0
- package/templates/frontend-next/.dockerignore +3 -0
- package/templates/frontend-next/AGENTS.md +59 -0
- package/templates/frontend-next/Dockerfile +32 -0
- package/templates/frontend-next/next.config.ts +19 -0
- package/templates/frontend-next/package.json +34 -0
- package/templates/frontend-next/public/favicon.svg +1 -0
- package/templates/frontend-next/src/app/[...path]/page.tsx +28 -0
- package/templates/frontend-next/src/app/blog/[slug]/page.tsx +60 -0
- package/templates/frontend-next/src/app/blog/page.tsx +20 -0
- package/templates/frontend-next/src/app/category/[slug]/page.tsx +34 -0
- package/templates/frontend-next/src/app/error.tsx +13 -0
- package/templates/frontend-next/src/app/globals.css +250 -0
- package/templates/frontend-next/src/app/layout.tsx +70 -0
- package/templates/frontend-next/src/app/not-found.tsx +15 -0
- package/templates/frontend-next/src/app/page.tsx +35 -0
- package/templates/frontend-next/src/app/preview/exit/route.ts +3 -0
- package/templates/frontend-next/src/app/preview/route.ts +4 -0
- package/templates/frontend-next/src/app/revalidate/route.ts +8 -0
- package/templates/frontend-next/src/app/robots.txt/route.ts +13 -0
- package/templates/frontend-next/src/app/rss.xml/route.ts +19 -0
- package/templates/frontend-next/src/app/sitemap.xml/route.ts +28 -0
- package/templates/frontend-next/src/app/tag/[slug]/page.tsx +29 -0
- package/templates/frontend-next/src/components/ContactForm.tsx +55 -0
- package/templates/frontend-next/src/components/NavLink.tsx +15 -0
- package/templates/frontend-next/src/components/PostCard.tsx +25 -0
- package/templates/frontend-next/src/components/PostList.tsx +28 -0
- package/templates/frontend-next/src/lib/api.ts +71 -0
- package/templates/frontend-next/src/lib/headless.ts +344 -0
- package/templates/frontend-next/src/lib/rss.ts +44 -0
- package/templates/frontend-next/src/proxy.ts +34 -0
- package/templates/frontend-next/template.json +5 -0
- package/templates/frontend-next/test/rss.test.ts +17 -0
- package/templates/frontend-next/tsconfig.json +42 -0
- package/templates/frontend-next/vendor/.gitkeep +0 -0
- package/templates/frontend-next/vitest.config.ts +7 -0
- package/templates/frontend-nuxt/.dockerignore +4 -0
- package/templates/frontend-nuxt/AGENTS.md +64 -0
- package/templates/frontend-nuxt/Dockerfile +32 -0
- package/templates/frontend-nuxt/nuxt.config.ts +69 -0
- package/templates/frontend-nuxt/package.json +34 -0
- package/templates/frontend-nuxt/public/favicon.svg +1 -0
- package/templates/frontend-nuxt/server/plugins/media-origins.ts +27 -0
- package/templates/frontend-nuxt/server/routes/_content/category/[slug].get.ts +13 -0
- package/templates/frontend-nuxt/server/routes/_content/home.get.ts +10 -0
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +13 -0
- package/templates/frontend-nuxt/server/routes/_content/page.get.ts +7 -0
- package/templates/frontend-nuxt/server/routes/_content/post/[slug].get.ts +7 -0
- package/templates/frontend-nuxt/server/routes/_content/posts.get.ts +4 -0
- package/templates/frontend-nuxt/server/routes/_content/redirect.get.ts +7 -0
- package/templates/frontend-nuxt/server/routes/_content/tag/[slug].get.ts +13 -0
- package/templates/frontend-nuxt/server/routes/preview/exit.get.ts +4 -0
- package/templates/frontend-nuxt/server/routes/preview/index.get.ts +26 -0
- package/templates/frontend-nuxt/server/routes/revalidate.post.ts +15 -0
- package/templates/frontend-nuxt/server/routes/robots.txt.get.ts +4 -0
- package/templates/frontend-nuxt/server/routes/rss.xml.get.ts +19 -0
- package/templates/frontend-nuxt/server/routes/sitemap.xml.get.ts +29 -0
- package/templates/frontend-nuxt/server/utils/cache.ts +48 -0
- package/templates/frontend-nuxt/server/utils/content.ts +41 -0
- package/templates/frontend-nuxt/server/utils/rss.ts +44 -0
- package/templates/frontend-nuxt/src/app.vue +5 -0
- package/templates/frontend-nuxt/src/assets/main.css +126 -0
- package/templates/frontend-nuxt/src/components/ContactForm.vue +66 -0
- package/templates/frontend-nuxt/src/components/PostCard.vue +43 -0
- package/templates/frontend-nuxt/src/components/PostList.vue +33 -0
- package/templates/frontend-nuxt/src/composables/useContent.ts +27 -0
- package/templates/frontend-nuxt/src/composables/useLayoutContent.ts +4 -0
- package/templates/frontend-nuxt/src/composables/usePageSeo.ts +33 -0
- package/templates/frontend-nuxt/src/error.vue +23 -0
- package/templates/frontend-nuxt/src/layouts/default.vue +99 -0
- package/templates/frontend-nuxt/src/lib/headless.ts +344 -0
- package/templates/frontend-nuxt/src/pages/[...path].vue +16 -0
- package/templates/frontend-nuxt/src/pages/blog/[slug].vue +43 -0
- package/templates/frontend-nuxt/src/pages/blog/index.vue +12 -0
- package/templates/frontend-nuxt/src/pages/category/[slug].vue +14 -0
- package/templates/frontend-nuxt/src/pages/index.vue +18 -0
- package/templates/frontend-nuxt/src/pages/tag/[slug].vue +13 -0
- package/templates/frontend-nuxt/template.json +5 -0
- package/templates/frontend-nuxt/test/cache.test.ts +20 -0
- package/templates/frontend-nuxt/test/rss.test.ts +17 -0
- package/templates/frontend-nuxt/tsconfig.json +9 -0
- package/templates/frontend-nuxt/vendor/.gitkeep +0 -0
- package/templates/frontend-nuxt/vitest.config.ts +7 -0
- package/templates/frontend-sveltekit/.dockerignore +4 -0
- package/templates/frontend-sveltekit/AGENTS.md +53 -0
- package/templates/frontend-sveltekit/Dockerfile +32 -0
- package/templates/frontend-sveltekit/package.json +39 -0
- package/templates/frontend-sveltekit/src/app.css +126 -0
- package/templates/frontend-sveltekit/src/app.d.ts +12 -0
- package/templates/frontend-sveltekit/src/app.html +12 -0
- package/templates/frontend-sveltekit/src/hooks.server.ts +70 -0
- package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +65 -0
- package/templates/frontend-sveltekit/src/lib/components/PostCard.svelte +41 -0
- package/templates/frontend-sveltekit/src/lib/components/PostList.svelte +39 -0
- package/templates/frontend-sveltekit/src/lib/headless.ts +344 -0
- package/templates/frontend-sveltekit/src/lib/server/api.ts +28 -0
- package/templates/frontend-sveltekit/src/lib/server/cache.ts +58 -0
- package/templates/frontend-sveltekit/src/lib/server/rss.ts +44 -0
- package/templates/frontend-sveltekit/src/routes/+error.svelte +14 -0
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +18 -0
- package/templates/frontend-sveltekit/src/routes/+layout.svelte +112 -0
- package/templates/frontend-sveltekit/src/routes/+page.server.ts +10 -0
- package/templates/frontend-sveltekit/src/routes/+page.svelte +19 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.server.ts +9 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +12 -0
- package/templates/frontend-sveltekit/src/routes/blog/+page.server.ts +7 -0
- package/templates/frontend-sveltekit/src/routes/blog/+page.svelte +10 -0
- package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.server.ts +9 -0
- package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.svelte +41 -0
- package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.server.ts +11 -0
- package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.svelte +12 -0
- package/templates/frontend-sveltekit/src/routes/preview/+server.ts +27 -0
- package/templates/frontend-sveltekit/src/routes/preview/exit/+server.ts +8 -0
- package/templates/frontend-sveltekit/src/routes/revalidate/+server.ts +16 -0
- package/templates/frontend-sveltekit/src/routes/robots.txt/+server.ts +7 -0
- package/templates/frontend-sveltekit/src/routes/rss.xml/+server.ts +20 -0
- package/templates/frontend-sveltekit/src/routes/sitemap.xml/+server.ts +29 -0
- package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.server.ts +11 -0
- package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.svelte +11 -0
- package/templates/frontend-sveltekit/static/favicon.svg +1 -0
- package/templates/frontend-sveltekit/template.json +5 -0
- package/templates/frontend-sveltekit/test/cache.test.ts +22 -0
- package/templates/frontend-sveltekit/test/rss.test.ts +17 -0
- package/templates/frontend-sveltekit/tsconfig.json +5 -0
- package/templates/frontend-sveltekit/vendor/.gitkeep +0 -0
- package/templates/frontend-sveltekit/vite.config.ts +47 -0
|
@@ -9,4 +9,4 @@ Problems in the packages it is built on go to their projects:
|
|
|
9
9
|
- Django Headless: https://github.com/dwarsbit/django-headless/security
|
|
10
10
|
- Django Content Studio: https://github.com/dwarsbit/django-content-studio/security
|
|
11
11
|
- Django: https://www.djangoproject.com/security/
|
|
12
|
-
-
|
|
12
|
+
- {{frontend_label}}: {{frontend_security}}
|
package/templates/base/gitignore
CHANGED
|
@@ -10,7 +10,13 @@ backend/db.sqlite3
|
|
|
10
10
|
__pycache__/
|
|
11
11
|
*.pyc
|
|
12
12
|
frontend/dist/
|
|
13
|
+
frontend/build/
|
|
13
14
|
frontend/.astro/
|
|
15
|
+
frontend/.svelte-kit/
|
|
16
|
+
frontend/.nuxt/
|
|
17
|
+
frontend/.output/
|
|
18
|
+
frontend/.next/
|
|
19
|
+
frontend/next-env.d.ts
|
|
14
20
|
.DS_Store
|
|
15
21
|
backend/backups/
|
|
16
22
|
backend/huey-stats.db*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "{{project_name}}",
|
|
3
3
|
"private": true,
|
|
4
|
-
"description": "{{project_name}}: Django, Django Headless, Content Studio and
|
|
4
|
+
"description": "{{project_name}}: Django, Django Headless, Content Studio and {{frontend_label}}, created with DjangoStack.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "docker compose up --build",
|
|
7
7
|
"start": "docker compose up --build --detach",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# The Astro frontend
|
|
2
|
+
|
|
3
|
+
Astro renders the site on the server (`output: "server"`, Node adapter).
|
|
4
|
+
The project's `AGENTS.md` describes the whole project; this file describes
|
|
5
|
+
the frontend.
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
src/pages/ routes: /, /[...slug], /blog, /blog/[slug], /category/[slug], /tag/[slug], sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
|
|
11
|
+
src/layouts/Base.astro the page shell: head, menus, footer, preview banner
|
|
12
|
+
src/components/ Blocks.astro maps block types to components in components/blocks/
|
|
13
|
+
src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview
|
|
14
|
+
src/lib/cache.ts the in-memory page cache that /revalidate empties
|
|
15
|
+
src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
|
|
16
|
+
src/middleware.ts page cache, editor-managed redirects, security headers
|
|
17
|
+
astro.config.mjs server output, Content-Security-Policy, the dev proxy to Django
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Read a content model
|
|
21
|
+
|
|
22
|
+
```astro
|
|
23
|
+
---
|
|
24
|
+
// src/pages/events.astro
|
|
25
|
+
import { getClient } from "../lib/api";
|
|
26
|
+
|
|
27
|
+
const events = await getClient(Astro.cookies).model("content.event").list();
|
|
28
|
+
---
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Render a block type
|
|
32
|
+
|
|
33
|
+
Add a component in `src/components/blocks/` that takes a `block` prop, and
|
|
34
|
+
map the block's type to it in `src/components/Blocks.astro`.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
Run them in the `frontend` container (`docker compose exec frontend <command>`):
|
|
39
|
+
|
|
40
|
+
| Task | Command |
|
|
41
|
+
|------|---------|
|
|
42
|
+
| Type-check | `npm run check` |
|
|
43
|
+
| Test | `npm test` |
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# The Next.js frontend
|
|
2
|
+
|
|
3
|
+
Next.js renders the site on the server with the App Router. Content is
|
|
4
|
+
loaded in server components and route handlers only, through `src/lib/api.ts`,
|
|
5
|
+
so the API key stays on the server. The project's `AGENTS.md` describes the
|
|
6
|
+
whole project; this file describes the frontend.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
src/app/ routes: /, /[...path], /blog, /blog/[slug], /category/[slug], /tag/[slug], sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
|
|
12
|
+
src/app/layout.tsx the page shell: menus, footer, preview banner
|
|
13
|
+
src/components/ the post list, the menu links and the contact form
|
|
14
|
+
src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview; pageMetadata()
|
|
15
|
+
src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
|
|
16
|
+
src/proxy.ts Content-Security-Policy with a nonce, security headers, the dev proxy to Django
|
|
17
|
+
next.config.ts standalone output, the in-memory data cache
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Blocks, rich text and images come from `@django-stack/react`; the
|
|
21
|
+
`/preview` and `/revalidate` handlers, the metadata and the
|
|
22
|
+
Content-Security-Policy from `@django-stack/next`.
|
|
23
|
+
|
|
24
|
+
API responses are kept in Next's data cache with the tag `content` for
|
|
25
|
+
`CACHE_TTL` seconds (default 300). `/revalidate` expires the tag when
|
|
26
|
+
editors publish. Previews and the dev server skip the cache.
|
|
27
|
+
|
|
28
|
+
## Read a content model
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// src/app/events/page.tsx
|
|
32
|
+
import { getClient } from "@/lib/api";
|
|
33
|
+
|
|
34
|
+
export default async function Events() {
|
|
35
|
+
const events = await (await getClient()).model("content.event").list();
|
|
36
|
+
return <ul>{events.data.map((event) => <li key={event.id}>{event.title}</li>)}</ul>;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`src/lib/api.ts` imports `server-only`: a client component (`"use client"`)
|
|
41
|
+
that imports it fails the build.
|
|
42
|
+
|
|
43
|
+
## Render a block type
|
|
44
|
+
|
|
45
|
+
Add a component that takes a `block` prop, and pass it to `Blocks` by the
|
|
46
|
+
block's type:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<Blocks blocks={page.body} components={{ gallery: Gallery }} />
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
Run them in the `frontend` container (`docker compose exec frontend <command>`):
|
|
55
|
+
|
|
56
|
+
| Task | Command |
|
|
57
|
+
|------|---------|
|
|
58
|
+
| Type-check | `npm run check` |
|
|
59
|
+
| Test | `npm test` |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
# The frontend image. The `dev` target runs the Next dev server with the
|
|
4
|
+
# source mounted; `prod` builds the site and runs Next's standalone server.
|
|
5
|
+
FROM node:22-alpine AS base
|
|
6
|
+
WORKDIR /app
|
|
7
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
8
|
+
|
|
9
|
+
FROM base AS deps
|
|
10
|
+
COPY package.json package-lock.json* ./
|
|
11
|
+
COPY vendor/ vendor/
|
|
12
|
+
RUN --mount=type=cache,target=/root/.npm \
|
|
13
|
+
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
|
14
|
+
|
|
15
|
+
FROM deps AS dev
|
|
16
|
+
EXPOSE 3000
|
|
17
|
+
CMD ["npm", "run", "dev"]
|
|
18
|
+
|
|
19
|
+
FROM deps AS build
|
|
20
|
+
COPY . .
|
|
21
|
+
RUN npm run build
|
|
22
|
+
|
|
23
|
+
FROM base AS prod
|
|
24
|
+
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
|
|
25
|
+
COPY --from=build --chown=node:node /app/.next/standalone ./
|
|
26
|
+
COPY --from=build --chown=node:node /app/.next/static ./.next/static
|
|
27
|
+
COPY --from=build --chown=node:node /app/public ./public
|
|
28
|
+
USER node
|
|
29
|
+
EXPOSE 3000
|
|
30
|
+
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=5 \
|
|
31
|
+
CMD wget -qO- http://127.0.0.1:3000/robots.txt > /dev/null || exit 1
|
|
32
|
+
CMD ["node", "server.js"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const config: NextConfig = {
|
|
4
|
+
// A self-contained server in .next/standalone, for a small production image.
|
|
5
|
+
output: "standalone",
|
|
6
|
+
// Django's paths end in a slash; the dev proxy in src/proxy.ts passes them on as they are.
|
|
7
|
+
skipTrailingSlashRedirect: true,
|
|
8
|
+
// Django calls /revalidate at http://frontend:3000 on the Compose network.
|
|
9
|
+
allowedDevOrigins: ["frontend"],
|
|
10
|
+
poweredByHeader: false,
|
|
11
|
+
experimental: {
|
|
12
|
+
// The data cache stays in memory: the production container's file system is read-only.
|
|
13
|
+
isrFlushToDisk: false,
|
|
14
|
+
// Media uploads in the admin pass through the dev proxy.
|
|
15
|
+
proxyClientMaxBodySize: "100mb",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default config;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{project_name}}-frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev --hostname 0.0.0.0 --port 3000",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start --hostname 0.0.0.0 --port 3000",
|
|
10
|
+
"check": "next typegen && tsc --noEmit",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"generate:types": "headless generate --url \"${API_URL:-http://localhost:8000/api}/schema\" --out src/lib/headless.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@django-headless/client": "^1.1.1",
|
|
16
|
+
"@django-stack/next": "^0.4.0",
|
|
17
|
+
"@django-stack/react": "^0.4.0",
|
|
18
|
+
"@django-stack/utils": "^0.4.0",
|
|
19
|
+
"next": "^16.4.4",
|
|
20
|
+
"react": "^19.3.1",
|
|
21
|
+
"react-dom": "^19.3.1",
|
|
22
|
+
"server-only": "^0.0.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.20.5",
|
|
26
|
+
"@types/react": "^19.2.18",
|
|
27
|
+
"@types/react-dom": "^19.2.7",
|
|
28
|
+
"typescript": "~6.0.3",
|
|
29
|
+
"vitest": "^5.1.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#0c4b33"/><path d="M8 9h10a6 6 0 0 1 0 12h-4v3H8z" fill="#44b78b"/><path d="M14 13h4a2 2 0 0 1 0 4h-4z" fill="#0c4b33"/></svg>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Blocks } from "@django-stack/react";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { ContactForm } from "@/components/ContactForm";
|
|
4
|
+
import { getContent, notFoundOrRedirect, pageMetadata } from "@/lib/api";
|
|
5
|
+
|
|
6
|
+
type Props = { params: Promise<{ path: string[] }> };
|
|
7
|
+
|
|
8
|
+
async function load(params: Props["params"]) {
|
|
9
|
+
const path = (await params).path.join("/");
|
|
10
|
+
return { path, page: await (await getContent()).getPage(path) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
14
|
+
const { path, page } = await load(params);
|
|
15
|
+
return page ? pageMetadata(page, `/${path}`) : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function ContentPage({ params }: Props) {
|
|
19
|
+
const { path, page } = await load(params);
|
|
20
|
+
if (!page) return notFoundOrRedirect(`/${path}`);
|
|
21
|
+
return (
|
|
22
|
+
<article className="narrow">
|
|
23
|
+
<h1>{page.title}</h1>
|
|
24
|
+
<Blocks blocks={page.body} />
|
|
25
|
+
{page.path === "contact" ? <ContactForm /> : null}
|
|
26
|
+
</article>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Blocks, Image } from "@django-stack/react";
|
|
2
|
+
import type { Author, Taxonomy } from "@django-stack/utils";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { getContent, notFoundOrRedirect, pageMetadata } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
type Props = { params: Promise<{ slug: string }> };
|
|
8
|
+
|
|
9
|
+
async function load(params: Props["params"]) {
|
|
10
|
+
const { slug } = await params;
|
|
11
|
+
return { slug, post: await (await getContent()).getPost(slug) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
15
|
+
const { slug, post } = await load(params);
|
|
16
|
+
return post ? pageMetadata(post, `/blog/${slug}`, "article") : {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default async function BlogPost({ params }: Props) {
|
|
20
|
+
const { slug, post } = await load(params);
|
|
21
|
+
if (!post) return notFoundOrRedirect(`/blog/${slug}`);
|
|
22
|
+
const author = typeof post.author === "object" ? (post.author as Author | null) : null;
|
|
23
|
+
const categories = post.categories.filter((item): item is Taxonomy => typeof item === "object");
|
|
24
|
+
const tags = post.tags.filter((item): item is Taxonomy => typeof item === "object");
|
|
25
|
+
const date = post.published_at ? new Date(post.published_at) : null;
|
|
26
|
+
return (
|
|
27
|
+
<article className="narrow">
|
|
28
|
+
<p className="muted">
|
|
29
|
+
{date ? (
|
|
30
|
+
<time dateTime={date.toISOString()}>{date.toLocaleDateString("en", { dateStyle: "long" })}</time>
|
|
31
|
+
) : null}
|
|
32
|
+
{author ? <span> · {author.name}</span> : null}
|
|
33
|
+
{categories.map((category) => (
|
|
34
|
+
<span key={category.slug}>
|
|
35
|
+
{" · "}
|
|
36
|
+
<Link href={`/category/${category.slug}`}>{category.name}</Link>
|
|
37
|
+
</span>
|
|
38
|
+
))}
|
|
39
|
+
</p>
|
|
40
|
+
<h1>{post.title}</h1>
|
|
41
|
+
<Image
|
|
42
|
+
media={post.cover}
|
|
43
|
+
size="content"
|
|
44
|
+
sizes="(min-width: 48rem) 44rem, 100vw"
|
|
45
|
+
className="post-cover"
|
|
46
|
+
eager
|
|
47
|
+
/>
|
|
48
|
+
<Blocks blocks={post.body} />
|
|
49
|
+
{tags.length > 0 ? (
|
|
50
|
+
<p className="tags">
|
|
51
|
+
{tags.map((tag) => (
|
|
52
|
+
<Link key={tag.slug} href={`/tag/${tag.slug}`}>
|
|
53
|
+
#{tag.name}
|
|
54
|
+
</Link>
|
|
55
|
+
))}
|
|
56
|
+
</p>
|
|
57
|
+
) : null}
|
|
58
|
+
</article>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { PostList } from "@/components/PostList";
|
|
3
|
+
import { getContent, pageMetadata, pageNumber } from "@/lib/api";
|
|
4
|
+
|
|
5
|
+
type Props = { searchParams: Promise<{ page?: string }> };
|
|
6
|
+
|
|
7
|
+
export function generateMetadata(): Promise<Metadata> {
|
|
8
|
+
return pageMetadata({ title: "Blog" }, "/blog");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default async function Blog({ searchParams }: Props) {
|
|
12
|
+
const page = pageNumber((await searchParams).page);
|
|
13
|
+
const result = await (await getContent()).getPosts({ page, limit: 9 });
|
|
14
|
+
return (
|
|
15
|
+
<section className="container">
|
|
16
|
+
<h1>Blog</h1>
|
|
17
|
+
<PostList result={result} base="/blog" />
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { PostList } from "@/components/PostList";
|
|
3
|
+
import { getContent, notFoundOrRedirect, pageMetadata, pageNumber } from "@/lib/api";
|
|
4
|
+
|
|
5
|
+
type Props = { params: Promise<{ slug: string }>; searchParams: Promise<{ page?: string }> };
|
|
6
|
+
|
|
7
|
+
async function load(params: Props["params"]) {
|
|
8
|
+
const { slug } = await params;
|
|
9
|
+
return { slug, category: await (await getContent()).getCategory(slug) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
13
|
+
const { slug, category } = await load(params);
|
|
14
|
+
if (!category) return {};
|
|
15
|
+
return pageMetadata(
|
|
16
|
+
{ title: category.name, seo_description: category.description ?? "" },
|
|
17
|
+
`/category/${slug}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function Category({ params, searchParams }: Props) {
|
|
22
|
+
const { slug, category } = await load(params);
|
|
23
|
+
if (!category) return notFoundOrRedirect(`/category/${slug}`);
|
|
24
|
+
const page = pageNumber((await searchParams).page);
|
|
25
|
+
const result = await (await getContent()).getPosts({ category: slug, page, limit: 9 });
|
|
26
|
+
return (
|
|
27
|
+
<section className="container">
|
|
28
|
+
<p className="muted">Category</p>
|
|
29
|
+
<h1>{category.name}</h1>
|
|
30
|
+
{category.description ? <p>{category.description}</p> : null}
|
|
31
|
+
<PostList result={result} base={`/category/${slug}`} />
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export default function ErrorPage() {
|
|
4
|
+
return (
|
|
5
|
+
<section className="narrow">
|
|
6
|
+
<h1>Something went wrong</h1>
|
|
7
|
+
<p>The page could not be shown. Try again in a moment.</p>
|
|
8
|
+
<p>
|
|
9
|
+
<a href="/">Go to the home page</a>
|
|
10
|
+
</p>
|
|
11
|
+
</section>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/* A small, plain stylesheet: replace it with your own design. */
|
|
2
|
+
:root {
|
|
3
|
+
--color-bg: #ffffff;
|
|
4
|
+
--color-text: #1d2420;
|
|
5
|
+
--color-muted: #5b6862;
|
|
6
|
+
--color-line: #e3e8e4;
|
|
7
|
+
--color-accent: #0c4b33;
|
|
8
|
+
--color-accent-soft: #e8f3ee;
|
|
9
|
+
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
10
|
+
--width: 72rem;
|
|
11
|
+
--width-text: 44rem;
|
|
12
|
+
color-scheme: light dark;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--color-bg: #111614;
|
|
18
|
+
--color-text: #e6ebe8;
|
|
19
|
+
--color-muted: #a1aba6;
|
|
20
|
+
--color-line: #2a322e;
|
|
21
|
+
--color-accent: #6fcf9f;
|
|
22
|
+
--color-accent-soft: #1b2a23;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
*,
|
|
27
|
+
*::before,
|
|
28
|
+
*::after {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body {
|
|
33
|
+
margin: 0;
|
|
34
|
+
background: var(--color-bg);
|
|
35
|
+
color: var(--color-text);
|
|
36
|
+
font-family: var(--font-body);
|
|
37
|
+
line-height: 1.6;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
a {
|
|
41
|
+
color: var(--color-accent);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
img {
|
|
45
|
+
max-width: 100%;
|
|
46
|
+
height: auto;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.container {
|
|
50
|
+
width: min(100% - 2rem, var(--width));
|
|
51
|
+
margin-inline: auto;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.narrow {
|
|
55
|
+
width: min(100% - 2rem, var(--width-text));
|
|
56
|
+
margin-inline: auto;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.muted {
|
|
60
|
+
color: var(--color-muted);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.prose h2,
|
|
64
|
+
.prose h3 {
|
|
65
|
+
line-height: 1.25;
|
|
66
|
+
margin-top: 2em;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.prose blockquote {
|
|
70
|
+
margin: 2rem 0;
|
|
71
|
+
padding-left: 1.25rem;
|
|
72
|
+
border-left: 4px solid var(--color-accent);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.button {
|
|
76
|
+
display: inline-block;
|
|
77
|
+
padding: 0.6rem 1.2rem;
|
|
78
|
+
border-radius: 0.5rem;
|
|
79
|
+
background: var(--color-accent);
|
|
80
|
+
color: var(--color-bg);
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
border: 0;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
font-family: inherit;
|
|
86
|
+
font-size: inherit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.grid {
|
|
90
|
+
display: grid;
|
|
91
|
+
gap: 2rem;
|
|
92
|
+
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.preview-banner {
|
|
96
|
+
background: #fde68a;
|
|
97
|
+
color: #422006;
|
|
98
|
+
padding: 0.5rem 1rem;
|
|
99
|
+
text-align: center;
|
|
100
|
+
font-size: 0.9rem;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Blocks, from @django-stack/react. */
|
|
104
|
+
.block-image {
|
|
105
|
+
margin: 2rem 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.block-image img {
|
|
109
|
+
border-radius: 0.5rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.block-image figcaption {
|
|
113
|
+
font-size: 0.9rem;
|
|
114
|
+
margin-top: 0.5rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.cta {
|
|
118
|
+
margin: 2.5rem 0;
|
|
119
|
+
padding: 2rem;
|
|
120
|
+
border-radius: 0.75rem;
|
|
121
|
+
background: var(--color-accent-soft);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.cta h2 {
|
|
125
|
+
margin-top: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* The page shell. */
|
|
129
|
+
.site-header {
|
|
130
|
+
border-bottom: 1px solid var(--color-line);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.bar {
|
|
134
|
+
display: flex;
|
|
135
|
+
flex-wrap: wrap;
|
|
136
|
+
gap: 1rem;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: space-between;
|
|
139
|
+
padding-block: 1rem;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.brand {
|
|
143
|
+
font-weight: 700;
|
|
144
|
+
font-size: 1.2rem;
|
|
145
|
+
color: inherit;
|
|
146
|
+
text-decoration: none;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.brand img {
|
|
150
|
+
height: 2.5rem;
|
|
151
|
+
width: auto;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.bar nav {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-wrap: wrap;
|
|
157
|
+
gap: 1.25rem;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.bar nav a {
|
|
161
|
+
text-decoration: none;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.bar nav a[aria-current="page"] {
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
text-decoration: underline;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
main {
|
|
170
|
+
min-height: 70vh;
|
|
171
|
+
padding-block: 2rem 4rem;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.site-footer {
|
|
175
|
+
border-top: 1px solid var(--color-line);
|
|
176
|
+
font-size: 0.9rem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.site-footer .footer-text p {
|
|
180
|
+
margin: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Posts. */
|
|
184
|
+
.card h3 {
|
|
185
|
+
margin: 0.75rem 0 0.25rem;
|
|
186
|
+
line-height: 1.3;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.card h3 a {
|
|
190
|
+
color: inherit;
|
|
191
|
+
text-decoration: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.card .cover {
|
|
195
|
+
width: 100%;
|
|
196
|
+
aspect-ratio: 16 / 10;
|
|
197
|
+
object-fit: cover;
|
|
198
|
+
border-radius: 0.5rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.card .meta {
|
|
202
|
+
font-size: 0.9rem;
|
|
203
|
+
margin: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.pagination {
|
|
207
|
+
display: flex;
|
|
208
|
+
gap: 1.5rem;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
margin: 3rem 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.post-cover {
|
|
214
|
+
width: 100%;
|
|
215
|
+
border-radius: 0.75rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.tags {
|
|
219
|
+
display: flex;
|
|
220
|
+
gap: 1rem;
|
|
221
|
+
margin-top: 3rem;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* The contact form. */
|
|
225
|
+
.contact-form {
|
|
226
|
+
display: grid;
|
|
227
|
+
gap: 1rem;
|
|
228
|
+
max-width: 32rem;
|
|
229
|
+
margin-top: 2rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.contact-form label {
|
|
233
|
+
display: grid;
|
|
234
|
+
gap: 0.25rem;
|
|
235
|
+
font-weight: 600;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.contact-form input,
|
|
239
|
+
.contact-form textarea {
|
|
240
|
+
font: inherit;
|
|
241
|
+
padding: 0.5rem 0.75rem;
|
|
242
|
+
border: 1px solid var(--color-line);
|
|
243
|
+
border-radius: 0.375rem;
|
|
244
|
+
background: transparent;
|
|
245
|
+
color: inherit;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.contact-form button {
|
|
249
|
+
justify-self: start;
|
|
250
|
+
}
|