mirror of
https://github.com/saicaca/fuwari.git
synced 2026-01-12 15:22:52 +01:00
feat: make archive page dynamic (#469)
Some checks failed
Code quality / quality (push) Failing after 4s
Build and Check / Astro Check for Node.js 22 (push) Failing after 4s
Build and Check / Astro Check for Node.js 23 (push) Failing after 4s
Build and Check / Astro Build for Node.js 22 (push) Failing after 4s
Build and Check / Astro Build for Node.js 23 (push) Failing after 3s
Some checks failed
Code quality / quality (push) Failing after 4s
Build and Check / Astro Check for Node.js 22 (push) Failing after 4s
Build and Check / Astro Check for Node.js 23 (push) Failing after 4s
Build and Check / Astro Build for Node.js 22 (push) Failing after 4s
Build and Check / Astro Build for Node.js 23 (push) Failing after 3s
This commit is contained in:
149
src/components/ArchivePanel.svelte
Normal file
149
src/components/ArchivePanel.svelte
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
import { UNCATEGORIZED } from "@constants/constants";
|
||||||
|
import I18nKey from "../i18n/i18nKey";
|
||||||
|
import { i18n } from "../i18n/translation";
|
||||||
|
import { getPostUrlBySlug } from "../utils/url-utils";
|
||||||
|
|
||||||
|
export let tags: string[];
|
||||||
|
export let categories: string[];
|
||||||
|
export let sortedPosts: Post[] = [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
tags = params.has("tag") ? params.getAll("tag") : [];
|
||||||
|
categories = params.has("category") ? params.getAll("category") : [];
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
slug: string;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
category?: string;
|
||||||
|
published: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
year: number;
|
||||||
|
posts: Post[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups: Group[] = [];
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
const day = date.getDate().toString().padStart(2, "0");
|
||||||
|
return `${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTag(tagList: string[]) {
|
||||||
|
return tagList.map((t) => `#${t}`).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
let filteredPosts: Post[] = sortedPosts;
|
||||||
|
|
||||||
|
if (tags.length > 0) {
|
||||||
|
filteredPosts = filteredPosts.filter(
|
||||||
|
(post) =>
|
||||||
|
Array.isArray(post.data.tags) &&
|
||||||
|
post.data.tags.some((tag) => tags.includes(tag)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categories.length > 0) {
|
||||||
|
filteredPosts = filteredPosts.filter(
|
||||||
|
(post) =>
|
||||||
|
(post.data.category && categories.includes(post.data.category)) ||
|
||||||
|
(!post.data.category && categories.includes(UNCATEGORIZED)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = filteredPosts.reduce(
|
||||||
|
(acc, post) => {
|
||||||
|
const year = post.data.published.getFullYear();
|
||||||
|
if (!acc[year]) {
|
||||||
|
acc[year] = [];
|
||||||
|
}
|
||||||
|
acc[year].push(post);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, Post[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedPostsArray = Object.keys(grouped).map((yearStr) => ({
|
||||||
|
year: Number.parseInt(yearStr),
|
||||||
|
posts: grouped[Number.parseInt(yearStr)],
|
||||||
|
}));
|
||||||
|
|
||||||
|
groupedPostsArray.sort((a, b) => b.year - a.year);
|
||||||
|
|
||||||
|
groups = groupedPostsArray;
|
||||||
|
});
|
||||||
|
</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(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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
@@ -3,7 +3,7 @@ import { Icon } from "astro-icon/components";
|
|||||||
import I18nKey from "../i18n/i18nKey";
|
import I18nKey from "../i18n/i18nKey";
|
||||||
import { i18n } from "../i18n/translation";
|
import { i18n } from "../i18n/translation";
|
||||||
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
||||||
import { url, getTagUrl } from "../utils/url-utils";
|
import { getCategoryUrl, getTagUrl } from "../utils/url-utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class: string;
|
class: string;
|
||||||
@@ -53,7 +53,7 @@ const className = Astro.props.class;
|
|||||||
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
|
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-nowrap items-center">
|
<div class="flex flex-row flex-nowrap items-center">
|
||||||
<a href={url(`/archive/category/${encodeURIComponent(category || 'uncategorized')}/`)} aria-label={`View all posts in the ${category} category`}
|
<a href={getCategoryUrl(category)} aria-label={`View all posts in the ${category} category`}
|
||||||
class="link-lg transition text-50 text-sm font-medium
|
class="link-lg transition text-50 text-sm font-medium
|
||||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
||||||
{category || i18n(I18nKey.uncategorized)}
|
{category || i18n(I18nKey.uncategorized)}
|
||||||
@@ -70,7 +70,7 @@ const className = Astro.props.class;
|
|||||||
<div class="flex flex-row flex-nowrap items-center">
|
<div class="flex flex-row flex-nowrap items-center">
|
||||||
{(tags && tags.length > 0) && tags.map((tag, i) => (
|
{(tags && tags.length > 0) && tags.map((tag, i) => (
|
||||||
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
|
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
|
||||||
<a href={getTagUrl(tag.trim())} aria-label={`View all posts with the ${tag.trim()} tag`}
|
<a href={getTagUrl(tag)} aria-label={`View all posts with the ${tag.trim()} tag`}
|
||||||
class="link-lg transition text-50 text-sm font-medium
|
class="link-lg transition text-50 text-sm font-medium
|
||||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
||||||
{tag.trim()}
|
{tag.trim()}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const style = Astro.props.style;
|
|||||||
>
|
>
|
||||||
{categories.map((c) =>
|
{categories.map((c) =>
|
||||||
<ButtonLink
|
<ButtonLink
|
||||||
url={getCategoryUrl(c.name.trim())}
|
url={getCategoryUrl(c.name)}
|
||||||
badge={String(c.count)}
|
badge={String(c.count)}
|
||||||
label={`View all posts in the ${c.name.trim()} category`}
|
label={`View all posts in the ${c.name.trim()} category`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const style = Astro.props.style;
|
|||||||
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
|
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{tags.map(t => (
|
{tags.map(t => (
|
||||||
<ButtonTag href={getTagUrl(t.name.trim())} label={`View all posts with the ${t.name.trim()} tag`}>
|
<ButtonTag href={getTagUrl(t.name)} label={`View all posts with the ${t.name.trim()} tag`}>
|
||||||
{t.name.trim()}
|
{t.name.trim()}
|
||||||
</ButtonTag>
|
</ButtonTag>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
title: CJK edge case for test
|
|
||||||
published: 2025-05-04
|
|
||||||
updated: 2025-05-04
|
|
||||||
description: 'CJK Test'
|
|
||||||
image: ''
|
|
||||||
tags: [C#, テスト, 技术, Fuwari]
|
|
||||||
category: '技术'
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
|
|
||||||
CJK Test
|
|
||||||
14
src/pages/archive.astro
Normal file
14
src/pages/archive.astro
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
import ArchivePanel from "@components/ArchivePanel.svelte";
|
||||||
|
import I18nKey from "@i18n/i18nKey";
|
||||||
|
import { i18n } from "@i18n/translation";
|
||||||
|
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
||||||
|
import { getSortedPosts } from "../utils/content-utils";
|
||||||
|
|
||||||
|
const sortedPosts = await getSortedPosts();
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainGridLayout title={i18n(I18nKey.archive)}>
|
||||||
|
<ArchivePanel sortedPosts={sortedPosts} client:only="svelte"></ArchivePanel>
|
||||||
|
</MainGridLayout>
|
||||||
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
|
||||||
import I18nKey from "@i18n/i18nKey";
|
|
||||||
import { i18n } from "@i18n/translation";
|
|
||||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
|
||||||
import { getCategoryList } from "@utils/content-utils";
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const categories = await getCategoryList();
|
|
||||||
|
|
||||||
const standardPaths = categories.map((category) => {
|
|
||||||
return {
|
|
||||||
params: {
|
|
||||||
category: encodeURIComponent(category.name.trim()),
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decodedCategory: category.name.trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonEncodedCJKPaths = categories
|
|
||||||
.filter((category) =>
|
|
||||||
/[\u3000-\u9fff\uac00-\ud7af\u4e00-\u9faf]/.test(category.name),
|
|
||||||
)
|
|
||||||
.map((category) => ({
|
|
||||||
params: {
|
|
||||||
category: category.name.trim(), // Do not encode CJK characters
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decodedCategory: category.name.trim(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [...standardPaths, ...nonEncodedCJKPaths];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { decodedCategory } = Astro.props;
|
|
||||||
const category =
|
|
||||||
decodedCategory || decodeURIComponent(Astro.params.category as string);
|
|
||||||
---
|
|
||||||
|
|
||||||
<MainGridLayout title={i18n(I18nKey.archive)} description={i18n(I18nKey.archive)}>
|
|
||||||
<ArchivePanel categories={[category]}></ArchivePanel>
|
|
||||||
</MainGridLayout>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
|
||||||
import { UNCATEGORIZED } from "@constants/constants";
|
|
||||||
import I18nKey from "@i18n/i18nKey";
|
|
||||||
import { i18n } from "@i18n/translation";
|
|
||||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<MainGridLayout title={i18n(I18nKey.archive)}>
|
|
||||||
<ArchivePanel categories={[UNCATEGORIZED]}></ArchivePanel>
|
|
||||||
</MainGridLayout>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
|
||||||
import I18nKey from "@i18n/i18nKey";
|
|
||||||
import { i18n } from "@i18n/translation";
|
|
||||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<MainGridLayout title={i18n(I18nKey.archive)}>
|
|
||||||
<ArchivePanel></ArchivePanel>
|
|
||||||
</MainGridLayout>
|
|
||||||
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
|
||||||
import I18nKey from "@i18n/i18nKey";
|
|
||||||
import { i18n } from "@i18n/translation";
|
|
||||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
|
||||||
import { getSortedPosts } from "@utils/content-utils";
|
|
||||||
import { decodePathSegment, encodePathSegment } from "@utils/encoding-utils";
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const posts = await getSortedPosts();
|
|
||||||
|
|
||||||
const allTags = posts.reduce<Set<string>>((acc, post) => {
|
|
||||||
if (Array.isArray(post.data.tags)) {
|
|
||||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
|
||||||
post.data.tags.forEach((tag) => {
|
|
||||||
if (typeof tag === "string") {
|
|
||||||
acc.add(tag.trim());
|
|
||||||
} else {
|
|
||||||
acc.add(String(tag).trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (post.data.tags && typeof post.data.tags === "string") {
|
|
||||||
// biome-ignore lint/complexity/noForEach: <explanation>
|
|
||||||
(post.data.tags as string)
|
|
||||||
.split(",")
|
|
||||||
.forEach((tag) => acc.add(tag.trim()));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, new Set());
|
|
||||||
|
|
||||||
const allTagsArray = Array.from(allTags);
|
|
||||||
|
|
||||||
// judge if the string is CJK
|
|
||||||
const isCJK = (str: string) =>
|
|
||||||
/[\u3000-\u9fff\uac00-\ud7af\u4e00-\u9faf]/.test(str);
|
|
||||||
|
|
||||||
const standardPaths = allTagsArray.map((tag) => ({
|
|
||||||
params: {
|
|
||||||
tag: encodePathSegment(tag),
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decodedTag: tag,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nonEncodedCJKPaths = allTagsArray.filter(isCJK).map((tag) => ({
|
|
||||||
params: {
|
|
||||||
tag: tag, // keep CJK characters unencoded
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decodedTag: tag,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [...standardPaths, ...nonEncodedCJKPaths];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { decodedTag } = Astro.props;
|
|
||||||
const tag = decodedTag || decodePathSegment(Astro.params.tag as string);
|
|
||||||
---
|
|
||||||
|
|
||||||
<MainGridLayout title={i18n(I18nKey.archive)} description={i18n(I18nKey.archive)}>
|
|
||||||
<ArchivePanel tags={[tag]}></ArchivePanel>
|
|
||||||
</MainGridLayout>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Utility functions for ensuring consistent URL encoding
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure consistent URL encoding across all tags and categories
|
|
||||||
*
|
|
||||||
* @param value The string to encode
|
|
||||||
* @returns The encoded string
|
|
||||||
*/
|
|
||||||
export function encodePathSegment(value: string): string {
|
|
||||||
if (!value) return "";
|
|
||||||
|
|
||||||
return encodeURIComponent(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode from the URL path
|
|
||||||
*
|
|
||||||
* @param value String to decode
|
|
||||||
* @returns Decoded string
|
|
||||||
*/
|
|
||||||
export function decodePathSegment(value: string): string {
|
|
||||||
if (!value) return "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(value);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to decode path segment: ${value}`, e);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import i18nKey from "@i18n/i18nKey";
|
|
||||||
import { i18n } from "@i18n/translation";
|
|
||||||
import { encodePathSegment } from "./encoding-utils";
|
|
||||||
|
|
||||||
export function pathsEqual(path1: string, path2: string) {
|
export function pathsEqual(path1: string, path2: string) {
|
||||||
const normalizedPath1 = path1.replace(/^\/|\/$/g, "").toLowerCase();
|
const normalizedPath1 = path1.replace(/^\/|\/$/g, "").toLowerCase();
|
||||||
const normalizedPath2 = path2.replace(/^\/|\/$/g, "").toLowerCase();
|
const normalizedPath2 = path2.replace(/^\/|\/$/g, "").toLowerCase();
|
||||||
@@ -18,26 +14,13 @@ export function getPostUrlBySlug(slug: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTagUrl(tag: string): string {
|
export function getTagUrl(tag: string): string {
|
||||||
if (!tag) return url("/archive/tag/");
|
if (!tag) return url("/archive/");
|
||||||
|
return url(`/archive/?tag=${encodeURIComponent(tag.trim())}`);
|
||||||
// use common encoding function
|
|
||||||
const encodedTag = encodePathSegment(tag);
|
|
||||||
const tagUrl = `/archive/tag/${encodedTag}/`;
|
|
||||||
console.log(`Generating URL for tag "${tag.trim()}" => "${tagUrl}"`);
|
|
||||||
return url(tagUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCategoryUrl(category: string): string {
|
export function getCategoryUrl(category: string): string {
|
||||||
console.log(`category: ${category}`);
|
if (!category) return url("/archive/");
|
||||||
if (!category) return url("/archive/category/");
|
return url(`/archive/?category=${encodeURIComponent(category.trim())}`);
|
||||||
|
|
||||||
const trimmedCategory = category.trim();
|
|
||||||
if (trimmedCategory === i18n(i18nKey.uncategorized))
|
|
||||||
return url("/archive/category/uncategorized/");
|
|
||||||
|
|
||||||
return url(
|
|
||||||
`/archive/category/${encodeURIComponent(trimmedCategory).replace(/%20/g, "+")}/`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDir(path: string): string {
|
export function getDir(path: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user