@django-stack/cli 0.3.0 → 0.5.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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import "./globals.css";
|
|
2
|
+
import { Image, RichText } from "@django-stack/react";
|
|
3
|
+
import { linkHref } from "@django-stack/utils";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
import { NavLink } from "@/components/NavLink";
|
|
6
|
+
import { getContent, previewToken, SITE_URL } from "@/lib/api";
|
|
7
|
+
|
|
8
|
+
export function generateMetadata(): Metadata {
|
|
9
|
+
return {
|
|
10
|
+
metadataBase: new URL(SITE_URL),
|
|
11
|
+
icons: { icon: { url: "/favicon.svg", type: "image/svg+xml" } },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
16
|
+
const content = await getContent();
|
|
17
|
+
const [settings, mainMenu, footerMenu, preview] = await Promise.all([
|
|
18
|
+
content.getSiteSettings(),
|
|
19
|
+
content.getMenu("main"),
|
|
20
|
+
content.getMenu("footer"),
|
|
21
|
+
previewToken(),
|
|
22
|
+
]);
|
|
23
|
+
return (
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<body>
|
|
26
|
+
{preview !== null ? (
|
|
27
|
+
<div className="preview-banner">
|
|
28
|
+
Preview: drafts are shown. <a href="/preview/exit">Leave preview</a>
|
|
29
|
+
</div>
|
|
30
|
+
) : null}
|
|
31
|
+
<header className="site-header">
|
|
32
|
+
<div className="container bar">
|
|
33
|
+
<a className="brand" href="/">
|
|
34
|
+
{settings?.logo ? (
|
|
35
|
+
<Image media={settings.logo} size="thumbnail" alt={settings.site_name} eager />
|
|
36
|
+
) : (
|
|
37
|
+
settings?.site_name
|
|
38
|
+
)}
|
|
39
|
+
</a>
|
|
40
|
+
<nav aria-label="Main">
|
|
41
|
+
{(mainMenu?.items ?? []).map((item) => (
|
|
42
|
+
<NavLink key={item.id} href={linkHref(item)}>
|
|
43
|
+
{item.value.label}
|
|
44
|
+
</NavLink>
|
|
45
|
+
))}
|
|
46
|
+
</nav>
|
|
47
|
+
</div>
|
|
48
|
+
</header>
|
|
49
|
+
<main>{children}</main>
|
|
50
|
+
<footer className="site-footer">
|
|
51
|
+
<div className="container bar muted">
|
|
52
|
+
<RichText html={settings?.footer_text} className="footer-text" />
|
|
53
|
+
<nav aria-label="Footer">
|
|
54
|
+
{(footerMenu?.items ?? []).map((item) => (
|
|
55
|
+
<a key={item.id} href={linkHref(item)}>
|
|
56
|
+
{item.value.label}
|
|
57
|
+
</a>
|
|
58
|
+
))}
|
|
59
|
+
{Object.entries(settings?.social_links ?? {}).map(([name, url]) => (
|
|
60
|
+
<a key={name} href={url} rel="me">
|
|
61
|
+
{name}
|
|
62
|
+
</a>
|
|
63
|
+
))}
|
|
64
|
+
</nav>
|
|
65
|
+
</div>
|
|
66
|
+
</footer>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
export const metadata = { title: "Page not found" };
|
|
4
|
+
|
|
5
|
+
export default function NotFound() {
|
|
6
|
+
return (
|
|
7
|
+
<section className="narrow">
|
|
8
|
+
<h1>Page not found</h1>
|
|
9
|
+
<p>The page you are looking for does not exist or has moved.</p>
|
|
10
|
+
<p>
|
|
11
|
+
<Link href="/">Go to the home page</Link>
|
|
12
|
+
</p>
|
|
13
|
+
</section>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Blocks } from "@django-stack/react";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { PostList } from "@/components/PostList";
|
|
4
|
+
import { getContent, pageMetadata } from "@/lib/api";
|
|
5
|
+
|
|
6
|
+
async function load() {
|
|
7
|
+
const content = await getContent();
|
|
8
|
+
const settings = await content.getSiteSettings();
|
|
9
|
+
return { settings, home: await content.getHomePage(settings), content };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
13
|
+
const { home } = await load();
|
|
14
|
+
return pageMetadata(home, "/");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function Home() {
|
|
18
|
+
const { settings, home, content } = await load();
|
|
19
|
+
if (home) {
|
|
20
|
+
return (
|
|
21
|
+
<article className="narrow">
|
|
22
|
+
<h1>{home.title}</h1>
|
|
23
|
+
<Blocks blocks={home.body} />
|
|
24
|
+
</article>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const posts = await content.getPosts({ limit: 9 });
|
|
28
|
+
return (
|
|
29
|
+
<section className="container">
|
|
30
|
+
<h1>{settings?.site_name ?? "Latest posts"}</h1>
|
|
31
|
+
{settings?.tagline ? <p className="muted">{settings.tagline}</p> : null}
|
|
32
|
+
<PostList result={posts} base="/" />
|
|
33
|
+
</section>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { revalidateHandler } from "@django-stack/next";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Django calls this after editors change content (REVALIDATE_URL), signed
|
|
5
|
+
* with REVALIDATE_SECRET. It expires the cached API responses, so the next
|
|
6
|
+
* visit renders the new content.
|
|
7
|
+
*/
|
|
8
|
+
export const POST = revalidateHandler();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { connection } from "next/server";
|
|
2
|
+
import { SITE_URL } from "@/lib/api";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
// Rendered on request: SITE_URL is set when the container starts.
|
|
6
|
+
await connection();
|
|
7
|
+
return new Response(
|
|
8
|
+
`User-agent: *\nDisallow: /admin/\nDisallow: /preview\n\nSitemap: ${SITE_URL}/sitemap.xml\n`,
|
|
9
|
+
{
|
|
10
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
11
|
+
},
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getContent, SITE_URL } from "@/lib/api";
|
|
2
|
+
import { rssFeed } from "@/lib/rss";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const content = await getContent();
|
|
6
|
+
const [settings, posts] = await Promise.all([content.getSiteSettings(), content.getPosts({ limit: 20 })]);
|
|
7
|
+
const body = rssFeed({
|
|
8
|
+
title: settings?.site_name ?? "Blog",
|
|
9
|
+
description: settings?.seo_description || settings?.tagline || "",
|
|
10
|
+
site: SITE_URL,
|
|
11
|
+
items: posts.data.map((post) => ({
|
|
12
|
+
title: post.title,
|
|
13
|
+
link: `/blog/${post.slug}`,
|
|
14
|
+
description: post.excerpt,
|
|
15
|
+
pubDate: post.published_at ? new Date(post.published_at) : null,
|
|
16
|
+
})),
|
|
17
|
+
});
|
|
18
|
+
return new Response(body, { headers: { "Content-Type": "application/rss+xml; charset=utf-8" } });
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getContent, SITE_URL } from "@/lib/api";
|
|
2
|
+
|
|
3
|
+
export async function GET() {
|
|
4
|
+
const content = await getContent();
|
|
5
|
+
const [pages, posts, settings] = await Promise.all([
|
|
6
|
+
content.getAllPages(),
|
|
7
|
+
content.getAllPosts(),
|
|
8
|
+
content.getSiteSettings(),
|
|
9
|
+
]);
|
|
10
|
+
const urls = [
|
|
11
|
+
{ loc: "/", lastmod: null },
|
|
12
|
+
{ loc: "/blog", lastmod: null },
|
|
13
|
+
...pages
|
|
14
|
+
.filter((page) => page.id !== settings?.home_page)
|
|
15
|
+
.map((page) => ({ loc: `/${page.path}`, lastmod: page.published_at })),
|
|
16
|
+
...posts.map((post) => ({ loc: `/blog/${post.slug}`, lastmod: post.published_at })),
|
|
17
|
+
];
|
|
18
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
19
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
20
|
+
${urls
|
|
21
|
+
.map(
|
|
22
|
+
({ loc, lastmod }) =>
|
|
23
|
+
` <url><loc>${SITE_URL}${loc}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ""}</url>`,
|
|
24
|
+
)
|
|
25
|
+
.join("\n")}
|
|
26
|
+
</urlset>`;
|
|
27
|
+
return new Response(body, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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, tag: await (await getContent()).getTag(slug) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
13
|
+
const { slug, tag } = await load(params);
|
|
14
|
+
return tag ? pageMetadata({ title: `#${tag.name}` }, `/tag/${slug}`) : {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function Tag({ params, searchParams }: Props) {
|
|
18
|
+
const { slug, tag } = await load(params);
|
|
19
|
+
if (!tag) return notFoundOrRedirect(`/tag/${slug}`);
|
|
20
|
+
const page = pageNumber((await searchParams).page);
|
|
21
|
+
const result = await (await getContent()).getPosts({ tag: slug, page, limit: 9 });
|
|
22
|
+
return (
|
|
23
|
+
<section className="container">
|
|
24
|
+
<p className="muted">Tag</p>
|
|
25
|
+
<h1>#{tag.name}</h1>
|
|
26
|
+
<PostList result={result} base={`/tag/${slug}`} />
|
|
27
|
+
</section>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The contact form. It posts to Django's public create route for form
|
|
5
|
+
* submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
|
|
6
|
+
* limited per IP address. Submissions appear in the admin.
|
|
7
|
+
*/
|
|
8
|
+
import { type FormEvent, useState } from "react";
|
|
9
|
+
|
|
10
|
+
export function ContactForm() {
|
|
11
|
+
const [status, setStatus] = useState("");
|
|
12
|
+
const [sending, setSending] = useState(false);
|
|
13
|
+
|
|
14
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
15
|
+
event.preventDefault();
|
|
16
|
+
const form = event.currentTarget;
|
|
17
|
+
setSending(true);
|
|
18
|
+
setStatus("Sending…");
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch("/api/content.formsubmission", {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
|
24
|
+
});
|
|
25
|
+
if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
|
|
26
|
+
if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
|
|
27
|
+
form.reset();
|
|
28
|
+
setStatus("Thank you, your message was sent.");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
31
|
+
} finally {
|
|
32
|
+
setSending(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<form className="contact-form" onSubmit={submit}>
|
|
38
|
+
<label>
|
|
39
|
+
Name <input name="name" required maxLength={200} autoComplete="name" />
|
|
40
|
+
</label>
|
|
41
|
+
<label>
|
|
42
|
+
Email <input name="email" type="email" required autoComplete="email" />
|
|
43
|
+
</label>
|
|
44
|
+
<label>
|
|
45
|
+
Message <textarea name="message" required maxLength={5000} rows={6} />
|
|
46
|
+
</label>
|
|
47
|
+
<button className="button" type="submit" disabled={sending}>
|
|
48
|
+
Send
|
|
49
|
+
</button>
|
|
50
|
+
<p className="status" role="status" aria-live="polite">
|
|
51
|
+
{status}
|
|
52
|
+
</p>
|
|
53
|
+
</form>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
/** A menu link, marked as the current page when the visitor is on it or below it. */
|
|
7
|
+
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
|
8
|
+
const path = usePathname();
|
|
9
|
+
const current = href === path || (href !== "/" && path.startsWith(`${href}/`));
|
|
10
|
+
return (
|
|
11
|
+
<Link href={href} aria-current={current ? "page" : undefined}>
|
|
12
|
+
{children}
|
|
13
|
+
</Link>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Image } from "@django-stack/react";
|
|
2
|
+
import type { Author, Post } from "@django-stack/utils";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
|
|
5
|
+
export function PostCard({ post }: { post: Post }) {
|
|
6
|
+
const author = typeof post.author === "object" ? (post.author as Author | null) : null;
|
|
7
|
+
const date = post.published_at ? new Date(post.published_at) : null;
|
|
8
|
+
return (
|
|
9
|
+
<article className="card">
|
|
10
|
+
<Link href={`/blog/${post.slug}`}>
|
|
11
|
+
<Image media={post.cover} size="card" sizes="(min-width: 40rem) 22rem, 100vw" className="cover" />
|
|
12
|
+
</Link>
|
|
13
|
+
<h3>
|
|
14
|
+
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
|
|
15
|
+
</h3>
|
|
16
|
+
<p className="muted meta">
|
|
17
|
+
{date ? (
|
|
18
|
+
<time dateTime={date.toISOString()}>{date.toLocaleDateString("en", { dateStyle: "medium" })}</time>
|
|
19
|
+
) : null}
|
|
20
|
+
{author ? <span> · {author.name}</span> : null}
|
|
21
|
+
</p>
|
|
22
|
+
{post.excerpt ? <p>{post.excerpt}</p> : null}
|
|
23
|
+
</article>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Paginated, Post } from "@django-stack/utils";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { PostCard } from "./PostCard";
|
|
4
|
+
|
|
5
|
+
/** A page of posts, with links to the newer and older pages. `base` is the URL of the list. */
|
|
6
|
+
export function PostList({ result, base }: { result: Paginated<Post>; base: string }) {
|
|
7
|
+
const { current, pages } = result.pagination;
|
|
8
|
+
const pageUrl = (page: number) => (page === 1 ? base : `${base}?page=${page}`);
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{result.data.length === 0 ? <p className="muted">No posts yet.</p> : null}
|
|
12
|
+
<div className="grid">
|
|
13
|
+
{result.data.map((post) => (
|
|
14
|
+
<PostCard key={post.id} post={post} />
|
|
15
|
+
))}
|
|
16
|
+
</div>
|
|
17
|
+
{pages > 1 ? (
|
|
18
|
+
<nav className="pagination" aria-label="Pages">
|
|
19
|
+
{current > 1 ? <Link href={pageUrl(current - 1)}>← Newer</Link> : null}
|
|
20
|
+
<span className="muted">
|
|
21
|
+
Page {current} of {pages}
|
|
22
|
+
</span>
|
|
23
|
+
{current < pages ? <Link href={pageUrl(current + 1)}>Older →</Link> : null}
|
|
24
|
+
</nav>
|
|
25
|
+
) : null}
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content access for server components and route handlers. Server-side only:
|
|
3
|
+
* the API key is read from API_READ_KEY, and `server-only` fails the build
|
|
4
|
+
* if a client component imports this module.
|
|
5
|
+
*/
|
|
6
|
+
import "server-only";
|
|
7
|
+
|
|
8
|
+
import { contentFetchInit, PREVIEW_COOKIE, toMetadata } from "@django-stack/next";
|
|
9
|
+
import { createClient, createContent, type SeoInput, seo } from "@django-stack/utils";
|
|
10
|
+
import type { Metadata } from "next";
|
|
11
|
+
import { cookies } from "next/headers";
|
|
12
|
+
import { notFound, permanentRedirect, redirect } from "next/navigation";
|
|
13
|
+
import type { Models } from "./headless";
|
|
14
|
+
|
|
15
|
+
export { PREVIEW_COOKIE };
|
|
16
|
+
|
|
17
|
+
export const SITE_URL = (process.env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
18
|
+
|
|
19
|
+
/** The preview token, when the visitor opened a preview link. */
|
|
20
|
+
export async function previewToken(): Promise<string | null> {
|
|
21
|
+
return (await cookies()).get(PREVIEW_COOKIE)?.value ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The typed Django Headless client, for queries the content helpers do not
|
|
26
|
+
* cover. Its responses are cached until Django calls /revalidate, except in
|
|
27
|
+
* preview and in development.
|
|
28
|
+
*/
|
|
29
|
+
export async function getClient() {
|
|
30
|
+
const token = await previewToken();
|
|
31
|
+
return createClient<Models>({
|
|
32
|
+
previewToken: token,
|
|
33
|
+
init: contentFetchInit({ preview: token !== null || process.env.NODE_ENV === "development" }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The content helpers, with drafts when the visitor is in preview. */
|
|
38
|
+
export async function getContent() {
|
|
39
|
+
return createContent(await getClient());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The page number of a list, from ?page=. */
|
|
43
|
+
export function pageNumber(page: string | string[] | undefined): number {
|
|
44
|
+
return Math.max(1, Number(Array.isArray(page) ? page[0] : page) || 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* For a path that does not exist: follows a redirect editors manage in the
|
|
49
|
+
* admin, or shows the not-found page.
|
|
50
|
+
*/
|
|
51
|
+
export async function notFoundOrRedirect(path: string): Promise<never> {
|
|
52
|
+
const target = await (await getContent()).resolveRedirect(path).catch(() => null);
|
|
53
|
+
if (target) (target.permanent ? permanentRedirect : redirect)(target.new_path);
|
|
54
|
+
notFound();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The metadata of a page: title, description, canonical URL and share image,
|
|
59
|
+
* with the site settings as the fallback. `path` is the page's path.
|
|
60
|
+
*/
|
|
61
|
+
export async function pageMetadata(
|
|
62
|
+
page: SeoInput | null | undefined,
|
|
63
|
+
path: string,
|
|
64
|
+
type: "website" | "article" = "website",
|
|
65
|
+
): Promise<Metadata> {
|
|
66
|
+
const settings = await (await getContent()).getSiteSettings();
|
|
67
|
+
const metadata = toMetadata(seo({ page, settings, siteUrl: SITE_URL, path, type }));
|
|
68
|
+
// Next replaces the layout's alternates with the page's, so the feed is listed here.
|
|
69
|
+
const feed = [{ url: "/rss.xml", title: settings?.site_name ?? "RSS" }];
|
|
70
|
+
return { ...metadata, alternates: { ...metadata.alternates, types: { "application/rss+xml": feed } } };
|
|
71
|
+
}
|