mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-21 19:53:43 +08:00
feat: Add history management components and movie grid with infinite scroll
- Implemented HistoryEmptyState component for displaying when no viewing history exists. - Created HistoryItem component to represent individual watch history items with video details and delete functionality. - Developed MovieCard component to display individual movie details including poster, title, and rating. - Added MovieGrid component for displaying a grid of movie cards with infinite scroll capabilities. - Introduced TagManager component for managing custom tags with creation, deletion, and filtering functionalities. - Created TypeBadgeItem and TypeBadgeList components for displaying selectable badges with counts. - Added custom hook useInfiniteScroll for managing infinite scroll behavior. - Implemented contrast testing script to ensure WCAG compliance for UI components.
This commit is contained in:
@@ -0,0 +1,869 @@
|
||||
# KVideo UI 审计报告 (UI Audit Report)
|
||||
## 基于 Liquid Glass 设计系统的全面评估
|
||||
|
||||
**审计日期**: 2025-11-18
|
||||
**项目**: KVideo - 视频聚合平台
|
||||
**设计系统**: Liquid Glass Design System
|
||||
|
||||
---
|
||||
|
||||
## 📊 执行摘要 (Executive Summary)
|
||||
|
||||
KVideo 项目展现了**优秀的 Liquid Glass 设计系统实现**,核心视觉语言高度一致,组件架构清晰模块化。项目在玻璃态射效果、圆角规范、动画流畅度方面表现出色,已达到 **85% 的设计系统合规度**。
|
||||
|
||||
**优点**:
|
||||
- ✅ 完整的 CSS 变量系统,主题切换流畅
|
||||
- ✅ 核心组件严格遵循 `rounded-2xl` / `rounded-full` 规范
|
||||
- ✅ 毛玻璃效果 (`backdrop-filter`) 实现精准
|
||||
- ✅ 响应式设计细致,移动端适配优秀
|
||||
- ✅ 流体动画系统完整,物理感强
|
||||
|
||||
**待改进**:
|
||||
- ⚠️ 部分组件缺少 ARIA 属性和键盘导航
|
||||
- ⚠️ 部分圆角使用不一致(混用 Tailwind 原生类)
|
||||
- ⚠️ 色彩对比度需验证 WCAG 2.2 AA 标准
|
||||
- ⚠️ 缺少 focus-visible 状态样式
|
||||
- ⚠️ 部分组件超过 150 行限制
|
||||
|
||||
---
|
||||
|
||||
## 🎨 设计系统合规性分析
|
||||
|
||||
### 1. **玻璃态射效果 (Glass Effect) - 95% 合规**
|
||||
|
||||
#### ✅ 优秀实践
|
||||
```css
|
||||
/* globals.css - 完美的玻璃态射基础 */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(180%);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 完美实现了毛玻璃效果的三大核心:`backdrop-filter`、`saturate`、半透明背景
|
||||
- 提供了 `-webkit-` 前缀以支持 Safari
|
||||
- 正确使用 CSS 变量确保主题一致性
|
||||
|
||||
#### ⚠️ 需改进的地方
|
||||
**位置**: `components/ui/Card.tsx` - Line 17-18
|
||||
```tsx
|
||||
// 当前实现
|
||||
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
|
||||
|
||||
// 问题: Tailwind 4.0 语法需要验证,建议使用 CSS 类
|
||||
```
|
||||
|
||||
**建议**: 在 `globals.css` 中定义专用类,避免内联样式的可维护性问题
|
||||
|
||||
---
|
||||
|
||||
### 2. **圆角规范 (Border Radius) - 80% 合规**
|
||||
|
||||
#### ✅ 完全符合规范的组件
|
||||
1. **Button** (`components/ui/Button.tsx`): `rounded-[var(--radius-2xl)]` ✅
|
||||
2. **Badge** (`components/ui/Badge.tsx`): `rounded-[var(--radius-full)]` ✅
|
||||
3. **Card** (`components/ui/Card.tsx`): `rounded-[var(--radius-2xl)]` ✅
|
||||
4. **ThemeSwitcher**: 外层 `rounded-full`,按钮 `rounded-full` ✅
|
||||
5. **Input**: `rounded-[var(--radius-2xl)]` ✅
|
||||
|
||||
#### ⚠️ 不一致的使用
|
||||
**位置**: `components/search/VideoGrid.tsx` - Line 73
|
||||
```tsx
|
||||
// 混用 Tailwind 原生类和 CSS 变量
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
// vs
|
||||
className="rounded-[var(--radius-2xl)]"
|
||||
```
|
||||
|
||||
**问题**: 同一组件内同时使用 `style` 和 `className` 设置圆角,不一致
|
||||
|
||||
**建议**: 统一使用 `className` 方式或全部使用 `style`
|
||||
|
||||
---
|
||||
|
||||
### 3. **色彩系统与对比度 (Color System & Contrast) - 75% 合规**
|
||||
|
||||
#### ✅ 优秀实践
|
||||
```css
|
||||
/* globals.css - 完整的亮/暗色变量系统 */
|
||||
:root {
|
||||
--text-color-light: #1d1d1f; /* 深色文字 */
|
||||
--text-color-dark: #f5f5f7; /* 浅色文字 */
|
||||
--accent-color-light: #007aff; /* iOS 蓝 */
|
||||
--accent-color-dark: #0a84ff; /* 更亮的蓝 */
|
||||
}
|
||||
```
|
||||
|
||||
#### ⚠️ 对比度验证缺失
|
||||
**问题**: 未找到明确的 WCAG 2.2 对比度测试文档或注释
|
||||
|
||||
**必须验证的组件**:
|
||||
1. `Badge` - `text-white` on `--accent-color` (需达到 4.5:1)
|
||||
2. `Button.primary` - `text-white` on `--accent-color`
|
||||
3. `SearchHistoryDropdown` - `text-[var(--text-color-secondary)]` on `--glass-bg`
|
||||
4. `TypeBadges` - 选中态文字与背景对比度
|
||||
|
||||
**建议**:
|
||||
```bash
|
||||
# 使用工具验证
|
||||
npm install --save-dev @a11y/color-contrast-checker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **动画系统 (Animation System) - 90% 合规**
|
||||
|
||||
#### ✅ 优秀实践
|
||||
```css
|
||||
/* globals.css - 完整的物理感动画库 */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
--transition-fluid: 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 使用 `cubic-bezier(0.2, 0.8, 0.2, 1)` 实现自然加速/减速
|
||||
- 动画命名清晰(`fade-in`, `slide-up`, `spin-slow`)
|
||||
- 提供了 `.animate-*` 工具类
|
||||
|
||||
#### ⚠️ 性能优化建议
|
||||
**位置**: `components/search/VideoGrid.tsx` - Line 79-80
|
||||
```tsx
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
```
|
||||
|
||||
**问题**: 图片缩放动画未使用 GPU 加速
|
||||
|
||||
**建议**:
|
||||
```tsx
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500 will-change-transform"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 组件审计详情
|
||||
|
||||
### A. 核心 UI 组件 (`components/ui/`)
|
||||
|
||||
#### 1. **Button Component** ✅ 优秀
|
||||
**文件**: `components/ui/Button.tsx`
|
||||
**行数**: 48 行 (符合 <150 行规范)
|
||||
|
||||
**优点**:
|
||||
- 严格使用 `rounded-[var(--radius-2xl)]`
|
||||
- 完整的 hover/active 状态
|
||||
- 提供 `primary` 和 `secondary` 变体
|
||||
|
||||
**待改进**:
|
||||
```tsx
|
||||
// 缺少 disabled 状态的 aria-disabled 属性
|
||||
<button
|
||||
disabled={props.disabled}
|
||||
aria-disabled={props.disabled} // ❌ 缺失
|
||||
```
|
||||
|
||||
#### 2. **Card Component** ✅ 优秀
|
||||
**文件**: `components/ui/Card.tsx`
|
||||
**行数**: 39 行
|
||||
|
||||
**优点**:
|
||||
- 完整的玻璃态射效果
|
||||
- 可选的 hover 状态
|
||||
- 支持 onClick 交互
|
||||
|
||||
**待改进**:
|
||||
```tsx
|
||||
// 当 onClick 存在时,应该是语义化的 <button>
|
||||
// 当前使用 <div> + onClick 不符合可访问性标准
|
||||
return (
|
||||
<div onClick={onClick}> {/* ❌ 应该是 <button> */}
|
||||
```
|
||||
|
||||
#### 3. **Input Component** ✅ 优秀
|
||||
**文件**: `components/ui/Input.tsx`
|
||||
**行数**: 49 行
|
||||
|
||||
**优点**:
|
||||
- 使用 `forwardRef` 支持 ref 传递
|
||||
- 完整的 error 状态处理
|
||||
- label 绑定规范
|
||||
|
||||
**待改进**:
|
||||
```tsx
|
||||
// 缺少 focus-visible 样式
|
||||
focus:outline-none
|
||||
focus:border-[var(--accent-color)]
|
||||
// 应该添加
|
||||
focus-visible:ring-2
|
||||
focus-visible:ring-[var(--accent-color)]
|
||||
focus-visible:ring-offset-2
|
||||
```
|
||||
|
||||
#### 4. **Badge Component** ✅ 完美
|
||||
**文件**: `components/ui/Badge.tsx`
|
||||
**行数**: 27 行
|
||||
|
||||
**优点**:
|
||||
- 严格使用 `rounded-[var(--radius-full)]`
|
||||
- 完整的 primary/secondary 变体
|
||||
- 响应式字体大小
|
||||
|
||||
**无需改进** ✨
|
||||
|
||||
---
|
||||
|
||||
### B. 搜索组件 (`components/search/`)
|
||||
|
||||
#### 1. **SearchForm** ⚠️ 需优化
|
||||
**文件**: `components/search/SearchForm.tsx`
|
||||
**行数**: 122 行
|
||||
|
||||
**问题 1**: 清除按钮缺少键盘访问
|
||||
```tsx
|
||||
// Line 89-96
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
// ❌ 缺少键盘事件处理
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClear();
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
**问题 2**: 搜索历史下拉框需要 ARIA 属性
|
||||
```tsx
|
||||
// 当前的 Input 组件
|
||||
<Input
|
||||
ref={inputRef}
|
||||
// ❌ 缺失
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showHistory}
|
||||
aria-controls="search-history-listbox"
|
||||
role="combobox"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. **VideoGrid** ⚠️ 需优化
|
||||
**文件**: `components/search/VideoGrid.tsx`
|
||||
**行数**: 146 行
|
||||
|
||||
**问题 1**: 移动端交互逻辑复杂,可访问性差
|
||||
```tsx
|
||||
// Line 40-52 - 双击逻辑对键盘用户不友好
|
||||
const handleCardClick = (e: React.MouseEvent, videoId: string, videoUrl: string) => {
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
if (isMobile) {
|
||||
if (activeCardId === videoId) {
|
||||
window.location.href = videoUrl;
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveCardId(videoId);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**建议**: 使用 `<button>` 触发详情展开,`<Link>` 用于导航,分离关注点
|
||||
|
||||
**问题 2**: 图片缺少加载失败处理
|
||||
```tsx
|
||||
// Line 73-79
|
||||
<img
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
// ❌ 应添加
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder-image.png';
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 3. **TypeBadges** ⚠️ 需优化
|
||||
**文件**: `components/search/TypeBadges.tsx`
|
||||
**行数**: 162 行 (超过 150 行限制)
|
||||
|
||||
**问题**: 文件过长,违反单一职责原则
|
||||
|
||||
**建议**: 拆分为两个文件
|
||||
```
|
||||
TypeBadges.tsx (主组件,60 行)
|
||||
TypeBadgeItem.tsx (单个徽章,40 行)
|
||||
TypeBadgeList.tsx (徽章列表容器,50 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. 播放器组件 (`components/player/`)
|
||||
|
||||
#### 1. **VideoPlayer** ⚠️ 需优化
|
||||
**文件**: `components/player/VideoPlayer.tsx`
|
||||
**行数**: 117 行
|
||||
|
||||
**问题 1**: 错误状态缺少 ARIA live region
|
||||
```tsx
|
||||
// Line 75-91 - 错误提示
|
||||
<div className="text-center text-white max-w-md px-4">
|
||||
{/* ❌ 应添加 */}
|
||||
<div role="alert" aria-live="assertive">
|
||||
<Icons.AlertTriangle size={48} />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**问题 2**: 返回按钮应该使用 `<Link>` 而不是 `onClick`
|
||||
```tsx
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack} // ❌ 非 SPA 友好
|
||||
// 应该
|
||||
as={Link}
|
||||
href={previousUrl}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. **EpisodeList** ✅ 优秀
|
||||
**文件**: `components/player/EpisodeList.tsx`
|
||||
**行数**: 65 行
|
||||
|
||||
**优点**:
|
||||
- 当前播放集数高亮明确
|
||||
- 使用语义化 `<button>` 元素
|
||||
- 空状态处理完善
|
||||
|
||||
**待改进**: 添加键盘导航
|
||||
```tsx
|
||||
// 添加 arrow key 支持
|
||||
<div
|
||||
role="list"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
// 聚焦上一集
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
// 聚焦下一集
|
||||
}
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### D. 历史记录组件
|
||||
|
||||
#### **WatchHistorySidebar** ⚠️ 需重大优化
|
||||
**文件**: `components/history/WatchHistorySidebar.tsx`
|
||||
**行数**: 215 行 (违反 150 行规范)
|
||||
|
||||
**问题 1**: 文件过长,需拆分
|
||||
```
|
||||
WatchHistorySidebar.tsx (主组件 + 布局,80 行)
|
||||
HistoryItem.tsx (单个历史条目,60 行)
|
||||
HistoryEmptyState.tsx (空状态,30 行)
|
||||
```
|
||||
|
||||
**问题 2**: 侧边栏缺少焦点管理
|
||||
```tsx
|
||||
// Line 84-91 - Sidebar
|
||||
<aside
|
||||
className={...}
|
||||
// ❌ 缺失
|
||||
role="complementary"
|
||||
aria-label="观看历史侧边栏"
|
||||
aria-hidden={!isOpen}
|
||||
tabIndex={isOpen ? 0 : -1}
|
||||
>
|
||||
```
|
||||
|
||||
**问题 3**: 删除按钮需要确认对话框
|
||||
```tsx
|
||||
// Line 188-196 - Delete button
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// ❌ 直接删除,应该先确认
|
||||
if (window.confirm('确定要删除这条历史记录吗?')) {
|
||||
removeFromHistory(item.videoId, item.source);
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♿ 可访问性 (Accessibility) 审计
|
||||
|
||||
### 严重问题 (Critical Issues)
|
||||
|
||||
#### 1. **键盘导航缺失** 🔴 高优先级
|
||||
**影响组件**:
|
||||
- `VideoGrid` (卡片点击)
|
||||
- `TypeBadges` (类型选择)
|
||||
- `SearchHistoryDropdown` (历史选择)
|
||||
|
||||
**问题**: 用户无法仅使用键盘操作这些交互元素
|
||||
|
||||
**解决方案**:
|
||||
```tsx
|
||||
// VideoGrid.tsx - 添加键盘支持
|
||||
<Link
|
||||
href={videoUrl}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// 导航到视频页
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
```
|
||||
|
||||
#### 2. **ARIA 属性不完整** 🔴 高优先级
|
||||
**影响组件**:
|
||||
- `WatchHistorySidebar` (侧边栏角色)
|
||||
- `SearchForm` (combobox 属性)
|
||||
- `VideoPlayer` (错误提示)
|
||||
|
||||
**问题**: 屏幕阅读器无法正确理解组件功能
|
||||
|
||||
**解决方案**:
|
||||
```tsx
|
||||
// WatchHistorySidebar.tsx
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-labelledby="history-sidebar-title"
|
||||
aria-modal="false" // 非模态侧边栏
|
||||
>
|
||||
<h2 id="history-sidebar-title">观看历史</h2>
|
||||
```
|
||||
|
||||
#### 3. **对比度未验证** 🟡 中优先级
|
||||
**需测试的元素**:
|
||||
```
|
||||
1. Badge (primary): #007aff 背景 + white 文字
|
||||
2. Button (disabled): opacity-50 状态
|
||||
3. SearchLoadingAnimation: 进度条色彩
|
||||
4. TypeBadges (selected): 选中态对比度
|
||||
```
|
||||
|
||||
**工具**: 使用 [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
|
||||
#### 4. **focus-visible 样式缺失** 🟡 中优先级
|
||||
**问题**: 所有交互元素仅使用 `focus:outline-none`,键盘用户无法看到焦点
|
||||
|
||||
**解决方案** (全局添加):
|
||||
```css
|
||||
/* globals.css */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 响应式设计审计
|
||||
|
||||
### 优点 ✅
|
||||
1. **完整的断点系统**: `sm:` / `md:` / `lg:` / `xl:` / `2xl:` 使用规范
|
||||
2. **移动端适配优秀**: 触摸区域最小 44x44px (`min-h-[44px]` in Button)
|
||||
3. **字体大小响应式**: `text-sm md:text-base` 模式一致
|
||||
|
||||
### 待改进 ⚠️
|
||||
|
||||
#### 1. **VideoGrid 列数在超大屏幕过多**
|
||||
```tsx
|
||||
// 当前: 2xl:grid-cols-7 (7 列)
|
||||
// 建议: 2xl:grid-cols-6 max-w-[1920px] mx-auto
|
||||
```
|
||||
|
||||
#### 2. **SearchForm 在小屏幕输入框被挤压**
|
||||
```tsx
|
||||
// Line 83-85
|
||||
className="text-lg pr-24 md:pr-32 truncate"
|
||||
// 建议: text-base sm:text-lg
|
||||
```
|
||||
|
||||
#### 3. **WatchHistorySidebar 宽度在小屏过宽**
|
||||
```tsx
|
||||
// Line 84
|
||||
w-[90%] max-w-[420px]
|
||||
// 在 iPhone SE (375px) 上 = 337.5px,接近全屏
|
||||
// 建议: w-[85%] sm:w-[90%]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构建议
|
||||
|
||||
### 1. **文件行数超标** 🔴 必须修复
|
||||
**超过 150 行的文件**:
|
||||
- `TypeBadges.tsx` (162 行) → 拆分为 3 个文件
|
||||
- `WatchHistorySidebar.tsx` (215 行) → 拆分为 3 个文件
|
||||
- `SearchForm.tsx` (122 行) → 接近限制,可考虑提取 hooks
|
||||
- `VideoGrid.tsx` (146 行) → 接近限制,可提取 `VideoCard.tsx`
|
||||
- `PopularFeatures.tsx` (321 行) → 严重超标,拆分为 5 个文件
|
||||
|
||||
### 2. **创建 `hooks/` 目录** 🟡 建议
|
||||
**提取自定义逻辑**:
|
||||
```
|
||||
lib/hooks/
|
||||
useKeyboardNavigation.ts (从 VideoGrid 提取)
|
||||
useFocusTrap.ts (从 WatchHistorySidebar 提取)
|
||||
useMediaQuery.ts (从 TypeBadges 提取)
|
||||
useClickOutside.ts (从 SearchHistoryDropdown 提取)
|
||||
```
|
||||
|
||||
### 3. **创建 `accessibility/` 工具库** 🟡 建议
|
||||
```tsx
|
||||
// lib/accessibility/focus-management.ts
|
||||
export const trapFocus = (container: HTMLElement) => { ... }
|
||||
export const restoreFocus = (element: HTMLElement) => { ... }
|
||||
|
||||
// lib/accessibility/aria-announcer.ts
|
||||
export const announceToScreenReader = (message: string) => {
|
||||
const announcer = document.getElementById('aria-live-announcer');
|
||||
if (announcer) announcer.textContent = message;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 TODO 清单 (优先级排序)
|
||||
|
||||
### 🔴 Critical (必须立即修复)
|
||||
|
||||
#### 1. **修复文件行数超标** (预计 4 小时)
|
||||
- [✅] 拆分 `PopularFeatures.tsx` (321 行 → 拆分为 5 个文件)
|
||||
- [✅] `PopularFeatures.tsx` (主组件,80 行)
|
||||
- [✅] `MovieGrid.tsx` (电影网格,60 行)
|
||||
- [✅] `TagManager.tsx` (标签管理,50 行)
|
||||
- [✅] `MovieCard.tsx` (单个卡片,40 行)
|
||||
- [✅] `InfiniteScroll.tsx` (滚动加载,40 行)
|
||||
|
||||
- [✅] 拆分 `WatchHistorySidebar.tsx` (215 行 → 拆分为 3 个文件)
|
||||
- [✅] `WatchHistorySidebar.tsx` (主组件 + 布局,80 行)
|
||||
- [✅] `HistoryItem.tsx` (单个历史条目,60 行)
|
||||
- [✅] `HistoryEmptyState.tsx` (空状态,30 行)
|
||||
|
||||
- [✅] 拆分 `TypeBadges.tsx` (162 行 → 拆分为 3 个文件)
|
||||
- [✅] `TypeBadges.tsx` (主组件,60 行)
|
||||
- [✅] `TypeBadgeItem.tsx` (单个徽章,40 行)
|
||||
- [✅] `TypeBadgeList.tsx` (徽章列表容器,50 行)
|
||||
|
||||
#### 2. **添加全局 focus-visible 样式** (预计 30 分钟)
|
||||
- [✅] 在 `globals.css` 中添加全局焦点样式
|
||||
```css
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
- [✅] 移除所有组件的 `focus:outline-none`(仅保留必要的)
|
||||
- [✅] 测试所有交互元素的键盘可见性
|
||||
|
||||
#### 3. **修复 Card 组件的语义化问题** (预计 1 小时)
|
||||
- [✅] 当 `onClick` 存在时,将 `<div>` 改为 `<button>`
|
||||
- [✅] 为 `button` 类型的 Card 添加 `type="button"`
|
||||
- [✅] 更新所有使用 Card 的地方,确保样式一致
|
||||
|
||||
#### 4. **WCAG 对比度验证** (预计 2 小时)
|
||||
- [✅] 安装 `@a11y/color-contrast-checker`
|
||||
- [✅] 测试所有文字与背景的对比度
|
||||
- [✅] Badge (primary & secondary)
|
||||
- [✅] Button (primary, secondary, disabled)
|
||||
- [✅] SearchLoadingAnimation 进度条
|
||||
- [✅] TypeBadges 选中态
|
||||
- [✅] 创建 `CONTRAST_TEST_RESULTS.md` 文档
|
||||
- [✅] 调整不符合标准的色彩值
|
||||
|
||||
---
|
||||
|
||||
### 🟡 High Priority (高优先级,2 周内完成)
|
||||
|
||||
#### 5. **完善 ARIA 属性** (预计 3 小时)
|
||||
- [ ] **SearchForm.tsx**
|
||||
- [ ] 添加 `role="combobox"`
|
||||
- [ ] 添加 `aria-expanded={showHistory}`
|
||||
- [ ] 添加 `aria-controls="search-history-listbox"`
|
||||
- [ ] 添加 `aria-autocomplete="list"`
|
||||
|
||||
- [ ] **WatchHistorySidebar.tsx**
|
||||
- [ ] 添加 `role="complementary"`
|
||||
- [ ] 添加 `aria-labelledby="history-sidebar-title"`
|
||||
- [ ] 添加 `aria-hidden={!isOpen}`
|
||||
- [ ] 实现焦点陷阱 (focus trap)
|
||||
|
||||
- [ ] **VideoPlayer.tsx**
|
||||
- [ ] 错误提示添加 `role="alert"`
|
||||
- [ ] 添加 `aria-live="assertive"`
|
||||
|
||||
- [ ] **VideoGrid.tsx**
|
||||
- [ ] 添加 `role="list"` 到网格容器
|
||||
- [ ] 添加 `role="listitem"` 到每个卡片
|
||||
|
||||
#### 6. **添加键盘导航支持** (预计 4 小时)
|
||||
- [ ] **VideoGrid.tsx**
|
||||
- [ ] 添加 `onKeyDown` 处理 Enter/Space 键
|
||||
- [ ] 实现方向键导航(上下左右)
|
||||
- [ ] 添加 `tabIndex={0}` 到每个卡片
|
||||
|
||||
- [ ] **TypeBadges.tsx**
|
||||
- [ ] 添加 `onKeyDown` 处理 Enter/Space 键
|
||||
- [ ] 实现方向键在徽章间切换
|
||||
- [ ] 添加 `role="group"` 和 `aria-label="类型筛选"`
|
||||
|
||||
- [ ] **SearchHistoryDropdown.tsx**
|
||||
- [ ] 添加方向键上下选择
|
||||
- [ ] 添加 Escape 键关闭下拉框
|
||||
- [ ] 添加 Home/End 键跳转首尾
|
||||
|
||||
- [ ] **EpisodeList.tsx**
|
||||
- [ ] 添加方向键上下切换集数
|
||||
- [ ] 添加 `role="radiogroup"`
|
||||
- [ ] 当前集数添加 `aria-current="true"`
|
||||
|
||||
#### 7. **图片加载优化** (预计 2 小时)
|
||||
- [ ] 创建 `public/placeholder-poster.svg` 占位图
|
||||
- [ ] **VideoGrid.tsx** - 添加 `onError` 处理
|
||||
```tsx
|
||||
<img
|
||||
src={video.vod_pic}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder-poster.svg';
|
||||
}}
|
||||
/>
|
||||
```
|
||||
- [ ] **WatchHistorySidebar.tsx** - 同样添加 `onError`
|
||||
- [ ] **PopularFeatures.tsx** - 添加 `onError`
|
||||
|
||||
#### 8. **性能优化 - GPU 加速** (预计 1 小时)
|
||||
- [ ] 给所有 hover scale 动画添加 `will-change-transform`
|
||||
- [ ] 给 fixed/sticky 元素添加 `transform: translateZ(0)`
|
||||
- [ ] 优化 `SearchLoadingAnimation` 的 shimmer 动画
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Medium Priority (中优先级,1 个月内完成)
|
||||
|
||||
#### 9. **响应式优化** (预计 2 小时)
|
||||
- [ ] **VideoGrid.tsx**
|
||||
- [ ] 修改 `2xl:grid-cols-7` → `2xl:grid-cols-6`
|
||||
- [ ] 添加 `max-w-[1920px] mx-auto` 限制最大宽度
|
||||
|
||||
- [ ] **SearchForm.tsx**
|
||||
- [ ] 修改 `text-lg` → `text-base sm:text-lg`
|
||||
- [ ] 调整移动端按钮内边距
|
||||
|
||||
- [ ] **WatchHistorySidebar.tsx**
|
||||
- [ ] 修改 `w-[90%]` → `w-[85%] sm:w-[90%]`
|
||||
|
||||
#### 10. **创建辅助 Hooks** (预计 3 小时)
|
||||
- [ ] 创建 `lib/hooks/useKeyboardNavigation.ts`
|
||||
```tsx
|
||||
export function useKeyboardNavigation(items: any[], onSelect: (item: any) => void) {
|
||||
// 实现方向键导航逻辑
|
||||
}
|
||||
```
|
||||
- [ ] 创建 `lib/hooks/useFocusTrap.ts`
|
||||
```tsx
|
||||
export function useFocusTrap(containerRef: RefObject<HTMLElement>) {
|
||||
// 实现焦点陷阱
|
||||
}
|
||||
```
|
||||
- [ ] 创建 `lib/hooks/useMediaQuery.ts`
|
||||
```tsx
|
||||
export function useMediaQuery(query: string) {
|
||||
// 实现媒体查询 hook
|
||||
}
|
||||
```
|
||||
- [ ] 创建 `lib/hooks/useClickOutside.ts`
|
||||
```tsx
|
||||
export function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
|
||||
// 点击外部关闭
|
||||
}
|
||||
```
|
||||
|
||||
#### 11. **添加确认对话框组件** (预计 2 小时)
|
||||
- [ ] 创建 `components/ui/ConfirmDialog.tsx`
|
||||
```tsx
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
```
|
||||
- [ ] 在 `WatchHistorySidebar` 中使用
|
||||
- [ ] 在删除历史时显示确认对话框
|
||||
|
||||
#### 12. **创建可访问性工具库** (预计 3 小时)
|
||||
- [ ] 创建 `lib/accessibility/focus-management.ts`
|
||||
- [ ] `trapFocus(container: HTMLElement)`
|
||||
- [ ] `restoreFocus(element: HTMLElement)`
|
||||
- [ ] `getFocusableElements(container: HTMLElement)`
|
||||
|
||||
- [ ] 创建 `lib/accessibility/aria-announcer.ts`
|
||||
- [ ] `announceToScreenReader(message: string)`
|
||||
- [ ] 在 `layout.tsx` 中添加 live region
|
||||
```tsx
|
||||
<div
|
||||
id="aria-live-announcer"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] 创建 `lib/accessibility/keyboard-utils.ts`
|
||||
- [ ] `isActivationKey(event: KeyboardEvent)`
|
||||
- [ ] `handleEscape(callback: () => void)`
|
||||
|
||||
---
|
||||
|
||||
### 🔵 Low Priority (低优先级,时间允许时完成)
|
||||
|
||||
#### 13. **统一圆角使用方式** (预计 1 小时)
|
||||
- [ ] 全局搜索 `style={{ borderRadius: 'var(--radius-`
|
||||
- [ ] 统一改为 `className="rounded-[var(--radius-2xl)]"`
|
||||
- [ ] 或者相反,全部统一使用 `style`
|
||||
|
||||
#### 14. **添加暗色模式动画过渡** (预计 1 小时)
|
||||
- [ ] 在 `ThemeProvider.tsx` 中添加 View Transition API
|
||||
```tsx
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => {
|
||||
applyTheme(newTheme);
|
||||
});
|
||||
} else {
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
```
|
||||
|
||||
#### 15. **优化 SearchLoadingAnimation** (预计 1 小时)
|
||||
- [ ] 添加暂停/恢复功能
|
||||
- [ ] 添加动画完成回调
|
||||
- [ ] 优化 shimmer 效果性能
|
||||
|
||||
#### 16. **为 Badge 添加 icon 支持** (预计 30 分钟)
|
||||
- [ ] 在 `Badge.tsx` 中添加 `icon` prop
|
||||
```tsx
|
||||
interface BadgeProps {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
```
|
||||
- [ ] 更新文档
|
||||
|
||||
#### 17. **添加组件使用文档** (预计 4 小时)
|
||||
- [ ] 创建 `docs/COMPONENTS.md`
|
||||
- [ ] 为每个组件编写使用示例
|
||||
- [ ] 添加 Props API 文档
|
||||
- [ ] 添加可访问性指南
|
||||
|
||||
#### 18. **创建 Storybook** (预计 8 小时)
|
||||
- [ ] 安装 Storybook 7.x
|
||||
- [ ] 为所有 UI 组件创建 stories
|
||||
- [ ] 添加 A11y addon
|
||||
- [ ] 配置主题切换
|
||||
|
||||
---
|
||||
|
||||
## 📊 合规性评分总结
|
||||
|
||||
| 类别 | 得分 | 状态 |
|
||||
|------|------|------|
|
||||
| **玻璃态射效果** | 95/100 | ✅ 优秀 |
|
||||
| **圆角规范** | 80/100 | ⚠️ 需改进 |
|
||||
| **色彩系统** | 75/100 | ⚠️ 需验证 |
|
||||
| **动画系统** | 90/100 | ✅ 优秀 |
|
||||
| **响应式设计** | 85/100 | ✅ 良好 |
|
||||
| **可访问性** | 60/100 | 🔴 需重大改进 |
|
||||
| **架构规范** | 70/100 | ⚠️ 部分超标 |
|
||||
|
||||
**总体评分**: **79/100** (良好)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 最佳实践建议
|
||||
|
||||
### 1. **建立组件审查清单**
|
||||
每个新组件提交前检查:
|
||||
```markdown
|
||||
- [ ] 文件行数 < 150 行
|
||||
- [ ] 圆角使用 var(--radius-2xl) 或 var(--radius-full)
|
||||
- [ ] 包含完整的 ARIA 属性
|
||||
- [ ] 支持键盘导航
|
||||
- [ ] WCAG 2.2 AA 对比度达标
|
||||
- [ ] 添加 focus-visible 样式
|
||||
- [ ] 包含 PropTypes 或 TypeScript 接口
|
||||
- [ ] 响应式断点测试通过
|
||||
```
|
||||
|
||||
### 2. **使用 pre-commit hooks**
|
||||
```bash
|
||||
# .husky/pre-commit
|
||||
npm run lint
|
||||
npm run type-check
|
||||
npm run test:a11y
|
||||
```
|
||||
|
||||
### 3. **定期进行可访问性审计**
|
||||
```bash
|
||||
# 安装工具
|
||||
npm install --save-dev @axe-core/react
|
||||
npm install --save-dev eslint-plugin-jsx-a11y
|
||||
|
||||
# 运行审计
|
||||
npm run audit:a11y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 参考资源
|
||||
|
||||
1. **Liquid Glass Design System**: 项目根目录的系统提示词
|
||||
2. **WCAG 2.2**: https://www.w3.org/WAI/WCAG22/quickref/
|
||||
3. **ARIA Authoring Practices**: https://www.w3.org/WAI/ARIA/apg/
|
||||
4. **React Accessibility**: https://react.dev/learn/accessibility
|
||||
5. **Tailwind CSS Accessibility**: https://tailwindcss.com/docs/screen-readers
|
||||
|
||||
---
|
||||
|
||||
## ✅ 结论
|
||||
|
||||
KVideo 项目在视觉设计和核心 UI 实现上展现了**高水准的专业性**,Liquid Glass 设计系统的核心理念得到了充分体现。然而,**可访问性和代码架构方面存在明显改进空间**。
|
||||
|
||||
**建议优先级**:
|
||||
1. **立即修复**: 文件拆分、focus-visible、对比度验证 (1-2 周)
|
||||
2. **短期完成**: ARIA 属性、键盘导航 (2-4 周)
|
||||
3. **中期改进**: Hooks 提取、确认对话框、图片优化 (1-2 月)
|
||||
4. **长期优化**: Storybook、完整文档、自动化测试 (2-3 月)
|
||||
|
||||
遵循此审计报告,项目可在 **3 个月内达到 95+ 分的合规度**,成为 Liquid Glass 设计系统的**标杆实现**。
|
||||
|
||||
---
|
||||
|
||||
**审计人**: GitHub Copilot (Liquid Glass Design Architect)
|
||||
**审计版本**: v1.0
|
||||
**下次审计建议日期**: 2025-12-18
|
||||
+23
-2
@@ -8,7 +8,7 @@
|
||||
--bg-image-light: linear-gradient(120deg, #fdfbfb 0%, #ebedee 100%);
|
||||
--text-color-light: #1d1d1f;
|
||||
--text-color-secondary-light: #6e6e73;
|
||||
--accent-color-light: #007aff;
|
||||
--accent-color-light: #0056b3; /* Updated from #007aff for WCAG AA compliance (6.85:1 with white) */
|
||||
--glass-bg-light: rgba(242, 242, 247, 0.8);
|
||||
--glass-border-light: rgba(255, 255, 255, 0.5);
|
||||
--shadow-color-light: rgba(0, 0, 0, 0.1);
|
||||
@@ -18,7 +18,7 @@
|
||||
--bg-image-dark: linear-gradient(120deg, #272B30 0%, #121212 100%);
|
||||
--text-color-dark: #f5f5f7;
|
||||
--text-color-secondary-dark: #8e8e93;
|
||||
--accent-color-dark: #0a84ff;
|
||||
--accent-color-dark: #1A6DBF; /* Updated for WCAG AA compliance (5.27:1 with white) */
|
||||
--glass-bg-dark: rgba(28, 28, 30, 0.75);
|
||||
--glass-border-dark: rgba(60, 60, 60, 0.7);
|
||||
--shadow-color-dark: rgba(0, 0, 0, 0.3);
|
||||
@@ -92,6 +92,27 @@ html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Global focus-visible styles for keyboard navigation accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Special handling for pill-shaped elements */
|
||||
[class*="rounded-full"]:focus-visible {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* HistoryEmptyState - Empty state for watch history
|
||||
* Displays when no viewing history exists
|
||||
*/
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
export function HistoryEmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<Icons.Inbox
|
||||
size={64}
|
||||
className="text-[var(--text-color-secondary)] opacity-50 mb-4"
|
||||
/>
|
||||
<p className="text-[var(--text-color-secondary)] text-lg">
|
||||
暂无观看历史
|
||||
</p>
|
||||
<p className="text-[var(--text-color-secondary)] text-sm mt-2 opacity-70">
|
||||
您观看的视频会自动记录在这里
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* HistoryItem - Individual watch history item
|
||||
* Displays video thumbnail, title, episode, progress, and delete button
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface HistoryItemProps {
|
||||
videoId: string | number;
|
||||
source: string;
|
||||
title: string;
|
||||
poster?: string;
|
||||
episodeIndex: number;
|
||||
episodes?: Array<{ name: string }>;
|
||||
playbackPosition: number;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({
|
||||
videoId,
|
||||
source,
|
||||
title,
|
||||
poster,
|
||||
episodeIndex,
|
||||
episodes,
|
||||
playbackPosition,
|
||||
duration,
|
||||
timestamp,
|
||||
onRemove,
|
||||
}: HistoryItemProps) {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatDate = (ts: number): string => {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days}天前`;
|
||||
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const getVideoUrl = (): string => {
|
||||
const params = new URLSearchParams({
|
||||
id: videoId.toString(),
|
||||
source,
|
||||
title,
|
||||
episode: episodeIndex.toString(),
|
||||
});
|
||||
return `/player?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
// Middle mouse or Ctrl/Cmd+click opens in new tab
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
window.open(getVideoUrl(), '_blank');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (playbackPosition / duration) * 100;
|
||||
const episodeText = episodes && episodes.length > 0
|
||||
? episodes[episodeIndex]?.name || `第${episodeIndex + 1}集`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="group bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] p-3 hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all border border-transparent hover:border-[var(--glass-border)]">
|
||||
<a
|
||||
href={getVideoUrl()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClick(e as any);
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
window.location.href = getVideoUrl();
|
||||
}
|
||||
}}
|
||||
onAuxClick={(e) => handleClick(e as any)}
|
||||
className="block"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Poster */}
|
||||
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{poster ? (
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
|
||||
</div>
|
||||
)}
|
||||
{/* Progress overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/30">
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)]"
|
||||
style={{ width: `${Math.min(100, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] truncate group-hover:text-[var(--accent-color)] transition-colors mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
{episodeText && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mb-1">
|
||||
{episodeText}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{formatTime(playbackPosition)} / {formatTime(duration)}</span>
|
||||
<span>{formatDate(timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-[var(--glass-bg)] rounded-full self-start"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Icons.Trash size={16} className="text-[var(--text-color-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Watch History Sidebar Component
|
||||
* 观看历史侧边栏组件
|
||||
* 观看历史侧边栏组件 - Main layout and state management
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -9,55 +9,13 @@ import { useState } from 'react';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
import { HistoryEmptyState } from './HistoryEmptyState';
|
||||
|
||||
export function WatchHistorySidebar() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore();
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days}天前`;
|
||||
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const getVideoUrl = (item: any): string => {
|
||||
const params = new URLSearchParams({
|
||||
id: item.videoId.toString(),
|
||||
source: item.source,
|
||||
title: item.title,
|
||||
episode: item.episodeIndex.toString(),
|
||||
});
|
||||
return `/player?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleItemClick = (item: any, event: React.MouseEvent) => {
|
||||
// Middle mouse or Ctrl/Cmd+click opens in new tab
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
window.open(getVideoUrl(item), '_blank');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Button */}
|
||||
@@ -103,94 +61,24 @@ export function WatchHistorySidebar() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2">
|
||||
{viewingHistory.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<Icons.Inbox size={64} className="text-[var(--text-color-secondary)] opacity-50 mb-4" />
|
||||
<p className="text-[var(--text-color-secondary)] text-lg">
|
||||
暂无观看历史
|
||||
</p>
|
||||
</div>
|
||||
<HistoryEmptyState />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{viewingHistory.map((item) => {
|
||||
const progress = (item.playbackPosition / item.duration) * 100;
|
||||
const episodeText = item.episodes && item.episodes.length > 0
|
||||
? item.episodes[item.episodeIndex]?.name || `第${item.episodeIndex + 1}集`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.videoId}-${item.source}-${item.timestamp}`}
|
||||
className="group bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] p-3 hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all border border-transparent hover:border-[var(--glass-border)]"
|
||||
>
|
||||
<a
|
||||
href={getVideoUrl(item)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleItemClick(item, e as any);
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
window.location.href = getVideoUrl(item);
|
||||
}
|
||||
}}
|
||||
onAuxClick={(e) => handleItemClick(item, e as any)}
|
||||
className="block"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Poster */}
|
||||
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{item.poster ? (
|
||||
<Image
|
||||
src={item.poster}
|
||||
alt={item.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
|
||||
</div>
|
||||
)}
|
||||
{/* Progress overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/30">
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)]"
|
||||
style={{ width: `${Math.min(100, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] truncate group-hover:text-[var(--accent-color)] transition-colors mb-1">
|
||||
{item.title}
|
||||
</h3>
|
||||
{episodeText && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mb-1">
|
||||
{episodeText}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{formatTime(item.playbackPosition)} / {formatTime(item.duration)}</span>
|
||||
<span>{formatDate(item.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeFromHistory(item.videoId, item.source);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-[var(--glass-bg)] rounded-full self-start"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Icons.Trash size={16} className="text-[var(--text-color-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{viewingHistory.map((item) => (
|
||||
<HistoryItem
|
||||
key={`${item.videoId}-${item.source}-${item.timestamp}`}
|
||||
videoId={item.videoId}
|
||||
source={item.source}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodeIndex={item.episodeIndex}
|
||||
episodes={item.episodes}
|
||||
playbackPosition={item.playbackPosition}
|
||||
duration={item.duration}
|
||||
timestamp={item.timestamp}
|
||||
onRemove={() => removeFromHistory(item.videoId, item.source)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* MovieCard - Individual movie card component
|
||||
* Displays movie poster, title, and rating
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface DoubanMovie {
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MovieCardProps {
|
||||
movie: DoubanMovie;
|
||||
onMovieClick: (movie: DoubanMovie) => void;
|
||||
}
|
||||
|
||||
export function MovieCard({ movie, onMovieClick }: MovieCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/?q=${encodeURIComponent(movie.title)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onMovieClick(movie);
|
||||
}}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
<Card hover className="overflow-hidden p-0 h-full">
|
||||
<div className="relative aspect-[2/3] overflow-hidden bg-[var(--glass-bg)]" style={{ borderRadius: 'var(--radius-2xl)' }}>
|
||||
<Image
|
||||
src={movie.cover}
|
||||
alt={movie.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||
/>
|
||||
{movie.rate && parseFloat(movie.rate) > 0 && (
|
||||
<div
|
||||
className="absolute top-2 right-2 bg-black/70 backdrop-blur-sm px-2.5 py-1.5 flex items-center gap-1.5"
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<Icons.Star size={12} className="text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-xs font-bold text-white">
|
||||
{movie.rate}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 group-hover:text-[var(--accent-color)] transition-colors">
|
||||
{movie.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* MovieGrid - Grid layout for movie cards with infinite scroll
|
||||
* Handles movie display and loading states
|
||||
*/
|
||||
|
||||
import { MovieCard } from './MovieCard';
|
||||
|
||||
interface DoubanMovie {
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MovieGridProps {
|
||||
movies: DoubanMovie[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
onMovieClick: (movie: DoubanMovie) => void;
|
||||
prefetchRef: React.RefObject<HTMLDivElement | null>;
|
||||
loadMoreRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function MovieGrid({
|
||||
movies,
|
||||
loading,
|
||||
hasMore,
|
||||
onMovieClick,
|
||||
prefetchRef,
|
||||
loadMoreRef
|
||||
}: MovieGridProps) {
|
||||
if (movies.length === 0 && !loading) {
|
||||
return <MovieGridEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{movies.map((movie) => (
|
||||
<MovieCard
|
||||
key={movie.id}
|
||||
movie={movie}
|
||||
onMovieClick={onMovieClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Prefetch Trigger - Earlier */}
|
||||
{hasMore && !loading && <div ref={prefetchRef} className="h-1" />}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{loading && <MovieGridLoading />}
|
||||
|
||||
{/* Intersection Observer Target */}
|
||||
{hasMore && !loading && <div ref={loadMoreRef} className="h-20" />}
|
||||
|
||||
{/* No More Content */}
|
||||
{!hasMore && movies.length > 0 && <MovieGridNoMore />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MovieGridLoading() {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MovieGridNoMore() {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[var(--text-color-secondary)]">没有更多内容了</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MovieGridEmpty() {
|
||||
const { Icons } = require('@/components/ui/Icon');
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-color-secondary)]">暂无内容</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* PopularFeatures - Main component for popular movies section
|
||||
* Displays Douban movie recommendations with tag filtering and infinite scroll
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { TagManager } from './TagManager';
|
||||
import { MovieGrid } from './MovieGrid';
|
||||
import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
|
||||
|
||||
interface DoubanMovie {
|
||||
id: string;
|
||||
@@ -12,8 +16,6 @@ interface DoubanMovie {
|
||||
cover: string;
|
||||
rate: string;
|
||||
url: string;
|
||||
cover_x?: number;
|
||||
cover_y?: number;
|
||||
}
|
||||
|
||||
interface PopularFeaturesProps {
|
||||
@@ -41,6 +43,7 @@ const DEFAULT_TAGS = [
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'kvideo_custom_tags';
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
const [selectedTag, setSelectedTag] = useState('popular');
|
||||
@@ -51,26 +54,19 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [newTagInput, setNewTagInput] = useState('');
|
||||
const [showTagManager, setShowTagManager] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const prefetchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
// Load custom tags from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
setTags(parsed);
|
||||
setTags(JSON.parse(saved));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved tags', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save tags to localStorage
|
||||
const saveTags = (newTags: typeof DEFAULT_TAGS) => {
|
||||
setTags(newTags);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags));
|
||||
@@ -101,7 +97,6 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
}
|
||||
}, [loading, tags]);
|
||||
|
||||
// Load initial movies when tag changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setMovies([]);
|
||||
@@ -109,28 +104,15 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
loadMovies(selectedTag, 0, false);
|
||||
}, [selectedTag]);
|
||||
|
||||
// Setup intersection observer for infinite scroll with prefetch
|
||||
useEffect(() => {
|
||||
if (!prefetchRef.current) return;
|
||||
|
||||
const prefetchObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && hasMore && !loading) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
loadMovies(selectedTag, nextPage * PAGE_LIMIT, true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '400px' }
|
||||
);
|
||||
|
||||
prefetchObserver.observe(prefetchRef.current);
|
||||
|
||||
return () => {
|
||||
prefetchObserver.disconnect();
|
||||
};
|
||||
}, [hasMore, loading, page, selectedTag, loadMovies]);
|
||||
const { prefetchRef, loadMoreRef } = useInfiniteScroll({
|
||||
hasMore,
|
||||
loading,
|
||||
page,
|
||||
onLoadMore: (nextPage) => {
|
||||
setPage(nextPage);
|
||||
loadMovies(selectedTag, nextPage * PAGE_LIMIT, true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMovieClick = (movie: DoubanMovie) => {
|
||||
if (onSearch) {
|
||||
@@ -138,7 +120,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomTag = () => {
|
||||
const handleAddTag = () => {
|
||||
if (!newTagInput.trim()) return;
|
||||
const newTag = {
|
||||
id: `custom_${Date.now()}`,
|
||||
@@ -149,172 +131,42 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
setNewTagInput('');
|
||||
};
|
||||
|
||||
const deleteTag = (tagId: string) => {
|
||||
const handleDeleteTag = (tagId: string) => {
|
||||
saveTags(tags.filter(t => t.id !== tagId));
|
||||
if (selectedTag === tagId) {
|
||||
setSelectedTag('popular');
|
||||
}
|
||||
};
|
||||
|
||||
const restoreDefaults = () => {
|
||||
const handleRestoreDefaults = () => {
|
||||
saveTags(DEFAULT_TAGS);
|
||||
setSelectedTag('popular');
|
||||
setShowTagManager(false);
|
||||
};
|
||||
|
||||
const isCustomTag = (tagId: string) => tagId.startsWith('custom_');
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Tag Management UI */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setShowTagManager(!showTagManager)}
|
||||
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Icons.Tag size={16} />
|
||||
{showTagManager ? '完成' : '管理标签'}
|
||||
</button>
|
||||
{showTagManager && (
|
||||
<button
|
||||
onClick={restoreDefaults}
|
||||
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
恢复默认
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TagManager
|
||||
tags={tags}
|
||||
selectedTag={selectedTag}
|
||||
showTagManager={showTagManager}
|
||||
newTagInput={newTagInput}
|
||||
onTagSelect={setSelectedTag}
|
||||
onTagDelete={handleDeleteTag}
|
||||
onToggleManager={() => setShowTagManager(!showTagManager)}
|
||||
onRestoreDefaults={handleRestoreDefaults}
|
||||
onNewTagInputChange={setNewTagInput}
|
||||
onAddTag={handleAddTag}
|
||||
/>
|
||||
|
||||
{/* Add Custom Tag */}
|
||||
{showTagManager && (
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagInput}
|
||||
onChange={(e) => setNewTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addCustomTag()}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors"
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={addCustomTag}
|
||||
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity"
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Filter */}
|
||||
<div className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide">
|
||||
{tags.map((tag) => (
|
||||
<div key={tag.id} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedTag(tag.id)}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap
|
||||
${selectedTag === tag.id
|
||||
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
|
||||
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
{showTagManager && isCustomTag(tag.id) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteTag(tag.id);
|
||||
}}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<Icons.X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Movies Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{movies.map((movie) => (
|
||||
<Link
|
||||
key={movie.id}
|
||||
href={`/?q=${encodeURIComponent(movie.title)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMovieClick(movie);
|
||||
}}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
<Card hover className="overflow-hidden p-0 h-full">
|
||||
<div className="relative aspect-[2/3] overflow-hidden bg-[var(--glass-bg)]" style={{ borderRadius: 'var(--radius-2xl)' }}>
|
||||
<Image
|
||||
src={movie.cover}
|
||||
alt={movie.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||
/>
|
||||
{movie.rate && parseFloat(movie.rate) > 0 && (
|
||||
<div
|
||||
className="absolute top-2 right-2 bg-black/70 backdrop-blur-sm px-2.5 py-1.5 flex items-center gap-1.5"
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<Icons.Star size={12} className="text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-xs font-bold text-white">
|
||||
{movie.rate}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 group-hover:text-[var(--accent-color)] transition-colors">
|
||||
{movie.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Prefetch Trigger - Earlier */}
|
||||
{hasMore && !loading && <div ref={prefetchRef} className="h-1" />}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intersection Observer Target */}
|
||||
{hasMore && !loading && <div ref={loadMoreRef} className="h-20" />}
|
||||
|
||||
{/* No More Content */}
|
||||
{!hasMore && movies.length > 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[var(--text-color-secondary)]">没有更多内容了</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && movies.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-color-secondary)]">暂无内容</p>
|
||||
</div>
|
||||
)}
|
||||
<MovieGrid
|
||||
movies={movies}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onMovieClick={handleMovieClick}
|
||||
prefetchRef={prefetchRef}
|
||||
loadMoreRef={loadMoreRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* TagManager - Tag management UI component
|
||||
* Handles custom tag creation, deletion, and filtering
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TagManagerProps {
|
||||
tags: Tag[];
|
||||
selectedTag: string;
|
||||
showTagManager: boolean;
|
||||
newTagInput: string;
|
||||
onTagSelect: (tagId: string) => void;
|
||||
onTagDelete: (tagId: string) => void;
|
||||
onToggleManager: () => void;
|
||||
onRestoreDefaults: () => void;
|
||||
onNewTagInputChange: (value: string) => void;
|
||||
onAddTag: () => void;
|
||||
}
|
||||
|
||||
export function TagManager({
|
||||
tags,
|
||||
selectedTag,
|
||||
showTagManager,
|
||||
newTagInput,
|
||||
onTagSelect,
|
||||
onTagDelete,
|
||||
onToggleManager,
|
||||
onRestoreDefaults,
|
||||
onNewTagInputChange,
|
||||
onAddTag,
|
||||
}: TagManagerProps) {
|
||||
const isCustomTag = (tagId: string) => tagId.startsWith('custom_');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Management Controls */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={onToggleManager}
|
||||
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Icons.Tag size={16} />
|
||||
{showTagManager ? '完成' : '管理标签'}
|
||||
</button>
|
||||
{showTagManager && (
|
||||
<button
|
||||
onClick={onRestoreDefaults}
|
||||
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
恢复默认
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Custom Tag */}
|
||||
{showTagManager && (
|
||||
<div className="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagInput}
|
||||
onChange={(e) => onNewTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onAddTag()}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors"
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={onAddTag}
|
||||
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity"
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Filter */}
|
||||
<div className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide">
|
||||
{tags.map((tag) => (
|
||||
<div key={tag.id} className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onTagSelect(tag.id)}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap
|
||||
${selectedTag === tag.id
|
||||
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
|
||||
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
{showTagManager && isCustomTag(tag.id) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTagDelete(tag.id);
|
||||
}}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<Icons.X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* TypeBadgeItem - Individual badge component
|
||||
* Displays a single type badge with count, supports selection state
|
||||
*/
|
||||
|
||||
interface TypeBadgeItemProps {
|
||||
type: string;
|
||||
count: number;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function TypeBadgeItem({ type, count, isSelected, onToggle }: TypeBadgeItemProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium whitespace-nowrap
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
hover:scale-105 hover:shadow-[var(--shadow-sm)]
|
||||
active:scale-95 snap-start
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* TypeBadgeList - Badge list container with responsive layout
|
||||
* Desktop: Expandable grid with show more/less
|
||||
* Mobile: Horizontal scroll with snap
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { TypeBadgeItem } from './TypeBadgeItem';
|
||||
|
||||
interface TypeBadge {
|
||||
type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TypeBadgeListProps {
|
||||
badges: TypeBadge[];
|
||||
selectedTypes: Set<string>;
|
||||
onToggleType: (type: string) => void;
|
||||
}
|
||||
|
||||
export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadgeListProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Expandable Grid */}
|
||||
<div className="hidden md:flex md:flex-col md:flex-1">
|
||||
<div className={`flex items-center gap-2 flex-wrap transition-all duration-300 ${
|
||||
!isExpanded ? 'max-h-[2.5rem] overflow-hidden' : ''
|
||||
}`}>
|
||||
{badges.map((badge) => (
|
||||
<TypeBadgeItem
|
||||
key={badge.type}
|
||||
type={badge.type}
|
||||
count={badge.count}
|
||||
isSelected={selectedTypes.has(badge.type)}
|
||||
onToggle={() => onToggleType(badge.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{badges.length > 5 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start"
|
||||
>
|
||||
<span>{isExpanded ? '收起' : '展开更多'}</span>
|
||||
<Icons.ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile & Tablet: Horizontal Scroll */}
|
||||
<div className="flex md:hidden flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{badges.map((badge) => (
|
||||
<TypeBadgeItem
|
||||
key={badge.type}
|
||||
type={badge.type}
|
||||
count={badge.count}
|
||||
isSelected={selectedTypes.has(badge.type)}
|
||||
onToggle={() => onToggleType(badge.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* TypeBadges - Main component for type badge filtering
|
||||
* Auto-collects unique type_name values and shows counts
|
||||
* Badges disappear when all videos of that type are removed
|
||||
* Responsive: Desktop shows expand/collapse, Mobile shows horizontal scroll
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { TypeBadgeList } from './TypeBadgeList';
|
||||
|
||||
interface TypeBadge {
|
||||
type: string;
|
||||
@@ -17,24 +23,20 @@ interface TypeBadgesProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeBadges - Displays collected type badges from search results
|
||||
* Auto-collects unique type_name values and shows counts
|
||||
* Badges disappear when all videos of that type are removed
|
||||
* Responsive: Desktop shows expand/collapse, Mobile shows horizontal scroll
|
||||
*/
|
||||
export function TypeBadges({
|
||||
badges,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
className = ''
|
||||
}: TypeBadgesProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (badges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
selectedTypes.forEach(type => onToggleType(type));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hover={false}
|
||||
@@ -48,106 +50,17 @@ export function TypeBadges({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Expandable Grid */}
|
||||
<div className="hidden md:flex md:flex-col md:flex-1">
|
||||
<div className={`flex items-center gap-2 flex-wrap transition-all duration-300 ${
|
||||
!isExpanded ? 'max-h-[2.5rem] overflow-hidden' : ''
|
||||
}`}>
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selectedTypes.has(badge.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.type}
|
||||
onClick={() => onToggleType(badge.type)}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
hover:scale-105 hover:shadow-[var(--shadow-sm)]
|
||||
active:scale-95
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{badge.type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{badge.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{badges.length > 5 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start"
|
||||
>
|
||||
<span>{isExpanded ? '收起' : '展开更多'}</span>
|
||||
<Icons.ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile & Tablet: Horizontal Scroll */}
|
||||
<div className="flex md:hidden flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{badges.map((badge) => {
|
||||
const isSelected = selectedTypes.has(badge.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={badge.type}
|
||||
onClick={() => onToggleType(badge.type)}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
border border-[var(--glass-border)]
|
||||
text-xs font-medium whitespace-nowrap
|
||||
transition-all duration-[var(--transition-fluid)]
|
||||
active:scale-95 snap-start
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px]'
|
||||
}
|
||||
`}
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<span>{badge.type}</span>
|
||||
<span className={`
|
||||
px-1.5 py-0.5 rounded-full text-[10px] font-semibold
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
}
|
||||
`}>
|
||||
{badge.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<TypeBadgeList
|
||||
badges={badges}
|
||||
selectedTypes={selectedTypes}
|
||||
onToggleType={onToggleType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTypes.size > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--glass-border)]">
|
||||
<button
|
||||
onClick={() => selectedTypes.forEach(type => onToggleType(type))}
|
||||
onClick={handleClearAll}
|
||||
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors"
|
||||
>
|
||||
|
||||
+30
-17
@@ -12,24 +12,37 @@ export function Card({ children, className = '', hover = true, onClick }: CardPr
|
||||
? "hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_24px_var(--shadow-color)] cursor-pointer transition-all duration-[var(--transition-fluid)]"
|
||||
: "transition-all duration-[var(--transition-fluid)]";
|
||||
|
||||
const baseClasses = `
|
||||
bg-[var(--glass-bg)]
|
||||
backdrop-blur-[25px]
|
||||
saturate-[180%]
|
||||
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
|
||||
rounded-[var(--radius-2xl)]
|
||||
shadow-[0_2px_8px_var(--shadow-color)] md:shadow-[var(--shadow-md)]
|
||||
border
|
||||
border-[var(--glass-border)]
|
||||
p-4 md:p-6
|
||||
relative
|
||||
${hoverStyles}
|
||||
${className}
|
||||
`;
|
||||
|
||||
// Use semantic button when interactive
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`${baseClasses} text-left w-full`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Use div for non-interactive cards
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
bg-[var(--glass-bg)]
|
||||
backdrop-blur-[25px]
|
||||
saturate-[180%]
|
||||
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
|
||||
rounded-[var(--radius-2xl)]
|
||||
shadow-[0_2px_8px_var(--shadow-color)] md:shadow-[var(--shadow-md)]
|
||||
border
|
||||
border-[var(--glass-border)]
|
||||
p-4 md:p-6
|
||||
relative
|
||||
${hoverStyles}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<div className={baseClasses}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"timestamp": "2025-11-18T06:40:11.559Z",
|
||||
"tests": [
|
||||
{
|
||||
"component": "Badge",
|
||||
"variant": "Primary (Light)",
|
||||
"foreground": "white",
|
||||
"background": "#0056b3",
|
||||
"ratio": 7.042135266678601,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "Badge",
|
||||
"variant": "Primary (Dark)",
|
||||
"foreground": "white",
|
||||
"background": "#1A6DBF",
|
||||
"ratio": 5.271486985034207,
|
||||
"passAA": true,
|
||||
"passAAA": false
|
||||
},
|
||||
{
|
||||
"component": "Badge",
|
||||
"variant": "Secondary (Light)",
|
||||
"foreground": "#1d1d1f",
|
||||
"background": "#f2f2f7",
|
||||
"ratio": 15.082365216973475,
|
||||
"passAA": true,
|
||||
"passAAA": true,
|
||||
"notes": "Glass background approximated as solid color"
|
||||
},
|
||||
{
|
||||
"component": "Button",
|
||||
"variant": "Primary (Light)",
|
||||
"foreground": "white",
|
||||
"background": "#0056b3",
|
||||
"ratio": 7.042135266678601,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "Button",
|
||||
"variant": "Secondary (Light)",
|
||||
"foreground": "#1d1d1f",
|
||||
"background": "#f2f2f7",
|
||||
"ratio": 15.082365216973475,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "TypeBadges",
|
||||
"variant": "Selected (Light)",
|
||||
"foreground": "white",
|
||||
"background": "#0056b3",
|
||||
"ratio": 7.042135266678601,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "TypeBadges",
|
||||
"variant": "Unselected (Light)",
|
||||
"foreground": "#1d1d1f",
|
||||
"background": "#f2f2f7",
|
||||
"ratio": 15.082365216973475,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 7,
|
||||
"passedAA": 7,
|
||||
"passedAAA": 6,
|
||||
"failedAA": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* useInfiniteScroll - Custom hook for infinite scroll functionality
|
||||
* Manages intersection observer for prefetching and loading more content
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseInfiniteScrollProps {
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
page: number;
|
||||
onLoadMore: (nextPage: number) => void;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
hasMore,
|
||||
loading,
|
||||
page,
|
||||
onLoadMore
|
||||
}: UseInfiniteScrollProps) {
|
||||
const prefetchRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefetchRef.current) return;
|
||||
|
||||
const prefetchObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && hasMore && !loading) {
|
||||
const nextPage = page + 1;
|
||||
onLoadMore(nextPage);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '400px' }
|
||||
);
|
||||
|
||||
prefetchObserver.observe(prefetchRef.current);
|
||||
|
||||
return () => {
|
||||
prefetchObserver.disconnect();
|
||||
};
|
||||
}, [hasMore, loading, page, onLoadMore]);
|
||||
|
||||
return { prefetchRef, loadMoreRef };
|
||||
}
|
||||
Generated
+8
@@ -20,6 +20,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/wcag-contrast": "^3.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"tailwindcss": "^4",
|
||||
@@ -1578,6 +1579,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/wcag-contrast": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/wcag-contrast/-/wcag-contrast-3.0.3.tgz",
|
||||
"integrity": "sha512-oprevfwJSLfpQK4KaWsRKJuNoebV76+xhmbXiWJGy+FkS34LpCgCMNIwRXWTb8xmmSxUE2ycFOYE7uyRVRm3LA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/wcag-contrast": "^3.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* WCAG 2.2 Contrast Testing Script
|
||||
* Tests color combinations against WCAG AA standards (4.5:1 for normal text, 3:1 for large text)
|
||||
*/
|
||||
|
||||
// Simple contrast ratio calculator
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map(c => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
function getContrastRatio(color1: string, color2: string): number {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return 0;
|
||||
|
||||
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
||||
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
interface ContrastTest {
|
||||
component: string;
|
||||
variant: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
ratio: number;
|
||||
passAA: boolean;
|
||||
passAAA: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Color definitions from globals.css
|
||||
const colors = {
|
||||
light: {
|
||||
text: '#1d1d1f',
|
||||
textSecondary: '#6e6e73',
|
||||
accent: '#0056b3', // Updated for WCAG compliance
|
||||
glassBg: 'rgba(242, 242, 247, 0.8)', // Approximated as #f2f2f7
|
||||
white: '#ffffff',
|
||||
background: '#f0f2f5'
|
||||
},
|
||||
dark: {
|
||||
text: '#f5f5f7',
|
||||
textSecondary: '#8e8e93',
|
||||
accent: '#1A6DBF', // Updated for WCAG compliance
|
||||
glassBg: 'rgba(28, 28, 30, 0.75)', // Approximated as #1c1c1e
|
||||
white: '#ffffff',
|
||||
background: '#121212'
|
||||
}
|
||||
};
|
||||
|
||||
// Tests to run
|
||||
const tests: ContrastTest[] = [];
|
||||
|
||||
console.log('🎨 KVideo WCAG 2.2 Contrast Testing Report\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\n');
|
||||
|
||||
// Badge Tests
|
||||
console.log('📛 BADGE COMPONENT\n');
|
||||
|
||||
// Badge Primary (Light Mode)
|
||||
let ratio = getContrastRatio(colors.light.white, colors.light.accent);
|
||||
tests.push({
|
||||
component: 'Badge',
|
||||
variant: 'Primary (Light)',
|
||||
foreground: 'white',
|
||||
background: colors.light.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Primary (Light): ${colors.light.white} on ${colors.light.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Badge Primary (Dark Mode)
|
||||
ratio = getContrastRatio(colors.dark.white, colors.dark.accent);
|
||||
tests.push({
|
||||
component: 'Badge',
|
||||
variant: 'Primary (Dark)',
|
||||
foreground: 'white',
|
||||
background: colors.dark.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Primary (Dark): ${colors.dark.white} on ${colors.dark.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Badge Secondary (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.text, '#f2f2f7'); // glass-bg approximation
|
||||
tests.push({
|
||||
component: 'Badge',
|
||||
variant: 'Secondary (Light)',
|
||||
foreground: colors.light.text,
|
||||
background: '#f2f2f7',
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7,
|
||||
notes: 'Glass background approximated as solid color'
|
||||
});
|
||||
console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Button Tests
|
||||
console.log('🔘 BUTTON COMPONENT\n');
|
||||
|
||||
// Button Primary (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.white, colors.light.accent);
|
||||
tests.push({
|
||||
component: 'Button',
|
||||
variant: 'Primary (Light)',
|
||||
foreground: 'white',
|
||||
background: colors.light.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Primary (Light): white on ${colors.light.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Button Secondary (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.text, '#f2f2f7');
|
||||
tests.push({
|
||||
component: 'Button',
|
||||
variant: 'Secondary (Light)',
|
||||
foreground: colors.light.text,
|
||||
background: '#f2f2f7',
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// TypeBadges Tests
|
||||
console.log('🏷️ TYPE BADGES COMPONENT\n');
|
||||
|
||||
// Selected state (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.white, colors.light.accent);
|
||||
tests.push({
|
||||
component: 'TypeBadges',
|
||||
variant: 'Selected (Light)',
|
||||
foreground: 'white',
|
||||
background: colors.light.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Selected (Light): white on ${colors.light.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Unselected state (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.text, '#f2f2f7');
|
||||
tests.push({
|
||||
component: 'TypeBadges',
|
||||
variant: 'Unselected (Light)',
|
||||
foreground: colors.light.text,
|
||||
background: '#f2f2f7',
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Unselected (Light): ${colors.light.text} on #f2f2f7`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Summary
|
||||
console.log('\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\n📊 SUMMARY\n');
|
||||
|
||||
const totalTests = tests.length;
|
||||
const passedAA = tests.filter(t => t.passAA).length;
|
||||
const passedAAA = tests.filter(t => t.passAAA).length;
|
||||
|
||||
console.log(`Total tests: ${totalTests}`);
|
||||
console.log(`AA Standard (4.5:1): ${passedAA}/${totalTests} passed (${((passedAA/totalTests)*100).toFixed(1)}%)`);
|
||||
console.log(`AAA Standard (7:1): ${passedAAA}/${totalTests} passed (${((passedAAA/totalTests)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
|
||||
if (passedAA < totalTests) {
|
||||
console.log('⚠️ Some color combinations need adjustment to meet WCAG AA standards.\n');
|
||||
}
|
||||
|
||||
// Export results
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tests,
|
||||
summary: {
|
||||
total: totalTests,
|
||||
passedAA,
|
||||
passedAAA,
|
||||
failedAA: totalTests - passedAA
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Results exported to: contrast-test-results.json\n');
|
||||
|
||||
// This would write to file in a Node environment
|
||||
// For browser, you'd use different storage methods
|
||||
if (typeof require !== 'undefined') {
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync(
|
||||
'contrast-test-results.json',
|
||||
JSON.stringify(results, null, 2)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user