diff --git a/src/app/den-hviide-knap/page.tsx b/src/app/den-hviide-knap/page.tsx index 6b01f07..b6d240f 100644 --- a/src/app/den-hviide-knap/page.tsx +++ b/src/app/den-hviide-knap/page.tsx @@ -1,31 +1,19 @@ -import Link from 'next/link'; import SectionNav from '@/components/SectionNav'; -import { getPostsBySection } from '@/lib/content'; +import PostListExpanded from '@/components/PostListExpanded'; +import { getFullPostsBySection } from '@/lib/content'; import { DHK_LINKS } from '@/lib/nav'; export const metadata = { title: 'Den Hviide Knap – Knappenytt' }; -export default function DHKIndex() { - const posts = getPostsBySection('den-hviide-knap'); +export default async function DHKIndex() { + const posts = await getFullPostsBySection('den-hviide-knap'); return ( <>

Den Hviide Knap – Anno 1899

Knappenytt

- +
); diff --git a/src/app/historierogmyter/page.tsx b/src/app/historierogmyter/page.tsx index fed306d..04ab499 100644 --- a/src/app/historierogmyter/page.tsx +++ b/src/app/historierogmyter/page.tsx @@ -1,26 +1,14 @@ -import Link from 'next/link'; -import { getPostsBySection } from '@/lib/content'; +import { getFullPostsBySection } from '@/lib/content'; +import PostListExpanded from '@/components/PostListExpanded'; export const metadata = { title: 'Historier og myter' }; -export default function HistorierIndex() { - const posts = getPostsBySection('historierogmyter'); +export default async function HistorierIndex() { + const posts = await getFullPostsBySection('historierogmyter'); return (

Historier og myter

- +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8966468..89a2d59 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,27 +1,12 @@ -import Link from 'next/link'; -import { getPostsBySection } from '@/lib/content'; +import { getFullPostsBySection } from '@/lib/content'; +import PostListExpanded from '@/components/PostListExpanded'; -export default function NyheterIndex() { - const posts = getPostsBySection('nyheter'); +export default async function NyheterIndex() { + const posts = await getFullPostsBySection('nyheter'); return (

Nyheter

- +
); } diff --git a/src/components/PostListExpanded.tsx b/src/components/PostListExpanded.tsx new file mode 100644 index 0000000..19f5cc5 --- /dev/null +++ b/src/components/PostListExpanded.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import type { PostFull } from '@/lib/content'; + +const PAGE_SIZE = 6; + +export default function PostListExpanded({ + posts, + hrefPrefix, +}: { + posts: PostFull[]; + hrefPrefix: string; +}) { + const [visible, setVisible] = useState(PAGE_SIZE); + const shown = posts.slice(0, visible); + + return ( + <> + + {visible < posts.length && ( + + )} + + ); +} diff --git a/src/css/main.scss b/src/css/main.scss index a7adf39..13add7f 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -428,3 +428,21 @@ hr { color: $color-text; margin: 0.4rem 0 0; } + +.show-more-btn { + display: block; + margin: 2rem auto 0; + padding: 0.5rem 2rem; + font-family: $font-sc; + font-size: 0.8rem; + letter-spacing: 0.1em; + color: $color-text; + background: none; + border: 1px solid $color-border; + cursor: pointer; + + &:hover { + border-color: $color-link; + color: $color-link; + } +} diff --git a/src/lib/content.ts b/src/lib/content.ts index 2557a5b..c26e000 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -1,24 +1,24 @@ -import fs from 'fs'; -import path from 'path'; -import matter from 'gray-matter'; -import { remark } from 'remark'; -import remarkHtml from 'remark-html'; -import { visit } from 'unist-util-visit'; -import type { Root, Paragraph } from 'mdast'; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { remark } from "remark"; +import remarkHtml from "remark-html"; +import { visit } from "unist-util-visit"; +import type { Root, Paragraph } from "mdast"; const YT_WATCH = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([\w-]+)/; const YT_SHORT = /^https?:\/\/youtu\.be\/([\w-]+)/; function remarkSlideshow() { return (tree: Root) => { - visit(tree, 'paragraph', (node: Paragraph, index, parent) => { - const images = node.children.filter((c) => c.type === 'image'); + visit(tree, "paragraph", (node: Paragraph, index, parent) => { + const images = node.children.filter((c) => c.type === "image"); const nonImageNonSpace = node.children.filter( (c) => !( - c.type === 'image' || - (c.type === 'text' && /^\s*$/.test((c as { value: string }).value)) - ) + c.type === "image" || + (c.type === "text" && /^\s*$/.test((c as { value: string }).value)) + ), ); if (images.length < 2 || nonImageNonSpace.length > 0) return; if (index == null || !parent) return; @@ -26,34 +26,43 @@ function remarkSlideshow() { const slides = images .map( (img) => - `
${(img as { alt: string }).alt ?? ''}
` + `
${(img as { alt: string }).alt ??
`, ) - .join(''); + .join(""); const html = `
${slides}
`; - (parent.children as unknown[]).splice(index, 1, { type: 'html', value: html }); + (parent.children as unknown[]).splice(index, 1, { + type: "html", + value: html, + }); }); }; } function remarkYouTube() { return (tree: Root) => { - visit(tree, 'paragraph', (node: Paragraph, index, parent) => { + visit(tree, "paragraph", (node: Paragraph, index, parent) => { if (node.children.length !== 1) return; const child = node.children[0]; const url = - child.type === 'text' ? child.value.trim() : - child.type === 'link' ? child.url : null; + child.type === "text" + ? child.value.trim() + : child.type === "link" + ? child.url + : null; if (!url) return; const videoId = (url.match(YT_WATCH) ?? url.match(YT_SHORT))?.[1]; if (!videoId || index == null || !parent) return; const html = `
`; - (parent.children as unknown[]).splice(index, 1, { type: 'html', value: html }); + (parent.children as unknown[]).splice(index, 1, { + type: "html", + value: html, + }); }); }; } -const CONTENT_ROOT = path.join(process.cwd(), 'content'); +const CONTENT_ROOT = path.join(process.cwd(), "content"); export interface PostMeta { slug: string; @@ -81,12 +90,12 @@ export interface PageFull { } function postSlugFromFilename(filename: string): string { - return filename.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/\.md$/, ''); + return filename.replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/\.md$/, ""); } function serializeDate(value: unknown): string { if (value instanceof Date) return value.toISOString().slice(0, 10); - return String(value ?? ''); + return String(value ?? ""); } async function markdownToHtml(markdown: string): Promise { @@ -98,59 +107,65 @@ async function markdownToHtml(markdown: string): Promise { return result.toString(); } -/** Returns published posts sorted newest first. */ -export function getPostsBySection(section: string): PostMeta[] { - const dir = path.join(CONTENT_ROOT, 'posts', section); - const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); - return files - .map((filename) => { - const { data } = matter( - fs.readFileSync(path.join(dir, filename), 'utf8') +/** Returns published posts with full HTML content, sorted newest first. */ +export async function getFullPostsBySection( + section: string, +): Promise { + const dir = path.join(CONTENT_ROOT, "posts", section); + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); + const posts = await Promise.all( + files.map(async (filename) => { + const { data, content } = matter( + fs.readFileSync(path.join(dir, filename), "utf8"), ); + const contentHtml = await markdownToHtml(content); return { slug: postSlugFromFilename(filename), - title: data.title ?? '', + title: data.title ?? "", date: serializeDate(data.date), - author: data.author ?? '', - status: data.status ?? '', + author: data.author ?? "", + status: data.status ?? "", section: data.section ?? section, - oldPermalink: data.oldPermalink ?? '', + oldPermalink: data.oldPermalink ?? "", excerpt: data.excerpt ?? undefined, - } satisfies PostMeta; - }) - .filter((p) => p.status === 'publish') + contentHtml, + } satisfies PostFull; + }), + ); + return posts + .filter((p) => p.status === "publish") .sort((a, b) => b.date.localeCompare(a.date)); } /** Returns all slugs for a post section (including drafts, for static params). */ export function getPostSlugs(section: string): string[] { - const dir = path.join(CONTENT_ROOT, 'posts', section); + const dir = path.join(CONTENT_ROOT, "posts", section); return fs .readdirSync(dir) - .filter((f) => f.endsWith('.md')) + .filter((f) => f.endsWith(".md")) .map(postSlugFromFilename); } export async function getPostBySlug( section: string, - slug: string + slug: string, ): Promise { - const dir = path.join(CONTENT_ROOT, 'posts', section); - const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); + const dir = path.join(CONTENT_ROOT, "posts", section); + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); const filename = files.find((f) => postSlugFromFilename(f) === slug); if (!filename) return null; const { data, content } = matter( - fs.readFileSync(path.join(dir, filename), 'utf8') + fs.readFileSync(path.join(dir, filename), "utf8"), ); const contentHtml = await markdownToHtml(content); return { slug, - title: data.title ?? '', + title: data.title ?? "", date: serializeDate(data.date), - author: data.author ?? '', - status: data.status ?? '', + author: data.author ?? "", + status: data.status ?? "", section: data.section ?? section, - oldPermalink: data.oldPermalink ?? '', + oldPermalink: data.oldPermalink ?? "", excerpt: data.excerpt ?? undefined, contentHtml, }; @@ -158,11 +173,11 @@ export async function getPostBySlug( /** Returns slugs for sub-pages in a section directory (excludes index.md). */ export function getPageSlugs(dir: string): string[] { - const dirPath = path.join(CONTENT_ROOT, 'pages', dir); + const dirPath = path.join(CONTENT_ROOT, "pages", dir); return fs .readdirSync(dirPath) - .filter((f) => f.endsWith('.md') && f !== 'index.md') - .map((f) => f.replace(/\.md$/, '')); + .filter((f) => f.endsWith(".md") && f !== "index.md") + .map((f) => f.replace(/\.md$/, "")); } /** @@ -171,37 +186,37 @@ export function getPageSlugs(dir: string): string[] { */ export async function getSectionPage( dir: string, - slug: string + slug: string, ): Promise { - const filename = slug === '' ? 'index.md' : `${slug}.md`; - const filePath = path.join(CONTENT_ROOT, 'pages', dir, filename); + const filename = slug === "" ? "index.md" : `${slug}.md`; + const filePath = path.join(CONTENT_ROOT, "pages", dir, filename); if (!fs.existsSync(filePath)) return null; - const { data, content } = matter(fs.readFileSync(filePath, 'utf8')); + const { data, content } = matter(fs.readFileSync(filePath, "utf8")); const contentHtml = await markdownToHtml(content); return { slug, - title: data.title ?? '', + title: data.title ?? "", date: serializeDate(data.date), - status: data.status ?? '', + status: data.status ?? "", section: data.section ?? null, - oldPermalink: data.oldPermalink ?? '', + oldPermalink: data.oldPermalink ?? "", contentHtml, }; } /** Returns a root-level standalone page (about, chateau-neuf, etc.). */ export async function getRootPage(slug: string): Promise { - const filePath = path.join(CONTENT_ROOT, 'pages', `${slug}.md`); + const filePath = path.join(CONTENT_ROOT, "pages", `${slug}.md`); if (!fs.existsSync(filePath)) return null; - const { data, content } = matter(fs.readFileSync(filePath, 'utf8')); + const { data, content } = matter(fs.readFileSync(filePath, "utf8")); const contentHtml = await markdownToHtml(content); return { slug, - title: data.title ?? '', + title: data.title ?? "", date: serializeDate(data.date), - status: data.status ?? '', + status: data.status ?? "", section: data.section ?? null, - oldPermalink: data.oldPermalink ?? '', + oldPermalink: data.oldPermalink ?? "", contentHtml, }; }