chore: reorganize project into monorepo
This commit is contained in:
404
frontend/src/lib/api/client.ts
Normal file
404
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import type {
|
||||
Category as UiCategory,
|
||||
FriendLink as UiFriendLink,
|
||||
Post as UiPost,
|
||||
SiteSettings,
|
||||
Tag as UiTag,
|
||||
} from '../types';
|
||||
|
||||
export const API_BASE_URL = 'http://localhost:5150/api';
|
||||
|
||||
export interface ApiPost {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
post_type: 'article' | 'tweet';
|
||||
image: string | null;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
post_id: string | null;
|
||||
post_slug: string | null;
|
||||
author: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
content: string | null;
|
||||
reply_to: string | null;
|
||||
approved: boolean | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentInput {
|
||||
postSlug: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
content: string;
|
||||
replyTo?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ApiCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ApiFriendLink {
|
||||
id: number;
|
||||
site_name: string;
|
||||
site_url: string;
|
||||
avatar_url: string | null;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateFriendLinkInput {
|
||||
siteName: string;
|
||||
siteUrl: string;
|
||||
avatarUrl?: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface ApiSiteSettings {
|
||||
id: number;
|
||||
site_name: string | null;
|
||||
site_short_name: string | null;
|
||||
site_url: string | null;
|
||||
site_title: string | null;
|
||||
site_description: string | null;
|
||||
hero_title: string | null;
|
||||
hero_subtitle: string | null;
|
||||
owner_name: string | null;
|
||||
owner_title: string | null;
|
||||
owner_bio: string | null;
|
||||
owner_avatar_url: string | null;
|
||||
social_github: string | null;
|
||||
social_twitter: string | null;
|
||||
social_email: string | null;
|
||||
location: string | null;
|
||||
tech_stack: string[] | null;
|
||||
}
|
||||
|
||||
export interface ApiSearchResult {
|
||||
id: number;
|
||||
title: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
content: string | null;
|
||||
category: string | null;
|
||||
tags: string[] | null;
|
||||
post_type: 'article' | 'tweet' | null;
|
||||
image: string | null;
|
||||
pinned: boolean | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: number;
|
||||
title: string;
|
||||
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
|
||||
rating: number;
|
||||
review_date: string;
|
||||
status: 'completed' | 'in-progress' | 'dropped';
|
||||
description: string;
|
||||
tags: string;
|
||||
cover: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type AppFriendLink = UiFriendLink & {
|
||||
status: ApiFriendLink['status'];
|
||||
};
|
||||
|
||||
export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
id: '1',
|
||||
siteName: 'InitCool',
|
||||
siteShortName: 'Termi',
|
||||
siteUrl: 'https://termi.dev',
|
||||
siteTitle: 'InitCool - 终端风格的内容平台',
|
||||
siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。',
|
||||
heroTitle: '欢迎来到我的极客终端博客',
|
||||
heroSubtitle: '这里记录技术、代码和生活点滴',
|
||||
ownerName: 'InitCool',
|
||||
ownerTitle: '前端开发者 / 技术博主',
|
||||
ownerBio: '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。',
|
||||
location: 'Hong Kong',
|
||||
social: {
|
||||
github: 'https://github.com',
|
||||
twitter: 'https://twitter.com',
|
||||
email: 'mailto:hello@termi.dev',
|
||||
},
|
||||
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
|
||||
};
|
||||
|
||||
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
||||
|
||||
const estimateReadTime = (content: string | null | undefined) => {
|
||||
const text = content?.trim() || '';
|
||||
const minutes = Math.max(1, Math.ceil(text.length / 300));
|
||||
return `${minutes} 分钟`;
|
||||
};
|
||||
|
||||
const normalizePost = (post: ApiPost): UiPost => ({
|
||||
id: String(post.id),
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
content: post.content,
|
||||
date: formatPostDate(post.created_at),
|
||||
readTime: estimateReadTime(post.content || post.description),
|
||||
type: post.post_type,
|
||||
tags: post.tags ?? [],
|
||||
category: post.category,
|
||||
image: post.image ?? undefined,
|
||||
pinned: post.pinned,
|
||||
});
|
||||
|
||||
const normalizeTag = (tag: ApiTag): UiTag => ({
|
||||
id: String(tag.id),
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
});
|
||||
|
||||
const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||
id: String(category.id),
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
count: category.count,
|
||||
});
|
||||
|
||||
const normalizeAvatarUrl = (value: string | null | undefined) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(value).hostname.toLowerCase();
|
||||
const isReservedExampleHost =
|
||||
host === 'example.com' ||
|
||||
host === 'example.org' ||
|
||||
host === 'example.net' ||
|
||||
host.endsWith('.example.com') ||
|
||||
host.endsWith('.example.org') ||
|
||||
host.endsWith('.example.net');
|
||||
|
||||
return isReservedExampleHost ? undefined : value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeTagToken = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
|
||||
id: String(friendLink.id),
|
||||
name: friendLink.site_name,
|
||||
url: friendLink.site_url,
|
||||
avatar: normalizeAvatarUrl(friendLink.avatar_url),
|
||||
description: friendLink.description ?? undefined,
|
||||
category: friendLink.category ?? undefined,
|
||||
status: friendLink.status,
|
||||
});
|
||||
|
||||
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
id: String(settings.id),
|
||||
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
|
||||
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
|
||||
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
|
||||
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
|
||||
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
|
||||
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
|
||||
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
|
||||
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
|
||||
ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
|
||||
ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
|
||||
ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
|
||||
location: settings.location || DEFAULT_SITE_SETTINGS.location,
|
||||
social: {
|
||||
github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
|
||||
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
|
||||
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
|
||||
},
|
||||
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
|
||||
});
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async getRawPosts(): Promise<ApiPost[]> {
|
||||
return this.fetch<ApiPost[]>('/posts');
|
||||
}
|
||||
|
||||
async getPosts(): Promise<UiPost[]> {
|
||||
const posts = await this.getRawPosts();
|
||||
return posts.map(normalizePost);
|
||||
}
|
||||
|
||||
async getPost(id: number): Promise<UiPost> {
|
||||
const post = await this.fetch<ApiPost>(`/posts/${id}`);
|
||||
return normalizePost(post);
|
||||
}
|
||||
|
||||
async getPostBySlug(slug: string): Promise<UiPost | null> {
|
||||
const posts = await this.getPosts();
|
||||
return posts.find(post => post.slug === slug) || null;
|
||||
}
|
||||
|
||||
async getComments(postSlug: string, options?: { approved?: boolean }): Promise<Comment[]> {
|
||||
const params = new URLSearchParams({ post_slug: postSlug });
|
||||
if (options?.approved !== undefined) {
|
||||
params.set('approved', String(options.approved));
|
||||
}
|
||||
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
|
||||
}
|
||||
|
||||
async createComment(comment: CreateCommentInput): Promise<Comment> {
|
||||
return this.fetch<Comment>('/comments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
postSlug: comment.postSlug,
|
||||
nickname: comment.nickname,
|
||||
email: comment.email,
|
||||
content: comment.content,
|
||||
replyTo: comment.replyTo,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getReviews(): Promise<Review[]> {
|
||||
return this.fetch<Review[]>('/reviews');
|
||||
}
|
||||
|
||||
async getReview(id: number): Promise<Review> {
|
||||
return this.fetch<Review>(`/reviews/${id}`);
|
||||
}
|
||||
|
||||
async getRawFriendLinks(): Promise<ApiFriendLink[]> {
|
||||
return this.fetch<ApiFriendLink[]>('/friend_links');
|
||||
}
|
||||
|
||||
async getFriendLinks(): Promise<AppFriendLink[]> {
|
||||
const friendLinks = await this.getRawFriendLinks();
|
||||
return friendLinks.map(normalizeFriendLink);
|
||||
}
|
||||
|
||||
async createFriendLink(friendLink: CreateFriendLinkInput): Promise<ApiFriendLink> {
|
||||
return this.fetch<ApiFriendLink>('/friend_links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(friendLink),
|
||||
});
|
||||
}
|
||||
|
||||
async getRawTags(): Promise<ApiTag[]> {
|
||||
return this.fetch<ApiTag[]>('/tags');
|
||||
}
|
||||
|
||||
async getTags(): Promise<UiTag[]> {
|
||||
const tags = await this.getRawTags();
|
||||
return tags.map(normalizeTag);
|
||||
}
|
||||
|
||||
async getRawSiteSettings(): Promise<ApiSiteSettings> {
|
||||
return this.fetch<ApiSiteSettings>('/site_settings');
|
||||
}
|
||||
|
||||
async getSiteSettings(): Promise<SiteSettings> {
|
||||
const settings = await this.getRawSiteSettings();
|
||||
return normalizeSiteSettings(settings);
|
||||
}
|
||||
|
||||
async getCategories(): Promise<UiCategory[]> {
|
||||
const categories = await this.fetch<ApiCategory[]>('/categories');
|
||||
return categories.map(normalizeCategory);
|
||||
}
|
||||
|
||||
async getPostsByCategory(category: string): Promise<UiPost[]> {
|
||||
const posts = await this.getPosts();
|
||||
return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase());
|
||||
}
|
||||
|
||||
async getPostsByTag(tag: string): Promise<UiPost[]> {
|
||||
const posts = await this.getPosts();
|
||||
const normalizedTag = normalizeTagToken(tag);
|
||||
return posts.filter(post =>
|
||||
post.tags?.some(item => normalizeTagToken(item) === normalizedTag)
|
||||
);
|
||||
}
|
||||
|
||||
async searchPosts(query: string, limit = 20): Promise<UiPost[]> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
});
|
||||
const results = await this.fetch<ApiSearchResult[]>(`/search?${params.toString()}`);
|
||||
|
||||
return results.map(result =>
|
||||
normalizePost({
|
||||
id: result.id,
|
||||
title: result.title || 'Untitled',
|
||||
slug: result.slug,
|
||||
description: result.description || '',
|
||||
content: result.content || '',
|
||||
category: result.category || '',
|
||||
tags: result.tags ?? [],
|
||||
post_type: result.post_type || 'article',
|
||||
image: result.image,
|
||||
pinned: result.pinned ?? false,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
export const apiClient = api;
|
||||
167
frontend/src/lib/config/terminal.ts
Normal file
167
frontend/src/lib/config/terminal.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export interface TerminalConfig {
|
||||
defaultCategory: string;
|
||||
welcomeMessage: string;
|
||||
prompt: {
|
||||
prefix: string;
|
||||
separator: string;
|
||||
path: string;
|
||||
suffix: string;
|
||||
mobile: string;
|
||||
};
|
||||
asciiArt: string;
|
||||
title: string;
|
||||
welcome: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
navLinks: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
href: string;
|
||||
}>;
|
||||
categories: {
|
||||
[key: string]: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
shortDesc?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
postTypes: {
|
||||
article: { color: string; label: string };
|
||||
tweet: { color: string; label: string };
|
||||
};
|
||||
pinnedPost?: {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
readTime: string;
|
||||
type: 'article' | 'tweet';
|
||||
tags: string[];
|
||||
link: string;
|
||||
};
|
||||
socialLinks: {
|
||||
github: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
};
|
||||
tools: Array<{
|
||||
icon: string;
|
||||
href: string;
|
||||
title: string;
|
||||
}>;
|
||||
search?: {
|
||||
placeholders: {
|
||||
default: string;
|
||||
small: string;
|
||||
medium: string;
|
||||
};
|
||||
promptText: string;
|
||||
emptyResultText: string;
|
||||
};
|
||||
terminal?: {
|
||||
defaultWindowTitle: string;
|
||||
controls: {
|
||||
colors: {
|
||||
close: string;
|
||||
minimize: string;
|
||||
expand: string;
|
||||
};
|
||||
};
|
||||
animation?: {
|
||||
glowDuration: string;
|
||||
};
|
||||
};
|
||||
branding?: {
|
||||
name: string;
|
||||
shortName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const terminalConfig: TerminalConfig = {
|
||||
defaultCategory: 'blog',
|
||||
welcomeMessage: '欢迎来到我的博客!',
|
||||
prompt: {
|
||||
prefix: 'user@blog',
|
||||
separator: ':',
|
||||
path: '~/',
|
||||
suffix: '$',
|
||||
mobile: '~$'
|
||||
},
|
||||
asciiArt: `
|
||||
I N N I TTTTT CCCC OOO OOO L
|
||||
I NN N I T C O O O O L
|
||||
I N N N I T C O O O O L
|
||||
I N NN I T C O O O O L
|
||||
I N N I T CCCC OOO OOO LLLLL`,
|
||||
title: '~/blog',
|
||||
welcome: {
|
||||
title: '欢迎来到我的极客终端博客',
|
||||
subtitle: '这里记录技术、代码和生活点滴'
|
||||
},
|
||||
navLinks: [
|
||||
{ icon: 'fa-file-code', text: '文章', href: '/articles' },
|
||||
{ icon: 'fa-folder', text: '分类', href: '/categories' },
|
||||
{ icon: 'fa-tags', text: '标签', href: '/tags' },
|
||||
{ icon: 'fa-stream', text: '时间轴', href: '/timeline' },
|
||||
{ icon: 'fa-star', text: '评价', href: '/reviews' },
|
||||
{ icon: 'fa-link', text: '友链', href: '/friends' },
|
||||
{ icon: 'fa-user-secret', text: '关于', href: '/about' }
|
||||
],
|
||||
categories: {
|
||||
blog: {
|
||||
title: '博客',
|
||||
description: '我的个人博客文章',
|
||||
items: [
|
||||
{
|
||||
command: 'help',
|
||||
description: '显示帮助信息',
|
||||
shortDesc: '显示帮助信息'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
postTypes: {
|
||||
article: { color: '#00ff9d', label: '博客文章' },
|
||||
tweet: { color: '#00b8ff', label: '推文' }
|
||||
},
|
||||
socialLinks: {
|
||||
github: '',
|
||||
twitter: '',
|
||||
email: ''
|
||||
},
|
||||
tools: [
|
||||
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: '站点地图' },
|
||||
{ icon: 'fa-rss', href: '/rss.xml', title: 'RSS订阅' }
|
||||
],
|
||||
search: {
|
||||
placeholders: {
|
||||
default: "'关键词' articles/*.md",
|
||||
small: "搜索...",
|
||||
medium: "搜索文章..."
|
||||
},
|
||||
promptText: "grep -i",
|
||||
emptyResultText: "输入关键词搜索文章"
|
||||
},
|
||||
terminal: {
|
||||
defaultWindowTitle: 'user@terminal: ~/blog',
|
||||
controls: {
|
||||
colors: {
|
||||
close: '#ff5f56',
|
||||
minimize: '#ffbd2e',
|
||||
expand: '#27c93f'
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
glowDuration: '4s'
|
||||
}
|
||||
},
|
||||
branding: {
|
||||
name: 'InitCool',
|
||||
shortName: 'Termi'
|
||||
}
|
||||
};
|
||||
435
frontend/src/lib/styles/theme.css
Normal file
435
frontend/src/lib/styles/theme.css
Normal file
@@ -0,0 +1,435 @@
|
||||
/* 现代化主题系统 - 使用 CSS 变量 + 媒体查询 */
|
||||
|
||||
:root {
|
||||
/* 声明支持的颜色方案 */
|
||||
color-scheme: light dark;
|
||||
|
||||
/* 全局变量 */
|
||||
--transition-duration: 0.3s;
|
||||
|
||||
/* 亮色模式默认 */
|
||||
--primary: #4285f4;
|
||||
--primary-rgb: 66 133 244;
|
||||
--primary-light: #4285f433;
|
||||
--primary-dark: #3367d6;
|
||||
|
||||
--secondary: #ea580c;
|
||||
--secondary-rgb: 234 88 12;
|
||||
--secondary-light: #ea580c33;
|
||||
|
||||
--bg: #f3f4f6;
|
||||
--bg-rgb: 243 244 246;
|
||||
--bg-secondary: #e5e7eb;
|
||||
--bg-tertiary: #d1d5db;
|
||||
--terminal-bg: #ffffff;
|
||||
|
||||
--text: #1a1a1a;
|
||||
--text-rgb: 26 26 26;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-tertiary: #cbd5e1;
|
||||
--terminal-text: #1a1a1a;
|
||||
--title-color: #1a1a1a;
|
||||
--button-text: #1a1a1a;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
--border-color-rgb: 229 231 235;
|
||||
--terminal-border: #e5e7eb;
|
||||
|
||||
--tag-bg: #f3f4f6;
|
||||
--tag-text: #1a1a1a;
|
||||
|
||||
--header-bg: #f9fafb;
|
||||
--code-bg: #f3f4f6;
|
||||
|
||||
/* 终端窗口控制按钮 */
|
||||
--btn-close: #ff5f56;
|
||||
--btn-minimize: #ffbd2e;
|
||||
--btn-expand: #27c93f;
|
||||
|
||||
/* 状态颜色 */
|
||||
--success: #10b981;
|
||||
--success-rgb: 16 185 129;
|
||||
--success-light: #d1fae5;
|
||||
--success-dark: #065f46;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-rgb: 245 158 11;
|
||||
--warning-light: #fef3c7;
|
||||
--warning-dark: #92400e;
|
||||
|
||||
--danger: #ef4444;
|
||||
--danger-rgb: 239 68 68;
|
||||
--danger-light: #fee2e2;
|
||||
--danger-dark: #991b1b;
|
||||
|
||||
--gray-light: #f3f4f6;
|
||||
--gray-dark: #374151;
|
||||
|
||||
/* 全局样式变量 */
|
||||
--border-radius: 0.5rem;
|
||||
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* 暗色模式 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary: #00ff9d;
|
||||
--primary-rgb: 0 255 157;
|
||||
--primary-light: #00ff9d33;
|
||||
--primary-dark: #00b8ff;
|
||||
|
||||
--secondary: #00b8ff;
|
||||
--secondary-rgb: 0 184 255;
|
||||
--secondary-light: #00b8ff33;
|
||||
|
||||
--bg: #0a0e17;
|
||||
--bg-rgb: 10 14 23;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--terminal-bg: #0d1117;
|
||||
|
||||
--text: #e6e6e6;
|
||||
--text-rgb: 230 230 230;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #6b7280;
|
||||
--terminal-text: #e6e6e6;
|
||||
--title-color: #ffffff;
|
||||
--button-text: #e6e6e6;
|
||||
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--border-color-rgb: 255 255 255;
|
||||
--terminal-border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--tag-bg: #161b22;
|
||||
--tag-text: #e6e6e6;
|
||||
|
||||
--header-bg: #161b22;
|
||||
--code-bg: #161b22;
|
||||
|
||||
--success-light: #064e3b;
|
||||
--success-dark: #d1fae5;
|
||||
|
||||
--warning-light: #78350f;
|
||||
--warning-dark: #fef3c7;
|
||||
|
||||
--danger-light: #7f1d1d;
|
||||
--danger-dark: #fee2e2;
|
||||
|
||||
--gray-light: #1f2937;
|
||||
--gray-dark: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手动暗色模式覆盖 - 使用 html.dark */
|
||||
html.dark {
|
||||
--primary: #00ff9d;
|
||||
--primary-rgb: 0 255 157;
|
||||
--primary-light: #00ff9d33;
|
||||
--primary-dark: #00b8ff;
|
||||
|
||||
--secondary: #00b8ff;
|
||||
--secondary-rgb: 0 184 255;
|
||||
--secondary-light: #00b8ff33;
|
||||
|
||||
--bg: #0a0e17;
|
||||
--bg-rgb: 10 14 23;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--terminal-bg: #0d1117;
|
||||
|
||||
--text: #e6e6e6;
|
||||
--text-rgb: 230 230 230;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #6b7280;
|
||||
--terminal-text: #e6e6e6;
|
||||
--title-color: #ffffff;
|
||||
--button-text: #e6e6e6;
|
||||
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--border-color-rgb: 255 255 255;
|
||||
--terminal-border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--tag-bg: #161b22;
|
||||
--tag-text: #e6e6e6;
|
||||
|
||||
--header-bg: #161b22;
|
||||
--code-bg: #161b22;
|
||||
|
||||
--success-light: #064e3b;
|
||||
--success-dark: #d1fae5;
|
||||
|
||||
--warning-light: #78350f;
|
||||
--warning-dark: #fef3c7;
|
||||
|
||||
--danger-light: #7f1d1d;
|
||||
--danger-dark: #fee2e2;
|
||||
|
||||
--gray-light: #1f2937;
|
||||
--gray-dark: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 优化的平滑过渡 - 只应用到需要的元素 */
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
transition: background-color var(--transition-duration) ease,
|
||||
color var(--transition-duration) ease,
|
||||
border-color var(--transition-duration) ease;
|
||||
}
|
||||
|
||||
/* 主题切换按钮动画 */
|
||||
.theme-toggle {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle i {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover i {
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
width: 100%;
|
||||
background-color: color-mix(in oklab, var(--terminal-bg) 84%, var(--bg-secondary)) !important;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(var(--primary-rgb), 0.04), rgba(var(--primary-rgb), 0.0));
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.42) !important;
|
||||
outline: 1px solid rgba(var(--primary-rgb), 0.22);
|
||||
outline-offset: -1px;
|
||||
color: var(--text) !important;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
caret-color: var(--primary);
|
||||
appearance: none;
|
||||
display: block;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--primary-rgb), 0.16),
|
||||
inset 0 10px 20px rgba(0, 0, 0, 0.10),
|
||||
inset 0 0 26px rgba(var(--primary-rgb), 0.08),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.14);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.terminal-input {
|
||||
background-color: color-mix(in oklab, var(--bg-tertiary) 88%, var(--terminal-bg)) !important;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.0)),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--primary-rgb), 0.06) 0px,
|
||||
rgba(var(--primary-rgb), 0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 6px
|
||||
);
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.48) !important;
|
||||
outline: 1px solid rgba(var(--primary-rgb), 0.22);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--primary-rgb), 0.16),
|
||||
inset 0 10px 20px rgba(0, 0, 0, 0.32),
|
||||
inset 0 0 26px rgba(var(--primary-rgb), 0.12),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .terminal-input {
|
||||
background-color: color-mix(in oklab, var(--bg-tertiary) 88%, var(--terminal-bg)) !important;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.0)),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--primary-rgb), 0.06) 0px,
|
||||
rgba(var(--primary-rgb), 0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 6px
|
||||
);
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.48) !important;
|
||||
outline: 1px solid rgba(var(--primary-rgb), 0.22);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--primary-rgb), 0.16),
|
||||
inset 0 10px 20px rgba(0, 0, 0, 0.32),
|
||||
inset 0 0 26px rgba(var(--primary-rgb), 0.12),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
.terminal-input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.terminal-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .terminal-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.terminal-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--primary-rgb), 0.22),
|
||||
inset 0 0 26px rgba(var(--primary-rgb), 0.14),
|
||||
0 0 0 2px rgba(var(--primary-rgb), 0.22),
|
||||
0 0 28px rgba(var(--primary-rgb), 0.24);
|
||||
}
|
||||
|
||||
.terminal-input.textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
/* Terminal Window Glow Effects */
|
||||
.terminal-window {
|
||||
background-color: var(--terminal-bg);
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid var(--primary) !important;
|
||||
box-shadow:
|
||||
0 0 8px rgba(var(--primary-rgb), 0.4),
|
||||
0 0 20px rgba(var(--primary-rgb), 0.2),
|
||||
0 10px 30px rgba(var(--primary-rgb), 0.2) !important;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: terminal-glow 4s ease-in-out infinite alternate !important;
|
||||
}
|
||||
|
||||
@keyframes terminal-glow {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 8px rgba(var(--primary-rgb), 0.4),
|
||||
0 0 20px rgba(var(--primary-rgb), 0.2),
|
||||
0 10px 30px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 12px rgba(var(--primary-rgb), 0.5),
|
||||
0 0 25px rgba(var(--primary-rgb), 0.3),
|
||||
0 10px 30px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode glow adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.terminal-window {
|
||||
animation: terminal-glow-dark 4s ease-in-out infinite alternate !important;
|
||||
}
|
||||
|
||||
@keyframes terminal-glow-dark {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 8px rgba(var(--primary-rgb), 0.3),
|
||||
0 0 20px rgba(var(--primary-rgb), 0.15),
|
||||
0 10px 40px rgba(var(--primary-rgb), 0.1);
|
||||
border-color: rgba(var(--primary-rgb), 0.5) !important;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 15px rgba(var(--primary-rgb), 0.5),
|
||||
0 0 30px rgba(var(--primary-rgb), 0.25),
|
||||
0 10px 50px rgba(var(--primary-rgb), 0.15);
|
||||
border-color: rgba(var(--primary-rgb), 0.8) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .terminal-window {
|
||||
animation: terminal-glow-dark 4s ease-in-out infinite alternate !important;
|
||||
}
|
||||
|
||||
/* Terminal Header */
|
||||
.terminal-header {
|
||||
border-bottom: 1px solid var(--primary) !important;
|
||||
box-shadow: 0 1px 5px rgba(var(--primary-rgb), 0.2) !important;
|
||||
}
|
||||
|
||||
/* Glow Text Effect */
|
||||
.glow-text {
|
||||
text-shadow: 0 0 10px rgba(var(--primary-rgb), 0.5);
|
||||
}
|
||||
|
||||
/* Post Card Hover Effects */
|
||||
.post-card {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.post-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background-color: var(--post-border-color, var(--primary));
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.post-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Cursor Blink Animation */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
background-color: var(--primary);
|
||||
animation: blink 1s infinite;
|
||||
vertical-align: middle;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ASCII Art Styling */
|
||||
.ascii-art {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
color: var(--primary);
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
letter-spacing: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ascii-art {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Link Hover Effects */
|
||||
a {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Button Hover Glow */
|
||||
button:hover,
|
||||
a:hover {
|
||||
text-shadow: 0 0 8px rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
88
frontend/src/lib/types/index.ts
Normal file
88
frontend/src/lib/types/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content?: string;
|
||||
date: string;
|
||||
readTime: string;
|
||||
type: 'article' | 'tweet';
|
||||
tags: string[];
|
||||
category: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
code?: string;
|
||||
language?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface FriendLink {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
id: string;
|
||||
siteName: string;
|
||||
siteShortName: string;
|
||||
siteUrl: string;
|
||||
siteTitle: string;
|
||||
siteDescription: string;
|
||||
heroTitle: string;
|
||||
heroSubtitle: string;
|
||||
ownerName: string;
|
||||
ownerTitle: string;
|
||||
ownerBio: string;
|
||||
ownerAvatarUrl?: string;
|
||||
location?: string;
|
||||
social: {
|
||||
github?: string;
|
||||
twitter?: string;
|
||||
email?: string;
|
||||
};
|
||||
techStack: string[];
|
||||
}
|
||||
|
||||
export interface SiteConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
url: string;
|
||||
social: {
|
||||
github?: string;
|
||||
twitter?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SystemStat {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface TechStackItem {
|
||||
name: string;
|
||||
icon?: string;
|
||||
level?: string;
|
||||
}
|
||||
194
frontend/src/lib/utils/data.ts
Normal file
194
frontend/src/lib/utils/data.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Post, Category, Tag, FriendLink } from '../types';
|
||||
|
||||
// Mock data for static site generation
|
||||
export const mockPosts: Post[] = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'welcome-to-termi',
|
||||
title: '欢迎来到 Termi 终端博客',
|
||||
description: '这是一个基于终端风格的现代博客平台,结合了极客美学与极致性能。',
|
||||
date: '2024-03-20',
|
||||
readTime: '3 分钟',
|
||||
type: 'article',
|
||||
tags: ['astro', 'svelte', 'tailwind'],
|
||||
category: '技术',
|
||||
pinned: true,
|
||||
image: 'https://picsum.photos/1200/600?random=1'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
slug: 'astro-ssg-guide',
|
||||
title: 'Astro 静态站点生成指南',
|
||||
description: '学习如何使用 Astro 构建高性能的静态网站,掌握群岛架构的核心概念。',
|
||||
date: '2024-03-18',
|
||||
readTime: '5 分钟',
|
||||
type: 'article',
|
||||
tags: ['astro', 'ssg', 'performance'],
|
||||
category: '前端'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
slug: 'tailwind-v4-features',
|
||||
title: 'Tailwind CSS v4 新特性解析',
|
||||
description: '探索 Tailwind CSS v4 带来的全新特性,包括改进的性能和更简洁的配置。',
|
||||
date: '2024-03-15',
|
||||
readTime: '4 分钟',
|
||||
type: 'article',
|
||||
tags: ['tailwind', 'css', 'design'],
|
||||
category: '前端'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
slug: 'daily-thought-1',
|
||||
title: '关于代码与咖啡的思考',
|
||||
description: '写代码就像冲咖啡,需要耐心和恰到好处的温度。今天尝试了几款新豆子,每一杯都有不同的风味。',
|
||||
date: '2024-03-14',
|
||||
readTime: '1 分钟',
|
||||
type: 'tweet',
|
||||
tags: ['life', 'coding'],
|
||||
category: '随笔',
|
||||
images: [
|
||||
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=400&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=400&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?w=400&h=400&fit=crop'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
slug: 'svelte-5-runes',
|
||||
title: 'Svelte 5 Runes 完全指南',
|
||||
description: '深入了解 Svelte 5 的 Runes 系统,掌握下一代响应式编程范式。',
|
||||
date: '2024-03-10',
|
||||
readTime: '8 分钟',
|
||||
type: 'article',
|
||||
tags: ['svelte', 'javascript', 'frontend'],
|
||||
category: '前端'
|
||||
}
|
||||
];
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ id: '1', name: '技术', slug: 'tech', icon: 'fa-code', count: 3 },
|
||||
{ id: '2', name: '前端', slug: 'frontend', icon: 'fa-laptop-code', count: 3 },
|
||||
{ id: '3', name: '随笔', slug: 'essay', icon: 'fa-pen', count: 1 },
|
||||
{ id: '4', name: '生活', slug: 'life', icon: 'fa-coffee', count: 1 }
|
||||
];
|
||||
|
||||
export const mockTags: Tag[] = [
|
||||
{ id: '1', name: 'astro', slug: 'astro', count: 1 },
|
||||
{ id: '2', name: 'svelte', slug: 'svelte', count: 2 },
|
||||
{ id: '3', name: 'tailwind', slug: 'tailwind', count: 2 },
|
||||
{ id: '4', name: 'frontend', slug: 'frontend', count: 2 },
|
||||
{ id: '5', name: 'ssg', slug: 'ssg', count: 1 },
|
||||
{ id: '6', name: 'css', slug: 'css', count: 1 },
|
||||
{ id: '7', name: 'javascript', slug: 'javascript', count: 1 },
|
||||
{ id: '8', name: 'life', slug: 'life', count: 1 },
|
||||
{ id: '9', name: 'coding', slug: 'coding', count: 1 }
|
||||
];
|
||||
|
||||
export const mockFriendLinks: FriendLink[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Astro 官网',
|
||||
url: 'https://astro.build',
|
||||
avatar: 'https://astro.build/favicon.svg',
|
||||
description: '极速内容驱动的网站框架',
|
||||
category: '技术博客'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Svelte 官网',
|
||||
url: 'https://svelte.dev',
|
||||
avatar: 'https://svelte.dev/favicon.png',
|
||||
description: '控制论增强的 Web 应用',
|
||||
category: '技术博客'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tailwind CSS',
|
||||
url: 'https://tailwindcss.com',
|
||||
avatar: 'https://tailwindcss.com/favicons/favicon-32x32.png',
|
||||
description: '实用优先的 CSS 框架',
|
||||
category: '技术博客'
|
||||
}
|
||||
];
|
||||
|
||||
export const mockSiteConfig = {
|
||||
name: 'Termi',
|
||||
description: '终端风格的内容平台',
|
||||
author: 'InitCool',
|
||||
url: 'https://termi.dev',
|
||||
social: {
|
||||
github: 'https://github.com',
|
||||
twitter: 'https://twitter.com',
|
||||
email: 'mailto:hello@termi.dev'
|
||||
}
|
||||
};
|
||||
|
||||
export const mockSystemStats = [
|
||||
{ label: 'Last Update', value: '2024-03-20' },
|
||||
{ label: 'Posts', value: '12' },
|
||||
{ label: 'Visitors', value: '1.2k' }
|
||||
];
|
||||
|
||||
export const mockTechStack = [
|
||||
{ name: 'Astro' },
|
||||
{ name: 'Svelte' },
|
||||
{ name: 'Tailwind CSS' },
|
||||
{ name: 'TypeScript' },
|
||||
{ name: 'Vercel' }
|
||||
];
|
||||
|
||||
export const mockHomeAboutIntro = '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。';
|
||||
|
||||
// Helper functions
|
||||
export function getPinnedPost(): Post | null {
|
||||
return mockPosts.find(p => p.pinned) || null;
|
||||
}
|
||||
|
||||
export function getRecentPosts(limit: number = 5): Post[] {
|
||||
return mockPosts.slice(0, limit);
|
||||
}
|
||||
|
||||
export function getAllPosts(): Post[] {
|
||||
return mockPosts;
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post | undefined {
|
||||
return mockPosts.find(p => p.slug === slug);
|
||||
}
|
||||
|
||||
export function getPostsByTag(tag: string): Post[] {
|
||||
return mockPosts.filter(p => p.tags.includes(tag));
|
||||
}
|
||||
|
||||
export function getPostsByCategory(category: string): Post[] {
|
||||
return mockPosts.filter(p => p.category === category);
|
||||
}
|
||||
|
||||
export function getAllCategories(): Category[] {
|
||||
return mockCategories;
|
||||
}
|
||||
|
||||
export function getAllTags(): Tag[] {
|
||||
return mockTags;
|
||||
}
|
||||
|
||||
export function getAllFriendLinks(): FriendLink[] {
|
||||
return mockFriendLinks;
|
||||
}
|
||||
|
||||
export function getSiteConfig() {
|
||||
return mockSiteConfig;
|
||||
}
|
||||
|
||||
export function getSystemStats() {
|
||||
return mockSystemStats;
|
||||
}
|
||||
|
||||
export function getTechStack() {
|
||||
return mockTechStack;
|
||||
}
|
||||
|
||||
export function getHomeAboutIntro() {
|
||||
return mockHomeAboutIntro;
|
||||
}
|
||||
94
frontend/src/lib/utils/index.ts
Normal file
94
frontend/src/lib/utils/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Format a date string to a more readable format
|
||||
*/
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a specified length with ellipsis
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize FontAwesome icon class
|
||||
*/
|
||||
export function normalizeFaIcon(icon: unknown): string {
|
||||
const raw = typeof icon === 'string' ? icon.trim() : '';
|
||||
if (!raw) return 'fa-folder';
|
||||
|
||||
if (raw.includes('fa-')) {
|
||||
const parts = raw.split(/\s+/);
|
||||
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
||||
const t = parts[i];
|
||||
if (t?.startsWith('fa-')) return t;
|
||||
}
|
||||
return 'fa-folder';
|
||||
}
|
||||
|
||||
return 'fa-folder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve file reference (for images)
|
||||
*/
|
||||
export function resolveFileRef(ref: string): string {
|
||||
if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('/')) {
|
||||
return ref;
|
||||
}
|
||||
return `/uploads/${ref}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter posts by type and tag
|
||||
*/
|
||||
export function filterPosts(
|
||||
posts: Array<{
|
||||
type: string;
|
||||
tags: string[];
|
||||
}>,
|
||||
postType: string,
|
||||
tag: string
|
||||
): typeof posts {
|
||||
return posts.filter(post => {
|
||||
if (postType !== 'all' && post.type !== postType) return false;
|
||||
if (tag && !post.tags.includes(tag)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for post type
|
||||
*/
|
||||
export function getPostTypeColor(type: string): string {
|
||||
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
|
||||
}
|
||||
Reference in New Issue
Block a user