chore: reorganize project into monorepo

This commit is contained in:
2026-03-28 10:40:22 +08:00
parent 60367a5f51
commit 1455d93246
201 changed files with 30081 additions and 93 deletions

View 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;