test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
This commit is contained in:
92
frontend/src/lib/reviews.ts
Normal file
92
frontend/src/lib/reviews.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Review } from './api/client';
|
||||
|
||||
export type ReviewStatus = 'completed' | 'in-progress' | 'dropped';
|
||||
|
||||
export type ParsedReview = Omit<Review, 'tags'> & {
|
||||
tags: string[];
|
||||
normalizedStatus: ReviewStatus;
|
||||
coverIsImage: boolean;
|
||||
coverUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
externalLink: boolean;
|
||||
};
|
||||
|
||||
const reviewCoverCatalog: Record<string, string> = {
|
||||
'《漫长的季节》': '/review-covers/the-long-season.svg',
|
||||
'《十三邀》': '/review-covers/thirteen-invites.svg',
|
||||
'《黑神话:悟空》': '/review-covers/black-myth-wukong.svg',
|
||||
'《置身事内》': '/review-covers/placed-within.svg',
|
||||
'《宇宙探索编辑部》': '/review-covers/journey-to-the-west-editorial.svg',
|
||||
'《疲惫生活中的英雄梦想》': '/review-covers/hero-dreams-in-tired-life.svg',
|
||||
};
|
||||
|
||||
export function normalizeReviewStatus(status: string | null | undefined): ReviewStatus {
|
||||
const normalized = String(status || '').trim().toLowerCase();
|
||||
|
||||
if (normalized === 'published' || normalized === 'completed' || normalized === 'done') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === 'draft' ||
|
||||
normalized === 'in-progress' ||
|
||||
normalized === 'watching' ||
|
||||
normalized === 'reading' ||
|
||||
normalized === 'listening'
|
||||
) {
|
||||
return 'in-progress';
|
||||
}
|
||||
|
||||
if (normalized === 'dropped' || normalized === 'abandoned') {
|
||||
return 'dropped';
|
||||
}
|
||||
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
export function parseReviewTags(value: string | null | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function isImageCover(cover: string | null | undefined) {
|
||||
const normalized = String(cover || '').trim();
|
||||
return /^(https?:)?\/\//.test(normalized) || normalized.startsWith('/');
|
||||
}
|
||||
|
||||
export function resolveReviewCover(review: Pick<Review, 'cover' | 'title'>) {
|
||||
if (isImageCover(review.cover)) {
|
||||
return String(review.cover).trim();
|
||||
}
|
||||
|
||||
return reviewCoverCatalog[review.title] || null;
|
||||
}
|
||||
|
||||
export function normalizeLinkUrl(value: string | null | undefined) {
|
||||
const trimmed = String(value || '').trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function isExternalLink(value: string | null | undefined) {
|
||||
return /^(https?:)?\/\//.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function parseReview(review: Review): ParsedReview {
|
||||
return {
|
||||
...review,
|
||||
tags: parseReviewTags(review.tags),
|
||||
normalizedStatus: normalizeReviewStatus(review.status),
|
||||
coverIsImage: isImageCover(review.cover),
|
||||
coverUrl: resolveReviewCover(review),
|
||||
linkUrl: normalizeLinkUrl(review.link_url),
|
||||
externalLink: isExternalLink(review.link_url),
|
||||
};
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { Post, Category, Tag, FriendLink } from '../types';
|
||||
|
||||
// Mock data for static site generation
|
||||
export const mockPosts: Post[] = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'welcome-to-termi',
|
||||
title: '欢迎来到 Termi 终端博客',
|
||||
description: '这是一个基于终端风格的现代博客平台,结合了极客美学与极致性能。',
|
||||
date: '2024-03-20',
|
||||
readTime: '3 分钟',
|
||||
type: 'article',
|
||||
tags: ['astro', 'svelte', 'tailwind'],
|
||||
category: '技术',
|
||||
pinned: true,
|
||||
image: 'https://picsum.photos/1200/600?random=1'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
slug: 'astro-ssg-guide',
|
||||
title: 'Astro 静态站点生成指南',
|
||||
description: '学习如何使用 Astro 构建高性能的静态网站,掌握群岛架构的核心概念。',
|
||||
date: '2024-03-18',
|
||||
readTime: '5 分钟',
|
||||
type: 'article',
|
||||
tags: ['astro', 'ssg', 'performance'],
|
||||
category: '前端'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
slug: 'tailwind-v4-features',
|
||||
title: 'Tailwind CSS v4 新特性解析',
|
||||
description: '探索 Tailwind CSS v4 带来的全新特性,包括改进的性能和更简洁的配置。',
|
||||
date: '2024-03-15',
|
||||
readTime: '4 分钟',
|
||||
type: 'article',
|
||||
tags: ['tailwind', 'css', 'design'],
|
||||
category: '前端'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
slug: 'daily-thought-1',
|
||||
title: '关于代码与咖啡的思考',
|
||||
description: '写代码就像冲咖啡,需要耐心和恰到好处的温度。今天尝试了几款新豆子,每一杯都有不同的风味。',
|
||||
date: '2024-03-14',
|
||||
readTime: '1 分钟',
|
||||
type: 'tweet',
|
||||
tags: ['life', 'coding'],
|
||||
category: '随笔',
|
||||
images: [
|
||||
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=400&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=400&h=400&fit=crop',
|
||||
'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?w=400&h=400&fit=crop'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
slug: 'svelte-5-runes',
|
||||
title: 'Svelte 5 Runes 完全指南',
|
||||
description: '深入了解 Svelte 5 的 Runes 系统,掌握下一代响应式编程范式。',
|
||||
date: '2024-03-10',
|
||||
readTime: '8 分钟',
|
||||
type: 'article',
|
||||
tags: ['svelte', 'javascript', 'frontend'],
|
||||
category: '前端'
|
||||
}
|
||||
];
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ id: '1', name: '技术', slug: 'tech', icon: 'fa-code', count: 3 },
|
||||
{ id: '2', name: '前端', slug: 'frontend', icon: 'fa-laptop-code', count: 3 },
|
||||
{ id: '3', name: '随笔', slug: 'essay', icon: 'fa-pen', count: 1 },
|
||||
{ id: '4', name: '生活', slug: 'life', icon: 'fa-coffee', count: 1 }
|
||||
];
|
||||
|
||||
export const mockTags: Tag[] = [
|
||||
{ id: '1', name: 'astro', slug: 'astro', count: 1 },
|
||||
{ id: '2', name: 'svelte', slug: 'svelte', count: 2 },
|
||||
{ id: '3', name: 'tailwind', slug: 'tailwind', count: 2 },
|
||||
{ id: '4', name: 'frontend', slug: 'frontend', count: 2 },
|
||||
{ id: '5', name: 'ssg', slug: 'ssg', count: 1 },
|
||||
{ id: '6', name: 'css', slug: 'css', count: 1 },
|
||||
{ id: '7', name: 'javascript', slug: 'javascript', count: 1 },
|
||||
{ id: '8', name: 'life', slug: 'life', count: 1 },
|
||||
{ id: '9', name: 'coding', slug: 'coding', count: 1 }
|
||||
];
|
||||
|
||||
export const mockFriendLinks: FriendLink[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Astro 官网',
|
||||
url: 'https://astro.build',
|
||||
avatar: 'https://astro.build/favicon.svg',
|
||||
description: '极速内容驱动的网站框架',
|
||||
category: '技术博客'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Svelte 官网',
|
||||
url: 'https://svelte.dev',
|
||||
avatar: 'https://svelte.dev/favicon.png',
|
||||
description: '控制论增强的 Web 应用',
|
||||
category: '技术博客'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tailwind CSS',
|
||||
url: 'https://tailwindcss.com',
|
||||
avatar: 'https://tailwindcss.com/favicons/favicon-32x32.png',
|
||||
description: '实用优先的 CSS 框架',
|
||||
category: '技术博客'
|
||||
}
|
||||
];
|
||||
|
||||
export const mockSiteConfig = {
|
||||
name: 'Termi',
|
||||
description: '终端风格的内容平台',
|
||||
author: 'InitCool',
|
||||
url: 'https://termi.dev',
|
||||
social: {
|
||||
github: 'https://github.com',
|
||||
twitter: 'https://twitter.com',
|
||||
email: 'mailto:hello@termi.dev'
|
||||
}
|
||||
};
|
||||
|
||||
export const mockSystemStats = [
|
||||
{ label: 'Last Update', value: '2024-03-20' },
|
||||
{ label: 'Posts', value: '12' },
|
||||
{ label: 'Visitors', value: '1.2k' }
|
||||
];
|
||||
|
||||
export const mockTechStack = [
|
||||
{ name: 'Astro' },
|
||||
{ name: 'Svelte' },
|
||||
{ name: 'Tailwind CSS' },
|
||||
{ name: 'TypeScript' },
|
||||
{ name: 'Vercel' }
|
||||
];
|
||||
|
||||
export const mockHomeAboutIntro = '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。';
|
||||
|
||||
// Helper functions
|
||||
export function getPinnedPost(): Post | null {
|
||||
return mockPosts.find(p => p.pinned) || null;
|
||||
}
|
||||
|
||||
export function getRecentPosts(limit: number = 5): Post[] {
|
||||
return mockPosts.slice(0, limit);
|
||||
}
|
||||
|
||||
export function getAllPosts(): Post[] {
|
||||
return mockPosts;
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post | undefined {
|
||||
return mockPosts.find(p => p.slug === slug);
|
||||
}
|
||||
|
||||
export function getPostsByTag(tag: string): Post[] {
|
||||
return mockPosts.filter(p => p.tags.includes(tag));
|
||||
}
|
||||
|
||||
export function getPostsByCategory(category: string): Post[] {
|
||||
return mockPosts.filter(p => p.category === category);
|
||||
}
|
||||
|
||||
export function getAllCategories(): Category[] {
|
||||
return mockCategories;
|
||||
}
|
||||
|
||||
export function getAllTags(): Tag[] {
|
||||
return mockTags;
|
||||
}
|
||||
|
||||
export function getAllFriendLinks(): FriendLink[] {
|
||||
return mockFriendLinks;
|
||||
}
|
||||
|
||||
export function getSiteConfig() {
|
||||
return mockSiteConfig;
|
||||
}
|
||||
|
||||
export function getSystemStats() {
|
||||
return mockSystemStats;
|
||||
}
|
||||
|
||||
export function getTechStack() {
|
||||
return mockTechStack;
|
||||
}
|
||||
|
||||
export function getHomeAboutIntro() {
|
||||
return mockHomeAboutIntro;
|
||||
}
|
||||
384
frontend/src/pages/reviews/[id].astro
Normal file
384
frontend/src/pages/reviews/[id].astro
Normal file
@@ -0,0 +1,384 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import { parseReview } from '../../lib/reviews';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { locale, t } = getI18n(Astro);
|
||||
const copy =
|
||||
locale === 'en'
|
||||
? {
|
||||
summary: 'Review note',
|
||||
metadata: 'Metadata',
|
||||
notes: 'Notes',
|
||||
tags: 'Tags',
|
||||
back: 'Back to reviews',
|
||||
openLink: 'Open related link',
|
||||
notFoundTitle: 'Review not found',
|
||||
notFoundDescription:
|
||||
'The requested review does not exist or is temporarily unavailable.',
|
||||
rating: 'Rating',
|
||||
type: 'Type',
|
||||
status: 'Status',
|
||||
reviewDate: 'Review date',
|
||||
updatedAt: 'Updated at',
|
||||
}
|
||||
: {
|
||||
summary: '评价摘要',
|
||||
metadata: '元信息',
|
||||
notes: '记录内容',
|
||||
tags: '标签',
|
||||
back: '返回评价列表',
|
||||
openLink: '打开相关链接',
|
||||
notFoundTitle: '评价不存在',
|
||||
notFoundDescription: '当前请求的评价不存在,或者暂时无法从后端读取。',
|
||||
rating: '评分',
|
||||
type: '类型',
|
||||
status: '状态',
|
||||
reviewDate: '记录日期',
|
||||
updatedAt: '更新时间',
|
||||
};
|
||||
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
try {
|
||||
siteSettings = await apiClient.getSiteSettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to load site settings for review detail:', error);
|
||||
}
|
||||
|
||||
const reviewId = Number(Astro.params.id);
|
||||
let review = null;
|
||||
|
||||
if (Number.isFinite(reviewId)) {
|
||||
try {
|
||||
review = parseReview(await apiClient.getReview(reviewId));
|
||||
} catch (error) {
|
||||
console.error(`Failed to load review ${reviewId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
Astro.response.status = 404;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
game: t('reviews.typeGame'),
|
||||
anime: t('reviews.typeAnime'),
|
||||
music: t('reviews.typeMusic'),
|
||||
book: t('reviews.typeBook'),
|
||||
movie: t('reviews.typeMovie'),
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
completed: t('reviews.statusCompleted'),
|
||||
'in-progress': t('reviews.statusInProgress'),
|
||||
dropped: t('reviews.statusDropped'),
|
||||
};
|
||||
|
||||
const pageTitle = review
|
||||
? `${review.title} | ${t('reviews.title')} | ${siteSettings.siteShortName}`
|
||||
: `${copy.notFoundTitle} | ${siteSettings.siteShortName}`;
|
||||
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',
|
||||
name: review.title,
|
||||
genre: typeLabels[review.review_type] || review.review_type,
|
||||
},
|
||||
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
|
||||
}
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
siteSettings={siteSettings}
|
||||
canonical={canonical}
|
||||
ogImage={review?.coverUrl || undefined}
|
||||
ogType={review ? 'article' : 'website'}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title={review ? `~/reviews/${review.id}` : '~/reviews/not-found'} class="w-full">
|
||||
<div class="space-y-6 px-4 py-4">
|
||||
<div class="space-y-4">
|
||||
<CommandPrompt
|
||||
command={review ? `sed -n '1,160p' ./reviews/${review.id}.md` : 'ls ./reviews'}
|
||||
path="~/reviews"
|
||||
/>
|
||||
|
||||
<div class="terminal-panel ml-4">
|
||||
<div class="terminal-kicker">{copy.summary}</div>
|
||||
<div class="terminal-section-title mt-4">
|
||||
<span class="terminal-section-icon">
|
||||
<i class={`fas ${review ? 'fa-star' : 'fa-triangle-exclamation'}`}></i>
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">
|
||||
{review ? review.title : copy.notFoundTitle}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{review ? review.description : copy.notFoundDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{review ? (
|
||||
<>
|
||||
<div>
|
||||
<CommandPrompt command="jq '.meta' review.json" path="~/reviews" />
|
||||
<div class="review-detail-shell ml-4 mt-2">
|
||||
<div class="review-detail-cover terminal-panel">
|
||||
{review.coverUrl ? (
|
||||
<ResponsiveImage
|
||||
src={review.coverUrl}
|
||||
alt={`${review.title} cover`}
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="review-detail-cover__image"
|
||||
widths={[640, 960, 1280, 1600]}
|
||||
sizes="(min-width: 1280px) 42rem, 100vw"
|
||||
/>
|
||||
) : (
|
||||
<div class="review-detail-cover__fallback">
|
||||
<div class="review-detail-cover__year">{review.review_date.slice(0, 4)}</div>
|
||||
<div class="review-detail-cover__emoji">{review.cover || '★'}</div>
|
||||
<div class="review-detail-cover__title">{review.title}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="terminal-panel review-detail-card">
|
||||
<div class="review-detail-card__header">
|
||||
<span class="terminal-kicker">{copy.metadata}</span>
|
||||
<div class="review-detail-card__rating">
|
||||
<strong>{review.rating.toFixed(1)}</strong>
|
||||
<span>/ 5</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div>
|
||||
<div class="review-detail-meta-grid__label">{copy.status}</div>
|
||||
<div>{statusLabels[review.normalizedStatus]}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="review-detail-meta-grid__label">{copy.reviewDate}</div>
|
||||
<div>{review.review_date}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="review-detail-meta-grid__label">{copy.updatedAt}</div>
|
||||
<div>{review.updated_at}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-panel review-detail-card">
|
||||
<div class="terminal-kicker">{copy.tags}</div>
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<span class="text-sm text-[var(--text-secondary)]">{t('common.noData')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-detail-actions">
|
||||
{review.linkUrl && (
|
||||
<a
|
||||
href={review.linkUrl}
|
||||
class="terminal-action-button"
|
||||
target={review.externalLink ? '_blank' : undefined}
|
||||
rel={review.externalLink ? 'noreferrer noopener' : undefined}
|
||||
>
|
||||
<i class={`fas ${review.externalLink ? 'fa-arrow-up-right-from-square' : 'fa-arrow-right'}`}></i>
|
||||
<span>{copy.openLink}</span>
|
||||
</a>
|
||||
)}
|
||||
<a href="/reviews" class="terminal-subtle-link">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span class="font-mono">{copy.back}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CommandPrompt command="cat review.md" path="~/reviews" />
|
||||
<div class="terminal-panel ml-4 mt-2 review-detail-card">
|
||||
<div class="terminal-kicker">{copy.notes}</div>
|
||||
<div class="review-detail-body mt-4">
|
||||
{review.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div class="ml-4 rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/60 px-5 py-8">
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{copy.notFoundDescription}
|
||||
</p>
|
||||
<a href="/reviews" class="terminal-subtle-link mt-4 inline-flex">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span class="font-mono">{copy.back}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.review-detail-shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||
}
|
||||
|
||||
.review-detail-cover {
|
||||
overflow: hidden;
|
||||
min-height: 22rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.review-detail-cover__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-detail-cover__fallback {
|
||||
min-height: 22rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
linear-gradient(160deg, color-mix(in oklab, var(--primary) 18%, var(--terminal-bg)), color-mix(in oklab, var(--secondary) 12%, var(--header-bg)) 48%, color-mix(in oklab, var(--terminal-bg) 96%, transparent)),
|
||||
radial-gradient(circle at top right, color-mix(in oklab, var(--primary) 24%, transparent), transparent 44%);
|
||||
}
|
||||
|
||||
.review-detail-cover__year {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.review-detail-cover__emoji {
|
||||
font-size: 4.75rem;
|
||||
line-height: 1;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.review-detail-cover__title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
.review-detail-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.review-detail-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.review-detail-card__rating {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.review-detail-card__rating strong {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.review-detail-meta-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.review-detail-meta-grid__label {
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.review-detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.review-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.review-detail-body {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.95;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.review-detail-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.review-detail-meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,21 +6,10 @@ import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||
import { apiClient } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Review } from '../../lib/api/client';
|
||||
import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
type ReviewStatus = 'completed' | 'in-progress' | 'dropped';
|
||||
|
||||
type ParsedReview = Omit<Review, 'tags'> & {
|
||||
tags: string[];
|
||||
normalizedStatus: ReviewStatus;
|
||||
coverIsImage: boolean;
|
||||
coverUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
externalLink: boolean;
|
||||
};
|
||||
|
||||
// Fetch reviews from backend API
|
||||
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
|
||||
const url = new URL(Astro.request.url);
|
||||
@@ -32,60 +21,7 @@ try {
|
||||
console.error('Failed to fetch reviews:', error);
|
||||
}
|
||||
|
||||
const normalizeReviewStatus = (status: string | null | undefined): ReviewStatus => {
|
||||
const normalized = String(status || '').trim().toLowerCase();
|
||||
|
||||
if (normalized === 'published' || normalized === 'completed' || normalized === 'done') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (normalized === 'draft' || normalized === 'in-progress' || normalized === 'watching' || normalized === 'reading' || normalized === 'listening') {
|
||||
return 'in-progress';
|
||||
}
|
||||
|
||||
if (normalized === 'dropped' || normalized === 'abandoned') {
|
||||
return 'dropped';
|
||||
}
|
||||
|
||||
return 'completed';
|
||||
};
|
||||
|
||||
const isImageCover = (cover: string | null | undefined) => /^(https?:)?\/\//.test(String(cover || '').trim()) || String(cover || '').trim().startsWith('/');
|
||||
|
||||
const reviewCoverCatalog: Record<string, string> = {
|
||||
'《漫长的季节》': '/review-covers/the-long-season.svg',
|
||||
'《十三邀》': '/review-covers/thirteen-invites.svg',
|
||||
'《黑神话:悟空》': '/review-covers/black-myth-wukong.svg',
|
||||
'《置身事内》': '/review-covers/placed-within.svg',
|
||||
'《宇宙探索编辑部》': '/review-covers/journey-to-the-west-editorial.svg',
|
||||
'《疲惫生活中的英雄梦想》': '/review-covers/hero-dreams-in-tired-life.svg',
|
||||
};
|
||||
|
||||
const resolveReviewCover = (review: Review) => {
|
||||
if (isImageCover(review.cover)) {
|
||||
return String(review.cover).trim();
|
||||
}
|
||||
|
||||
return reviewCoverCatalog[review.title] || null;
|
||||
};
|
||||
|
||||
const normalizeLinkUrl = (value: string | null | undefined) => {
|
||||
const trimmed = String(value || '').trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const isExternalLink = (value: string | null | undefined) => /^(https?:)?\/\//.test(String(value || '').trim());
|
||||
|
||||
// Parse tags from JSON string
|
||||
const parsedReviews: ParsedReview[] = reviews.map(r => ({
|
||||
...r,
|
||||
tags: r.tags ? JSON.parse(r.tags) as string[] : [],
|
||||
normalizedStatus: normalizeReviewStatus(r.status),
|
||||
coverIsImage: isImageCover(r.cover),
|
||||
coverUrl: resolveReviewCover(r),
|
||||
linkUrl: normalizeLinkUrl(r.link_url),
|
||||
externalLink: isExternalLink(r.link_url),
|
||||
}));
|
||||
const parsedReviews: ParsedReview[] = reviews.map(parseReview);
|
||||
|
||||
const filteredReviews = selectedType === 'all'
|
||||
? parsedReviews
|
||||
@@ -290,25 +226,27 @@ const statCards = [
|
||||
style={`--review-accent: ${typeColors[review.review_type] || '#888'};`}
|
||||
>
|
||||
<div class="review-card__poster">
|
||||
{review.coverUrl ? (
|
||||
<ResponsiveImage
|
||||
src={review.coverUrl}
|
||||
alt={`${review.title} cover`}
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="review-card__poster-image"
|
||||
widths={[320, 480, 720, 960]}
|
||||
sizes="(min-width: 1536px) 18vw, (min-width: 1024px) 28vw, (min-width: 640px) 45vw, 100vw"
|
||||
/>
|
||||
) : (
|
||||
<div class="review-card__poster-fallback">
|
||||
<div class="review-card__poster-kicker">
|
||||
<span>{typeLabels[review.review_type] || review.review_type}</span>
|
||||
<span>{review.review_date.slice(0, 4)}</span>
|
||||
<a href={`/reviews/${review.id}`} class="review-card__poster-link" aria-label={`查看 ${review.title} 详情`}>
|
||||
{review.coverUrl ? (
|
||||
<ResponsiveImage
|
||||
src={review.coverUrl}
|
||||
alt={`${review.title} cover`}
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="review-card__poster-image"
|
||||
widths={[320, 480, 720, 960]}
|
||||
sizes="(min-width: 1536px) 18vw, (min-width: 1024px) 28vw, (min-width: 640px) 45vw, 100vw"
|
||||
/>
|
||||
) : (
|
||||
<div class="review-card__poster-fallback">
|
||||
<div class="review-card__poster-kicker">
|
||||
<span>{typeLabels[review.review_type] || review.review_type}</span>
|
||||
<span>{review.review_date.slice(0, 4)}</span>
|
||||
</div>
|
||||
<div class="review-card__poster-emoji">{review.cover || '★'}</div>
|
||||
<div class="review-card__poster-title">{review.title}</div>
|
||||
</div>
|
||||
<div class="review-card__poster-emoji">{review.cover || '★'}</div>
|
||||
<div class="review-card__poster-title">{review.title}</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="review-card__body">
|
||||
@@ -322,7 +260,11 @@ const statCards = [
|
||||
{statusLabels[review.normalizedStatus]}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="review-card__title">{review.title}</h2>
|
||||
<h2 class="review-card__title">
|
||||
<a href={`/reviews/${review.id}`} class="review-card__title-link">
|
||||
{review.title}
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="review-card__rating">
|
||||
<div class="review-card__rating-value">{review.rating || 0}.0</div>
|
||||
@@ -337,6 +279,10 @@ const statCards = [
|
||||
<p class="review-card__description">{review.description}</p>
|
||||
|
||||
<div class="review-card__meta">
|
||||
<a href={`/reviews/${review.id}`} class="review-card__link review-card__link--internal">
|
||||
<i class="fas fa-file-lines"></i>
|
||||
<span class="font-mono">cat review.md</span>
|
||||
</a>
|
||||
<span class="terminal-chip text-xs py-1 px-2.5">
|
||||
<i class="fas fa-calendar text-[10px]"></i>
|
||||
<span>{review.review_date}</span>
|
||||
@@ -629,6 +575,13 @@ const statCards = [
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.review-card__poster-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.review-card__poster-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -715,6 +668,16 @@ const statCards = [
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
.review-card__title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.review-card__title-link:hover {
|
||||
color: var(--review-accent);
|
||||
}
|
||||
|
||||
.review-card__rating {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
@@ -772,6 +735,10 @@ const statCards = [
|
||||
background: color-mix(in oklab, var(--review-accent) 16%, var(--terminal-bg));
|
||||
}
|
||||
|
||||
.review-card__link--internal {
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
.review-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -23,6 +23,7 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS
|
||||
let posts = await api.getRawPosts().catch(() => [])
|
||||
const reviews = await api.getReviews().catch(() => [])
|
||||
|
||||
try {
|
||||
siteSettings = await api.getSiteSettings()
|
||||
@@ -62,7 +63,14 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
priority: post.pinned ? '0.9' : '0.7',
|
||||
}))
|
||||
|
||||
const xmlBody = [...staticUrls, ...postUrls]
|
||||
const reviewUrls = reviews.map((review) => ({
|
||||
loc: ensureAbsoluteUrl(siteUrl, `/reviews/${review.id}`),
|
||||
lastmod: new Date(review.updated_at || review.created_at).toISOString(),
|
||||
changefreq: 'monthly',
|
||||
priority: '0.6',
|
||||
}))
|
||||
|
||||
const xmlBody = [...staticUrls, ...postUrls, ...reviewUrls]
|
||||
.map(
|
||||
(item) => `
|
||||
<url>
|
||||
|
||||
Reference in New Issue
Block a user