chore: reorganize project into monorepo
This commit is contained in:
258
frontend/src/pages/articles/index.astro
Normal file
258
frontend/src/pages/articles/index.astro
Normal file
@@ -0,0 +1,258 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { terminalConfig } from '../../lib/config/terminal';
|
||||
import { api } from '../../lib/api/client';
|
||||
import type { Category, Post, Tag } from '../../lib/types';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
let allPosts: Post[] = [];
|
||||
let allTags: Tag[] = [];
|
||||
let allCategories: Category[] = [];
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedSearch = url.searchParams.get('search') || '';
|
||||
|
||||
try {
|
||||
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts();
|
||||
allCategories = await api.getCategories();
|
||||
const rawTags = await api.getTags();
|
||||
const seenTagIds = new Set<string>();
|
||||
allTags = rawTags.filter(tag => {
|
||||
const key = `${tag.slug}:${tag.name}`.toLowerCase();
|
||||
if (seenTagIds.has(key)) return false;
|
||||
seenTagIds.add(key);
|
||||
return true;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
}
|
||||
|
||||
const selectedType = url.searchParams.get('type') || 'all';
|
||||
const selectedTag = url.searchParams.get('tag') || '';
|
||||
const selectedCategory = url.searchParams.get('category') || '';
|
||||
const currentPage = parseInt(url.searchParams.get('page') || '1');
|
||||
const postsPerPage = 10;
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const isMatchingTag = (value: string) => value.trim().toLowerCase() === normalizedSelectedTag;
|
||||
const isSelectedTag = (tag: Tag) =>
|
||||
tag.name.trim().toLowerCase() === normalizedSelectedTag || tag.slug.trim().toLowerCase() === normalizedSelectedTag;
|
||||
|
||||
const filteredPosts = allPosts.filter(post => {
|
||||
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
||||
if (selectedTag && !post.tags?.some(isMatchingTag)) return false;
|
||||
if (selectedCategory && post.category?.toLowerCase() !== selectedCategory.toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPosts = filteredPosts.length;
|
||||
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
||||
const startIndex = (currentPage - 1) * postsPerPage;
|
||||
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
|
||||
|
||||
const postTypeFilters = [
|
||||
{ id: 'all', name: '全部', icon: 'fa-stream' },
|
||||
{ id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' },
|
||||
{ id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' }
|
||||
];
|
||||
|
||||
const typePromptCommand = `./filter --type ${selectedType || 'all'}`;
|
||||
const categoryPromptCommand = `./filter --category ${selectedCategory ? `"${selectedCategory}"` : 'all'}`;
|
||||
const tagPromptCommand = `./filter --tag ${selectedTag ? `"${selectedTag}"` : 'all'}`;
|
||||
|
||||
const buildArticlesUrl = ({
|
||||
type = selectedType,
|
||||
search = selectedSearch,
|
||||
tag = selectedTag,
|
||||
category = selectedCategory,
|
||||
page,
|
||||
}: {
|
||||
type?: string;
|
||||
search?: string;
|
||||
tag?: string;
|
||||
category?: string;
|
||||
page?: number;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (type && type !== 'all') params.set('type', type);
|
||||
if (search) params.set('search', search);
|
||||
if (tag) params.set('tag', tag);
|
||||
if (category) params.set('category', category);
|
||||
if (page && page > 1) params.set('page', String(page));
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/articles?${queryString}` : '/articles';
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title="文章列表 - Termi">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/articles/index" class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command="fd . ./content/posts --full-path" />
|
||||
|
||||
<div class="ml-4 mt-4 space-y-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">文章索引</h1>
|
||||
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||
按类型、分类和标签筛选内容。这里保留更轻的 prompt 标题结构,下方筛选拆成独立区域。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||
共 {filteredPosts.length} 篇
|
||||
</span>
|
||||
{selectedSearch && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
|
||||
grep: {selectedSearch}
|
||||
</span>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-folder-open text-[var(--primary)]"></i>
|
||||
{selectedCategory}
|
||||
</span>
|
||||
)}
|
||||
{selectedTag && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-hashtag text-[var(--primary)]"></i>
|
||||
{selectedTag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-2 space-y-4">
|
||||
<div class="ml-4">
|
||||
<CommandPrompt command={typePromptCommand} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
{postTypeFilters.map(filter => (
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ type: filter.id, page: 1 })}
|
||||
tone="blue"
|
||||
active={selectedType === filter.id}
|
||||
>
|
||||
<i class={`fas ${filter.icon}`}></i>
|
||||
<span class="font-medium">{filter.name}</span>
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allCategories.length > 0 && (
|
||||
<div class="ml-4">
|
||||
<CommandPrompt command={categoryPromptCommand} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ category: '', page: 1 })}
|
||||
tone="amber"
|
||||
active={!selectedCategory}
|
||||
>
|
||||
<i class="fas fa-folder-tree"></i>
|
||||
<span class="font-medium">全部分类</span>
|
||||
</FilterPill>
|
||||
{allCategories.map(category => (
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ category: category.name, page: 1 })}
|
||||
tone="amber"
|
||||
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
||||
>
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span class="font-medium">{category.name}</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{category.count}</span>
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div class="ml-4">
|
||||
<CommandPrompt command={tagPromptCommand} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ tag: '', page: 1 })}
|
||||
tone="teal"
|
||||
active={!selectedTag}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span class="font-medium">全部标签</span>
|
||||
</FilterPill>
|
||||
{allTags.map(tag => (
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
|
||||
tone="teal"
|
||||
active={isSelectedTag(tag)}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span class="font-medium">{tag.name}</span>
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
{paginatedPosts.length > 0 ? (
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{paginatedPosts.map(post => (
|
||||
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="terminal-empty ml-4 mt-4">
|
||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</span>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">没有匹配结果</h2>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。
|
||||
</p>
|
||||
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>reset filters</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div class="px-4 py-6">
|
||||
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
page {currentPage}/{totalPages} · {totalPosts} results
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{currentPage > 1 && (
|
||||
<a
|
||||
href={buildArticlesUrl({ page: currentPage - 1 })}
|
||||
class="terminal-action-button"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span>prev</span>
|
||||
</a>
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<a
|
||||
href={buildArticlesUrl({ page: currentPage + 1 })}
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
>
|
||||
<span>next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
Reference in New Issue
Block a user