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;
|
||||
Reference in New Issue
Block a user