feat: 添加文章置顶功能

- 添加 pinned 和 category_pinned 字段支持文章置顶
- archive 页面置顶文章独立于时间轴显示
- 主页卡片显示置顶图标
- 支持全局置顶和分类置顶两种模式
- 修复置顶文章在标签/未分类筛选时的过滤问题
This commit is contained in:
2026-03-22 11:27:17 +08:00
parent 370b421eed
commit b7ebf3bef7
10 changed files with 231 additions and 83 deletions

View File

@@ -3,33 +3,29 @@ import { onMount } from "svelte";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import type { PostForList } from "../utils/content-utils";
import { getPostUrlBySlug } from "../utils/url-utils";
export let tags: string[];
export let categories: string[];
export let sortedPosts: Post[] = [];
export let sortedPosts: PostForList[] = [];
export let pinnedPosts: PostForList[] = [];
export let categoryPinnedPosts: PostForList[] = [];
const params = new URLSearchParams(window.location.search);
tags = params.has("tag") ? params.getAll("tag") : [];
categories = params.has("category") ? params.getAll("category") : [];
const uncategorized = params.get("uncategorized");
interface Post {
slug: string;
data: {
title: string;
tags: string[];
category?: string;
published: Date;
};
}
const isCategoryMode = categories.length > 0;
interface Group {
year: number;
posts: Post[];
posts: PostForList[];
}
let groups: Group[] = [];
let displayPinnedPosts: PostForList[] = [];
function formatDate(date: Date) {
const month = (date.getMonth() + 1).toString().padStart(2, "0");
@@ -42,7 +38,7 @@ function formatTag(tagList: string[]) {
}
onMount(async () => {
let filteredPosts: Post[] = sortedPosts;
let filteredPosts: PostForList[] = sortedPosts;
if (tags.length > 0) {
filteredPosts = filteredPosts.filter(
@@ -62,6 +58,39 @@ onMount(async () => {
filteredPosts = filteredPosts.filter((post) => !post.data.category);
}
if (isCategoryMode) {
displayPinnedPosts = categoryPinnedPosts.filter(
(post) => post.data.category && categories.includes(post.data.category),
);
if (tags.length > 0) {
displayPinnedPosts = displayPinnedPosts.filter(
(post) =>
Array.isArray(post.data.tags) &&
post.data.tags.some((tag) => tags.includes(tag)),
);
}
filteredPosts = filteredPosts.filter(
(post) => (post.data.category_pinned || 0) === 0,
);
} else {
displayPinnedPosts = pinnedPosts;
if (tags.length > 0) {
displayPinnedPosts = displayPinnedPosts.filter(
(post) =>
Array.isArray(post.data.tags) &&
post.data.tags.some((tag) => tags.includes(tag)),
);
}
if (uncategorized) {
displayPinnedPosts = displayPinnedPosts.filter(
(post) => !post.data.category,
);
}
filteredPosts = filteredPosts.filter(
(post) => (post.data.pinned || 0) === 0,
);
}
const grouped = filteredPosts.reduce(
(acc, post) => {
const year = post.data.published.getFullYear();
@@ -71,7 +100,7 @@ onMount(async () => {
acc[year].push(post);
return acc;
},
{} as Record<number, Post[]>,
{} as Record<number, PostForList[]>,
);
const groupedPostsArray = Object.keys(grouped).map((yearStr) => ({
@@ -86,66 +115,121 @@ onMount(async () => {
</script>
<div class="card-base px-8 py-6">
{#each groups as group}
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
{group.year}
</div>
<div class="w-[15%] md:w-[10%]">
<div
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
-outline-offset-[2px] z-50 outline-3"
></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">
{group.posts.length} {i18n(group.posts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
</div>
</div>
{#if displayPinnedPosts.length > 0}
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
{i18n(I18nKey.pinned)}
</div>
<div class="w-[15%] md:w-[10%]">
<div
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
-outline-offset-[2px] z-50 outline-3"
></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">
{displayPinnedPosts.length} {i18n(displayPinnedPosts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
</div>
</div>
{#each group.posts as post}
<a
href={getPostUrlBySlug(post.slug)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<!-- date -->
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
{#each displayPinnedPosts as post}
<a
href={getPostUrlBySlug(post.slug)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
<!-- dot and line -->
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]"
></div>
</div>
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]"
></div>
</div>
<!-- post title -->
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<!-- tag list -->
<div
class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/each}
</div>
<div
class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/if}
{#each groups as group}
<div>
<div class="flex flex-row w-full items-center h-[3.75rem]">
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
{group.year}
</div>
<div class="w-[15%] md:w-[10%]">
<div
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
-outline-offset-[2px] z-50 outline-3"
></div>
</div>
<div class="w-[70%] md:w-[80%] transition text-left text-50">
{group.posts.length} {i18n(group.posts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
</div>
</div>
{#each group.posts as post}
<a
href={getPostUrlBySlug(post.slug)}
aria-label={post.data.title}
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
>
<div class="flex flex-row justify-start items-center h-full">
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
{formatDate(post.data.published)}
</div>
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
<div
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
outline outline-4 z-50
outline-[var(--card-bg)]
group-hover:outline-[var(--btn-plain-bg-hover)]
group-active:outline-[var(--btn-plain-bg-active)]"
></div>
</div>
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{post.data.title}
</div>
<div
class="hidden md:block md:w-[15%] text-left text-sm transition
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
>
{formatTag(post.data.tags)}
</div>
</div>
</a>
{/each}
</div>
{/each}
</div>

View File

@@ -21,6 +21,7 @@ interface Props {
description: string;
draft: boolean;
style: string;
pinned?: number;
}
const {
entry,
@@ -33,6 +34,7 @@ const {
image,
description,
style,
pinned = 0,
} = Astro.props;
const className = Astro.props.class;
@@ -52,6 +54,7 @@ const { remarkPluginFrontmatter } = await entry.render();
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
">
{title}
{pinned > 0 && <Icon name="material-symbols:push-pin" class="inline text-[1.5rem] text-[var(--primary)] ml-2 translate-y-[-2px]" />}
<Icon class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
<Icon class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
</a>

View File

@@ -21,6 +21,7 @@ const interval = 50;
image={entry.data.image}
description={entry.data.description}
draft={entry.data.draft}
pinned={entry.data.pinned}
class:list="onload-animation"
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
></PostCard>

View File

@@ -11,6 +11,8 @@ const postsCollection = defineCollection({
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().nullable().default(""),
lang: z.string().optional().default(""),
pinned: z.number().optional().default(0),
category_pinned: z.number().optional().default(0),
/* For internal use */
prevTitle: z.string().default(""),

View File

@@ -36,6 +36,7 @@ enum I18nKey {
notFound = "notFound",
notFoundDesc = "notFoundDesc",
backToHome = "backToHome",
pinned = "pinned",
}
export default I18nKey;

View File

@@ -39,4 +39,5 @@ export const en: Translation = {
[Key.notFound]: "Page Not Found",
[Key.notFoundDesc]: "The link is broken, the page has gone missing",
[Key.backToHome]: "Back to Home",
[Key.pinned]: "Pinned",
};

View File

@@ -39,4 +39,5 @@ export const zh_CN: Translation = {
[Key.notFound]: "页面未找到",
[Key.notFoundDesc]: "你访问的链接已断开,页面走丢了",
[Key.backToHome]: "返回首页",
[Key.pinned]: "置顶",
};

View File

@@ -39,4 +39,5 @@ export const zh_TW: Translation = {
[Key.notFound]: "頁面未找到",
[Key.notFoundDesc]: "你訪問的連結已斷開,頁面走丟了",
[Key.backToHome]: "返回首頁",
[Key.pinned]: "置頂",
};

View File

@@ -3,12 +3,18 @@ import ArchivePanel from "@components/ArchivePanel.svelte";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPostsList } from "../utils/content-utils";
import {
getCategoryPinnedPosts,
getPinnedPosts,
getSortedPostsList,
} from "../utils/content-utils";
const sortedPostsList = await getSortedPostsList();
const pinnedPosts = await getPinnedPosts();
const categoryPinnedPosts = await getCategoryPinnedPosts();
---
<MainGridLayout title={i18n(I18nKey.archive)}>
<ArchivePanel sortedPosts={sortedPostsList} client:only="svelte"></ArchivePanel>
<ArchivePanel sortedPosts={sortedPostsList} pinnedPosts={pinnedPosts} categoryPinnedPosts={categoryPinnedPosts} client:only="svelte"></ArchivePanel>
</MainGridLayout>

View File

@@ -3,21 +3,35 @@ import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import { getCategoryUrl } from "@utils/url-utils.ts";
// // Retrieve posts and sort them by publication date
async function getRawSortedPosts() {
type Post = CollectionEntry<"posts">;
function sortPosts(posts: Post[], useCategoryPinned = false): Post[] {
return posts.sort((a, b) => {
const pinnedA = useCategoryPinned
? a.data.category_pinned || 0
: a.data.pinned || 0;
const pinnedB = useCategoryPinned
? b.data.category_pinned || 0
: b.data.pinned || 0;
const pinnedDiff = pinnedB - pinnedA;
if (pinnedDiff !== 0) return pinnedDiff;
const dateA = new Date(a.data.published);
const dateB = new Date(b.data.published);
return dateB.valueOf() - dateA.valueOf();
});
}
async function getRawSortedPosts(): Promise<Post[]> {
const allBlogPosts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const sorted = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.data.published);
const dateB = new Date(b.data.published);
return dateA > dateB ? -1 : 1;
});
return sorted;
return sortPosts(allBlogPosts);
}
export async function getSortedPosts() {
export async function getSortedPosts(): Promise<Post[]> {
const sorted = await getRawSortedPosts();
for (let i = 1; i < sorted.length; i++) {
@@ -112,3 +126,37 @@ export async function getCategoryList(): Promise<Category[]> {
}
return ret;
}
export async function getPinnedPosts(): Promise<PostForList[]> {
const allBlogPosts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const pinnedPosts = allBlogPosts.filter(
(post) => (post.data.pinned || 0) > 0,
);
const sorted = sortPosts(pinnedPosts);
return sorted.map((post) => ({
slug: post.slug,
data: post.data,
}));
}
export async function getCategoryPinnedPosts(): Promise<PostForList[]> {
const allBlogPosts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const categoryPinnedPosts = allBlogPosts.filter(
(post) => (post.data.category_pinned || 0) > 0,
);
const sorted = sortPosts(categoryPinnedPosts, true);
return sorted.map((post) => ({
slug: post.slug,
data: post.data,
}));
}