Expand post content

This commit is contained in:
ponas committed 2026-03-13 02:16:25 +01:00
1 parent 1e9886242f
commit 1c93df5eac
6 files changed
+155 -116

No files matched your search

+5 -17
View File
@@ -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 (
<>
<SectionNav links={DHK_LINKS} />
<div className="page-content">
<h1 className="page-content__title">Den Hviide Knap – Anno 1899</h1>
<h2>Knappenytt</h2>
<ul className="post-list">
{posts.map((post) => (
<li key={post.slug}>
<p className="post-list__title">
<Link href={`/den-hviide-knap/nyheter/${post.slug}`}>{post.title}</Link>
</p>
<p className="post-list__meta">
{post.date}
{post.author && ` · ${post.author}`}
</p>
</li>
))}
</ul>
<PostListExpanded posts={posts} hrefPrefix="/den-hviide-knap/nyheter" />
</div>
</>
);
+5 -17
View File
@@ -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 (
<div className="page-content">
<h1 className="page-content__title">Historier og myter</h1>
<ul className="post-list">
{posts.map((post) => (
<li key={post.slug}>
<p className="post-list__title">
<Link href={`/historierogmyter/${post.slug}`}>{post.title}</Link>
</p>
<p className="post-list__meta">
{post.date}
{post.author && ` · ${post.author}`}
</p>
</li>
))}
</ul>
<PostListExpanded posts={posts} hrefPrefix="/historierogmyter" />
</div>
);
}
+5 -20
View File
@@ -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 (
<div className="page-content">
<h1 className="page-content__title">Nyheter</h1>
<ul className="post-list">
{posts.map((post) => (
<li key={post.slug}>
<p className="post-list__title">
<Link href={`/nyheter/${post.slug}`}>{post.title}</Link>
</p>
<p className="post-list__meta">
{post.date}
{post.author && ` · ${post.author}`}
</p>
{post.excerpt && (
<p className="post-list__excerpt">{post.excerpt}</p>
)}
</li>
))}
</ul>
<PostListExpanded posts={posts} hrefPrefix="/nyheter" />
</div>
);
}
+45
View File
@@ -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 (
<>
<ul className="post-list">
{shown.map((post) => (
<li key={post.slug}>
<p className="post-list__title">
<Link href={`${hrefPrefix}/${post.slug}`}>{post.title}</Link>
</p>
<p className="post-list__meta">
{post.date}
{post.author && ` · ${post.author}`}
</p>
<div
className="article-body"
dangerouslySetInnerHTML={{ __html: post.contentHtml }}
/>
</li>
))}
</ul>
{visible < posts.length && (
<button className="show-more-btn" onClick={() => setVisible(visible + PAGE_SIZE)}>
Vis mer
</button>
)}
</>
);
}
+18
View File
@@ -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;
}
}
+77 -62
View File
@@ -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) =>
`<figure class="slideshow__slide"><img src="${(img as { url: string }).url}" alt="${(img as { alt: string }).alt ?? ''}" loading="lazy" /></figure>`
`<figure class="slideshow__slide"><img src="${(img as { url: string }).url}" alt="${(img as { alt: string }).alt ?? ""}" loading="lazy" /></figure>`,
)
.join('');
.join("");
const html = `<div class="slideshow"><div class="slideshow__track">${slides}</div><button class="slideshow__btn slideshow__btn--prev" aria-label="Forrige">&#8249;</button><button class="slideshow__btn slideshow__btn--next" aria-label="Neste">&#8250;</button></div>`;
(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 = `<div class="video-embed"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" allowfullscreen loading="lazy" title="YouTube video"></iframe></div>`;
(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<string> {
@@ -98,59 +107,65 @@ async function markdownToHtml(markdown: string): Promise<string> {
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<PostFull[]> {
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<PostFull | null> {
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<PageFull | null> {
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<PageFull | null> {
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,
};
}