diff --git a/src/components/layout/SideBar.astro b/src/components/layout/SideBar.astro index 64644cfb27..2ab5fffd1c 100644 --- a/src/components/layout/SideBar.astro +++ b/src/components/layout/SideBar.astro @@ -4,6 +4,7 @@ import Advertisement from "@/components/widget/Advertisement.astro"; import Announcement from "@/components/widget/Announcement.astro"; import Calendar from "@/components/widget/Calendar.astro"; import Categories from "@/components/widget/Categories.astro"; +import LatestShuoshuo from "@/components/widget/LatestShuoshuo.astro"; import Music from "@/components/widget/Music.astro"; import Profile from "@/components/widget/Profile.astro"; import SidebarTOC from "@/components/widget/SidebarTOC.astro"; @@ -42,6 +43,7 @@ const ANIMATION_DELAY_UNIT = 50; // ms const componentMap = { profile: Profile, announcement: Announcement, + latestShuoshuo: LatestShuoshuo, categories: Categories, tags: Tags, sidebarToc: SidebarTOC, diff --git a/src/components/pages/shuoshuo/ShuoshuoCard.astro b/src/components/pages/shuoshuo/ShuoshuoCard.astro new file mode 100644 index 0000000000..26240080cd --- /dev/null +++ b/src/components/pages/shuoshuo/ShuoshuoCard.astro @@ -0,0 +1,118 @@ +--- +import ImageWrapper from "@/components/common/ImageWrapper.astro"; + +export interface Props { + author: string; + avatar: string; + relativeTime: string; + wordCount: number; + wordUnit: string; + maxCollapsedHeight?: number; + anchorId?: string; +} + +const { + author, + avatar, + relativeTime, + wordCount, + wordUnit, + maxCollapsedHeight = 260, + anchorId, +} = Astro.props; +--- + +
+
+ +
+
+
+
+ {author} +
+
+ {relativeTime} +
+
+
+ {wordCount} + {wordUnit} +
+
+ +
+
+
+ +
+
+
+ + +
+
+
+
+ + diff --git a/src/components/widget/LatestShuoshuo.astro b/src/components/widget/LatestShuoshuo.astro new file mode 100644 index 0000000000..05457c8306 --- /dev/null +++ b/src/components/widget/LatestShuoshuo.astro @@ -0,0 +1,77 @@ +--- +import { Icon } from "astro-icon/components"; +import WidgetLayout from "@/components/common/WidgetLayout.astro"; +import { profileConfig, siteConfig } from "@/config"; +import { getSortedShuoshuo } from "@/utils/shuoshuo-utils"; +import { + formatRelativeTime, + markdownToPlainText, + toDomId, +} from "@/utils/text-utils"; +import { url } from "@/utils/url-utils"; + +interface Props { + class?: string; + style?: string; + limit?: number; +} + +const className = Astro.props.class; +const style = Astro.props.style; +const limit = Astro.props.limit ?? 3; + +const isZh = (siteConfig.lang || "").startsWith("zh"); +const title = isZh ? "最新说说" : "Latest Shuoshuo"; + +const entries = (await getSortedShuoshuo()).slice(0, limit); +const items = entries.map((entry) => { + const author = entry.data.author || profileConfig.name; + const relativeTime = formatRelativeTime(entry.data.published); + const excerpt = entry.body ? markdownToPlainText(entry.body) : ""; + const anchorId = toDomId("shuoshuo", entry.id); + const href = url(`/shuoshuo/#${anchorId}`); + return { author, relativeTime, excerpt, href }; +}); +--- + + + + {isZh ? "更多" : "More"} + + + + {items.length > 0 ? ( +
+ {items.map((item) => ( + +
+
+ {item.author} +
+
+ {item.relativeTime} +
+
+
+ {item.excerpt} +
+
+ {isZh ? "查看全文" : "Read more"} +
+
+ ))} +
+ ) : ( +
+ {isZh ? "暂无说说" : "No shuoshuo yet"} +
+ )} +
diff --git a/src/components/widget/SiteStats.astro b/src/components/widget/SiteStats.astro index 0fafac65a5..d324beaf8c 100644 --- a/src/components/widget/SiteStats.astro +++ b/src/components/widget/SiteStats.astro @@ -10,6 +10,7 @@ import { getSortedPosts, getTagList, } from "@/utils/content-utils"; +import { getSortedShuoshuo } from "@/utils/shuoshuo-utils"; interface Props { class?: string; @@ -28,6 +29,7 @@ const siteStartDate = siteConfig.siteStartDate || "2025-01-01"; const posts = await getSortedPosts(); const categories = await getCategoryList(); const tags = await getTagList(); +const shuoshuo = await getSortedShuoshuo(); // 计算总字数 let totalWords = 0; @@ -66,7 +68,15 @@ const lastActivityDate = posts.reduce((latest, post) => { return latest; }, null); -const lastPostDate = lastActivityDate ? lastActivityDate.toISOString() : null; +const lastShuoshuoDate = shuoshuo[0]?.data.published ?? null; +const latestActivityDate = + lastActivityDate && lastShuoshuoDate + ? new Date(Math.max(lastActivityDate.getTime(), lastShuoshuoDate.getTime())) + : (lastActivityDate ?? lastShuoshuoDate); + +const lastPostDate = latestActivityDate + ? latestActivityDate.toISOString() + : null; const todayText = i18n(I18nKey.today); @@ -76,6 +86,11 @@ const stats = [ label: i18n(I18nKey.siteStatsPostCount), value: posts.length, }, + { + icon: "material-symbols:chat-bubble-outline", + label: i18n(I18nKey.siteStatsShuoshuoCount), + value: shuoshuo.length, + }, { icon: "material-symbols:folder-outline", label: i18n(I18nKey.siteStatsCategoryCount), diff --git a/src/config/navBarConfig.ts b/src/config/navBarConfig.ts index 5a02f92523..1988a0665d 100644 --- a/src/config/navBarConfig.ts +++ b/src/config/navBarConfig.ts @@ -14,6 +14,8 @@ const getDynamicNavBarConfig = (): NavBarConfig => { const links: NavBarLink[] = [ // 主页 LinkPresets.Home, + // 说说 + LinkPresets.Shuoshuo, ]; // 文章及其子菜单 @@ -135,6 +137,11 @@ export const LinkPresets: Record = { url: "/archive/", icon: "material-symbols:archive", }, + Shuoshuo: { + name: "说说", + url: "/shuoshuo/", + icon: "material-symbols:chat-bubble-outline", + }, Categories: { name: "分类", url: "/categories/", diff --git a/src/config/sidebarConfig.ts b/src/config/sidebarConfig.ts index 728355b4d5..742a2c1d03 100644 --- a/src/config/sidebarConfig.ts +++ b/src/config/sidebarConfig.ts @@ -44,6 +44,14 @@ export const sidebarLayoutConfig: SidebarLayoutConfig = { // 是否在文章详情页显示 showOnPostPage: true, }, + { + // 组件类型:最新说说组件 + type: "latestShuoshuo", + // 是否启用该组件 + enable: true, + // 组件位置 + position: "sticky", + }, { // 组件类型:公告组件 type: "announcement", diff --git a/src/content.config.ts b/src/content.config.ts index 014db7aac9..58e9cc0db7 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -36,7 +36,19 @@ const specCollection = defineCollection({ schema: z.object({}), }); +const shuoshuoCollection = defineCollection({ + loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/shuoshuo" }), + schema: z.object({ + published: z.date(), + updated: z.date().optional(), + draft: z.boolean().optional().default(false), + author: z.string().optional().default(""), + avatar: z.string().optional().default(""), + }), +}); + export const collections = { posts: postsCollection, spec: specCollection, + shuoshuo: shuoshuoCollection, }; diff --git a/src/content/shuoshuo/hello.md b/src/content/shuoshuo/hello.md new file mode 100644 index 0000000000..8ef63a0c54 --- /dev/null +++ b/src/content/shuoshuo/hello.md @@ -0,0 +1,8 @@ +--- +published: 2026-01-01 +author: Firefly +--- + +这是第一条说说示例。 + +说说适合记录简短动态、灵感片段和不需要写成完整文章的内容。 diff --git a/src/content/shuoshuo/long-note.md b/src/content/shuoshuo/long-note.md new file mode 100644 index 0000000000..01ae1380b2 --- /dev/null +++ b/src/content/shuoshuo/long-note.md @@ -0,0 +1,14 @@ +--- +published: 2026-01-02 +author: Firefly +--- + +这是一条较长的说说示例,用来展示内容折叠效果。 + +你可以在这里写多段文字、列表或普通 Markdown 内容。页面会在内容超过设定高度时自动折叠,并显示“展开全文”按钮。 + +- 支持 Markdown 基础语法 +- 自动生成锚点链接 +- 可在侧栏展示最新说说 + +继续补充一些文本,让示例更接近真实使用场景。说说并不需要像文章一样结构完整,它更像是一段即时记录,可以是今天的进展、一个想法,也可以是稍后再整理成文章的素材。 diff --git a/src/i18n/i18nKey.ts b/src/i18n/i18nKey.ts index 270af4daaa..2833ff15c0 100644 --- a/src/i18n/i18nKey.ts +++ b/src/i18n/i18nKey.ts @@ -298,6 +298,7 @@ enum I18nKey { // 站点统计 siteStats = "siteStats", siteStatsPostCount = "siteStatsPostCount", + siteStatsShuoshuoCount = "siteStatsShuoshuoCount", siteStatsCategoryCount = "siteStatsCategoryCount", siteStatsTagCount = "siteStatsTagCount", siteStatsTotalWords = "siteStatsTotalWords", diff --git a/src/i18n/languages/en.ts b/src/i18n/languages/en.ts index 2299687386..5be7a7dffd 100644 --- a/src/i18n/languages/en.ts +++ b/src/i18n/languages/en.ts @@ -311,6 +311,7 @@ export const en: Translation = { // Site Statistics [Key.siteStats]: "Site Statistics", [Key.siteStatsPostCount]: "Posts", + [Key.siteStatsShuoshuoCount]: "Shuoshuo", [Key.siteStatsCategoryCount]: "Categories", [Key.siteStatsTagCount]: "Tags", [Key.siteStatsTotalWords]: "Total Words", diff --git a/src/i18n/languages/ja.ts b/src/i18n/languages/ja.ts index ac28600fcf..d406f0bb84 100644 --- a/src/i18n/languages/ja.ts +++ b/src/i18n/languages/ja.ts @@ -310,6 +310,7 @@ export const ja: Translation = { // サイト統計 [Key.siteStats]: "サイト統計", [Key.siteStatsPostCount]: "記事", + [Key.siteStatsShuoshuoCount]: "つぶやき", [Key.siteStatsCategoryCount]: "カテゴリー", [Key.siteStatsTagCount]: "タグ", [Key.siteStatsTotalWords]: "総文字数", diff --git a/src/i18n/languages/ru.ts b/src/i18n/languages/ru.ts index 33b97653d2..6296e4f0ef 100644 --- a/src/i18n/languages/ru.ts +++ b/src/i18n/languages/ru.ts @@ -313,6 +313,7 @@ export const ru: Translation = { // Статистика сайта [Key.siteStats]: "Статистика сайта", [Key.siteStatsPostCount]: "Статьи", + [Key.siteStatsShuoshuoCount]: "Шоушо", [Key.siteStatsCategoryCount]: "Категории", [Key.siteStatsTagCount]: "Теги", [Key.siteStatsTotalWords]: "Всего слов", diff --git a/src/i18n/languages/zh_CN.ts b/src/i18n/languages/zh_CN.ts index 478f5aca2e..3067867808 100644 --- a/src/i18n/languages/zh_CN.ts +++ b/src/i18n/languages/zh_CN.ts @@ -302,6 +302,7 @@ export const zh_CN: Translation = { // 站点统计 [Key.siteStats]: "站点统计", [Key.siteStatsPostCount]: "文章", + [Key.siteStatsShuoshuoCount]: "说说", [Key.siteStatsCategoryCount]: "分类", [Key.siteStatsTagCount]: "标签", [Key.siteStatsTotalWords]: "总字数", diff --git a/src/i18n/languages/zh_TW.ts b/src/i18n/languages/zh_TW.ts index c638121161..ff966bce55 100644 --- a/src/i18n/languages/zh_TW.ts +++ b/src/i18n/languages/zh_TW.ts @@ -304,6 +304,7 @@ export const zh_TW: Translation = { // 站點統計 [Key.siteStats]: "站點統計", [Key.siteStatsPostCount]: "文章", + [Key.siteStatsShuoshuoCount]: "說說", [Key.siteStatsCategoryCount]: "分類", [Key.siteStatsTagCount]: "標籤", [Key.siteStatsTotalWords]: "總字數", diff --git a/src/pages/shuoshuo.astro b/src/pages/shuoshuo.astro new file mode 100644 index 0000000000..0999065755 --- /dev/null +++ b/src/pages/shuoshuo.astro @@ -0,0 +1,144 @@ +--- +import { render } from "astro:content"; +import { Icon } from "astro-icon/components"; +import ShuoshuoCard from "@/components/pages/shuoshuo/ShuoshuoCard.astro"; +import { profileConfig, siteConfig } from "@/config"; +import MainGridLayout from "@/layouts/MainGridLayout.astro"; +import { getSortedShuoshuo } from "@/utils/shuoshuo-utils"; +import { + countCjkAndEnglishWords, + formatRelativeTime, + toDomId, +} from "@/utils/text-utils"; + +const title = "说说"; +const description = "随便写点简短的口水话。"; + +const entries = await getSortedShuoshuo(); +const isZh = (siteConfig.lang || "").startsWith("zh"); +const wordUnit = isZh ? "字" : " words"; + +const items = await Promise.all( + entries.map(async (entry) => { + const { Content } = await render(entry); + + return { + author: entry.data.author || profileConfig.name, + avatar: + entry.data.avatar || + profileConfig.avatar || + "/assets/images/avatar.webp", + anchorId: toDomId("shuoshuo", entry.id), + relativeTime: formatRelativeTime(entry.data.published), + wordCount: entry.body ? countCjkAndEnglishWords(entry.body) : 0, + Content, + }; + }), +); +--- + + +
+
+
+
+
+ +
+

+ {title} +

+
+ {description && ( +

+ {description} +

+ )} +
+ + { + items.length > 0 ? ( +
+ {items.map(({ author, avatar, anchorId, relativeTime, wordCount, Content }) => ( + + + + ))} +
+ ) : ( + + ) + } +
+
+ + +
diff --git a/src/types/sidebarConfig.ts b/src/types/sidebarConfig.ts index 15bd9f65a1..d9f82552e2 100644 --- a/src/types/sidebarConfig.ts +++ b/src/types/sidebarConfig.ts @@ -2,6 +2,7 @@ export type WidgetComponentType = | "profile" | "announcement" + | "latestShuoshuo" | "categories" | "tags" | "sidebarToc" diff --git a/src/utils/shuoshuo-utils.ts b/src/utils/shuoshuo-utils.ts new file mode 100644 index 0000000000..6a544c8a08 --- /dev/null +++ b/src/utils/shuoshuo-utils.ts @@ -0,0 +1,13 @@ +import { getCollection } from "astro:content"; + +export async function getSortedShuoshuo() { + const all = await getCollection("shuoshuo", ({ data }) => { + return import.meta.env.PROD ? data.draft !== true : true; + }); + + return all.sort((a, b) => { + const dateA = a.data.published.getTime(); + const dateB = b.data.published.getTime(); + return dateB - dateA; + }); +} diff --git a/src/utils/text-utils.ts b/src/utils/text-utils.ts new file mode 100644 index 0000000000..0a33a79dfd --- /dev/null +++ b/src/utils/text-utils.ts @@ -0,0 +1,75 @@ +import { siteConfig } from "@/config"; + +const localeMap: Record = { + zh_CN: "zh-CN", + zh_TW: "zh-TW", + en: "en-US", + ja: "ja-JP", + ko: "ko-KR", + es: "es-ES", + th: "th-TH", + vi: "vi-VN", + tr: "tr-TR", + id: "id-ID", + fr: "fr-FR", + de: "de-DE", + ru: "ru-RU", + ar: "ar-SA", +}; + +function getLocale(): string { + const lang = siteConfig.lang || "en"; + return localeMap[lang] || "en-US"; +} + +export function countCjkAndEnglishWords(markdown: string): number { + const text = markdown.replace(/\s+/g, " ").trim(); + const cjkChars = + text.match( + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu, + ) || []; + const englishWords = text.match(/[a-zA-Z]+/g) || []; + return cjkChars.length + englishWords.length; +} + +export function markdownToPlainText(markdown: string): string { + let text = markdown; + + text = text.replace(/```[\s\S]*?```/g, " "); + text = text.replace(/!\[[^\]]*]\([^)]+\)/g, " "); + text = text.replace(/\[([^\]]+)]\([^)]+\)/g, "$1"); + text = text.replace(/`[^`]*`/g, " "); + text = text + .replace(/^#{1,6}\s+/gm, "") + .replace(/^>\s?/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, ""); + text = text.replace(/[*_~]+/g, ""); + + return text.replace(/\s+/g, " ").trim(); +} + +export function toDomId(prefix: string, raw: string): string { + const safe = raw + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return `${prefix}-${safe || "item"}`; +} + +export function formatRelativeTime(date: Date, now: Date = new Date()): string { + const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000); + const absSeconds = Math.abs(diffSeconds); + const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: "auto" }); + + if (absSeconds < 60) return rtf.format(diffSeconds, "second"); + if (absSeconds < 60 * 60) + return rtf.format(Math.round(diffSeconds / 60), "minute"); + if (absSeconds < 60 * 60 * 24) + return rtf.format(Math.round(diffSeconds / (60 * 60)), "hour"); + if (absSeconds < 60 * 60 * 24 * 30) + return rtf.format(Math.round(diffSeconds / (60 * 60 * 24)), "day"); + if (absSeconds < 60 * 60 * 24 * 365) + return rtf.format(Math.round(diffSeconds / (60 * 60 * 24 * 30)), "month"); + return rtf.format(Math.round(diffSeconds / (60 * 60 * 24 * 365)), "year"); +}