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 (
);
}
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 (
);
}
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) =>
- `
`
+ `
`,
)
- .join('');
+ .join("");
const html = ``;
- (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,
};
}