feat: Refactor service management scripts to use a unified dev script

- Added package.json to manage development scripts.
- Updated restart-services.ps1 to call the new dev script for starting services.
- Refactored start-admin.ps1, start-backend.ps1, start-frontend.ps1, and start-mcp.ps1 to utilize the dev script for starting respective services.
- Enhanced stop-services.ps1 to improve process termination logic by matching command patterns.
This commit is contained in:
2026-03-29 21:36:13 +08:00
parent 84f82c2a7e
commit 92a85eef20
137 changed files with 14181 additions and 2691 deletions

View File

@@ -6,7 +6,14 @@ import type {
Tag as UiTag,
} from '../types';
export const API_BASE_URL = 'http://localhost:5150/api';
const envApiBaseUrl = import.meta.env.PUBLIC_API_BASE_URL?.trim();
export const API_BASE_URL =
envApiBaseUrl && envApiBaseUrl.length > 0
? envApiBaseUrl.replace(/\/$/, '')
: import.meta.env.DEV
? 'http://127.0.0.1:5150/api'
: 'https://init.cool/api';
export interface ApiPost {
id: number;
@@ -18,6 +25,7 @@ export interface ApiPost {
tags: string[];
post_type: 'article' | 'tweet';
image: string | null;
images: string[] | null;
pinned: boolean;
created_at: string;
updated_at: string;
@@ -111,11 +119,22 @@ export interface ApiSiteSettings {
social_email: string | null;
location: string | null;
tech_stack: string[] | null;
music_playlist: Array<{
title: string;
artist?: string | null;
album?: string | null;
url: string;
cover_image_url?: string | null;
accent_color?: string | null;
description?: string | null;
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
}
export interface AiSource {
slug: string;
href: string;
title: string;
excerpt: string;
score: number;
@@ -152,10 +171,11 @@ export interface Review {
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
rating: number;
review_date: string;
status: 'completed' | 'in-progress' | 'dropped';
status: 'published' | 'draft' | 'completed' | 'in-progress' | 'dropped';
description: string;
tags: string;
cover: string;
link_url: string | null;
created_at: string;
updated_at: string;
}
@@ -168,24 +188,59 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
id: '1',
siteName: 'InitCool',
siteShortName: 'Termi',
siteUrl: 'https://termi.dev',
siteUrl: 'https://init.cool',
siteTitle: 'InitCool - 终端风格的内容平台',
siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。',
heroTitle: '欢迎来到我的极客终端博客',
heroSubtitle: '这里记录技术、代码和生活点滴',
ownerName: 'InitCool',
ownerTitle: '前端开发者 / 技术博主',
ownerBio: '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。',
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
location: 'Hong Kong',
social: {
github: 'https://github.com',
twitter: 'https://twitter.com',
email: 'mailto:hello@termi.dev',
github: 'https://github.com/limitcool',
twitter: '',
email: 'mailto:initcoool@gmail.com',
},
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
musicPlaylist: [
{
title: '山中来信',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
coverImageUrl:
'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80',
accentColor: '#2f6b5f',
description: '适合文章阅读时循环播放的轻氛围曲。',
},
{
title: '风吹松声',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
coverImageUrl:
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80',
accentColor: '#8a5b35',
description: '偏木质感的器乐氛围,适合深夜浏览。',
},
{
title: '夜航小记',
artist: 'InitCool Radio',
album: '站点默认歌单',
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
coverImageUrl:
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80',
accentColor: '#375a7f',
description: '节奏更明显一点,适合切换阅读状态。',
},
],
ai: {
enabled: false,
},
comments: {
paragraphsEnabled: true,
},
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
@@ -208,6 +263,7 @@ const normalizePost = (post: ApiPost): UiPost => ({
tags: post.tags ?? [],
category: post.category,
image: post.image ?? undefined,
images: post.images ?? undefined,
pinned: post.pinned,
});
@@ -277,9 +333,26 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
musicPlaylist:
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
? settings.music_playlist
.filter((item) => item.title.trim() && item.url.trim())
.map((item) => ({
title: item.title,
artist: item.artist ?? undefined,
album: item.album ?? undefined,
url: item.url,
coverImageUrl: item.cover_image_url ?? undefined,
accentColor: item.accent_color ?? undefined,
description: item.description ?? undefined,
}))
: DEFAULT_SITE_SETTINGS.musicPlaylist,
ai: {
enabled: Boolean(settings.ai_enabled),
},
comments: {
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
},
});
class ApiClient {
@@ -450,6 +523,7 @@ class ApiClient {
tags: result.tags ?? [],
post_type: result.post_type || 'article',
image: result.image,
images: null,
pinned: result.pinned ?? false,
created_at: result.created_at,
updated_at: result.updated_at,

View File

@@ -140,11 +140,11 @@ I N N I T CCCC OOO OOO LLLLL`,
],
search: {
placeholders: {
default: "'关键词' articles/*.md",
default: "'关键词' 文章 / 标签 / 分类",
small: "搜索...",
medium: "搜索文章..."
},
promptText: "grep -i",
promptText: "搜索",
emptyResultText: "输入关键词搜索文章"
},
terminal: {

View File

@@ -66,12 +66,7 @@ export function resolveLocale(options: {
return fromCookie;
}
const acceptLanguages = String(options.acceptLanguage || '')
.split(',')
.map((part) => normalizeLocale(part.split(';')[0]))
.filter(Boolean) as Locale[];
return acceptLanguages[0] || DEFAULT_LOCALE;
return DEFAULT_LOCALE;
}
export function translate(locale: Locale, key: string, params?: TranslateParams): string {

View File

@@ -4,7 +4,7 @@ export const messages = {
language: '语言',
languages: {
'zh-CN': '简体中文',
en: 'English',
en: '英文',
},
all: '全部',
search: '搜索',
@@ -63,6 +63,7 @@ export const messages = {
featureOff: '功能未开启',
emptyState: '当前还没有内容。',
apiUnavailable: 'API 暂时不可用',
unknownError: '未知错误',
},
nav: {
articles: '文章',
@@ -77,19 +78,31 @@ export const messages = {
header: {
navigation: '导航',
themeToggle: '切换主题',
themePanelTitle: '外观模式',
themeLight: '浅色',
themeDark: '深色',
themeSystem: '跟随系统',
themeLightHint: '始终使用亮色界面',
themeDarkHint: '始终使用暗色界面',
themeSystemHint: '跟随设备当前主题',
themeResolvedAs: '当前生效:{mode}',
toggleMenu: '切换菜单',
searchModeKeyword: '搜索',
searchModeAi: 'AI',
searchModeKeywordMobile: '关键词搜索',
searchModeAiMobile: 'AI 搜索',
shellLabel: '站点终端',
musicPanel: '播放控制',
searchPromptKeyword: '站内搜索',
searchPromptAi: 'AI 问答',
searchPlaceholderKeyword: "'关键词'",
searchPlaceholderAi: '输入问题,交给站内 AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: '手动确认',
searchHintKeyword: '文章 / 标签 / 分类',
searchHintAi: '前往问答页',
aiModeTitle: 'AI 问答模式',
aiModeHeading: '把这个问题交给站内 AI',
aiModeDescription: 'AI 会先检索站内知识库,再给出总结式回答,并附带相关文章来源。',
aiModeNotice: '进入问答页后不会自动调用模型,需要你手动确认发送。',
aiModeDescription: '在问答页输入问题后,系统会优先参考站内内容并给出整理后的回答。',
aiModeNotice: '回答会附带相关文章,方便继续阅读。',
aiModeCta: '前往 AI 问答页确认',
liveResults: '实时搜索结果',
searching: '正在搜索 {query} ...',
@@ -106,12 +119,22 @@ export const messages = {
copyright: '© {year} {site}. 保留所有权利。',
sitemap: '站点地图',
rss: 'RSS 订阅',
summary: '持续整理文章、记录与站内阅读入口。',
},
home: {
pinned: '置顶',
quickJump: '快速跳转',
about: '关于我',
techStack: '技术栈',
systemStatus: '系统状态',
promptWelcome: 'pwd',
promptDiscoverDefault: "find ./posts -type f | sort",
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
promptPostsDefault: "find ./posts -type f | head -n {count}",
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
promptFriends: "find ./links -maxdepth 1 -type f | sort",
promptAbout: "sed -n '1,80p' ~/profile.md",
},
articlesPage: {
title: '文章索引',
@@ -131,16 +154,20 @@ export const messages = {
filePath: '文件路径',
},
relatedPosts: {
kicker: '关联轨迹',
title: '相关文章',
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
linked: '{count} 条关联',
},
comments: {
title: '评论终端',
kicker: '讨论缓冲区',
description: '这里是整篇文章的讨论区,当前缓冲区共有 {count} 条已展示评论,新的留言提交后会进入审核队列。',
writeComment: '写评论',
nickname: '昵称',
nicknamePlaceholder: '山客',
email: '邮箱',
emailPlaceholder: 'name@example.com',
message: '内容',
messagePlaceholder: "$ echo '留下你的想法...'",
maxChars: '最多 500 字',
@@ -160,15 +187,19 @@ export const messages = {
},
paragraphComments: {
title: '段落评论已启用',
kicker: '段落批注',
intro: '正文里的自然段都会挂一个轻量讨论入口,适合只针对某一段补充上下文、指出问题或继续展开讨论。',
scanning: '正在扫描段落缓冲区...',
noParagraphs: '当前文章没有可挂载评论的自然段。',
summary: '已为 {paragraphCount} 个自然段挂载评论入口,其中 {discussedCount} 段已有讨论,当前共展示 {approvedCount} 条已审核段落评论。',
focusCurrent: '聚焦当前段落',
panelTitle: '段落讨论面板',
panelKicker: '段落讨论线程',
close: '关闭',
nickname: '昵称',
nicknamePlaceholder: '林泉',
email: '邮箱',
emailPlaceholder: 'name@example.com',
comment: '评论',
commentPlaceholder: "$ echo '只评论这一段...'",
maxChars: '最多 500 字',
@@ -192,22 +223,29 @@ export const messages = {
zeroNotes: '评论',
waitingReview: '等待审核',
locateParagraph: '定位段落',
showMarkers: '显示段落评论',
hideMarkers: '隐藏段落评论',
markersHidden: '段落评论入口已隐藏,你仍然可以随时重新打开。',
badgeLabel: '打开这一段的评论面板',
},
ask: {
pageTitle: 'AI 问答',
pageDescription: '基于 {siteName} 内容知识库的站内 AI 问答',
pageDescription: '{siteName} 的站内 AI 问答入口',
title: 'AI 站内问答',
subtitle: '基于博客 Markdown 内容建立索引,回答会优先引用站内真实资料。',
subtitle: '围绕本站内容回答问题,并附上可继续阅读的相关文章。',
terminalLabel: '问答助手',
assistantLabel: '回答输出',
disabledStateLabel: '功能已关闭',
disabledTitle: '后台暂未开启 AI 问答',
disabledDescription: '这个入口已经接好了真实后端,但当前站点设置里没有开启公开问答。管理员开启后,这里会自动变成可用状态,导航也会同步显示。',
textareaPlaceholder: '输入你想问的问题,比如:这个博客关于前端写过哪些内容?',
submit: '开始提问',
idleStatus: '知识库已接入,等待问题输入。',
idleStatus: '可以直接输入问题开始提问。',
examples: '示例问题',
workflow: '工作流',
workflow1: '1. 后台开启 AI 开关并配置聊天模型。',
workflow2: '2. 重建索引,把 Markdown 文章切块后由后端本地生成 embedding并写入 PostgreSQL pgvector。',
workflow3: '3. 前台提问时先在 pgvector 中做相似度检索,再交给聊天模型基于上下文回答。',
guide: '提问建议',
guide1: '1. 直接问主题、文章、观点或站内某类内容。',
guide2: '2. 回答会优先结合本站已有内容,并给出可继续阅读的文章。',
guide3: '3. 如果问题太宽泛,换成更具体的关键词通常会更准确。',
emptyAnswer: '暂无回答。',
requestFailed: '请求失败:{message}',
streamUnsupported: '当前浏览器无法读取流式响应。',
@@ -220,7 +258,15 @@ export const messages = {
streamInterrupted: '流式响应被提前中断。',
retryLater: '这次请求没有成功,可以稍后重试。',
prefixedQuestion: '已带入搜索词,确认后开始提问。',
promptIdle: 'cat > question.txt',
promptEditing: "sed -n '1,12p' question.txt",
promptSubmitting: 'tail -f answer.stream',
promptComplete: "printf 'sources=%s\\n' {count}",
promptFailed: "echo 'retry'",
sources: '来源',
sourceScore: '相关度 {score}',
metaSources: '{count} 篇相关文章',
metaSourcesWithTime: '{count} 篇相关文章 · 更新于 {time}',
},
about: {
pageTitle: '关于',
@@ -236,8 +282,11 @@ export const messages = {
title: '文章分类',
intro: '按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。',
quickJump: '快速跳转分类文章',
allCategoriesDescription: '查看全部分类下的文章与更新记录。',
categoryPosts: '浏览 {name} 主题下的全部文章和更新记录。',
selectedSummary: '{name} 分类下找到 {count} 篇文章',
empty: '暂无分类数据',
emptyPosts: '当前分类下没有文章',
},
friends: {
pageTitle: '友情链接',
@@ -254,6 +303,9 @@ export const messages = {
name: '名称',
description: '描述',
link: '链接',
promptBrowse: "find ./links -maxdepth 1 -type f | sort",
promptApply: 'cat > friend-link.txt',
promptRules: "sed -n '1,120p' rules.md",
},
friendForm: {
title: '提交友链申请',
@@ -312,6 +364,9 @@ export const messages = {
emptyData: '暂无评价数据,请检查后端 API 连接',
emptyFiltered: '当前筛选下暂无评价',
currentFilter: '当前筛选: {type}',
statusCompleted: '已完成',
statusInProgress: '进行中',
statusDropped: '已弃置',
typeAll: '全部',
typeGame: '游戏',
typeAnime: '动画',
@@ -330,7 +385,7 @@ export const messages = {
time: '时间',
actions: '可执行操作',
actionsIntro: '像命令面板一样,优先给出直接可走的恢复路径。',
searchHint: '也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。',
searchHint: '也可以直接使用顶部的搜索输入框,重新搜索相关文章。',
recommended: '推荐入口',
recommendedIntro: '使用真实文章数据,避免 404 页面再把人带进不存在的地址。',
cannotLoad: '暂时无法读取文章列表。',
@@ -409,6 +464,7 @@ export const messages = {
featureOff: 'Feature off',
emptyState: 'Nothing here yet.',
apiUnavailable: 'API temporarily unavailable',
unknownError: 'unknown error',
},
nav: {
articles: 'Articles',
@@ -423,19 +479,31 @@ export const messages = {
header: {
navigation: 'Navigation',
themeToggle: 'Toggle theme',
themePanelTitle: 'Appearance',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
themeLightHint: 'Always use the light interface',
themeDarkHint: 'Always use the dark interface',
themeSystemHint: 'Follow the device appearance',
themeResolvedAs: 'Currently applied: {mode}',
toggleMenu: 'Toggle menu',
searchModeKeyword: 'Search',
searchModeAi: 'AI',
searchModeKeywordMobile: 'Keyword Search',
searchModeAiMobile: 'AI Search',
shellLabel: 'Site Terminal',
musicPanel: 'Playback',
searchPromptKeyword: 'Site Search',
searchPromptAi: 'Ask AI',
searchPlaceholderKeyword: "'keyword'",
searchPlaceholderAi: 'Type a question for the site AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: 'manual confirm',
searchHintKeyword: 'posts / tags / categories',
searchHintAi: 'open AI Q&A',
aiModeTitle: 'AI Q&A mode',
aiModeHeading: 'Send this question to the site AI',
aiModeDescription: 'The AI will search the site knowledge base first, then answer with source-backed summaries.',
aiModeNotice: 'The model will not run automatically after navigation. You must confirm manually.',
aiModeDescription: 'Ask on the Q&A page and the system will answer with priority given to on-site content.',
aiModeNotice: 'Answers include related articles so visitors can keep reading.',
aiModeCta: 'Open AI Q&A to confirm',
liveResults: 'Live results',
searching: 'Searching {query} ...',
@@ -452,12 +520,22 @@ export const messages = {
copyright: '© {year} {site}. All rights reserved.',
sitemap: 'Sitemap',
rss: 'RSS feed',
summary: 'A place for posts, notes, and on-site reading paths.',
},
home: {
pinned: 'Pinned',
quickJump: 'Quick jump',
about: 'About',
techStack: 'Tech stack',
systemStatus: 'System status',
promptWelcome: 'pwd',
promptDiscoverDefault: "find ./posts -type f | sort",
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
promptPostsDefault: "find ./posts -type f | head -n {count}",
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
promptFriends: "find ./links -maxdepth 1 -type f | sort",
promptAbout: "sed -n '1,80p' ~/profile.md",
},
articlesPage: {
title: 'Article Index',
@@ -477,16 +555,20 @@ export const messages = {
filePath: 'File path',
},
relatedPosts: {
kicker: 'Related traces',
title: 'Related Posts',
description: 'More nearby reading paths based on the current category and shared tags.',
linked: '{count} linked',
},
comments: {
title: 'Comment Terminal',
kicker: 'Discussion Buffer',
description: 'This is the discussion thread for the whole article. {count} approved comments are shown right now, and new messages enter moderation first.',
writeComment: 'Write comment',
nickname: 'Nickname',
nicknamePlaceholder: 'trail_reader',
email: 'Email',
emailPlaceholder: 'you@example.com',
message: 'Message',
messagePlaceholder: "$ echo 'Leave your thoughts here...'",
maxChars: 'Max 500 chars',
@@ -506,15 +588,19 @@ export const messages = {
},
paragraphComments: {
title: 'Paragraph comments are enabled',
kicker: 'Paragraph Notes',
intro: 'Each natural paragraph in the article gets a lightweight discussion entry point, perfect for focused context, corrections, or follow-up questions.',
scanning: 'Scanning paragraph buffer...',
noParagraphs: 'No commentable paragraphs were found in this article.',
summary: '{paragraphCount} paragraphs have comment entries, {discussedCount} already have discussion, and {approvedCount} approved paragraph comments are currently visible.',
focusCurrent: 'Focus current paragraph',
panelTitle: 'Paragraph discussion panel',
panelKicker: 'Paragraph thread',
close: 'Close',
nickname: 'Nickname',
nicknamePlaceholder: 'inline_reader',
email: 'Email',
emailPlaceholder: 'you@example.com',
comment: 'Comment',
commentPlaceholder: "$ echo 'Comment on this paragraph only...'",
maxChars: 'Max 500 chars',
@@ -538,22 +624,29 @@ export const messages = {
zeroNotes: 'comment',
waitingReview: 'waiting review',
locateParagraph: 'Locate paragraph',
showMarkers: 'Show paragraph comments',
hideMarkers: 'Hide paragraph comments',
markersHidden: 'Paragraph comment markers are hidden. You can turn them back on anytime.',
badgeLabel: 'Open comments for this paragraph',
},
ask: {
pageTitle: 'Ask AI',
pageDescription: 'An on-site AI Q&A experience grounded in the {siteName} knowledge base',
pageDescription: 'An on-site AI Q&A entry for {siteName}',
title: 'On-site AI Q&A',
subtitle: 'Answers are grounded in indexed Markdown content from the blog and prioritize real on-site references.',
subtitle: 'Ask about the site and get answers with related articles attached for follow-up reading.',
terminalLabel: 'Q&A Assistant',
assistantLabel: 'Assistant Output',
disabledStateLabel: 'Feature Disabled',
disabledTitle: 'AI Q&A is not enabled yet',
disabledDescription: 'The real backend integration is already in place, but public Q&A is still disabled in site settings. Once it is enabled, this page and the navigation entry will become available automatically.',
textareaPlaceholder: 'Ask anything, for example: what has this blog written about frontend topics?',
submit: 'Ask now',
idleStatus: 'Knowledge base connected. Waiting for a question.',
idleStatus: 'Type a question to get started.',
examples: 'Example questions',
workflow: 'Workflow',
workflow1: '1. Enable the AI switch in the admin and configure the chat model.',
workflow2: '2. Rebuild the index so Markdown content is chunked, embedded locally by the backend, and written into PostgreSQL pgvector.',
workflow3: '3. Each user question retrieves similar chunks from pgvector first, then the chat model answers with that context.',
guide: 'Asking tips',
guide1: '1. Ask directly about topics, posts, viewpoints, or recurring themes on the site.',
guide2: '2. Answers prioritize on-site material and include related reading when available.',
guide3: '3. If the answer feels broad, try a more specific keyword or article topic.',
emptyAnswer: 'No answer yet.',
requestFailed: 'Request failed: {message}',
streamUnsupported: 'This browser cannot read streaming responses.',
@@ -566,7 +659,15 @@ export const messages = {
streamInterrupted: 'The streaming response ended early.',
retryLater: 'This request did not complete successfully. Please try again later.',
prefixedQuestion: 'The search query has been prefilled. Confirm manually to ask AI.',
promptIdle: 'cat > question.txt',
promptEditing: "sed -n '1,12p' question.txt",
promptSubmitting: 'tail -f answer.stream',
promptComplete: "printf 'sources=%s\\n' {count}",
promptFailed: "echo 'retry'",
sources: 'Sources',
sourceScore: 'Score {score}',
metaSources: '{count} related articles',
metaSourcesWithTime: '{count} related articles · updated {time}',
},
about: {
pageTitle: 'About',
@@ -582,8 +683,11 @@ export const messages = {
title: 'Categories',
intro: 'Browse posts by topic. This page now follows the same terminal language as the other list views.',
quickJump: 'Jump straight into category posts',
allCategoriesDescription: 'Browse posts and updates from every category.',
categoryPosts: 'Browse all posts and updates under {name}.',
selectedSummary: '{count} posts in {name}',
empty: 'No category data yet',
emptyPosts: 'No posts found in this category',
},
friends: {
pageTitle: 'Links',
@@ -600,6 +704,9 @@ export const messages = {
name: 'Name',
description: 'Description',
link: 'Link',
promptBrowse: "find ./links -maxdepth 1 -type f | sort",
promptApply: 'cat > friend-link.txt',
promptRules: "sed -n '1,120p' rules.md",
},
friendForm: {
title: 'Submit a link request',
@@ -658,6 +765,9 @@ export const messages = {
emptyData: 'No review data yet. Please check the backend API connection.',
emptyFiltered: 'No reviews match the current filter',
currentFilter: 'Current filter: {type}',
statusCompleted: 'Completed',
statusInProgress: 'In progress',
statusDropped: 'Dropped',
typeAll: 'All',
typeGame: 'Games',
typeAnime: 'Anime',
@@ -676,7 +786,7 @@ export const messages = {
time: 'time',
actions: 'Actions',
actionsIntro: 'Like a command palette, this page surfaces the most direct recovery paths first.',
searchHint: 'You can also use the search box in the header and grep through `articles/*.md` again.',
searchHint: 'You can also use the search box in the header to search related posts again.',
recommended: 'Recommended entries',
recommendedIntro: 'These use real article data so the 404 page does not send people into more dead ends.',
cannotLoad: 'Unable to load the article list right now.',

View File

@@ -61,9 +61,23 @@ export interface SiteSettings {
email?: string;
};
techStack: string[];
musicPlaylist: MusicTrack[];
ai: {
enabled: boolean;
};
comments: {
paragraphsEnabled: boolean;
};
}
export interface MusicTrack {
title: string;
artist?: string;
album?: string;
url: string;
coverImageUrl?: string;
accentColor?: string;
description?: string;
}
export interface SiteConfig {

View File

@@ -68,6 +68,147 @@ export function debounce<T extends (...args: unknown[]) => unknown>(
};
}
export interface AccentTheme {
color: string;
rgb: string;
}
const POST_TYPE_THEMES: Record<string, AccentTheme> = {
article: {
color: '#2563eb',
rgb: '37 99 235',
},
tweet: {
color: '#f97316',
rgb: '249 115 22',
},
};
const DEFAULT_THEME: AccentTheme = {
color: '#64748b',
rgb: '100 116 139',
};
function normalizeToken(value: string | null | undefined): string {
return value?.trim().toLowerCase() || '';
}
function hashToken(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hexToRgbTriplet(hex: string): string {
const normalized = hex.replace('#', '');
const safeHex = normalized.length === 3
? normalized.split('').map((char) => `${char}${char}`).join('')
: normalized;
const value = parseInt(safeHex, 16);
return `${(value >> 16) & 255} ${(value >> 8) & 255} ${value & 255}`;
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const normalizedHue = ((hue % 360) + 360) % 360;
const s = saturation / 100;
const l = lightness / 100;
const chroma = (1 - Math.abs(2 * l - 1)) * s;
const section = normalizedHue / 60;
const x = chroma * (1 - Math.abs((section % 2) - 1));
let red = 0;
let green = 0;
let blue = 0;
if (section >= 0 && section < 1) {
red = chroma;
green = x;
} else if (section < 2) {
red = x;
green = chroma;
} else if (section < 3) {
green = chroma;
blue = x;
} else if (section < 4) {
green = x;
blue = chroma;
} else if (section < 5) {
red = x;
blue = chroma;
} else {
red = chroma;
blue = x;
}
const match = l - chroma / 2;
const toHex = (value: number) => Math.round((value + match) * 255).toString(16).padStart(2, '0');
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
function getGeneratedTheme(
value: string | null | undefined,
{
salt,
saturation,
lightness,
}: {
salt: string;
saturation: number;
lightness: number;
}
): AccentTheme {
const normalized = normalizeToken(value);
if (!normalized) {
return DEFAULT_THEME;
}
const hue = hashToken(`${salt}:${normalized}`) % 360;
const color = hslToHex(hue, saturation, lightness);
return {
color,
rgb: hexToRgbTriplet(color),
};
}
export function getAccentVars(theme: AccentTheme): string {
return [
`--accent-color:${theme.color}`,
`--accent-rgb:${theme.rgb}`,
`--pill-fg:${theme.color}`,
`--pill-rgb:${theme.rgb}`,
`--tile-rgb:${theme.rgb}`,
].join(';') + ';';
}
export function getPostTypeTheme(type: string | null | undefined): AccentTheme {
return POST_TYPE_THEMES[normalizeToken(type)] || DEFAULT_THEME;
}
export function getCategoryTheme(category: string | null | undefined): AccentTheme {
return getGeneratedTheme(category, {
salt: 'category',
saturation: 72,
lightness: 46,
});
}
export function getTagTheme(tag: string | null | undefined): AccentTheme {
return getGeneratedTheme(tag, {
salt: 'tag',
saturation: 68,
lightness: 50,
});
}
/**
* Filter posts by type and tag
*/
@@ -90,7 +231,7 @@ export function filterPosts(
* Get color for post type
*/
export function getPostTypeColor(type: string): string {
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
return getPostTypeTheme(type).color;
}
export {