Expand post content
This commit is contained in:
1 parent
1e9886242f
commit
1c93df5eac
6 files changed
+155
-116
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">‹</button><button class="slideshow__btn slideshow__btn--next" aria-label="Neste">›</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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user