feat: 实现前台系列列表、详情页及文章内系列导航
This commit is contained in:
1 parent
495e453237
commit
a1efd25332
6 files changed
+390
-14
No files matched your search
@@ -1,5 +1,22 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error, isHttpError } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
interface PostRow {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
cover: string | null;
|
||||||
|
date: string;
|
||||||
|
category_name: string | null;
|
||||||
|
category_slug: string | null;
|
||||||
|
author: string | null;
|
||||||
|
series_id: number | null;
|
||||||
|
series_name: string | null;
|
||||||
|
series_slug: string | null;
|
||||||
|
series_description: string | null;
|
||||||
|
series_weight: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ platform, params }) => {
|
export const load: PageServerLoad = async ({ platform, params }) => {
|
||||||
const db = platform?.env?.DB;
|
const db = platform?.env?.DB;
|
||||||
@@ -28,51 +45,88 @@ export const load: PageServerLoad = async ({ platform, params }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
post: {
|
post: {
|
||||||
|
id: 1,
|
||||||
|
slug,
|
||||||
title: slug === 'hello-world' ? 'Hello, BlogFlare! (Fallback)' : 'The Beauty of Typecho Theme VOID',
|
title: slug === 'hello-world' ? 'Hello, BlogFlare! (Fallback)' : 'The Beauty of Typecho Theme VOID',
|
||||||
content: dummyMarkdown,
|
content: dummyMarkdown,
|
||||||
|
cover: null,
|
||||||
date: '2026-07-12',
|
date: '2026-07-12',
|
||||||
author: 'Admin'
|
author: 'Admin'
|
||||||
},
|
},
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'Welcome', id: 1 },
|
{ name: 'Welcome', id: 1 },
|
||||||
{ name: 'Design', id: 2 }
|
{ name: 'Design', id: 2 }
|
||||||
]
|
],
|
||||||
|
series: null,
|
||||||
|
seriesPosts: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const postRes = await db.prepare(
|
const post = await db.prepare(
|
||||||
`SELECT p.id, p.slug, p.title, p.content, p.cover, p.published_at as date,
|
`SELECT p.id, p.slug, p.title, p.content, p.cover, p.published_at as date,
|
||||||
c.name as category_name, c.slug as category_slug,
|
c.name as category_name, c.slug as category_slug,
|
||||||
u.username as author_name
|
u.username as author,
|
||||||
|
s.id as series_id, s.name as series_name, s.slug as series_slug,
|
||||||
|
s.description as series_description, p.series_weight
|
||||||
FROM posts p
|
FROM posts p
|
||||||
LEFT JOIN categories c ON p.category_id = c.id
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
LEFT JOIN users u ON p.author_id = u.id
|
LEFT JOIN users u ON p.author_id = u.id
|
||||||
|
LEFT JOIN series s ON p.series_id = s.id
|
||||||
WHERE p.slug = ?`
|
WHERE p.slug = ?`
|
||||||
).bind(slug).first();
|
).bind(slug).first<PostRow>();
|
||||||
|
|
||||||
const post: any = postRes;
|
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
error(404, 'Post not found');
|
error(404, 'Post not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsQuery: any = await db.prepare(
|
const tagsQuery = await db.prepare(
|
||||||
`SELECT t.name, t.id
|
`SELECT t.name, t.id
|
||||||
FROM tags t
|
FROM tags t
|
||||||
JOIN post_tags pt ON t.id = pt.tag_id
|
JOIN post_tags pt ON t.id = pt.tag_id
|
||||||
WHERE pt.post_id = ?`
|
WHERE pt.post_id = ?`
|
||||||
).bind(post.id).all();
|
).bind(post.id).all<{ name: string; id: number }>();
|
||||||
const tags = tagsQuery.results || [];
|
const tags = tagsQuery.results || [];
|
||||||
|
let series: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
} | null = null;
|
||||||
|
let seriesPosts: Array<{
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
series_weight: number | null;
|
||||||
|
published_at: string;
|
||||||
|
}> = [];
|
||||||
|
if (post.series_id && post.series_name && post.series_slug) {
|
||||||
|
series = {
|
||||||
|
id: post.series_id,
|
||||||
|
name: post.series_name,
|
||||||
|
slug: post.series_slug,
|
||||||
|
description: post.series_description
|
||||||
|
};
|
||||||
|
const seriesQuery = await db.prepare(
|
||||||
|
`SELECT id, slug, title, series_weight, published_at
|
||||||
|
FROM posts
|
||||||
|
WHERE series_id = ?
|
||||||
|
ORDER BY series_weight IS NULL, series_weight ASC, published_at ASC, id ASC`
|
||||||
|
).bind(post.series_id).all();
|
||||||
|
seriesPosts = seriesQuery.results as unknown as typeof seriesPosts;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
post: {
|
post: {
|
||||||
...post,
|
...post,
|
||||||
date: new Date(post.date as string).toLocaleDateString()
|
date: new Date(post.date as string).toLocaleDateString()
|
||||||
},
|
},
|
||||||
tags
|
tags,
|
||||||
|
series,
|
||||||
|
seriesPosts
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
|
if (isHttpError(err, 404)) throw err;
|
||||||
console.error('Error fetching post:', err);
|
console.error('Error fetching post:', err);
|
||||||
error(500, 'Internal Server Error');
|
error(500, 'Internal Server Error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import tocbot from 'tocbot';
|
import tocbot from 'tocbot';
|
||||||
@@ -9,6 +10,9 @@
|
|||||||
let hasToc = $state(false);
|
let hasToc = $state(false);
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
let currentSeriesIndex = $derived(data.seriesPosts.findIndex((post) => post.id === data.post.id));
|
||||||
|
let previousSeriesPost = $derived(currentSeriesIndex > 0 ? data.seriesPosts[currentSeriesIndex - 1] : null);
|
||||||
|
let nextSeriesPost = $derived(currentSeriesIndex >= 0 && currentSeriesIndex < data.seriesPosts.length - 1 ? data.seriesPosts[currentSeriesIndex + 1] : null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const headers = document.querySelectorAll('.js-toc-content h1, .js-toc-content h2, .js-toc-content h3, .js-toc-content h4, .js-toc-content h5, .js-toc-content h6');
|
const headers = document.querySelectorAll('.js-toc-content h1, .js-toc-content h2, .js-toc-content h3, .js-toc-content h4, .js-toc-content h5, .js-toc-content h6');
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
<div class="banner-title">
|
<div class="banner-title">
|
||||||
<h1 class="post-title">{data.post.title}</h1>
|
<h1 class="post-title">{data.post.title}</h1>
|
||||||
<p class="post-meta">
|
<p class="post-meta">
|
||||||
<span><a href="/">{data.post.author}</a></span> •
|
<span><a href={resolve('/')}>{data.post.author}</a></span> •
|
||||||
<time>{data.post.date}</time>
|
<time>{data.post.date}</time>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +80,41 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.series && data.seriesPosts.length > 0}
|
||||||
|
<section class="series-navigation" aria-labelledby="series-navigation-title">
|
||||||
|
<h2 id="series-navigation-title">
|
||||||
|
{$page.data.aliases?.series || '系列'}:<a href={resolve('/series/[slug]', { slug: data.series.slug })}>{data.series.name}</a>
|
||||||
|
</h2>
|
||||||
|
<ol>
|
||||||
|
{#each data.seriesPosts as seriesPost (seriesPost.id)}
|
||||||
|
<li class:current={seriesPost.id === data.post.id}>
|
||||||
|
{#if seriesPost.id === data.post.id}
|
||||||
|
<span aria-current="page">{seriesPost.title}</span>
|
||||||
|
{:else}
|
||||||
|
<a href={resolve('/posts/[slug]', { slug: seriesPost.slug })}>{seriesPost.title}</a>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{#if previousSeriesPost || nextSeriesPost}
|
||||||
|
<nav class="series-prev-next" aria-label="系列文章翻页">
|
||||||
|
{#if previousSeriesPost}
|
||||||
|
<a href={resolve('/posts/[slug]', { slug: previousSeriesPost.slug })}>← {previousSeriesPost.title}</a>
|
||||||
|
{:else}<span></span>{/if}
|
||||||
|
{#if nextSeriesPost}
|
||||||
|
<a href={resolve('/posts/[slug]', { slug: nextSeriesPost.slug })}>{nextSeriesPost.title} →</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<MarkdownRenderer content={data.post.content} />
|
<MarkdownRenderer content={data.post.content} />
|
||||||
|
|
||||||
{#if data.tags && data.tags.length > 0}
|
{#if data.tags && data.tags.length > 0}
|
||||||
<section class="tags">
|
<section class="tags">
|
||||||
{#each data.tags as tag}
|
{#each data.tags as tag (tag.id)}
|
||||||
<a href="/tags/{tag.name}" rel="tag" class="tag-item">{tag.name}</a>
|
<a href={resolve(`/tags/${tag.name}`)} rel="tag" class="tag-item">{tag.name}</a>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -104,3 +137,64 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.series-navigation {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border: 2px solid var(--text-color);
|
||||||
|
background: rgba(0, 0, 0, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-navigation h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-navigation h2 a,
|
||||||
|
.series-navigation li a,
|
||||||
|
.series-prev-next a {
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-navigation h2 a:hover,
|
||||||
|
.series-navigation li a:hover,
|
||||||
|
.series-prev-next a:hover {
|
||||||
|
color: var(--accent-color, #419b38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-navigation ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-navigation li {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-navigation li.current {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-color, #419b38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-prev-next {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-prev-next a:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.series-prev-next { grid-template-columns: 1fr; }
|
||||||
|
.series-prev-next a:last-child { text-align: left; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ platform }) => {
|
||||||
|
const db = platform?.env?.DB;
|
||||||
|
if (!db) throw error(500, '数据库未连接');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.id, s.name, s.slug, s.description, COUNT(p.id) AS post_count
|
||||||
|
FROM series s
|
||||||
|
LEFT JOIN posts p ON p.series_id = s.id
|
||||||
|
GROUP BY s.id, s.name, s.slug, s.description
|
||||||
|
ORDER BY s.name COLLATE NOCASE ASC`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return {
|
||||||
|
series: results as unknown as Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
post_count: number;
|
||||||
|
}>
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching public series list:', err);
|
||||||
|
throw error(500, '读取系列列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>全部{$page.data.aliases?.series || '系列'} - {$page.data.site?.title || 'BlogFlare'}</title
|
||||||
|
>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main id="pjax-container">
|
||||||
|
<div class="wrapper container narrow contents-wrap">
|
||||||
|
<article class="article-detail yue">
|
||||||
|
<div class="banner-title">
|
||||||
|
<h1 class="post-title">全部{$page.data.aliases?.series || '系列'}</h1>
|
||||||
|
<p class="post-meta">按阅读顺序探索成组文章</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="series-grid">
|
||||||
|
{#each data.series as item (item.id)}
|
||||||
|
<a href={resolve('/series/[slug]', { slug: item.slug })} class="series-card">
|
||||||
|
<h2>{item.name}</h2>
|
||||||
|
{#if item.description}<p>{item.description}</p>{/if}
|
||||||
|
<span>{item.post_count} 篇文章</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-state">暂无系列。</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.series-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.series-card {
|
||||||
|
display: block;
|
||||||
|
padding: 1.4rem;
|
||||||
|
border: 2px solid var(--text-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
.series-card:hover {
|
||||||
|
border-color: var(--accent-color, #419b38);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.series-card h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
.series-card p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.series-card span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { error, isHttpError } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ platform, params }) => {
|
||||||
|
const db = platform?.env?.DB;
|
||||||
|
if (!db) throw error(500, '数据库未连接');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const series = await db
|
||||||
|
.prepare('SELECT id, name, slug, description FROM series WHERE slug = ?')
|
||||||
|
.bind(params.slug)
|
||||||
|
.first<{ id: number; name: string; slug: string; description: string | null }>();
|
||||||
|
if (!series) throw error(404, '系列不存在');
|
||||||
|
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, slug, title, series_weight, published_at
|
||||||
|
FROM posts
|
||||||
|
WHERE series_id = ?
|
||||||
|
ORDER BY series_weight IS NULL, series_weight ASC, published_at ASC, id ASC`
|
||||||
|
)
|
||||||
|
.bind(series.id)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return {
|
||||||
|
series,
|
||||||
|
posts: results as unknown as Array<{
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
series_weight: number | null;
|
||||||
|
published_at: string;
|
||||||
|
}>
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isHttpError(err, 404)) throw err;
|
||||||
|
console.error('Error fetching series detail:', err);
|
||||||
|
throw error(500, '读取系列失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.series.name} - {$page.data.site?.title || 'BlogFlare'}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main id="pjax-container">
|
||||||
|
<div class="wrapper container narrow contents-wrap">
|
||||||
|
<article class="article-detail yue">
|
||||||
|
<div class="banner-title">
|
||||||
|
<h1 class="post-title">{data.series.name}</h1>
|
||||||
|
{#if data.series.description}<p class="post-meta">{data.series.description}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="series-posts">
|
||||||
|
{#each data.posts as post, index (post.id)}
|
||||||
|
<li>
|
||||||
|
<span class="series-order">{post.series_weight ?? index + 1}</span>
|
||||||
|
<a href={resolve('/posts/[slug]', { slug: post.slug })}>{post.title}</a>
|
||||||
|
<time>{formatDate(post.published_at)}</time>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="empty-state">这个系列还没有文章。</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.series-posts {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 2px solid var(--text-color);
|
||||||
|
}
|
||||||
|
.series-posts li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem 1fr auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px dashed var(--border-color, rgba(0, 0, 0, 0.15));
|
||||||
|
}
|
||||||
|
.series-order {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.series-posts a {
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.series-posts a:hover {
|
||||||
|
color: var(--accent-color, #419b38);
|
||||||
|
}
|
||||||
|
.series-posts time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
display: block !important;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.series-posts li {
|
||||||
|
grid-template-columns: 2rem 1fr;
|
||||||
|
}
|
||||||
|
.series-posts time {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user