feat: Enhance PlayerSettings with Danmaku options and improve UI components

- Added Danmaku settings including API URL, opacity, font size, and display area to PlayerSettings.
- Improved button styles and transitions across various UI components for better user experience.
- Updated BackToTop, Badge, Button, Card, ConfirmDialog, Input, ModalBackdrop, ModalHeader, SegmentedControl, and Switch components for consistency with Liquid Glass design.
- Adjusted package versions in package.json and package-lock.json to reflect version 4.5.0.
This commit is contained in:
kuekhaoyang
2026-03-15 17:01:51 +08:00
parent 5782da19c6
commit 1439a15d22
29 changed files with 926 additions and 790 deletions
+52 -13
View File
@@ -1,3 +1,8 @@
/**
* FavoritesSidebar - Main favorites sidebar component
* Positioned on the left side of the screen (opposite to WatchHistorySidebar)
*/
'use client';
import { useState, useEffect, useRef } from 'react';
@@ -22,10 +27,12 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean })
const sidebarRef = useRef<HTMLElement>(null);
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
// Setup focus trap when sidebar opens
useEffect(() => {
if (isOpen && sidebarRef.current) {
cleanupFocusTrapRef.current = trapFocus(sidebarRef.current);
}
return () => {
if (cleanupFocusTrapRef.current) {
cleanupFocusTrapRef.current();
@@ -34,14 +41,24 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean })
};
}, [isOpen]);
// Handle escape key to close sidebar
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) setIsOpen(false);
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
if (isOpen) document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
// Handle delete confirmation
const handleDeleteItem = (videoId: string | number, source: string) => {
setDeleteConfirm({ isOpen: true, videoId: String(videoId), source });
};
@@ -51,8 +68,15 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean })
};
const confirmDelete = () => {
if (deleteConfirm.isClearAll) clearFavorites();
else if (deleteConfirm.videoId && deleteConfirm.source) removeFavorite(deleteConfirm.videoId, deleteConfirm.source);
if (deleteConfirm.isClearAll) {
clearFavorites();
} else if (deleteConfirm.videoId && deleteConfirm.source) {
removeFavorite(deleteConfirm.videoId, deleteConfirm.source);
}
setDeleteConfirm({ isOpen: false });
};
const cancelDelete = () => {
setDeleteConfirm({ isOpen: false });
};
@@ -61,16 +85,16 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean })
{/* Toggle Button - Left side */}
<button
onClick={() => setIsOpen(true)}
className="fixed left-4 top-1/2 -translate-y-1/2 z-40 w-10 h-10 flex items-center justify-center bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[160%] [-webkit-backdrop-filter:blur(12px)_saturate(160%)] border border-[var(--glass-border)] rounded-[var(--radius-full)] shadow-[var(--shadow-md)] hover:shadow-[var(--shadow-lg)] hover:scale-110 transition-all duration-200 cursor-pointer"
className="fixed left-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer"
aria-label="打开收藏夹"
>
<Icons.Heart size={20} className="text-[var(--text-color-secondary)]" />
<Icons.Heart size={24} className="text-[var(--text-color)]" />
</button>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[1999] bg-black/40 backdrop-blur-[6px] [-webkit-backdrop-filter:blur(6px)] opacity-0 animate-[fadeIn_0.2s_ease-out_forwards]"
className="fixed inset-0 z-[1999] bg-black/40 opacity-0 animate-[fadeIn_0.2s_ease-out_forwards]"
onClick={() => setIsOpen(false)}
/>
)}
@@ -85,21 +109,36 @@ export function FavoritesSidebar({ isPremium = false }: { isPremium?: boolean })
transform: isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)',
willChange: isOpen ? 'transform' : 'auto'
}}
className="fixed top-0 left-0 bottom-0 w-[85%] sm:w-[90%] max-w-[400px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[20px] saturate-[160%] [-webkit-backdrop-filter:blur(20px)_saturate(160%)] border-r border-[var(--glass-border)] rounded-r-[var(--radius-2xl)] p-5 sm:p-6 flex flex-col shadow-[var(--shadow-lg)] transition-transform duration-300 [transition-timing-function:cubic-bezier(0.22,0.68,0,1)]"
className={`fixed top-0 left-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border-r border-[var(--glass-border)] rounded-tr-[var(--radius-2xl)] rounded-br-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
>
<FavoritesHeader onClose={() => setIsOpen(false)} />
<FavoritesList favorites={favorites} onRemove={handleDeleteItem} isPremium={isPremium} />
<FavoritesFooter hasFavorites={favorites.length > 0} onClearAll={handleClearAll} />
<FavoritesList
favorites={favorites}
onRemove={handleDeleteItem}
isPremium={isPremium}
/>
<FavoritesFooter
hasFavorites={favorites.length > 0}
onClearAll={handleClearAll}
/>
</aside>
{/* Watch History Sidebar - Right side */}
<WatchHistorySidebar isPremium={isPremium} />
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
title={deleteConfirm.isClearAll ? '清空收藏夹' : '取消收藏'}
message={deleteConfirm.isClearAll ? '确定要清空所有收藏吗?此操作无法撤销。' : '确定要取消收藏这个视频吗?'}
message={
deleteConfirm.isClearAll
? '确定要清空所有收藏吗?此操作无法撤销。'
: '确定要取消收藏这个视频吗?'
}
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirm({ isOpen: false })}
onCancel={cancelDelete}
confirmText="确定"
cancelText="取消"
variant="danger"
+51 -13
View File
@@ -1,3 +1,8 @@
/**
* Watch History Sidebar Component
* 观看历史侧边栏组件 - Main layout and state management
*/
'use client';
import { useState, useEffect, useRef } from 'react';
@@ -20,10 +25,12 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean
const sidebarRef = useRef<HTMLElement>(null);
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
// Setup focus trap when sidebar opens
useEffect(() => {
if (isOpen && sidebarRef.current) {
cleanupFocusTrapRef.current = trapFocus(sidebarRef.current);
}
return () => {
if (cleanupFocusTrapRef.current) {
cleanupFocusTrapRef.current();
@@ -32,14 +39,24 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean
};
}, [isOpen]);
// Handle escape key to close sidebar
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) setIsOpen(false);
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
if (isOpen) document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
// Handle delete confirmation
const handleDeleteItem = (showIdentifier: string) => {
setDeleteConfirm({ isOpen: true, showIdentifier });
};
@@ -49,8 +66,15 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean
};
const confirmDelete = () => {
if (deleteConfirm.isClearAll) clearHistory();
else if (deleteConfirm.showIdentifier) removeFromHistory(deleteConfirm.showIdentifier);
if (deleteConfirm.isClearAll) {
clearHistory();
} else if (deleteConfirm.showIdentifier) {
removeFromHistory(deleteConfirm.showIdentifier);
}
setDeleteConfirm({ isOpen: false });
};
const cancelDelete = () => {
setDeleteConfirm({ isOpen: false });
};
@@ -59,16 +83,16 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean
{/* Toggle Button */}
<button
onClick={() => setIsOpen(true)}
className="fixed right-4 top-1/2 -translate-y-1/2 z-40 w-10 h-10 flex items-center justify-center bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[160%] [-webkit-backdrop-filter:blur(12px)_saturate(160%)] border border-[var(--glass-border)] rounded-[var(--radius-full)] shadow-[var(--shadow-md)] hover:shadow-[var(--shadow-lg)] hover:scale-110 transition-all duration-200 cursor-pointer"
className="fixed right-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer"
aria-label="打开观看历史"
>
<Icons.History size={20} className="text-[var(--text-color-secondary)]" />
<Icons.History size={24} className="text-[var(--text-color)]" />
</button>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[1999] bg-black/40 backdrop-blur-[6px] [-webkit-backdrop-filter:blur(6px)] opacity-0 animate-[fadeIn_0.2s_ease-out_forwards]"
className="fixed inset-0 z-[1999] bg-black/40 opacity-0 animate-[fadeIn_0.2s_ease-out_forwards]"
onClick={() => setIsOpen(false)}
/>
)}
@@ -83,19 +107,33 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean
transform: isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)',
willChange: isOpen ? 'transform' : 'auto'
}}
className="fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[400px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[20px] saturate-[160%] [-webkit-backdrop-filter:blur(20px)_saturate(160%)] border-l border-[var(--glass-border)] rounded-l-[var(--radius-2xl)] p-5 sm:p-6 flex flex-col shadow-[var(--shadow-lg)] transition-transform duration-300 [transition-timing-function:cubic-bezier(0.22,0.68,0,1)]"
className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
>
<HistoryHeader onClose={() => setIsOpen(false)} />
<HistoryList history={viewingHistory} onRemove={handleDeleteItem} isPremium={isPremium} />
<HistoryFooter hasHistory={viewingHistory.length > 0} onClearAll={handleClearAll} />
<HistoryList
history={viewingHistory}
onRemove={handleDeleteItem}
isPremium={isPremium}
/>
<HistoryFooter
hasHistory={viewingHistory.length > 0}
onClearAll={handleClearAll}
/>
</aside>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
title={deleteConfirm.isClearAll ? '清空历史记录' : '删除历史记录'}
message={deleteConfirm.isClearAll ? '确定要清空所有观看历史吗?此操作无法撤销。' : '确定要删除这条历史记录吗?'}
message={
deleteConfirm.isClearAll
? '确定要清空所有观看历史吗?此操作无法撤销。'
: '确定要删除这条历史记录吗?'
}
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirm({ isOpen: false })}
onCancel={cancelDelete}
confirmText="删除"
cancelText="取消"
variant="danger"
+10 -6
View File
@@ -93,25 +93,29 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
return (
<div className="animate-fade-in">
{/* Content Type Toggle */}
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
{!effectiveRecommendSelected && (
<div className="mb-8 flex justify-center">
<div className="relative w-72 sm:w-80 p-1 bg-[var(--glass-bg)] backdrop-blur-[20px] saturate-[180%] [-webkit-backdrop-filter:blur(20px)_saturate(180%)] border border-[var(--glass-border)] rounded-[var(--radius-full)] grid grid-cols-2 shadow-[var(--shadow-sm)] overflow-hidden">
<div className="mb-10 flex justify-center">
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
{/* Sliding Indicator */}
<div
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-[calc(var(--radius-full)-4px)] transition-transform duration-[0.35s] [transition-timing-function:cubic-bezier(0.22,0.68,0,1)] shadow-[0_2px_12px_color-mix(in_srgb,var(--accent-color)_35%,transparent)]"
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
style={{
transform: `translateX(${contentType === 'movie' ? '4px' : 'calc(100% + 4px)'})`,
}}
/>
<button
onClick={() => setContentType('movie')}
className={`relative z-10 py-2.5 text-sm font-semibold transition-colors duration-200 cursor-pointer flex justify-center items-center select-none ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'}`}
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
}`}
>
</button>
<button
onClick={() => setContentType('tv')}
className={`relative z-10 py-2.5 text-sm font-semibold transition-colors duration-200 cursor-pointer flex justify-center items-center select-none ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'}`}
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
}`}
>
</button>
+56 -49
View File
@@ -24,88 +24,95 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const handleLogout = () => {
clearSession();
// Navigate to root to clear search query params
window.location.href = '/';
};
const iconBtnClass = `
w-9 h-9 sm:w-10 sm:h-10
flex items-center justify-center
rounded-[var(--radius-full)]
bg-[var(--glass-bg)]
backdrop-blur-[8px]
[-webkit-backdrop-filter:blur(8px)]
border border-[var(--glass-border)]
text-[var(--text-color-secondary)]
hover:text-[var(--text-color)]
hover:bg-[color-mix(in_srgb,var(--accent-color)_8%,var(--glass-bg))]
hover:border-[color-mix(in_srgb,var(--accent-color)_15%,var(--glass-border))]
transition-all duration-200
cursor-pointer
`;
return (
<nav className="sticky top-0 z-[2000] pt-3 pb-1.5">
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}>
<div className="max-w-7xl mx-auto px-4">
<div className="
bg-[var(--glass-bg)]
backdrop-blur-[24px] saturate-[180%]
[-webkit-backdrop-filter:blur(24px)_saturate(180%)]
border border-[var(--glass-border)]
shadow-[var(--shadow-md)]
px-4 sm:px-6 py-2.5 sm:py-3
rounded-[var(--radius-2xl)]
transition-all duration-[0.35s]
[transition-timing-function:cubic-bezier(0.22,0.68,0,1)]
">
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] px-3 sm:px-6 py-2 sm:py-4 rounded-[var(--radius-2xl)]" style={{
transform: 'translate3d(0, 0, 0)'
}}>
<div className="flex items-center justify-between gap-2 sm:gap-4">
<Link
href={isPremiumMode ? '/premium' : '/'}
className="flex items-center gap-2.5 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
onClick={onReset}
data-focusable
>
<div className="w-8 h-8 sm:w-9 sm:h-9 relative flex items-center justify-center flex-shrink-0">
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
<Image
src="/icon.png"
alt={siteConfig.name}
width={36}
height={36}
width={40}
height={40}
className="object-contain"
/>
</div>
<div className="flex flex-col min-w-0">
<h1 className="text-lg sm:text-xl font-bold text-[var(--text-color)] truncate tracking-[-0.02em]">{siteConfig.name}</h1>
<p className="text-[11px] text-[var(--text-color-secondary)] hidden sm:block truncate">{siteConfig.description}</p>
<h1 className="text-lg sm:text-2xl font-bold text-[var(--text-color)] truncate">{siteConfig.name}</h1>
<p className="text-xs text-[var(--text-color-secondary)] hidden sm:block truncate">{siteConfig.description}</p>
</div>
</Link>
<div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
{hasPermission('iptv_access') && (
<Link href="/iptv" className={iconBtnClass} aria-label="直播" title="直播" data-focusable>
<Icons.TV size={16} className="sm:w-[18px] sm:h-[18px]" />
</Link>
<Link
href="/iptv"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="直播"
title="直播"
data-focusable
>
<Icons.TV size={16} className="sm:w-5 sm:h-5" />
</Link>
)}
{/* User Info */}
{session && (
<div className="hidden sm:flex items-center gap-1.5">
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-[11px]">
<div className="hidden sm:flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-xs">
<div className="w-5 h-5 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-[10px] border border-[var(--glass-border)]">
{session.name.charAt(0)}
</div>
<span className="text-[var(--text-color)] max-w-[60px] truncate font-medium">{session.name}</span>
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span>
{session.role === 'admin' && (
<span className="px-1.5 py-0.5 bg-[var(--accent-color)]/10 text-[var(--accent-color)] rounded-[var(--radius-xs)] text-[10px] font-semibold"></span>
<span className="px-1 py-0.5 bg-[var(--accent-color)]/10 text-[var(--accent-color)] rounded text-[10px] font-medium">
</span>
)}
</div>
<button onClick={handleLogout} className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer" aria-label="退出登录" title="退出登录">
<LogOut size={13} />
<button
onClick={handleLogout}
className="w-8 h-8 sm:w-8 sm:h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer"
aria-label="退出登录"
title="退出登录"
>
<LogOut size={14} />
</button>
</div>
)}
<a href="https://github.com/KuekHaoYang/KVideo" target="_blank" rel="noopener noreferrer" className={`${iconBtnClass} hidden sm:flex`} aria-label="GitHub 仓库">
<Icons.Github size={18} />
<a
href="https://github.com/KuekHaoYang/KVideo"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer hidden sm:flex"
aria-label="GitHub 仓库"
>
<Icons.Github size={20} />
</a>
<Link href={settingsHref} className={iconBtnClass} aria-label="设置" data-focusable>
<svg className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px]" viewBox="0 -960 960 960" fill="currentColor">
<Link
href={settingsHref}
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="设置"
data-focusable
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</Link>
+41 -16
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, FormEvent, useEffect, useRef } from 'react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
@@ -18,28 +18,52 @@ export function SearchBox({ onSearch, onClear, initialQuery = '', placeholder =
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
// Search history hook
const {
searchHistory, isDropdownOpen, highlightedIndex,
showDropdown, hideDropdown, addSearch, removeSearch,
clearAll, selectHistoryItem, navigateDropdown, resetHighlight,
searchHistory,
isDropdownOpen,
highlightedIndex,
showDropdown,
hideDropdown,
addSearch,
removeSearch,
clearAll,
selectHistoryItem,
navigateDropdown,
resetHighlight,
} = useSearchHistory((selectedQuery) => {
setQuery(selectedQuery);
onSearch(selectedQuery);
// Blur the input after selecting from history
inputRef.current?.blur();
}, isPremium);
// Update query when initialQuery changes
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
const {
handleSubmit, handleClear, handleInputFocus,
handleInputBlur, handleKeyDown,
handleSubmit,
handleClear,
handleInputFocus,
handleInputBlur,
handleKeyDown,
} = useSearchBoxHandlers({
query, setQuery, onSearch, onClear, inputRef,
isDropdownOpen, highlightedIndex, searchHistory,
addSearch, hideDropdown, showDropdown,
resetHighlight, selectHistoryItem, navigateDropdown,
query,
setQuery,
onSearch,
onClear,
inputRef,
isDropdownOpen,
highlightedIndex,
searchHistory,
addSearch,
hideDropdown,
showDropdown,
resetHighlight,
selectHistoryItem,
navigateDropdown,
});
return (
@@ -53,7 +77,7 @@ export function SearchBox({ onSearch, onClear, initialQuery = '', placeholder =
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="text-base sm:text-lg pr-28 sm:pr-36 md:pr-44 truncate shadow-[var(--shadow-md)] hover:shadow-[var(--shadow-lg)] transition-shadow"
className="text-base sm:text-lg pr-28 sm:pr-36 md:pr-44 truncate"
aria-label="搜索视频内容"
aria-expanded={isDropdownOpen}
aria-controls="search-history-dropdown"
@@ -66,25 +90,26 @@ export function SearchBox({ onSearch, onClear, initialQuery = '', placeholder =
<button
type="button"
onClick={handleClear}
className="p-2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors touch-manipulation cursor-pointer rounded-[var(--radius-full)] hover:bg-[color-mix(in_srgb,var(--text-color)_8%,transparent)]"
className="p-2 text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity touch-manipulation cursor-pointer"
aria-label="清除搜索"
>
<Icons.X size={18} />
<Icons.X size={20} />
</button>
)}
<Button
type="submit"
disabled={!query.trim()}
variant="primary"
className="px-3 sm:px-4 md:px-6 rounded-[var(--radius-2xl)]"
className="px-3 sm:px-4 md:px-6"
>
<span className="flex items-center gap-2">
<Icons.Search size={18} />
<span className="hidden sm:inline font-semibold"></span>
<Icons.Search size={20} />
<span className="hidden sm:inline"></span>
</span>
</Button>
</div>
{/* Search History Dropdown */}
<SearchHistoryDropdown
isOpen={isDropdownOpen}
searchHistory={searchHistory}
+92 -58
View File
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/Badge';
import { Icons } from '@/components/ui/Icon';
import { LatencyBadge } from '@/components/ui/LatencyBadge';
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
import { Video } from '@/lib/types';
import { parseVideoTitle } from '@/lib/utils/video';
import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe';
@@ -25,108 +26,132 @@ interface VideoCardProps {
}
export const VideoCard = memo<VideoCardProps>(({
video, videoUrl, cardId, isActive, onCardClick,
isPremium = false, latencies = {}, resolution, isProbing = false,
video,
videoUrl,
cardId,
isActive,
onCardClick,
isPremium = false,
latencies = {},
resolution,
isProbing = false,
}) => {
const displayLatency = latencies[video.source] ?? video.latency;
const { cleanTitle } = parseVideoTitle(video.vod_name);
return (
<div
className="relative"
style={{ zIndex: 1 }}
style={{
position: 'relative',
zIndex: 1,
}}
onMouseEnter={(e) => (e.currentTarget.style.zIndex = '100')}
onMouseLeave={(e) => (e.currentTarget.style.zIndex = '1')}
>
<Link
key={cardId}
href={videoUrl}
onClick={(e) => onCardClick(e, cardId, videoUrl)}
role="listitem"
aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`}
prefetch={false}
data-focusable
className="group block h-full"
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out block h-full"
>
<Card
className="p-0 flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-[var(--shadow-sm)] border-[var(--glass-border)] hover:shadow-[var(--shadow-lg)] hover:translate-y-[-3px] transition-all duration-[0.3s] [transition-timing-function:cubic-bezier(0.22,0.68,0,1)]"
className="p-0 flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] hover:shadow-lg transition-shadow"
hover={false}
blur={false}
style={{ backfaceVisibility: 'hidden' }}
style={{
backfaceVisibility: 'hidden',
}}
>
{/* Poster */}
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-t-[var(--radius-2xl)] overflow-hidden">
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] overflow-hidden">
{video.vod_pic ? (
<Image
src={video.vod_pic}
alt={video.vod_name}
fill
className="object-cover group-hover:scale-[1.04] transition-transform duration-500 ease-out"
sizes="(max-width: 640px) 45vw, (max-width: 1024px) 22vw, 16vw"
className="object-cover rounded-[var(--radius-2xl)]"
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
loading="eager"
unoptimized
referrerPolicy="no-referrer"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.opacity = '0';
const target = e.currentTarget as HTMLImageElement;
target.style.opacity = '0';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={56} className="text-[var(--text-color-secondary)] opacity-30" />
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
</div>
)}
{/* Fallback Icon */}
{/* Fallback Icon - visible when image fails */}
<div className="absolute inset-0 flex flex-col items-center justify-center -z-10 gap-2">
<Icons.Film size={40} className="text-[var(--text-color-secondary)] opacity-30" />
<span className="text-[10px] text-[var(--text-color-secondary)] opacity-50 px-3 text-center line-clamp-2">{video.vod_name}</span>
<Icons.Film size={48} className="text-[var(--text-color-secondary)] opacity-40" />
<span className="text-xs text-[var(--text-color-secondary)] opacity-60 px-2 text-center line-clamp-2">{video.vod_name}</span>
</div>
{/* Bottom gradient overlay */}
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/60 to-transparent pointer-events-none" />
{/* Top badges row */}
<div className="absolute top-2 left-2 right-2 z-10 flex items-start justify-between gap-1">
<div className="flex items-center gap-1 min-w-0 flex-wrap">
{/* Badge Container */}
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
<div className="flex items-center gap-1 min-w-0">
{video.sourceName && (
<Badge variant="primary" className="flex-shrink-0 max-w-[60%] truncate text-[9px]">
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
{video.sourceName}
</Badge>
)}
{video.type_name && (
<Badge variant="secondary" className="flex-shrink-0 max-w-[45%] truncate text-[9px] bg-black/40 border-white/10 text-white/90">
<Badge variant="secondary" className="flex-shrink-0 max-w-[40%] truncate text-[10px]">
{video.type_name}
</Badge>
)}
</div>
{displayLatency !== undefined && (
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
)}
</div>
{/* Favorite Button */}
<div className={`absolute top-9 right-2 z-20 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
{/* Favorite Button - Top Right */}
<div className={`absolute top-2 right-2 z-20 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
<FavoriteButton
videoId={video.vod_id} source={video.source}
title={video.vod_name} poster={video.vod_pic}
sourceName={video.sourceName} type={video.type_name}
year={video.vod_year} remarks={video.vod_remarks}
size={16} className="shadow-lg" isPremium={isPremium}
videoId={video.vod_id}
source={video.source}
title={video.vod_name}
poster={video.vod_pic}
sourceName={video.sourceName}
type={video.type_name}
year={video.vod_year}
remarks={video.vod_remarks}
size={16}
className="shadow-md"
isPremium={isPremium}
/>
</div>
{/* Hover overlay */}
{/* Overlay */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-250 ${isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}
style={{
willChange: 'opacity',
}}
>
<div className="absolute bottom-0 left-0 right-0 p-3 space-y-1">
<div className="absolute bottom-0 left-0 right-0 p-3">
{isActive && (
<div className="lg:hidden text-white/90 text-[11px] font-medium">
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
</div>
)}
{video.type_name && (
<Badge variant="secondary" className="text-xs mb-2">
{video.type_name}
</Badge>
)}
{video.vod_year && (
<div className="flex items-center gap-1.5 text-white/80 text-[11px]">
<Icons.Calendar size={11} />
<div className="flex items-center gap-1 text-white/80 text-xs">
<Icons.Calendar size={12} />
<span>{video.vod_year}</span>
</div>
)}
@@ -135,26 +160,34 @@ export const VideoCard = memo<VideoCardProps>(({
</div>
{/* Info */}
<div className="px-3 py-2.5 flex-1 flex flex-col gap-1">
<h4 className="font-semibold text-[13px] text-[var(--text-color)] line-clamp-2 min-h-[2.25rem] leading-[1.3] tracking-[-0.01em]">
{cleanTitle}
</h4>
<div className="flex items-center gap-1.5 flex-wrap mt-auto">
{resolution ? (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded-[var(--radius-xs)] text-[9px] font-bold text-white ${resolution.color}`}>
{resolution.label}
</span>
) : isProbing ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-[var(--radius-xs)] text-[9px] font-bold text-white/40 bg-gray-500/40 animate-pulse">
...
</span>
) : null}
{video.vod_lang && (
<span className="text-[11px] text-[var(--text-color-secondary)]">
{video.vod_lang}
</span>
)}
</div>
<div className="p-3 flex-1 flex flex-col">
{(() => {
const { cleanTitle } = parseVideoTitle(video.vod_name);
return (
<>
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] mb-1">
{cleanTitle}
</h4>
<div className="flex items-center gap-1.5 flex-wrap">
{resolution ? (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${resolution.color}`}>
{resolution.label}
</span>
) : isProbing ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold text-white/50 bg-gray-500/50 animate-pulse">
...
</span>
) : null}
</div>
{video.vod_lang && (
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
{video.vod_lang}
</p>
)}
</>
);
})()}
</div>
</Card>
</Link>
@@ -163,3 +196,4 @@ export const VideoCard = memo<VideoCardProps>(({
});
VideoCard.displayName = 'VideoCard';
+1 -1
View File
@@ -191,7 +191,7 @@ export const VideoGrid = memo(function VideoGrid({
<>
<div
ref={gridRef}
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-5 max-w-[1920px] mx-auto ${className}`}
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-6 max-w-[1920px] mx-auto ${className}`}
role="list"
aria-label="视频搜索结果"
>
-117
View File
@@ -1,117 +0,0 @@
'use client';
import { Icons } from '@/components/ui/Icon';
interface DanmakuSettingsProps {
danmakuApiUrl: string;
onDanmakuApiUrlChange: (url: string) => void;
danmakuOpacity: number;
onDanmakuOpacityChange: (value: number) => void;
danmakuFontSize: number;
onDanmakuFontSizeChange: (value: number) => void;
danmakuDisplayArea: number;
onDanmakuDisplayAreaChange: (value: number) => void;
showDanmakuApi?: boolean;
}
const FONT_SIZES = [14, 18, 20, 24, 28];
const DISPLAY_AREAS = [
{ value: 0.25, label: '1/4屏' },
{ value: 0.5, label: '半屏' },
{ value: 0.75, label: '3/4屏' },
{ value: 1.0, label: '全屏' },
];
const pillActive = 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_2px_8px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]';
const pillInactive = 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_8%,transparent)]';
export function DanmakuSettings({
danmakuApiUrl, onDanmakuApiUrlChange,
danmakuOpacity, onDanmakuOpacityChange,
danmakuFontSize, onDanmakuFontSizeChange,
danmakuDisplayArea, onDanmakuDisplayAreaChange,
showDanmakuApi = true,
}: DanmakuSettingsProps) {
return (
<div>
<h3 className="font-medium text-sm text-[var(--text-color)] mb-1.5 inline-flex items-center gap-2">
<Icons.Danmaku size={16} className="text-[var(--accent-color)]" />
</h3>
<p className="text-xs text-[var(--text-color-secondary)] mb-3">
API
</p>
<div className="space-y-4">
{showDanmakuApi && (
<div>
<label className="block text-xs font-medium text-[var(--text-color)] mb-1.5">API </label>
<input
type="text"
placeholder="https://your-danmu-api.example.com"
value={danmakuApiUrl}
onChange={(e) => onDanmakuApiUrlChange(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--glass-bg)] backdrop-blur-[8px] [-webkit-backdrop-filter:blur(8px)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/40 focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_15%,transparent)] transition-all duration-200 text-sm"
/>
<p className="text-[11px] text-[var(--text-color-secondary)] mt-1.5">
<a href="https://github.com/huangxd-/danmu_api" target="_blank" rel="noopener noreferrer" className="text-[var(--accent-color)] hover:underline">danmu_api</a>
</p>
</div>
)}
{/* Opacity Slider */}
<div>
<label className="block text-xs font-medium text-[var(--text-color)] mb-2">
{Math.round(danmakuOpacity * 100)}%
</label>
<div className="relative w-full h-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-[var(--accent-color)] rounded-[var(--radius-full)] transition-[width] duration-100"
style={{ width: `${danmakuOpacity * 100}%` }}
/>
</div>
<input
type="range"
min="10"
max="100"
value={Math.round(danmakuOpacity * 100)}
onChange={(e) => onDanmakuOpacityChange(parseInt(e.target.value) / 100)}
className="w-full accent-[var(--accent-color)] h-2 opacity-0 absolute mt-[-10px] cursor-pointer"
/>
</div>
{/* Font Size */}
<div>
<label className="block text-xs font-medium text-[var(--text-color)] mb-2"></label>
<div className="flex gap-2 flex-wrap">
{FONT_SIZES.map((size) => (
<button
key={size}
onClick={() => onDanmakuFontSizeChange(size)}
className={`px-3 py-1.5 rounded-[var(--radius-full)] border text-xs font-semibold transition-all duration-200 cursor-pointer ${danmakuFontSize === size ? pillActive : pillInactive}`}
>
{size}px
</button>
))}
</div>
</div>
{/* Display Area */}
<div>
<label className="block text-xs font-medium text-[var(--text-color)] mb-2"></label>
<div className="flex gap-2 flex-wrap">
{DISPLAY_AREAS.map(({ value, label }) => (
<button
key={value}
onClick={() => onDanmakuDisplayAreaChange(value)}
className={`px-3 py-1.5 rounded-[var(--radius-full)] border text-xs font-semibold transition-all duration-200 cursor-pointer ${danmakuDisplayArea === value ? pillActive : pillInactive}`}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
);
}
+92 -46
View File
@@ -1,5 +1,10 @@
'use client';
/**
* DisplaySettings - Settings for search display and latency
* Following Liquid Glass design system
*/
import { type SearchDisplayMode, type LocaleOption } from '@/lib/store/settings-store';
import { Switch } from '@/components/ui/Switch';
@@ -14,69 +19,110 @@ interface DisplaySettingsProps {
onLocaleChange: (locale: LocaleOption) => void;
}
const activeBtn = 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_2px_12px_color-mix(in_srgb,var(--accent-color)_35%,transparent)]';
const inactiveBtn = 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_8%,transparent)] hover:border-[color-mix(in_srgb,var(--accent-color)_20%,var(--glass-border))]';
const btnBase = 'px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer';
export function DisplaySettings({
realtimeLatency, searchDisplayMode, rememberScrollPosition, locale,
onRealtimeLatencyChange, onSearchDisplayModeChange, onRememberScrollPositionChange, onLocaleChange,
realtimeLatency,
searchDisplayMode,
rememberScrollPosition,
locale,
onRealtimeLatencyChange,
onSearchDisplayModeChange,
onRememberScrollPositionChange,
onLocaleChange,
}: DisplaySettingsProps) {
return (
<div className="bg-[var(--glass-bg)] backdrop-blur-[12px] [-webkit-backdrop-filter:blur(12px)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-5 sm:p-6 mb-5">
<h2 className="text-lg font-bold text-[var(--text-color)] mb-4 tracking-[-0.02em]"></h2>
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
{/* Toggle settings */}
<div className="space-y-4 mb-5">
<div className="flex items-center justify-between gap-4">
{/* Remember Scroll Position Toggle */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-sm text-[var(--text-color)]"></h3>
<p className="text-xs text-[var(--text-color-secondary)] mt-0.5">退</p>
<h3 className="font-medium text-[var(--text-color)]"></h3>
<p className="text-sm text-[var(--text-color-secondary)] mt-1">
退
</p>
</div>
<Switch checked={rememberScrollPosition} onChange={onRememberScrollPositionChange} ariaLabel="记住滚动位置开关" />
</div>
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="font-medium text-sm text-[var(--text-color)]"></h3>
<p className="text-xs text-[var(--text-color-secondary)] mt-0.5"> 5 </p>
</div>
<Switch checked={realtimeLatency} onChange={onRealtimeLatencyChange} ariaLabel="实时延迟显示开关" />
<Switch
checked={rememberScrollPosition}
onChange={onRememberScrollPositionChange}
ariaLabel="记住滚动位置开关"
/>
</div>
</div>
<div className="border-t border-[var(--glass-border)] mb-5" />
{/* Real-time Latency Toggle */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-[var(--text-color)]"></h3>
<p className="text-sm text-[var(--text-color-secondary)] mt-1">
5
</p>
</div>
<Switch
checked={realtimeLatency}
onChange={onRealtimeLatencyChange}
ariaLabel="实时延迟显示开关"
/>
</div>
</div>
{/* Search Display Mode */}
<div className="mb-5">
<h3 className="font-medium text-sm text-[var(--text-color)] mb-1.5"></h3>
<p className="text-xs text-[var(--text-color-secondary)] mb-3"></p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<button onClick={() => onSearchDisplayModeChange('normal')} className={`${btnBase} ${searchDisplayMode === 'normal' ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm"></div>
<div className="text-xs opacity-75 mt-0.5"></div>
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2"></h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
onClick={() => onSearchDisplayModeChange('normal')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${searchDisplayMode === 'normal'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button onClick={() => onSearchDisplayModeChange('grouped')} className={`${btnBase} ${searchDisplayMode === 'grouped' ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm"></div>
<div className="text-xs opacity-75 mt-0.5"></div>
<button
onClick={() => onSearchDisplayModeChange('grouped')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${searchDisplayMode === 'grouped'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
</div>
<div className="border-t border-[var(--glass-border)] mb-5" />
{/* Locale */}
<div>
<h3 className="font-medium text-sm text-[var(--text-color)] mb-1.5"></h3>
<p className="text-xs text-[var(--text-color-secondary)] mb-3"></p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<button onClick={() => onLocaleChange('zh-CN')} className={`${btnBase} ${locale === 'zh-CN' ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm"></div>
<div className="text-xs opacity-75 mt-0.5">使</div>
{/* Locale / Language Toggle */}
<div className="mt-6">
<h3 className="font-medium text-[var(--text-color)] mb-2"></h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
/
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
onClick={() => onLocaleChange('zh-CN')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${locale === 'zh-CN'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button onClick={() => onLocaleChange('zh-TW')} className={`${btnBase} ${locale === 'zh-TW' ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm"></div>
<div className="text-xs opacity-75 mt-0.5">使</div>
<button
onClick={() => onLocaleChange('zh-TW')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${locale === 'zh-TW'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
</div>
</div>
+179 -50
View File
@@ -1,8 +1,12 @@
'use client';
/**
* PlayerSettings - Global settings for the video player
* Following Liquid Glass design system
*/
import { Icons } from '@/components/ui/Icon';
import type { ProxyMode } from '@/lib/store/settings-store';
import { DanmakuSettings } from './DanmakuSettings';
interface PlayerSettingsProps {
fullscreenType: 'auto' | 'native' | 'window';
@@ -20,77 +24,202 @@ interface PlayerSettingsProps {
showDanmakuApi?: boolean;
}
const activeBtn = 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_2px_12px_color-mix(in_srgb,var(--accent-color)_35%,transparent)]';
const inactiveBtn = 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_8%,transparent)] hover:border-[color-mix(in_srgb,var(--accent-color)_20%,var(--glass-border))]';
const btnBase = 'px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer';
export function PlayerSettings(props: PlayerSettingsProps) {
const { fullscreenType, onFullscreenTypeChange, proxyMode, onProxyModeChange } = props;
const DANMAKU_FONT_SIZES = [14, 18, 20, 24, 28];
const DANMAKU_DISPLAY_AREAS = [
{ value: 0.25, label: '1/4屏' },
{ value: 0.5, label: '半屏' },
{ value: 0.75, label: '3/4屏' },
{ value: 1.0, label: '全屏' },
];
export function PlayerSettings({
fullscreenType,
onFullscreenTypeChange,
proxyMode,
onProxyModeChange,
danmakuApiUrl,
onDanmakuApiUrlChange,
danmakuOpacity,
onDanmakuOpacityChange,
danmakuFontSize,
onDanmakuFontSizeChange,
danmakuDisplayArea,
onDanmakuDisplayAreaChange,
showDanmakuApi = true,
}: PlayerSettingsProps) {
return (
<div className="bg-[var(--glass-bg)] backdrop-blur-[12px] [-webkit-backdrop-filter:blur(12px)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-5 sm:p-6 mb-5">
<h2 className="text-lg font-bold text-[var(--text-color)] mb-4 tracking-[-0.02em]"></h2>
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
<div className="space-y-5">
{/* Fullscreen Mode */}
<div className="space-y-6">
{/* Fullscreen Mode Selection */}
<div>
<h3 className="font-medium text-sm text-[var(--text-color)] mb-1.5 inline-flex items-center gap-2">
<Icons.Maximize size={16} className="text-[var(--accent-color)]" />
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Maximize size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-xs text-[var(--text-color-secondary)] mb-3"></p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<button onClick={() => onFullscreenTypeChange('native')} className={`${btnBase} ${fullscreenType === 'native' ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm"></div>
<div className="text-xs opacity-75 mt-0.5"></div>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
onClick={() => onFullscreenTypeChange('native')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${fullscreenType === 'native'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button onClick={() => onFullscreenTypeChange('window')} className={`${btnBase} ${fullscreenType === 'window' ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm"></div>
<div className="text-xs opacity-75 mt-0.5"></div>
<button
onClick={() => onFullscreenTypeChange('window')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${fullscreenType === 'window'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
</div>
<div className="border-t border-[var(--glass-border)]" />
{/* Proxy Mode */}
{/* Proxy Mode Selection */}
<div>
<h3 className="font-medium text-sm text-[var(--text-color)] mb-1.5 inline-flex items-center gap-2">
<Icons.Globe size={16} className="text-[var(--accent-color)]" />
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Globe size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-xs text-[var(--text-color-secondary)] mb-3"></p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
{(['retry', 'none', 'always'] as ProxyMode[]).map((mode) => {
const labels: Record<ProxyMode, [string, string]> = {
retry: ['智能重试 (推荐)', '直连优先,失败时尝试代理'],
none: ['仅直连', '不使用代理,失败则报错'],
always: ['总是代理', '所有请求都通过代理转发'],
};
return (
<button key={mode} onClick={() => onProxyModeChange(mode)} className={`${btnBase} ${proxyMode === mode ? activeBtn : inactiveBtn}`}>
<div className="font-semibold text-sm">{labels[mode][0]}</div>
<div className="text-xs opacity-75 mt-0.5">{labels[mode][1]}</div>
</button>
);
})}
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onProxyModeChange('none')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
</div>
<div className="border-t border-[var(--glass-border)]" />
{/* Danmaku Settings */}
<DanmakuSettings
danmakuApiUrl={props.danmakuApiUrl}
onDanmakuApiUrlChange={props.onDanmakuApiUrlChange}
danmakuOpacity={props.danmakuOpacity}
onDanmakuOpacityChange={props.onDanmakuOpacityChange}
danmakuFontSize={props.danmakuFontSize}
onDanmakuFontSizeChange={props.onDanmakuFontSizeChange}
danmakuDisplayArea={props.danmakuDisplayArea}
onDanmakuDisplayAreaChange={props.onDanmakuDisplayAreaChange}
showDanmakuApi={props.showDanmakuApi}
/>
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Danmaku size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
API
</p>
{/* API URL */}
<div className="space-y-4">
{showDanmakuApi && (
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
API
</label>
<input
type="text"
placeholder="https://your-danmu-api.example.com"
value={danmakuApiUrl}
onChange={(e) => onDanmakuApiUrlChange(e.target.value)}
className="w-full px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] transition-colors text-sm"
/>
<p className="text-xs text-[var(--text-color-secondary)] mt-1.5">
<a href="https://github.com/huangxd-/danmu_api" target="_blank" rel="noopener noreferrer" className="text-[var(--accent-color)] hover:underline">danmu_api</a>
</p>
</div>
)}
{/* Opacity */}
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
{Math.round(danmakuOpacity * 100)}%
</label>
<input
type="range"
min="10"
max="100"
value={Math.round(danmakuOpacity * 100)}
onChange={(e) => onDanmakuOpacityChange(parseInt(e.target.value) / 100)}
className="w-full accent-[var(--accent-color)] h-2"
/>
</div>
{/* Font Size */}
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
</label>
<div className="flex gap-2 flex-wrap">
{DANMAKU_FONT_SIZES.map((size) => (
<button
key={size}
onClick={() => onDanmakuFontSizeChange(size)}
className={`px-3 py-1.5 rounded-[var(--radius-2xl)] border text-sm font-medium transition-all duration-200 cursor-pointer ${danmakuFontSize === size
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
{size}px
</button>
))}
</div>
</div>
{/* Display Area */}
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
</label>
<div className="flex gap-2 flex-wrap">
{DANMAKU_DISPLAY_AREAS.map(({ value, label }) => (
<button
key={value}
onClick={() => onDanmakuDisplayAreaChange(value)}
className={`px-3 py-1.5 rounded-[var(--radius-2xl)] border text-sm font-medium transition-all duration-200 cursor-pointer ${danmakuDisplayArea === value
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
+5 -11
View File
@@ -38,17 +38,11 @@ export function BackToTop() {
return (
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-8 z-[9999] p-3
rounded-[var(--radius-full)]
bg-[var(--glass-bg)]
backdrop-blur-[25px] saturate-[180%]
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
border border-[var(--glass-border)]
shadow-[var(--shadow-md)]
text-[var(--text-color)]
transition-all duration-[0.4s]
[transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]
className={`fixed bottom-8 right-8 z-[9999] p-3 rounded-full
bg-[var(--glass-bg)] border border-[var(--glass-border)]
shadow-[var(--shadow-md)] backdrop-blur-xl
text-[var(--text-color)] transition-all duration-300 ease-out
hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]
hover:scale-110 active:scale-95
${isVisible
? 'opacity-100 translate-y-0 scale-100'
+19 -17
View File
@@ -16,24 +16,20 @@ const BadgeComponent = memo(function Badge({
iconPosition = 'left'
}: BadgeProps) {
const variants = {
primary: `
bg-[var(--accent-color)]
text-white
shadow-[0_1px_4px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
`,
secondary: `
bg-[var(--glass-bg)]
backdrop-blur-[8px]
[-webkit-backdrop-filter:blur(8px)]
border border-[var(--glass-border)]
text-[var(--text-color)]
`,
primary: "bg-[var(--accent-color)] text-white shadow-[var(--shadow-sm)]",
secondary: "bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)]",
};
const iconElement = icon && (
<span
className={`inline-flex items-center justify-center ${iconPosition === 'left' ? 'mr-1' : 'ml-1'}`}
style={{ width: '0.875em', height: '0.875em' }}
className={`inline-flex items-center justify-center ${iconPosition === 'left' ? 'mr-1' : 'ml-1'
}`}
style={{
width: '0.875em',
height: '0.875em',
transform: 'translateZ(0)',
willChange: 'auto',
}}
>
{icon}
</span>
@@ -43,13 +39,16 @@ const BadgeComponent = memo(function Badge({
<span
className={`
inline-flex items-center justify-center
px-2 py-0.5
px-1.5 py-0.5
rounded-[var(--radius-full)]
text-[10px] font-bold tracking-[0.02em]
leading-tight whitespace-nowrap
text-[10px] font-semibold
${variants[variant]}
${className}
`}
style={{
transform: 'translateZ(0)',
willChange: 'auto',
}}
>
{icon && iconPosition === 'left' && iconElement}
{children}
@@ -58,4 +57,7 @@ const BadgeComponent = memo(function Badge({
);
});
// Export both named and default for compatibility
export const Badge = BadgeComponent;
+25 -39
View File
@@ -1,71 +1,55 @@
'use client';
import React, { forwardRef } from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
variant = 'primary',
size = 'md',
children,
className = '',
...props
}, ref) => {
const baseStyles = `
inline-flex items-center justify-center
font-semibold
rounded-[var(--radius-2xl)]
transition-all duration-200
disabled:opacity-40 disabled:cursor-not-allowed
disabled:transform-none disabled:shadow-none
min-h-[44px] touch-manipulation cursor-pointer
select-none
`;
const sizes = {
sm: 'px-3 py-1.5 text-xs gap-1.5',
md: 'px-4 py-2.5 md:px-5 md:py-2.5 text-sm gap-2',
lg: 'px-6 py-3 md:px-8 md:py-3.5 text-base gap-2.5',
};
const baseStyles = "inline-flex items-center justify-center px-4 py-2.5 md:px-6 md:py-3 font-semibold text-sm md:text-base transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] touch-manipulation cursor-pointer";
const variants = {
primary: `
bg-[var(--accent-color)]
text-white
border-none
shadow-[0_2px_8px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
hover:bg-[var(--accent-hover,var(--accent-color))]
hover:translate-y-[-1px]
hover:shadow-[0_4px_16px_color-mix(in_srgb,var(--accent-color)_40%,transparent)]
active:translate-y-0 active:scale-[0.97]
bg-[var(--accent-color)]
text-white
border-none
rounded-[var(--radius-2xl)]
shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)]
hover:brightness-110
hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)]
active:scale-[0.98]
active:brightness-95
`,
secondary: `
bg-[var(--glass-bg)]
backdrop-blur-[20px] saturate-[180%]
[-webkit-backdrop-filter:blur(20px)_saturate(180%)]
border border-[var(--glass-border)]
bg-[var(--glass-bg)]
backdrop-blur-xl
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
saturate-[180%]
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
shadow-[var(--shadow-sm)]
hover:shadow-[var(--shadow-md)]
hover:border-[color-mix(in_srgb,var(--accent-color)_20%,var(--glass-border))]
active:scale-[0.97]
shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)]
hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)]
active:scale-[0.98]
`,
ghost: `
bg-transparent
text-[var(--text-color)]
hover:bg-[color-mix(in_srgb,var(--text-color)_8%,transparent)]
active:scale-[0.97]
hover:bg-[var(--glass-border)]
active:scale-[0.98]
`,
};
return (
<button
ref={ref}
className={`${baseStyles} ${sizes[size]} ${variants[variant]} ${className}`}
className={`${baseStyles} ${variants[variant]} ${className}`}
{...props}
>
{children}
@@ -74,3 +58,5 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
});
Button.displayName = 'Button';
+9 -5
View File
@@ -11,26 +11,27 @@ interface CardProps {
export function Card({ children, className = '', hover = true, blur = true, onClick, style }: CardProps) {
const hoverStyles = hover
? "hover:translate-y-[-3px] hover:shadow-[var(--shadow-lg)] hover:z-10 cursor-pointer"
? "hover:translate-y-[-2px] hover:shadow-[0_8px_24px_var(--shadow-color)] cursor-pointer transition-transform duration-200 ease-out"
: "";
// Conditionally apply blur classes - NOW DISABLED GLOBALLY via CSS override, but logic kept for structure
const blurClasses = blur
? "bg-[var(--glass-bg)] backdrop-blur-[20px] saturate-[180%] [-webkit-backdrop-filter:blur(20px)_saturate(180%)]"
: "bg-[var(--bg-color)]/90";
? "bg-[var(--glass-bg)]" // Removed backdrop-blur classes here as they are expensive
: "bg-[var(--bg-color)]/90"; // More opaque fallback
const baseClasses = `
${blurClasses}
rounded-[var(--radius-2xl)]
shadow-[var(--shadow-md)]
shadow-[0_2px_8px_var(--shadow-color)] md:shadow-[var(--shadow-md)]
border
border-[var(--glass-border)]
p-4 md:p-6
relative
transition-all duration-[0.35s] [transition-timing-function:cubic-bezier(0.22,0.68,0,1)]
${hoverStyles}
${className}
`;
// Use semantic button when interactive
if (onClick) {
return (
<button
@@ -44,9 +45,12 @@ export function Card({ children, className = '', hover = true, blur = true, onCl
);
}
// Use div for non-interactive cards
return (
<div className={baseClasses} style={style}>
{children}
</div>
);
}
+29 -12
View File
@@ -27,26 +27,36 @@ export function ConfirmDialog({
variant = 'warning',
dangerous = false,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Focus the cancel button when dialog opens
cancelButtonRef.current?.focus();
// Prevent body scroll
document.body.style.overflow = 'hidden';
} else {
// Restore body scroll
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onCancel]);
@@ -54,55 +64,62 @@ export function ConfirmDialog({
if (!isOpen) return null;
const variantStyles = {
danger: 'bg-red-500 hover:bg-red-600 shadow-[0_2px_8px_rgba(239,68,68,0.3)]',
warning: 'bg-[var(--accent-color)] hover:bg-[var(--accent-hover,var(--accent-color))]',
info: 'bg-blue-500 hover:bg-blue-600 shadow-[0_2px_8px_rgba(59,130,246,0.3)]',
danger: 'bg-red-500 hover:bg-red-600',
warning: 'bg-[var(--accent-color)] hover:brightness-110',
info: 'bg-blue-500 hover:bg-blue-600',
};
// Use dangerous prop to override variant
const finalVariant = dangerous ? 'danger' : variant;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-[12px] [-webkit-backdrop-filter:blur(12px)] animate-fade-in"
className="fixed inset-0 z-[9998] bg-black/30 backdrop-blur-sm animate-fade-in"
onClick={onCancel}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 -translate-y-1/2 animate-scale-in"
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 -translate-y-1/2 animate-slide-up"
>
<Card hover={false} className="p-6 md:p-8">
<Card className="p-6">
{/* Header */}
<h2
id="dialog-title"
className="text-lg font-bold text-[var(--text-color)] mb-2 tracking-[-0.02em]"
className="text-xl font-semibold text-[var(--text-color)] mb-3"
>
{title}
</h2>
{/* Message */}
<p
id="dialog-description"
className="text-[var(--text-color-secondary)] mb-6 leading-relaxed text-sm"
className="text-[var(--text-color-secondary)] mb-6 leading-relaxed"
>
{message}
</p>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
ref={cancelButtonRef}
variant="secondary"
onClick={onCancel}
className="min-w-[90px]"
className="min-w-[100px]"
>
{cancelText}
</Button>
<Button
onClick={onConfirm}
className={`min-w-[90px] ${variantStyles[finalVariant]}`}
className={`min-w-[100px] ${variantStyles[finalVariant]}`}
>
{confirmText}
</Button>
+15 -12
View File
@@ -10,35 +10,37 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-[var(--text-color)] mb-2 tracking-[-0.01em]">
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
{label}
</label>
)}
<input
ref={ref}
className={`
w-full px-4 py-3 md:px-5 md:py-3.5
text-base
w-full px-4 py-3 md:px-6 md:py-4
text-base md:text-[var(--text-color)]
bg-[var(--glass-bg)]
backdrop-blur-[12px] saturate-[160%]
[-webkit-backdrop-filter:blur(12px)_saturate(160%)]
border border-[var(--glass-border)]
backdrop-blur-[10px]
saturate-[150%]
[-webkit-backdrop-filter:blur(10px)_saturate(150%)]
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
placeholder:text-[var(--text-color-secondary)]/60
placeholder:text-[var(--text-color-secondary)]
focus:outline-none
focus:border-[var(--accent-color)]
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_18%,transparent),var(--shadow-md)]
transition-all duration-[0.35s]
[transition-timing-function:cubic-bezier(0.22,0.68,0,1)]
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
transition-all
duration-[var(--transition-fluid)]
touch-manipulation
${error ? 'border-red-500 focus:border-red-500 focus:shadow-[0_0_0_3px_rgba(239,68,68,0.15)]' : ''}
${error ? 'border-red-500' : ''}
${className}
`}
{...props}
/>
{error && (
<p className="mt-2 text-sm text-red-400 font-medium">{error}</p>
<p className="mt-2 text-sm text-red-400">{error}</p>
)}
</div>
);
@@ -46,3 +48,4 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
);
Input.displayName = 'Input';
+3 -10
View File
@@ -1,5 +1,5 @@
/**
* Reusable modal backdrop component - Liquid Glass design
* Reusable modal backdrop component
*/
interface ModalBackdropProps {
@@ -10,16 +10,9 @@ interface ModalBackdropProps {
export function ModalBackdrop({ isOpen, onClose }: ModalBackdropProps) {
return (
<div
className={`
fixed inset-0 z-[9998]
bg-black/30
backdrop-blur-[8px]
[-webkit-backdrop-filter:blur(8px)]
transition-opacity duration-300
${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
aria-hidden="true"
/>
);
}
+2 -11
View File
@@ -1,5 +1,5 @@
/**
* Reusable modal header component - Liquid Glass design
* Reusable modal header component
*/
interface ModalHeaderProps {
@@ -15,16 +15,7 @@ export function ModalHeader({ title, onClose }: ModalHeaderProps) {
</h3>
<button
onClick={onClose}
className="
w-8 h-8 flex items-center justify-center
rounded-[var(--radius-full)]
bg-[var(--glass-bg)]
border border-[var(--glass-border)]
text-[var(--text-color)]
hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]
transition-all duration-200
cursor-pointer
"
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="关闭"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+11 -17
View File
@@ -2,6 +2,10 @@
import { useRef, useEffect, useState } from 'react';
/**
* SegmentedControl - A switch-style tab component following Liquid Glass design
*/
interface SegmentedControlProps<T extends string> {
options: { label: string; value: T }[];
value: T;
@@ -34,6 +38,7 @@ export function SegmentedControl<T extends string>({
};
updateIndicator();
// Update on window resize as well
window.addEventListener('resize', updateIndicator);
return () => window.removeEventListener('resize', updateIndicator);
}, [value, options]);
@@ -42,19 +47,14 @@ export function SegmentedControl<T extends string>({
<div
ref={containerRef}
className={`
relative flex p-1
bg-[var(--glass-bg)]
backdrop-blur-[20px] saturate-[180%]
[-webkit-backdrop-filter:blur(20px)_saturate(180%)]
border border-[var(--glass-border)]
rounded-[var(--radius-full)]
shadow-[var(--shadow-sm)]
${className}
relative flex p-1 bg-[var(--glass-bg)] backdrop-blur-xl
border border-[var(--glass-border)] rounded-[var(--radius-2xl)]
shadow-[var(--shadow-sm)] ${className}
`}
>
{/* Sliding Indicator */}
<div
className="absolute top-1 bottom-1 bg-[var(--accent-color)] rounded-[calc(var(--radius-full)-4px)] shadow-[0_2px_12px_color-mix(in_srgb,var(--accent-color)_35%,transparent)] transition-all duration-[0.35s] [transition-timing-function:cubic-bezier(0.22,0.68,0,1)]"
className="absolute top-1 bottom-1 bg-[var(--accent-color)] rounded-[calc(var(--radius-2xl)-4px)] shadow-[0_2px_8px_rgba(0,122,255,0.3)] transition-all duration-300 cubic-bezier(0.2, 0.8, 0.2, 1)"
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
@@ -68,14 +68,8 @@ export function SegmentedControl<T extends string>({
data-value={option.value}
onClick={() => onChange(option.value)}
className={`
relative z-10 flex-1 py-2 px-5
text-sm font-semibold tracking-[-0.01em]
transition-colors duration-200
cursor-pointer select-none
${value === option.value
? 'text-white'
: 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
}
relative z-10 flex-1 py-2 px-4 text-sm font-semibold transition-colors duration-200
${value === option.value ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'}
`}
>
{option.label}
+24 -23
View File
@@ -1,5 +1,10 @@
'use client';
/**
* Switch - A reusable toggle switch component
* Following Liquid Glass design system
*/
import React from 'react';
interface SwitchProps {
@@ -20,11 +25,11 @@ export function Switch({
return (
<label
className={`
relative inline-flex items-center cursor-pointer
h-[28px] w-[48px] shrink-0
${disabled ? 'opacity-40 cursor-not-allowed' : ''}
${className}
`}
switch relative inline-flex items-center cursor-pointer
h-[30px] w-[50px] shrink-0
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
>
<input
type="checkbox"
@@ -36,24 +41,20 @@ export function Switch({
/>
<div
className={`
w-full h-full rounded-[var(--radius-full)]
bg-[color-mix(in_srgb,var(--text-color)_15%,transparent)]
peer-checked:bg-[var(--accent-color)]
transition-[background-color,box-shadow] duration-[0.35s]
[transition-timing-function:cubic-bezier(0.22,0.68,0,1)]
peer-checked:shadow-[0_0_8px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
before:content-[''] before:absolute
before:h-[22px] before:w-[22px]
before:left-[3px] before:top-[3px]
before:bg-white before:rounded-[var(--radius-full)]
before:transition-[transform,width] before:duration-[0.35s]
before:[transition-timing-function:cubic-bezier(0.22,0.68,0,1)]
before:shadow-[0_1px_4px_rgba(0,0,0,0.15)]
peer-checked:before:translate-x-[20px]
active:before:w-[26px]
peer-checked:active:before:translate-x-[16px]
`}
/>
switch-slider w-full h-full rounded-[var(--radius-full)]
bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]
peer-checked:bg-[var(--accent-color)]
transition-colors duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)
before:content-[''] before:absolute before:h-[26px] before:w-[26px]
before:left-[2px] before:bottom-[2px]
before:bg-white before:rounded-[var(--radius-full)]
before:transition-transform before:duration-[0.4s]
before:cubic-bezier(0.2,0.8,0.2,1)
before:shadow-[0_1px_3px_rgba(0,0,0,0.2)]
peer-checked:before:translate-x-[20px]
active:before:scale-95
`}
></div>
</label>
);
}