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
+5 -7
View File
@@ -10,7 +10,6 @@
**在线体验:[https://kvideo.pages.dev/](https://kvideo.pages.dev/)**
[![Version](https://img.shields.io/badge/Version-4.7.0-blue?style=for-the-badge)](https://github.com/KuekHaoYang/KVideo)
[![Next.js](https://img.shields.io/badge/Next.js-16.1-black?style=for-the-badge&logo=next.js)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.2-blue?style=for-the-badge&logo=react)](https://react.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-4.0-38B2AC?style=for-the-badge&logo=tailwind-css)](https://tailwindcss.com/)
@@ -25,12 +24,11 @@
项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言:
- **玻璃拟态效果**:通过 `backdrop-filter: blur(25px) saturate(180%)` 实现的磨砂半透明效果,让 UI 元素如同真实的玻璃材质,动态反射和折射背景内容
- **通用柔和度**严格使用 `rounded-2xl`1.5rem`rounded-full`9999px两种圆角半径,创造和谐统一的视觉体验
- **光影交互**:悬停和聚焦状态下的内发光效果lensing,模拟光线被"捕获"的物理现象,配合动态阴影响应用户交互
- **流畅动画**:基于物理的 `cubic-bezier(0.2, 0.8, 0.2, 1)` 曲线,实现自然的加速和减速过渡
- **深度层级**:清晰的 z-axis 层次结构,主交互层始终在视觉最前方,背景元素通过更多模糊和更低饱和度自然后退
- **固定渐变背景**`background-attachment: fixed` 的渐变背景,让玻璃效果在滚动时保持一致的视觉效果
- **玻璃拟态效果**:通过 `backdrop-filter` 实现的磨砂半透明效果,让 UI 元素如同真实的玻璃材质
- **通用柔和度**统一使用 `rounded-2xl` `rounded-full` 两种圆角半径,创造和谐的视觉体验
- **光影交互**:悬停和聚焦状态下的内发光效果,模拟光线被"捕获"的物理现象
- **流畅动画**:基于物理的 `cubic-bezier` 曲线,实现自然的加速和减速过渡
- **深度层级**:清晰的 z-axis 层次结构,增强空间感和交互反馈
## 核心功能
+2 -2
View File
@@ -40,8 +40,8 @@ function HomePage() {
{/* Glass Navbar */}
<Navbar onReset={handleReset} />
{/* Search Form */}
<div className="max-w-7xl mx-auto px-4 mt-5 mb-6 relative" style={{
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
transform: 'translate3d(0, 0, 0)',
zIndex: 1000
}}>
+19 -9
View File
@@ -1,4 +1,4 @@
/* Base Styles - Liquid Glass Design System v5 */
/* Base Styles */
*,
*::before,
*::after {
@@ -46,7 +46,7 @@ select:focus-visible {
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
width: 10px;
}
::-webkit-scrollbar-track {
@@ -54,19 +54,21 @@ select:focus-visible {
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-color) 15%, transparent);
background: rgba(128, 128, 128, 0.5);
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--accent-color) 50%, transparent);
background: rgba(0, 122, 255, 0.6);
background: color-mix(in srgb, var(--accent-color) 60%, transparent);
background-clip: padding-box;
}
.search-history-dropdown::-webkit-scrollbar {
width: 5px;
width: 6px;
}
.search-history-dropdown::-webkit-scrollbar-track {
@@ -74,7 +76,8 @@ select:focus-visible {
}
.search-history-dropdown::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-color) 15%, transparent);
background: rgba(128, 128, 128, 0.5);
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
border-radius: var(--radius-full);
}
@@ -111,8 +114,13 @@ nav:where(.sticky, .fixed),
transform: translateZ(0);
}
/* [class*="grid"]>* {
content-visibility: auto;
contain-intrinsic-size: auto 400px;
} */
[class*="grid"]>a {
animation: fadeInUp 0.25s cubic-bezier(0.22, 0.68, 0, 1);
animation: fadeInUp 0.2s ease-out;
}
@supports (content-visibility: auto) {
@@ -129,10 +137,12 @@ body.scrolling * {
pointer-events: none !important;
}
.group:hover img,
.glass-card,
nav {
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
[class*="badge"],
@@ -150,7 +160,7 @@ nav {
body.tv-mode [data-focusable]:focus {
outline: 3px solid var(--accent-color);
outline-offset: 4px;
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent-color) 20%, transparent);
box-shadow: 0 0 0 6px rgba(0, 122, 255, 0.2);
transform: scale(1.03);
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
}
@@ -174,4 +184,4 @@ body.tv-mode [class*="gap-3"] {
body.tv-mode [class*="gap-4"] {
gap: 1.5rem;
}
}
+54 -55
View File
@@ -1,38 +1,71 @@
/* Effects - Liquid Glass Design System v5 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes float {
0%, 100% {
0%,
100% {
transform: translateY(0) translateX(0);
opacity: 0.3;
}
50% {
transform: translateY(-20px) translateX(10px);
opacity: 0.8;
@@ -40,47 +73,13 @@
}
@keyframes gradient-x {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 8px color-mix(in srgb, var(--accent-color) 20%, transparent); }
50% { box-shadow: 0 0 20px color-mix(in srgb, var(--accent-color) 40%, transparent); }
}
0%,
100% {
background-position: 0% 50%;
}
/* Lensing inner-glow effect for interactive elements */
.glass-glow:hover {
box-shadow:
var(--shadow-lg),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.glass-glow:focus-visible {
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent-color) 25%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Hover lift effect */
.hover-lift {
transition: transform var(--transition-fluid), box-shadow var(--transition-fluid);
}
.hover-lift:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
}
/* Subtle scale on hover */
.hover-scale {
transition: transform var(--transition-snappy);
}
.hover-scale:hover {
transform: scale(1.03);
}
.hover-scale:active {
transform: scale(0.97);
}
50% {
background-position: 100% 50%;
}
}
+22 -71
View File
@@ -1,115 +1,66 @@
/* Glass Components - Liquid Glass Design System v5 */
/* Glass Components */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
background: var(--bg-color);
opacity: 1;
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--glass-border);
transition: all var(--transition-fluid);
position: relative;
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
transform: translateZ(0);
will-change: transform;
}
.glass-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
z-index: 10;
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.glass-input {
width: 100%;
background: var(--glass-bg);
backdrop-filter: blur(12px) saturate(160%);
-webkit-backdrop-filter: blur(12px) saturate(160%);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2xl);
color: var(--text-color);
transition: all var(--transition-fluid);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.glass-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 20%, transparent),
var(--shadow-md);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.3);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent);
}
.glass-button {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent-color);
color: white;
border: none;
border-radius: var(--radius-2xl);
padding: 0.75rem 1.5rem;
padding: 0.75rem 1.25rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-snappy);
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent-color) 35%, transparent);
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.glass-button:hover {
transform: translateY(-1px);
background: var(--accent-hover, var(--accent-color));
box-shadow: 0 4px 16px color-mix(in srgb, var(--accent-color) 40%, transparent);
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: var(--shadow-md);
}
.glass-button:active {
transform: translateY(0) scale(0.97);
}
.glass-button:disabled {
background-color: var(--text-color-secondary);
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
transform: translateY(0) scale(0.98);
filter: brightness(0.95);
}
.glass-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem 0.625rem;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-size: 0.8rem;
font-weight: 600;
background-color: var(--accent-color);
color: white;
letter-spacing: 0.01em;
}
.glass-badge-secondary {
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
color: var(--text-color);
}
/* Glass backdrop for modals/drawers */
.glass-backdrop {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(12px) saturate(120%);
-webkit-backdrop-filter: blur(12px) saturate(120%);
}
/* Glass popover/dropdown */
.glass-popover {
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-lg);
border: 1px solid var(--glass-border);
}
/* Glass surface - subtle inner highlight */
.glass-surface {
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2xl);
}
+67 -65
View File
@@ -1,89 +1,91 @@
/* Transitions - Liquid Glass Design System v5 */
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-in {
0% { opacity: 0; transform: scale(0.96) translateY(8px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
0% {
opacity: 0;
transform: scale(0.9) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes scale-out {
0% { opacity: 1; transform: scale(1) translateY(0); }
100% { opacity: 0; transform: scale(0.96) translateY(-8px); }
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
from {
transform: translateX(100%);
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
to {
transform: translateX(0);
}
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
from {
opacity: 0;
transform: translateY(8px);
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes sidebar-enter {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes sidebar-enter-left {
from { opacity: 0; transform: translateX(-100%); }
to { opacity: 1; transform: translateX(0); }
}
/* Utility animation classes */
.animate-fade-in {
animation: fade-in 0.35s cubic-bezier(0.22, 0.68, 0, 1);
}
.animate-slide-up {
animation: slide-up 0.3s cubic-bezier(0.22, 0.68, 0, 1);
}
.animate-scale-in {
animation: scale-in 0.3s cubic-bezier(0.22, 0.68, 0, 1);
}
.animate-toast-in {
animation: toast-in 0.4s cubic-bezier(0.22, 0.68, 0, 1);
}
/* Staggered children animation */
.stagger-children > * {
animation: fadeInUp 0.3s cubic-bezier(0.22, 0.68, 0, 1) backwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 40ms; }
.stagger-children > *:nth-child(3) { animation-delay: 80ms; }
.stagger-children > *:nth-child(4) { animation-delay: 120ms; }
.stagger-children > *:nth-child(5) { animation-delay: 160ms; }
.stagger-children > *:nth-child(6) { animation-delay: 200ms; }
to {
opacity: 1;
transform: translateY(0);
}
}
+30 -41
View File
@@ -1,55 +1,46 @@
/* CSS Custom Properties - Liquid Glass Design System v5 */
/* CSS Custom Properties */
:root {
--font-family-system: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
/* Light Mode Palette */
--bg-color-light: #f0f2f5;
--bg-image-light: linear-gradient(135deg, #fdfbfb 0%, #e8ecf1 50%, #f0f2f5 100%);
--text-color-light: #1a1a2e;
--text-color-secondary-light: #64748b;
--accent-color-light: #2563eb;
--accent-hover-light: #1d4ed8;
--glass-bg-light: rgba(255, 255, 255, 0.72);
--glass-border-light: rgba(255, 255, 255, 0.45);
--shadow-color-light: rgba(0, 0, 0, 0.08);
--bg-color-light: #f2f4f7;
--bg-image-light: none;
--text-color-light: #1d1d1f;
--text-color-secondary-light: #6e6e73;
--accent-color-light: #0056b3;
--glass-bg-light: rgba(255, 255, 255, 0.95);
--glass-border-light: rgba(0, 0, 0, 0.05);
--shadow-color-light: rgba(0, 0, 0, 0.05);
/* Dark Mode Palette */
--bg-color-dark: #0a0a0f;
--bg-image-dark: linear-gradient(135deg, #0a0a0f 0%, #141420 50%, #0d0d14 100%);
--text-color-dark: #f0f0f5;
--text-color-secondary-dark: #7c8298;
--accent-color-dark: #3b82f6;
--accent-hover-dark: #60a5fa;
--glass-bg-dark: rgba(22, 22, 35, 0.78);
--glass-border-dark: rgba(255, 255, 255, 0.06);
--shadow-color-dark: rgba(0, 0, 0, 0.45);
--bg-color-dark: #121212;
--bg-image-dark: linear-gradient(120deg, #1a1a1a 0%, #121212 100%);
--text-color-dark: #f5f5f7;
--text-color-secondary-dark: #8e8e93;
--accent-color-dark: #1A6DBF;
--glass-bg-dark: rgba(30, 30, 30, 0.9);
--glass-border-dark: rgba(255, 255, 255, 0.1);
--shadow-color-dark: rgba(0, 0, 0, 0.3);
/* Universal Variables */
--radius-xs: 0.5rem;
--radius-sm: 0.75rem;
--radius-md: 1rem;
--radius-lg: 1.25rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
--shadow-sm: 0 1px 3px var(--shadow-color), 0 1px 2px var(--shadow-color);
--shadow-md: 0 4px 16px var(--shadow-color), 0 2px 4px var(--shadow-color);
--shadow-lg: 0 8px 32px var(--shadow-color), 0 4px 8px var(--shadow-color);
--transition-fluid: 0.35s cubic-bezier(0.22, 0.68, 0, 1);
--transition-spring: 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-snappy: 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
--shadow-sm: 0 1px 2px var(--shadow-color);
--shadow-md: 0 4px 6px var(--shadow-color);
--transition-fluid: 0.3s ease;
/* Active Theme Variables (default: light) */
/* Active Theme Variables */
--bg-color: var(--bg-color-light);
--bg-image: var(--bg-image-light);
--text-color: var(--text-color-light);
--text-color-secondary: var(--text-color-secondary-light);
--accent-color: var(--accent-color-light);
--accent-hover: var(--accent-hover-light);
--glass-bg: var(--glass-bg-light);
--glass-border: var(--glass-border-light);
--shadow-color: var(--shadow-color-light);
--background: #f0f2f5;
--foreground: #1a1a2e;
--background: #f2f4f7;
--foreground: #1d1d1f;
}
body {
@@ -58,17 +49,15 @@ body {
--text-color: var(--text-color-light);
--text-color-secondary: var(--text-color-secondary-light);
--accent-color: var(--accent-color-light);
--accent-hover: var(--accent-hover-light);
--glass-bg: var(--glass-bg-light);
--glass-border: var(--glass-border-light);
--shadow-color: var(--shadow-color-light);
background-color: var(--bg-color);
background-image: var(--bg-image);
background-attachment: fixed;
color: var(--text-color);
font-family: var(--font-family-system);
transition: background 0.4s ease;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: none;
}
@@ -80,10 +69,10 @@ body.dark,
--text-color: var(--text-color-dark);
--text-color-secondary: var(--text-color-secondary-dark);
--accent-color: var(--accent-color-dark);
--accent-hover: var(--accent-hover-dark);
--glass-bg: var(--glass-bg-dark);
--glass-border: var(--glass-border-dark);
--shadow-color: var(--shadow-color-dark);
--background: #0a0a0f;
--foreground: #f0f0f5;
}
--background: #121212;
--foreground: #f5f5f7;
}
+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>
);
}
+9 -12
View File
@@ -1,18 +1,18 @@
{
"name": "kvideo",
"version": "4.7.0",
"version": "4.5.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.7.0",
"version": "4.5.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@upstash/redis": "^1.37.0",
"@vercel/analytics": "^2.0.1",
"@vercel/analytics": "^2.0.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.577.0",
"next": "16.1.6",
@@ -3507,9 +3507,9 @@
}
},
"node_modules/@vercel/analytics": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.0.tgz",
"integrity": "sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw==",
"license": "MIT",
"peerDependencies": {
"@remix-run/react": "^2",
@@ -3531,9 +3531,6 @@
"next": {
"optional": true
},
"nuxt": {
"optional": true
},
"react": {
"optional": true
},
@@ -11066,9 +11063,9 @@
}
},
"@vercel/analytics": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.0.tgz",
"integrity": "sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw=="
},
"acorn": {
"version": "8.16.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.7.0",
"version": "4.5.0",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",
@@ -14,7 +14,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@upstash/redis": "^1.37.0",
"@vercel/analytics": "^2.0.1",
"@vercel/analytics": "^2.0.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.577.0",
"next": "16.1.6",