feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s
This commit is contained in:
@@ -4,6 +4,8 @@ import CodeBlock from './CodeBlock.astro';
|
||||
import ResponsiveImage from './ui/ResponsiveImage.astro';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import {
|
||||
buildCategoryUrl,
|
||||
buildTagUrl,
|
||||
getAccentVars,
|
||||
getCategoryTheme,
|
||||
getPostTypeColor,
|
||||
@@ -16,10 +18,10 @@ interface Props {
|
||||
post: Post;
|
||||
selectedTag?: string;
|
||||
highlightTerm?: string;
|
||||
tagHrefPrefix?: string;
|
||||
tagHrefPrefix?: string | null;
|
||||
}
|
||||
|
||||
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = '/tags?tag=' } = Astro.props;
|
||||
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = null } = Astro.props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
const typeColor = getPostTypeColor(post.type);
|
||||
@@ -49,6 +51,8 @@ const highlightText = (value: string, query: string) => {
|
||||
};
|
||||
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const resolveTagHref = (tag: string) =>
|
||||
tagHrefPrefix ? `${tagHrefPrefix}${encodeURIComponent(tag)}` : buildTagUrl(tag);
|
||||
---
|
||||
|
||||
<article
|
||||
@@ -77,9 +81,13 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5" style={getAccentVars(categoryTheme)}>
|
||||
<a
|
||||
href={buildCategoryUrl(post.category)}
|
||||
class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5"
|
||||
style={getAccentVars(categoryTheme)}
|
||||
>
|
||||
#{post.category}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]" set:html={highlightText(post.description, highlightTerm)} />
|
||||
@@ -122,7 +130,7 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
<div class="relative z-10 pl-3 flex flex-wrap gap-2">
|
||||
{post.tags?.map(tag => (
|
||||
<a
|
||||
href={`${tagHrefPrefix}${encodeURIComponent(tag)}`}
|
||||
href={resolveTagHref(tag)}
|
||||
class:list={[
|
||||
'terminal-chip text-xs py-1 px-2.5',
|
||||
'terminal-chip--accent',
|
||||
|
||||
@@ -129,6 +129,16 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label class="subscription-popup-field">
|
||||
<span class="subscription-popup-field-label">称呼</span>
|
||||
<input
|
||||
type="text"
|
||||
name="displayName"
|
||||
placeholder="怎么称呼你(可选)"
|
||||
autocomplete="name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="subscription-popup-field">
|
||||
<span class="subscription-popup-field-label">邮箱地址</span>
|
||||
<input
|
||||
|
||||
@@ -47,6 +47,51 @@ export function resolveFileRef(ref: string): string {
|
||||
return `/uploads/${ref}`;
|
||||
}
|
||||
|
||||
function resolveTaxonomyToken(
|
||||
value:
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
},
|
||||
): string {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return (value?.slug || value?.name || '').trim();
|
||||
}
|
||||
|
||||
export function buildCategoryUrl(
|
||||
value:
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
},
|
||||
): string {
|
||||
const token = resolveTaxonomyToken(value);
|
||||
return token ? `/categories/${encodeURIComponent(token)}` : '/categories';
|
||||
}
|
||||
|
||||
export function buildTagUrl(
|
||||
value:
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
| {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
},
|
||||
): string {
|
||||
const token = resolveTaxonomyToken(value);
|
||||
return token ? `/tags/${encodeURIComponent(token)}` : '/tags';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,8 @@ import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { formatReadTime, getI18n } from '../../lib/i18n';
|
||||
import type { PopularPostHighlight } from '../../lib/types';
|
||||
import {
|
||||
buildCategoryUrl,
|
||||
buildTagUrl,
|
||||
getAccentVars,
|
||||
getCategoryTheme,
|
||||
getPostTypeColor,
|
||||
@@ -206,10 +208,14 @@ const breadcrumbJsonLd = {
|
||||
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
|
||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent" style={getAccentVars(categoryTheme)}>
|
||||
<a
|
||||
href={buildCategoryUrl(post.category)}
|
||||
class="terminal-chip terminal-chip--accent"
|
||||
style={getAccentVars(categoryTheme)}
|
||||
>
|
||||
<i class="fas fa-folder-tree"></i>
|
||||
{post.category}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +244,7 @@ const breadcrumbJsonLd = {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{post.tags.map(tag => (
|
||||
<a
|
||||
href={`/tags?tag=${encodeURIComponent(tag)}`}
|
||||
href={buildTagUrl(tag)}
|
||||
class="terminal-filter"
|
||||
style={getAccentVars(getTagTheme(tag))}
|
||||
>
|
||||
|
||||
173
frontend/src/pages/categories/[slug].astro
Normal file
173
frontend/src/pages/categories/[slug].astro
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { api } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Category, Post } from '../../lib/types';
|
||||
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let categories: Category[] = [];
|
||||
let posts: Post[] = [];
|
||||
|
||||
try {
|
||||
[categories, posts] = await Promise.all([api.getCategories(), api.getPosts()]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch category detail data:', error);
|
||||
}
|
||||
|
||||
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
|
||||
const category =
|
||||
categories.find((item) => {
|
||||
return [item.slug, item.name].some(
|
||||
(value) => (value || '').trim().toLowerCase() === requested,
|
||||
);
|
||||
}) || null;
|
||||
|
||||
if (!category) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const canonicalUrl = buildCategoryUrl(category);
|
||||
if (slug && slug !== category.slug && category.slug) {
|
||||
return Astro.redirect(canonicalUrl, 301);
|
||||
}
|
||||
|
||||
const filteredPosts = posts.filter(
|
||||
(post) => (post.category || '').trim().toLowerCase() === category.name.trim().toLowerCase(),
|
||||
);
|
||||
const categoryTheme = getCategoryTheme(category.name);
|
||||
const pageTitle = category.seoTitle || `${category.name} - ${t('categories.title')}`;
|
||||
const pageDescription =
|
||||
category.seoDescription || category.description || t('categories.categoryPosts', { name: category.name });
|
||||
const siteBaseUrl = new URL(Astro.request.url).origin;
|
||||
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
url: absoluteCanonicalUrl,
|
||||
about: {
|
||||
'@type': 'Thing',
|
||||
name: category.name,
|
||||
description: category.description || pageDescription,
|
||||
},
|
||||
keywords: [category.name, category.slug].filter(Boolean),
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Termi',
|
||||
item: siteBaseUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('categories.title'),
|
||||
item: new URL('/categories', siteBaseUrl).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: category.name,
|
||||
item: absoluteCanonicalUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
ogImage={category.coverImage}
|
||||
canonical={canonicalUrl}
|
||||
jsonLd={jsonLd}
|
||||
twitterCard={category.coverImage ? 'summary_large_image' : 'summary'}
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title={`~/categories/${category.slug || category.name}`} class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command={`posts query --category "${category.name}"`} />
|
||||
|
||||
<div class="terminal-panel ml-4 mt-4 space-y-5" style={getAccentVars(categoryTheme)}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-4">
|
||||
<a href="/categories" class="terminal-link-arrow">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回分类目录</span>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-folder-tree"></i>
|
||||
category detail
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
{category.slug || category.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||
<span>{t('common.postsCount', { count: filteredPosts.length })}</span>
|
||||
</span>
|
||||
{category.accentColor ? (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-droplet text-[var(--primary)]"></i>
|
||||
<span>{category.accentColor}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{category.name}</h1>
|
||||
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
|
||||
</div>
|
||||
|
||||
{category.coverImage ? (
|
||||
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
|
||||
<img
|
||||
src={category.coverImage}
|
||||
alt={category.name}
|
||||
class="h-56 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-8">
|
||||
<CommandPrompt command={`posts list --category "${category.name}"`} typing={false} />
|
||||
{filteredPosts.length > 0 ? (
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{filteredPosts.map((post) => (
|
||||
<PostCard post={post} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="terminal-empty ml-4 mt-4">
|
||||
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
|
||||
<p class="text-[var(--text-secondary)]">{t('categories.emptyPosts')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -2,59 +2,25 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { api } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Post } from '../../lib/types';
|
||||
import { getAccentVars, getCategoryTheme } from '../../lib/utils';
|
||||
import type { Category } from '../../lib/types';
|
||||
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||
let allPosts: Post[] = [];
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedCategoryParam = url.searchParams.get('category') || '';
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let categories: Category[] = [];
|
||||
|
||||
try {
|
||||
[categories, allPosts] = await Promise.all([
|
||||
api.getCategories(),
|
||||
api.getPosts(),
|
||||
]);
|
||||
categories = await api.getCategories();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
}
|
||||
|
||||
const selectedCategoryRecord = categories.find((category) => {
|
||||
const wanted = selectedCategoryParam.trim().toLowerCase();
|
||||
if (!wanted) return false;
|
||||
return [category.name, category.slug].some(
|
||||
(value) => (value || '').trim().toLowerCase() === wanted
|
||||
);
|
||||
}) || null;
|
||||
const selectedCategory = selectedCategoryRecord?.name || selectedCategoryParam;
|
||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||
const filteredPosts = selectedCategory
|
||||
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
|
||||
: [];
|
||||
const categoryPromptCommand = selectedCategory
|
||||
? `posts query --category "${selectedCategory}"`
|
||||
: 'categories list --sort name';
|
||||
const resultsPromptCommand = selectedCategory
|
||||
? `posts list --category "${selectedCategory}"`
|
||||
: 'posts list --group-by category';
|
||||
const categoryAccentMap = Object.fromEntries(
|
||||
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
||||
);
|
||||
const pageTitle = selectedCategoryRecord?.seoTitle || t('categories.pageTitle');
|
||||
const pageDescription = selectedCategoryRecord?.seoDescription || selectedCategoryRecord?.description;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} - Termi`}
|
||||
description={pageDescription}
|
||||
ogImage={selectedCategoryRecord?.coverImage}
|
||||
>
|
||||
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/categories" class="w-full">
|
||||
<div class="mb-6 px-4">
|
||||
@@ -79,89 +45,50 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
||||
<span>{t('categories.quickJump')}</span>
|
||||
</span>
|
||||
<span
|
||||
id="categories-current-pill"
|
||||
class:list={['terminal-stat-pill terminal-stat-pill--accent', !selectedCategory && 'hidden']}
|
||||
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
|
||||
>
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span id="categories-current-label">{selectedCategory}</span>
|
||||
<span>点击进入分类专题详情页</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 space-y-6">
|
||||
<div class="px-4">
|
||||
<div class="ml-4">
|
||||
<CommandPrompt promptId="categories-filter-prompt" command={categoryPromptCommand} typing={false} />
|
||||
<CommandPrompt command="categories browse --mode detail-page" typing={false} />
|
||||
{categories.length > 0 ? (
|
||||
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="/categories"
|
||||
data-category-filter=""
|
||||
class:list={[
|
||||
'terminal-panel terminal-interactive-card group p-5',
|
||||
!selectedCategory && 'is-active'
|
||||
]}
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border">
|
||||
<i class="fas fa-layer-group text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
|
||||
all
|
||||
</div>
|
||||
<h2 class="font-bold text-[var(--title-color)] transition-colors text-lg">
|
||||
{t('articlesPage.allCategories')}
|
||||
</h2>
|
||||
</div>
|
||||
<span class="terminal-chip text-xs py-1 px-2.5">
|
||||
<span>{t('common.postsCount', { count: allPosts.length })}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{t('categories.allCategoriesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category) => (
|
||||
<a
|
||||
href={`/categories?category=${encodeURIComponent(category.name)}`}
|
||||
data-category-filter={category.name}
|
||||
class:list={[
|
||||
'terminal-panel terminal-panel-accent terminal-interactive-card group p-5',
|
||||
normalizedSelectedCategory === category.name.trim().toLowerCase() && 'is-active'
|
||||
]}
|
||||
href={buildCategoryUrl(category)}
|
||||
class="terminal-panel terminal-panel-accent terminal-interactive-card group p-5"
|
||||
style={getAccentVars(getCategoryTheme(category.name))}
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border">
|
||||
<i class="fas fa-folder-open text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-2 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="mb-1 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{category.slug || category.name}
|
||||
</div>
|
||||
<h2 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg">
|
||||
<h2 class="truncate text-lg font-bold text-[var(--title-color)] transition-colors group-hover:text-[var(--primary)]">
|
||||
{category.name}
|
||||
</h2>
|
||||
</div>
|
||||
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(category.name))}>
|
||||
<span>{t('common.postsCount', { count: category.count })}</span>
|
||||
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5">
|
||||
<span>{t('common.postsCount', { count: category.count ?? 0 })}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{t('categories.categoryPosts', { name: category.name })}
|
||||
|
||||
<p class="line-clamp-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{category.description || t('categories.categoryPosts', { name: category.name })}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 terminal-link-arrow">
|
||||
<span>查看分类专题</span>
|
||||
<i class="fas fa-arrow-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -174,191 +101,7 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="categories-results-wrap" class:list={['ml-4', !selectedCategory && 'hidden']}>
|
||||
<CommandPrompt promptId="categories-results-prompt" command={resultsPromptCommand} typing={false} />
|
||||
<div class="mt-4 terminal-panel">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p id="categories-selected-summary" class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{selectedCategory ? t('categories.selectedSummary', { name: selectedCategory, count: filteredPosts.length }) : ''}
|
||||
</p>
|
||||
<a id="categories-clear-btn" href="/categories" class:list={['terminal-link-arrow', !selectedCategory && 'hidden']}>
|
||||
<span>{t('common.clearFilters')}</span>
|
||||
<i class="fas fa-rotate-left text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
{selectedCategoryRecord && (selectedCategoryRecord.description || selectedCategoryRecord.coverImage) ? (
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
|
||||
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{selectedCategoryRecord.description ? (
|
||||
<p>{selectedCategoryRecord.description}</p>
|
||||
) : null}
|
||||
{selectedCategoryRecord.accentColor ? (
|
||||
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedCategoryRecord.accentColor}`}></span>
|
||||
<span>{selectedCategoryRecord.accentColor}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedCategoryRecord.coverImage ? (
|
||||
<img
|
||||
src={selectedCategoryRecord.coverImage}
|
||||
alt={selectedCategoryRecord.name}
|
||||
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
{allPosts.length > 0 ? (
|
||||
<div id="categories-posts-list" class:list={['divide-y divide-[var(--border-color)]', !selectedCategory && 'hidden']}>
|
||||
{allPosts.map((post) => {
|
||||
const matchesInitial = selectedCategory
|
||||
? (post.category || '').trim().toLowerCase() === normalizedSelectedCategory
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-category-post
|
||||
data-category-name={(post.category || '').trim().toLowerCase()}
|
||||
class:list={[!matchesInitial && 'hidden']}
|
||||
>
|
||||
<PostCard post={post} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div id="categories-empty-state" class:list={['terminal-empty', (!selectedCategory || filteredPosts.length > 0) && 'hidden']}>
|
||||
<i class="fas fa-search text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
|
||||
<p class="text-[var(--text-secondary)]">{t('categories.emptyPosts')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
categoryAccentMap,
|
||||
initialSelectedCategory: selectedCategory,
|
||||
}}
|
||||
>
|
||||
(function() {
|
||||
/** @type {Window['__termiCommandPrompt']} */
|
||||
let promptApi;
|
||||
|
||||
const categoryButtons = Array.from(document.querySelectorAll('[data-category-filter]'));
|
||||
const postCards = Array.from(document.querySelectorAll('[data-category-post]'));
|
||||
const currentPill = document.getElementById('categories-current-pill');
|
||||
const currentLabel = document.getElementById('categories-current-label');
|
||||
const resultsWrap = document.getElementById('categories-results-wrap');
|
||||
const selectedSummary = document.getElementById('categories-selected-summary');
|
||||
const postsList = document.getElementById('categories-posts-list');
|
||||
const emptyState = document.getElementById('categories-empty-state');
|
||||
const clearBtn = document.getElementById('categories-clear-btn');
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
promptApi = window.__termiCommandPrompt;
|
||||
|
||||
const state = {
|
||||
category: initialSelectedCategory || '',
|
||||
};
|
||||
|
||||
function updatePrompts() {
|
||||
const filterCommand = state.category
|
||||
? `posts query --category "${state.category}"`
|
||||
: 'categories list --sort name';
|
||||
const resultsCommand = state.category
|
||||
? `posts list --category "${state.category}"`
|
||||
: 'posts list --group-by category';
|
||||
|
||||
promptApi?.set?.('categories-filter-prompt', filterCommand, { typing: false });
|
||||
promptApi?.set?.('categories-results-prompt', resultsCommand, { typing: false });
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (state.category) params.set('category', state.category);
|
||||
const nextUrl = params.toString() ? `/categories?${params.toString()}` : '/categories';
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
|
||||
function syncButtons() {
|
||||
const activeValue = state.category.trim().toLowerCase();
|
||||
|
||||
categoryButtons.forEach((button) => {
|
||||
const value = (button.getAttribute('data-category-filter') || '').trim().toLowerCase();
|
||||
const isActive = activeValue ? value === activeValue : value === '';
|
||||
button.classList.toggle('is-active', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
function applyCategoryFilter(pushHistory = true) {
|
||||
const activeValue = state.category.trim().toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
postCards.forEach((card) => {
|
||||
const value = (card.getAttribute('data-category-name') || '').trim().toLowerCase();
|
||||
const matches = Boolean(activeValue) && value === activeValue;
|
||||
card.classList.toggle('hidden', !matches);
|
||||
if (matches) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
syncButtons();
|
||||
updatePrompts();
|
||||
|
||||
if (currentPill && currentLabel) {
|
||||
currentPill.classList.toggle('hidden', !state.category);
|
||||
if (state.category) {
|
||||
currentLabel.textContent = state.category;
|
||||
currentPill.setAttribute('style', categoryAccentMap[String(state.category).trim().toLowerCase()] || '');
|
||||
} else {
|
||||
currentLabel.textContent = '';
|
||||
currentPill.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
|
||||
resultsWrap?.classList.toggle('hidden', !state.category);
|
||||
postsList?.classList.toggle('hidden', !state.category);
|
||||
emptyState?.classList.toggle('hidden', !state.category || visibleCount > 0);
|
||||
clearBtn?.classList.toggle('hidden', !state.category);
|
||||
|
||||
if (selectedSummary) {
|
||||
selectedSummary.textContent = state.category
|
||||
? t('categories.selectedSummary', { name: state.category, count: visibleCount })
|
||||
: '';
|
||||
}
|
||||
|
||||
if (pushHistory) {
|
||||
updateUrl();
|
||||
}
|
||||
}
|
||||
|
||||
categoryButtons.forEach((button) => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const nextValue = button.getAttribute('data-category-filter') || '';
|
||||
const normalizedCurrent = state.category.trim().toLowerCase();
|
||||
state.category = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
|
||||
applyCategoryFilter();
|
||||
});
|
||||
});
|
||||
|
||||
clearBtn?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.category = '';
|
||||
applyCategoryFilter();
|
||||
});
|
||||
|
||||
applyCategoryFilter(false);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@ import CommandPrompt from '../components/ui/CommandPrompt.astro';
|
||||
import FilterPill from '../components/ui/FilterPill.astro';
|
||||
import PostCard from '../components/PostCard.astro';
|
||||
import FriendLinkCard from '../components/FriendLinkCard.astro';
|
||||
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
|
||||
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
|
||||
import StatsList from '../components/StatsList.astro';
|
||||
import TechStackList from '../components/TechStackList.astro';
|
||||
@@ -275,6 +276,13 @@ const navLinks = [
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="mb-8 px-4">
|
||||
<CommandPrompt command="subscriptions create --channel email" />
|
||||
<div class="ml-4">
|
||||
<SubscriptionSignup requestUrl={Astro.request.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="discover" class="mb-6 px-4">
|
||||
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
|
||||
<div class="ml-4 terminal-panel home-discovery-shell">
|
||||
|
||||
@@ -79,6 +79,14 @@ const statusLabels = {
|
||||
'in-progress': t('reviews.statusInProgress'),
|
||||
dropped: t('reviews.statusDropped'),
|
||||
};
|
||||
const buildReviewFilterUrl = (params: { type?: string; status?: string; tag?: string }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params.type) search.set('type', params.type);
|
||||
if (params.status) search.set('status', params.status);
|
||||
if (params.tag) search.set('tag', params.tag);
|
||||
const query = search.toString();
|
||||
return query ? `/reviews?${query}` : '/reviews';
|
||||
};
|
||||
|
||||
const pageTitle = review
|
||||
? `${review.title} | ${t('reviews.title')} | ${siteSettings.siteShortName}`
|
||||
@@ -86,26 +94,54 @@ const pageTitle = review
|
||||
const pageDescription = review?.description || copy.notFoundDescription;
|
||||
const canonical = review ? `/reviews/${review.id}` : '/reviews';
|
||||
const jsonLd = review
|
||||
? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
name: review.title,
|
||||
reviewBody: review.description,
|
||||
datePublished: review.review_date,
|
||||
dateModified: review.updated_at,
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: review.rating,
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
},
|
||||
itemReviewed: {
|
||||
'@type': 'CreativeWork',
|
||||
? [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
name: review.title,
|
||||
genre: typeLabels[review.review_type] || review.review_type,
|
||||
reviewBody: review.description,
|
||||
datePublished: review.review_date,
|
||||
dateModified: review.updated_at,
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: review.rating,
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
},
|
||||
itemReviewed: {
|
||||
'@type': 'CreativeWork',
|
||||
name: review.title,
|
||||
genre: typeLabels[review.review_type] || review.review_type,
|
||||
keywords: review.tags,
|
||||
},
|
||||
keywords: review.tags,
|
||||
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
|
||||
},
|
||||
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
|
||||
}
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: siteSettings.siteName,
|
||||
item: siteSettings.siteUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('reviews.title'),
|
||||
item: new URL('/reviews', siteSettings.siteUrl).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: review.title,
|
||||
item: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
---
|
||||
|
||||
@@ -181,11 +217,25 @@ const jsonLd = review
|
||||
<div class="review-detail-meta-grid">
|
||||
<div>
|
||||
<div class="review-detail-meta-grid__label">{copy.type}</div>
|
||||
<div>{typeLabels[review.review_type] || review.review_type}</div>
|
||||
<div>
|
||||
<a
|
||||
href={buildReviewFilterUrl({ type: review.review_type })}
|
||||
class="review-detail-filter-link"
|
||||
>
|
||||
{typeLabels[review.review_type] || review.review_type}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="review-detail-meta-grid__label">{copy.status}</div>
|
||||
<div>{statusLabels[review.normalizedStatus]}</div>
|
||||
<div>
|
||||
<a
|
||||
href={buildReviewFilterUrl({ status: review.normalizedStatus })}
|
||||
class="review-detail-filter-link"
|
||||
>
|
||||
{statusLabels[review.normalizedStatus]}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="review-detail-meta-grid__label">{copy.reviewDate}</div>
|
||||
@@ -203,7 +253,12 @@ const jsonLd = review
|
||||
<div class="review-detail-tags mt-4">
|
||||
{review.tags.length ? (
|
||||
review.tags.map((tag) => (
|
||||
<span class="terminal-chip text-xs py-1 px-2.5">#{tag}</span>
|
||||
<a
|
||||
href={`/reviews?tag=${encodeURIComponent(tag)}`}
|
||||
class="terminal-chip text-xs py-1 px-2.5 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<span class="text-sm text-[var(--text-secondary)]">{t('common.noData')}</span>
|
||||
@@ -350,6 +405,21 @@ const jsonLd = review
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.review-detail-filter-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.review-detail-filter-link:hover {
|
||||
color: var(--primary);
|
||||
border-bottom-color: color-mix(in oklab, var(--primary) 55%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.review-detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -10,11 +10,14 @@ import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/rev
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Fetch reviews from backend API
|
||||
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedType = url.searchParams.get('type') || 'all';
|
||||
const selectedStatus = url.searchParams.get('status') || 'all';
|
||||
const selectedTag = url.searchParams.get('tag') || '';
|
||||
const selectedQuery = url.searchParams.get('q')?.trim() || '';
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
try {
|
||||
reviews = await apiClient.getReviews();
|
||||
} catch (error) {
|
||||
@@ -22,10 +25,32 @@ try {
|
||||
}
|
||||
|
||||
const parsedReviews: ParsedReview[] = reviews.map(parseReview);
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const normalizedSelectedQuery = selectedQuery.toLowerCase();
|
||||
|
||||
const filteredReviews = selectedType === 'all'
|
||||
? parsedReviews
|
||||
: parsedReviews.filter(review => review.review_type === selectedType);
|
||||
const filteredReviews = parsedReviews.filter((review) => {
|
||||
if (selectedType !== 'all' && review.review_type !== selectedType) return false;
|
||||
if (selectedStatus !== 'all' && review.normalizedStatus !== selectedStatus) return false;
|
||||
if (
|
||||
normalizedSelectedTag &&
|
||||
!review.tags.some((tag) => tag.trim().toLowerCase() === normalizedSelectedTag)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedSelectedQuery) {
|
||||
const haystack = [
|
||||
review.title,
|
||||
review.description,
|
||||
review.review_type,
|
||||
review.status,
|
||||
review.tags.join(' '),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(normalizedSelectedQuery)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: filteredReviews.length,
|
||||
@@ -76,6 +101,20 @@ const statusColors: Record<ReviewStatus, string> = {
|
||||
dropped: 'var(--text-tertiary)',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ id: 'all', label: '全部状态' },
|
||||
{ id: 'completed', label: t('reviews.statusCompleted') },
|
||||
{ id: 'in-progress', label: t('reviews.statusInProgress') },
|
||||
{ id: 'dropped', label: t('reviews.statusDropped') },
|
||||
];
|
||||
|
||||
const tagOptions = Array.from(
|
||||
parsedReviews.reduce((set, review) => {
|
||||
review.tags.forEach((tag) => set.add(tag));
|
||||
return set;
|
||||
}, new Set<string>()),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
id: 'total',
|
||||
@@ -114,28 +153,54 @@ const statCards = [
|
||||
barWidth: `${inProgressRatio}%`,
|
||||
}
|
||||
];
|
||||
|
||||
const buildReviewsUrl = ({
|
||||
type = selectedType,
|
||||
status = selectedStatus,
|
||||
tag = selectedTag,
|
||||
q = selectedQuery,
|
||||
}: {
|
||||
type?: string;
|
||||
status?: string;
|
||||
tag?: string;
|
||||
q?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (type && type !== 'all') params.set('type', type);
|
||||
if (status && status !== 'all') params.set('status', status);
|
||||
if (tag) params.set('tag', tag);
|
||||
if (q) params.set('q', q);
|
||||
const query = params.toString();
|
||||
return query ? `/reviews?${query}` : '/reviews';
|
||||
};
|
||||
|
||||
const activeFilters = [
|
||||
selectedType !== 'all' ? typeLabels[selectedType] || selectedType : '',
|
||||
selectedStatus !== 'all'
|
||||
? statusOptions.find((item) => item.id === selectedStatus)?.label || selectedStatus
|
||||
: '',
|
||||
selectedTag ? `#${selectedTag}` : '',
|
||||
selectedQuery ? `q=${selectedQuery}` : '',
|
||||
].filter(Boolean);
|
||||
---
|
||||
|
||||
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Single Terminal Window for entire page -->
|
||||
<TerminalWindow title="~/reviews" class="w-full">
|
||||
<div class="px-4 py-4 space-y-6">
|
||||
|
||||
<!-- Header Section -->
|
||||
<div>
|
||||
<CommandPrompt command="less README.md" path="~/reviews" />
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<div class="terminal-kicker">review ledger</div>
|
||||
<div class="terminal-section-title mt-4">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-star"></i>
|
||||
</span>
|
||||
<div>
|
||||
<i class="fas fa-star"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('reviews.title')}</h1>
|
||||
<p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{t('reviews.subtitle')}
|
||||
{selectedType !== 'all' && ` · ${t('reviews.currentFilter', { type: typeLabels[selectedType] || selectedType })}`}
|
||||
{activeFilters.length > 0 && ` · ${activeFilters.join(' · ')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +208,7 @@ const statCards = [
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommandPrompt promptId="reviews-stats-prompt" command="jq '.summary' stats.json" path="~/reviews" />
|
||||
<CommandPrompt command="jq '.summary' stats.json" path="~/reviews" />
|
||||
<div class="reviews-stats-grid ml-4 mt-2">
|
||||
{statCards.map((card) => (
|
||||
<div class="reviews-stat-card" style={`--review-stat-color: ${card.color};`}>
|
||||
@@ -155,25 +220,13 @@ const statCards = [
|
||||
</div>
|
||||
<div class="mt-3 flex items-end justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
id={card.id === 'total' ? 'reviews-total' : card.id === 'average' ? 'reviews-average' : card.id === 'completed' ? 'reviews-completed' : 'reviews-progress'}
|
||||
class="reviews-stat-card__value"
|
||||
>
|
||||
{card.value}
|
||||
<div class="reviews-stat-card__value">{card.value}</div>
|
||||
<div class="reviews-stat-card__detail">
|
||||
{card.id === 'average' ? '/ 5' : card.detail}
|
||||
</div>
|
||||
<div
|
||||
id={card.id === 'total' ? 'reviews-total-detail' : card.id === 'completed' ? 'reviews-completed-detail' : card.id === 'progress' ? 'reviews-progress-detail' : undefined}
|
||||
class:list={[
|
||||
'reviews-stat-card__detail',
|
||||
card.id === 'average' && 'hidden'
|
||||
]}
|
||||
>
|
||||
{card.id === 'average' ? '' : card.detail}
|
||||
</div>
|
||||
{card.id === 'average' && <div class="reviews-stat-card__detail">/ 5</div>}
|
||||
</div>
|
||||
{card.id === 'average' && (
|
||||
<div id="reviews-average-stars" class="reviews-average-stars">
|
||||
<div class="reviews-average-stars">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<i class={`fas fa-star ${index < Math.round(Number(stats.avgRating)) ? '' : 'opacity-25'}`}></i>
|
||||
))}
|
||||
@@ -181,11 +234,7 @@ const statCards = [
|
||||
)}
|
||||
</div>
|
||||
<div class="reviews-stat-card__bar">
|
||||
<div
|
||||
id={card.id === 'total' ? 'reviews-total-bar' : card.id === 'completed' ? 'reviews-completed-bar' : card.id === 'progress' ? 'reviews-progress-bar' : 'reviews-average-bar'}
|
||||
class="reviews-stat-card__bar-fill"
|
||||
style={`width: ${card.barWidth};`}
|
||||
></div>
|
||||
<div class="reviews-stat-card__bar-fill" style={`width: ${card.barWidth};`}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -193,13 +242,11 @@ const statCards = [
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommandPrompt promptId="reviews-filter-prompt" command="printf '%s\n' all game anime music book movie" path="~/reviews" />
|
||||
<CommandPrompt command="printf '%s\n' all game anime music book movie" path="~/reviews" />
|
||||
<div class="ml-4 mt-4 flex flex-wrap gap-2">
|
||||
{filters.map(filter => (
|
||||
<FilterPill
|
||||
href={filter.id === 'all' ? '/reviews' : `/reviews?type=${filter.id}`}
|
||||
data-review-filter={filter.id}
|
||||
data-review-label={filter.name}
|
||||
href={buildReviewsUrl({ type: filter.id })}
|
||||
tone={filter.id === 'all' ? 'neutral' : filter.id === 'game' ? 'blue' : filter.id === 'book' ? 'amber' : filter.id === 'music' ? 'teal' : 'violet'}
|
||||
active={selectedType === filter.id}
|
||||
class="review-filter"
|
||||
@@ -213,16 +260,56 @@ const statCards = [
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommandPrompt promptId="reviews-list-prompt" command="find . -maxdepth 1 -name '*.md' | sort" path="~/reviews" />
|
||||
<CommandPrompt command="reviews filter --public-only" path="~/reviews" />
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<form method="GET" action="/reviews" class="reviews-filter-form">
|
||||
<label class="reviews-filter-field">
|
||||
<span>搜索</span>
|
||||
<input type="text" name="q" value={selectedQuery} placeholder="标题 / 简介 / 标签" />
|
||||
</label>
|
||||
|
||||
<label class="reviews-filter-field">
|
||||
<span>状态</span>
|
||||
<select name="status">
|
||||
{statusOptions.map((option) => (
|
||||
<option value={option.id} selected={selectedStatus === option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="reviews-filter-field">
|
||||
<span>标签</span>
|
||||
<select name="tag">
|
||||
<option value="" selected={!selectedTag}>全部标签</option>
|
||||
{tagOptions.map((tag) => (
|
||||
<option value={tag} selected={selectedTag === tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedType !== 'all' ? <input type="hidden" name="type" value={selectedType} /> : null}
|
||||
|
||||
<div class="reviews-filter-actions">
|
||||
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-filter"></i>
|
||||
<span>应用筛选</span>
|
||||
</button>
|
||||
<a href="/reviews" class="terminal-action-button">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>{t('common.clearFilters')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommandPrompt command="find . -maxdepth 1 -name '*.md' | sort" path="~/reviews" />
|
||||
<div class="ml-4 mt-2">
|
||||
<div class="reviews-card-grid">
|
||||
{parsedReviews.map(review => (
|
||||
{filteredReviews.map(review => (
|
||||
<article
|
||||
class="review-card terminal-panel group"
|
||||
data-review-card
|
||||
data-review-type={review.review_type}
|
||||
data-review-status={review.normalizedStatus}
|
||||
data-review-rating={review.rating || 0}
|
||||
style={`--review-accent: ${typeColors[review.review_type] || '#888'};`}
|
||||
>
|
||||
<div class="review-card__poster">
|
||||
@@ -253,7 +340,7 @@ const statCards = [
|
||||
<div class="review-card__head">
|
||||
<div class="min-w-0">
|
||||
<div class="review-card__badges">
|
||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={`--accent-color:${typeColors[review.review_type] || '#888'};--accent-rgb:${typeColors[review.review_type] === '#4285f4' ? '66 133 244' : review.review_type === 'anime' ? '255 107 107' : review.review_type === 'music' ? '0 255 157' : review.review_type === 'book' ? '245 158 11' : '155 89 182'};`}>
|
||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={`--accent-color:${typeColors[review.review_type] || '#888'};--accent-rgb:${review.review_type === 'game' ? '66 133 244' : review.review_type === 'anime' ? '255 107 107' : review.review_type === 'music' ? '0 255 157' : review.review_type === 'book' ? '245 158 11' : '155 89 182'};`}>
|
||||
{typeLabels[review.review_type] || review.review_type}
|
||||
</span>
|
||||
<span class="terminal-chip text-[10px] py-1 px-2" style={`color:${statusColors[review.normalizedStatus]};`}>
|
||||
@@ -306,9 +393,9 @@ const statCards = [
|
||||
|
||||
<div class="review-card__tags">
|
||||
{review.tags.map((tag: string) => (
|
||||
<span class="terminal-chip text-xs py-1 px-2.5">
|
||||
<a class="terminal-chip text-xs py-1 px-2.5" href={buildReviewsUrl({ tag, status: selectedStatus, q: selectedQuery })}>
|
||||
#{tag}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +414,6 @@ const statCards = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back to Home -->
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<a href="/" class="terminal-subtle-link">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
@@ -337,136 +423,6 @@ const statCards = [
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
reviewTypeLabels: {
|
||||
game: t('reviews.typeGame'),
|
||||
anime: t('reviews.typeAnime'),
|
||||
music: t('reviews.typeMusic'),
|
||||
book: t('reviews.typeBook'),
|
||||
movie: t('reviews.typeMovie'),
|
||||
all: t('reviews.typeAll'),
|
||||
},
|
||||
reviewsBaseSubtitle: t('reviews.subtitle'),
|
||||
}}
|
||||
>
|
||||
(function() {
|
||||
/** @type {Window['__termiCommandPrompt']} */
|
||||
let promptApi;
|
||||
|
||||
const typeLabels = reviewTypeLabels;
|
||||
|
||||
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
|
||||
const filters = Array.from(document.querySelectorAll('[data-review-filter]'));
|
||||
const subtitle = document.getElementById('reviews-subtitle');
|
||||
const totalEl = document.getElementById('reviews-total');
|
||||
const avgEl = document.getElementById('reviews-average');
|
||||
const completedEl = document.getElementById('reviews-completed');
|
||||
const progressEl = document.getElementById('reviews-progress');
|
||||
const totalDetailEl = document.getElementById('reviews-total-detail');
|
||||
const totalBarEl = document.getElementById('reviews-total-bar');
|
||||
const averageStarsEl = document.getElementById('reviews-average-stars');
|
||||
const completedDetailEl = document.getElementById('reviews-completed-detail');
|
||||
const completedBarEl = document.getElementById('reviews-completed-bar');
|
||||
const progressDetailEl = document.getElementById('reviews-progress-detail');
|
||||
const progressBarEl = document.getElementById('reviews-progress-bar');
|
||||
const emptyState = document.getElementById('reviews-empty-state');
|
||||
const t = window.__termiTranslate;
|
||||
const baseSubtitle = reviewsBaseSubtitle;
|
||||
promptApi = window.__termiCommandPrompt;
|
||||
|
||||
function updateReviewPrompts(type) {
|
||||
const selectedType = type || 'all';
|
||||
const statsCommand = selectedType === 'all'
|
||||
? "jq '.summary' stats.json"
|
||||
: `jq '.summary.${selectedType}' stats.json`;
|
||||
const filterCommand = selectedType === 'all'
|
||||
? "printf '%s\\n' all game anime music book movie"
|
||||
: `printf '%s\\n' ${selectedType}`;
|
||||
const listCommand = selectedType === 'all'
|
||||
? "find . -maxdepth 1 -name '*.md' | sort"
|
||||
: `grep -El '^type: ${selectedType}$' ./*.md`;
|
||||
|
||||
promptApi?.set?.('reviews-stats-prompt', statsCommand, { typing: false });
|
||||
promptApi?.set?.('reviews-filter-prompt', filterCommand, { typing: false });
|
||||
promptApi?.set?.('reviews-list-prompt', listCommand, { typing: false });
|
||||
}
|
||||
|
||||
function updateFilterUi(activeType) {
|
||||
filters.forEach((filter) => {
|
||||
const isActive = filter.dataset.reviewFilter === activeType;
|
||||
filter.classList.toggle('is-active', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
function updateStats(visibleCards) {
|
||||
const total = visibleCards.length;
|
||||
const average = total
|
||||
? (visibleCards.reduce((sum, card) => sum + Number(card.dataset.reviewRating || 0), 0) / total).toFixed(1)
|
||||
: '0';
|
||||
const completed = visibleCards.filter((card) => card.dataset.reviewStatus === 'completed').length;
|
||||
const inProgress = visibleCards.filter((card) => card.dataset.reviewStatus === 'in-progress').length;
|
||||
const highRatingCount = visibleCards.filter((card) => Number(card.dataset.reviewRating || 0) >= 4).length;
|
||||
const completedRatio = total ? Math.round((completed / total) * 100) : 0;
|
||||
const inProgressRatio = total ? Math.round((inProgress / total) * 100) : 0;
|
||||
|
||||
if (totalEl) totalEl.textContent = String(total);
|
||||
if (avgEl) avgEl.textContent = average;
|
||||
if (completedEl) completedEl.textContent = String(completed);
|
||||
if (progressEl) progressEl.textContent = String(inProgress);
|
||||
if (totalDetailEl) totalDetailEl.textContent = `≥4 ★ · ${highRatingCount}`;
|
||||
if (totalBarEl) totalBarEl.style.width = `${total ? Math.max((highRatingCount / total) * 100, 12) : 12}%`;
|
||||
if (completedDetailEl) completedDetailEl.textContent = `${completedRatio}%`;
|
||||
if (completedBarEl) completedBarEl.style.width = `${completedRatio}%`;
|
||||
if (progressDetailEl) progressDetailEl.textContent = `${inProgressRatio}%`;
|
||||
if (progressBarEl) progressBarEl.style.width = `${inProgressRatio}%`;
|
||||
if (averageStarsEl) {
|
||||
const roundedAverage = Math.round(Number(average));
|
||||
averageStarsEl.innerHTML = Array.from({ length: 5 }, (_, index) =>
|
||||
`<i class="fas fa-star ${index < roundedAverage ? '' : 'opacity-25'}"></i>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter(type, pushState = true) {
|
||||
const visibleCards = cards.filter((card) => type === 'all' || card.dataset.reviewType === type);
|
||||
|
||||
cards.forEach((card) => {
|
||||
card.style.display = visibleCards.includes(card) ? '' : 'none';
|
||||
});
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.classList.toggle('hidden', visibleCards.length > 0);
|
||||
}
|
||||
|
||||
updateFilterUi(type);
|
||||
updateStats(visibleCards);
|
||||
|
||||
if (subtitle) {
|
||||
subtitle.textContent = type === 'all'
|
||||
? baseSubtitle
|
||||
: `${baseSubtitle} · ${t('reviews.currentFilter', { type: typeLabels[type] || type })}`;
|
||||
}
|
||||
|
||||
if (pushState) {
|
||||
const nextUrl = type === 'all' ? '/reviews' : `/reviews?type=${encodeURIComponent(type)}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
|
||||
updateReviewPrompts(type);
|
||||
}
|
||||
|
||||
filters.forEach((filter) => {
|
||||
filter.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
applyFilter(filter.dataset.reviewFilter || 'all');
|
||||
});
|
||||
});
|
||||
|
||||
applyFilter(new URL(window.location.href).searchParams.get('type') || 'all', false);
|
||||
})();
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
@@ -539,6 +495,43 @@ const statCards = [
|
||||
color: #e0a100;
|
||||
}
|
||||
|
||||
.reviews-filter-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.reviews-filter-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.reviews-filter-field span {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.reviews-filter-field input,
|
||||
.reviews-filter-field select {
|
||||
min-height: 2.85rem;
|
||||
width: 100%;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
|
||||
color: var(--title-color);
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.reviews-filter-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.reviews-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -762,14 +755,16 @@ const statCards = [
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.reviews-stats-grid,
|
||||
.reviews-card-grid {
|
||||
.reviews-card-grid,
|
||||
.reviews-filter-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.reviews-stats-grid,
|
||||
.reviews-card-grid {
|
||||
.reviews-card-grid,
|
||||
.reviews-filter-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
|
||||
176
frontend/src/pages/tags/[slug].astro
Normal file
176
frontend/src/pages/tags/[slug].astro
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { apiClient } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Post, Tag } from '../../lib/types';
|
||||
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let tags: Tag[] = [];
|
||||
let posts: Post[] = [];
|
||||
|
||||
try {
|
||||
[tags, posts] = await Promise.all([apiClient.getTags(), apiClient.getPosts()]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tag detail data:', error);
|
||||
}
|
||||
|
||||
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
|
||||
const tag =
|
||||
tags.find((item) => {
|
||||
return [item.slug, item.name].some(
|
||||
(value) => (value || '').trim().toLowerCase() === requested,
|
||||
);
|
||||
}) || null;
|
||||
|
||||
if (!tag) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const canonicalUrl = buildTagUrl(tag);
|
||||
if (slug && slug !== tag.slug && tag.slug) {
|
||||
return Astro.redirect(canonicalUrl, 301);
|
||||
}
|
||||
|
||||
const filteredPosts = posts.filter((post) =>
|
||||
post.tags.some((item) => item.trim().toLowerCase() === tag.name.trim().toLowerCase()),
|
||||
);
|
||||
const tagTheme = getTagTheme(tag.name);
|
||||
const pageTitle = tag.seoTitle || `${tag.name} - ${t('tags.title')}`;
|
||||
const pageDescription = tag.seoDescription || tag.description || t('tags.selectedSummary', {
|
||||
tag: tag.name,
|
||||
count: filteredPosts.length,
|
||||
});
|
||||
const siteBaseUrl = new URL(Astro.request.url).origin;
|
||||
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
url: absoluteCanonicalUrl,
|
||||
about: {
|
||||
'@type': 'DefinedTerm',
|
||||
name: tag.name,
|
||||
termCode: tag.slug || tag.name,
|
||||
description: tag.description || pageDescription,
|
||||
},
|
||||
keywords: [tag.name, tag.slug].filter(Boolean),
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Termi',
|
||||
item: siteBaseUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('tags.title'),
|
||||
item: new URL('/tags', siteBaseUrl).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: tag.name,
|
||||
item: absoluteCanonicalUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
ogImage={tag.coverImage}
|
||||
canonical={canonicalUrl}
|
||||
jsonLd={jsonLd}
|
||||
twitterCard={tag.coverImage ? 'summary_large_image' : 'summary'}
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title={`~/tags/${tag.slug || tag.name}`} class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command={`posts query --tag "${tag.name}"`} />
|
||||
|
||||
<div class="terminal-panel ml-4 mt-4 space-y-5" style={getAccentVars(tagTheme)}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-4">
|
||||
<a href="/tags" class="terminal-link-arrow">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>返回标签目录</span>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-hashtag"></i>
|
||||
tag detail
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent">
|
||||
<i class="fas fa-tag"></i>
|
||||
{tag.slug || tag.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||
<span>{t('common.postsCount', { count: filteredPosts.length })}</span>
|
||||
</span>
|
||||
{typeof tag.count === 'number' ? (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-signal text-[var(--primary)]"></i>
|
||||
<span>{tag.count}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{tag.name}</h1>
|
||||
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
|
||||
</div>
|
||||
|
||||
{tag.coverImage ? (
|
||||
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
|
||||
<img
|
||||
src={tag.coverImage}
|
||||
alt={tag.name}
|
||||
class="h-56 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-8">
|
||||
<CommandPrompt command={`posts list --tag "${tag.name}"`} typing={false} />
|
||||
{filteredPosts.length > 0 ? (
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{filteredPosts.map((post) => (
|
||||
<PostCard post={post} selectedTag={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="terminal-empty ml-4 mt-4">
|
||||
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
|
||||
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -4,59 +4,28 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import { apiClient } from '../../lib/api/client';
|
||||
import { getI18n, formatReadTime } from '../../lib/i18n';
|
||||
import type { Post, Tag } from '../../lib/types';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Tag } from '../../lib/types';
|
||||
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Fetch tags from backend
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let tags: Tag[] = [];
|
||||
let allPosts: Post[] = [];
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
try {
|
||||
[tags, allPosts] = await Promise.all([
|
||||
apiClient.getTags(),
|
||||
apiClient.getPosts(),
|
||||
]);
|
||||
tags = await apiClient.getTags();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags:', error);
|
||||
}
|
||||
|
||||
// Get URL params
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedTagParam = url.searchParams.get('tag') || '';
|
||||
const selectedTagRecord = tags.find((tag) => {
|
||||
const wanted = selectedTagParam.trim().toLowerCase();
|
||||
if (!wanted) return false;
|
||||
return [tag.name, tag.slug].some((value) => (value || '').trim().toLowerCase() === wanted);
|
||||
}) || null;
|
||||
const selectedTag = selectedTagRecord?.name || selectedTagParam;
|
||||
const selectedTagToken = selectedTag.trim().toLowerCase();
|
||||
const selectedTagTheme = getTagTheme(selectedTag);
|
||||
const isSelectedTag = (tag: Tag) =>
|
||||
tag.name.trim().toLowerCase() === selectedTagToken || tag.slug.trim().toLowerCase() === selectedTagToken;
|
||||
|
||||
const filteredPosts = selectedTag
|
||||
? allPosts.filter((post) => post.tags?.some((tag) => (tag || '').trim().toLowerCase() === selectedTagToken))
|
||||
: [];
|
||||
const tagAccentMap = Object.fromEntries(
|
||||
tags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
|
||||
);
|
||||
const pageTitle = selectedTagRecord?.seoTitle || t('tags.pageTitle');
|
||||
const pageDescription = selectedTagRecord?.seoDescription || selectedTagRecord?.description;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} - Termi`}
|
||||
description={pageDescription}
|
||||
ogImage={selectedTagRecord?.coverImage}
|
||||
>
|
||||
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/tags" class="w-full">
|
||||
<div class="mb-6 px-4">
|
||||
<CommandPrompt command="cut -d',' -f1 tags.index | sort -u" />
|
||||
<CommandPrompt command="tags list --sort popularity" />
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<div class="terminal-kicker">tag index</div>
|
||||
<div class="terminal-section-title mt-4">
|
||||
@@ -75,253 +44,43 @@ const pageDescription = selectedTagRecord?.seoDescription || selectedTagRecord?.
|
||||
<i class="fas fa-tags text-[var(--primary)]"></i>
|
||||
<span>{t('common.tagsCount', { count: tags.length })}</span>
|
||||
</span>
|
||||
<span
|
||||
id="tags-current-pill"
|
||||
class:list={['terminal-stat-pill terminal-stat-pill--accent', !selectedTag && 'hidden']}
|
||||
style={selectedTag ? getAccentVars(selectedTagTheme) : undefined}
|
||||
>
|
||||
<i class="fas fa-filter"></i>
|
||||
<span id="tags-current-label">{selectedTag ? t('tags.currentTag', { tag: selectedTag }) : ''}</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
||||
<span>点击进入标签详情页</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tags-summary-section" class:list={['mb-6 px-4', !selectedTag && 'hidden']}>
|
||||
<CommandPrompt promptId="tags-match-prompt" command={selectedTag ? `grep -Ril "#${selectedTag}" ./posts` : 'grep -Ril "#tag" ./posts'} />
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p id="tags-selected-summary" class="text-[var(--text-secondary)] leading-6">
|
||||
{t('tags.selectedSummary', { tag: selectedTag, count: filteredPosts.length })}
|
||||
</p>
|
||||
<a id="tags-clear-btn" href="/tags" class="ui-filter-pill ui-filter-pill--teal">
|
||||
<i class="fas fa-times"></i>
|
||||
<span>{t('common.clearFilters')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{selectedTagRecord && (selectedTagRecord.description || selectedTagRecord.coverImage) ? (
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
|
||||
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{selectedTagRecord.description ? <p>{selectedTagRecord.description}</p> : null}
|
||||
{selectedTagRecord.accentColor ? (
|
||||
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedTagRecord.accentColor}`}></span>
|
||||
<span>{selectedTagRecord.accentColor}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedTagRecord.coverImage ? (
|
||||
<img
|
||||
src={selectedTagRecord.coverImage}
|
||||
alt={selectedTagRecord.name}
|
||||
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 mb-8">
|
||||
<div class="px-4">
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-4">
|
||||
<div class="mb-4 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{t('tags.browseTags')}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<div class="terminal-empty w-full">
|
||||
{t('tags.emptyTags')}
|
||||
</div>
|
||||
) : (
|
||||
tags.map(tag => (
|
||||
<div class="terminal-empty w-full">
|
||||
{t('tags.emptyTags')}
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{tags.map((tag) => (
|
||||
<FilterPill
|
||||
tone="accent"
|
||||
active={isSelectedTag(tag)}
|
||||
href={`/tags?tag=${encodeURIComponent(tag.slug || tag.name || '')}`}
|
||||
data-tag-filter={tag.slug || tag.name || ''}
|
||||
href={buildTagUrl(tag)}
|
||||
style={getAccentVars(getTagTheme(tag.name))}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span>{tag.name}</span>
|
||||
{typeof tag.count === 'number' ? (
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{tag.count}</span>
|
||||
) : null}
|
||||
</FilterPill>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
<div id="tags-results-section" class:list={['border-t border-[var(--border-color)] pt-6', !selectedTag && 'hidden']}>
|
||||
<CommandPrompt promptId="tags-results-prompt" command={selectedTag ? `find ./posts -type f | xargs grep -il "#${selectedTag}"` : "find ./posts -type f | sort"} />
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{allPosts.map((post) => {
|
||||
const matchesInitial = selectedTag
|
||||
? post.tags?.some((tag) => (tag || '').trim().toLowerCase() === selectedTagToken)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
data-tag-post
|
||||
data-tags={post.tags.map((tag) => (tag || '').trim().toLowerCase()).join('|')}
|
||||
class:list={[
|
||||
'terminal-panel terminal-panel-accent terminal-interactive-card block p-5',
|
||||
!matchesInitial && 'hidden'
|
||||
]}
|
||||
style={getAccentVars(getPostTypeTheme(post.type))}
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
|
||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<h3 class="font-bold text-[var(--title-color)] text-lg">{post.title}</h3>
|
||||
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(post.category))}>
|
||||
<span>{post.category}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {formatReadTime(locale, post.readTime, t)}</p>
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
|
||||
<div class="mt-4 terminal-link-arrow">
|
||||
<span>{t('common.viewArticle')}</span>
|
||||
<i class="fas fa-arrow-right text-xs"></i>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
<div id="tags-empty-wrap" class:list={['border-t border-[var(--border-color)] pt-6', (!selectedTag || filteredPosts.length > 0) && 'hidden']}>
|
||||
<div id="tags-empty-state" class="terminal-empty ml-4 mt-4">
|
||||
<i class="fas fa-search text-4xl text-[var(--text-tertiary)] mb-4"></i>
|
||||
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
initialSelectedTag: selectedTag,
|
||||
tagAccentMap,
|
||||
}}
|
||||
>
|
||||
(function() {
|
||||
/** @type {Window['__termiCommandPrompt']} */
|
||||
let promptApi;
|
||||
|
||||
const tagButtons = Array.from(document.querySelectorAll('[data-tag-filter]'));
|
||||
const tagPosts = Array.from(document.querySelectorAll('[data-tag-post]'));
|
||||
const currentPill = document.getElementById('tags-current-pill');
|
||||
const currentLabel = document.getElementById('tags-current-label');
|
||||
const summarySection = document.getElementById('tags-summary-section');
|
||||
const selectedSummary = document.getElementById('tags-selected-summary');
|
||||
const resultsSection = document.getElementById('tags-results-section');
|
||||
const emptyWrap = document.getElementById('tags-empty-wrap');
|
||||
const clearBtn = document.getElementById('tags-clear-btn');
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
promptApi = window.__termiCommandPrompt;
|
||||
|
||||
const state = {
|
||||
tag: initialSelectedTag || '',
|
||||
};
|
||||
|
||||
function syncTagButtons() {
|
||||
tagButtons.forEach((button) => {
|
||||
const value = (button.getAttribute('data-tag-filter') || '').trim().toLowerCase();
|
||||
button.classList.toggle('is-active', Boolean(state.tag) && value === state.tag.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
function updateTagPrompts() {
|
||||
const matchCommand = state.tag
|
||||
? `grep -Ril "#${state.tag}" ./posts`
|
||||
: 'grep -Ril "#tag" ./posts';
|
||||
const resultCommand = state.tag
|
||||
? `find ./posts -type f | xargs grep -il "#${state.tag}"`
|
||||
: "find ./posts -type f | sort";
|
||||
|
||||
promptApi?.set?.('tags-match-prompt', matchCommand, { typing: false });
|
||||
promptApi?.set?.('tags-results-prompt', resultCommand, { typing: false });
|
||||
}
|
||||
|
||||
function updateTagUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (state.tag) params.set('tag', state.tag);
|
||||
const nextUrl = params.toString() ? `/tags?${params.toString()}` : '/tags';
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
|
||||
function applyTagFilter(pushHistory = true) {
|
||||
const normalizedTag = state.tag.trim().toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
tagPosts.forEach((post) => {
|
||||
const tags = `|${(post.getAttribute('data-tags') || '').toLowerCase()}|`;
|
||||
const matches = normalizedTag ? tags.includes(`|${normalizedTag}|`) : false;
|
||||
post.classList.toggle('hidden', !matches);
|
||||
if (matches) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
syncTagButtons();
|
||||
updateTagPrompts();
|
||||
|
||||
if (currentPill && currentLabel) {
|
||||
currentPill.classList.toggle('hidden', !state.tag);
|
||||
if (state.tag) {
|
||||
currentLabel.textContent = t('tags.currentTag', { tag: state.tag });
|
||||
currentPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
|
||||
} else {
|
||||
currentLabel.textContent = '';
|
||||
currentPill.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
|
||||
if (summarySection) {
|
||||
summarySection.classList.toggle('hidden', !state.tag);
|
||||
}
|
||||
if (resultsSection) {
|
||||
resultsSection.classList.toggle('hidden', !state.tag);
|
||||
}
|
||||
if (selectedSummary) {
|
||||
selectedSummary.textContent = state.tag
|
||||
? t('tags.selectedSummary', { tag: state.tag, count: visibleCount })
|
||||
: '';
|
||||
}
|
||||
if (emptyWrap) {
|
||||
emptyWrap.classList.toggle('hidden', !state.tag || visibleCount > 0);
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.classList.toggle('hidden', !state.tag);
|
||||
}
|
||||
|
||||
if (pushHistory) {
|
||||
updateTagUrl();
|
||||
}
|
||||
}
|
||||
|
||||
tagButtons.forEach((button) => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.tag = button.getAttribute('data-tag-filter') || '';
|
||||
applyTagFilter();
|
||||
});
|
||||
});
|
||||
|
||||
clearBtn?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.tag = '';
|
||||
applyTagFilter();
|
||||
});
|
||||
|
||||
applyTagFilter(false);
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user