mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
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:
@@ -10,7 +10,6 @@
|
||||
|
||||
**在线体验:[https://kvideo.pages.dev/](https://kvideo.pages.dev/)**
|
||||
|
||||
[](https://github.com/KuekHaoYang/KVideo)
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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="视频搜索结果"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+9
-12
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user