feat: 添加文章置顶功能
- 添加 pinned 和 category_pinned 字段支持文章置顶 - archive 页面置顶文章独立于时间轴显示 - 主页卡片显示置顶图标 - 支持全局置顶和分类置顶两种模式 - 修复置顶文章在标签/未分类筛选时的过滤问题
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -36,6 +36,7 @@ enum I18nKey {
|
||||
notFound = "notFound",
|
||||
notFoundDesc = "notFoundDesc",
|
||||
backToHome = "backToHome",
|
||||
pinned = "pinned",
|
||||
}
|
||||
|
||||
export default I18nKey;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -39,4 +39,5 @@ export const zh_CN: Translation = {
|
||||
[Key.notFound]: "页面未找到",
|
||||
[Key.notFoundDesc]: "你访问的链接已断开,页面走丢了",
|
||||
[Key.backToHome]: "返回首页",
|
||||
[Key.pinned]: "置顶",
|
||||
};
|
||||
|
||||
@@ -39,4 +39,5 @@ export const zh_TW: Translation = {
|
||||
[Key.notFound]: "頁面未找到",
|
||||
[Key.notFoundDesc]: "你訪問的連結已斷開,頁面走丟了",
|
||||
[Key.backToHome]: "返回首頁",
|
||||
[Key.pinned]: "置頂",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user