mirror of https://github.com/djteang/OrangeTV.git
Refactor UI toward HeroUI
This commit is contained in:
parent
eb71c83aa5
commit
9389be8b97
File diff suppressed because one or more lines are too long
|
|
@ -10,6 +10,10 @@ const MenuActionContext = createContext<{
|
|||
onAction?: (key: React.Key) => void;
|
||||
selectedKeys?: Iterable<React.Key>;
|
||||
}>({});
|
||||
const SelectContext = createContext<{
|
||||
value?: React.Key | React.Key[] | null;
|
||||
onChange?: (value: React.Key | React.Key[] | null) => void;
|
||||
}>({});
|
||||
|
||||
type OverlayStateValue = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -370,11 +374,132 @@ export const Dropdown = Object.assign(DropdownRoot, {
|
|||
ItemIndicator: () => <span aria-hidden='true' />,
|
||||
});
|
||||
|
||||
export const ScrollShadow = ({
|
||||
const SelectRoot = ({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
fullWidth: _fullWidth,
|
||||
isDisabled: _isDisabled,
|
||||
variant: _variant,
|
||||
placeholder: _placeholder,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
value?: React.Key | React.Key[] | null;
|
||||
onChange?: (value: React.Key | React.Key[] | null) => void;
|
||||
fullWidth?: boolean;
|
||||
isDisabled?: boolean;
|
||||
variant?: string;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<SelectContext.Provider value={{ value, onChange }}>
|
||||
<div {...props}>{children}</div>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
|
||||
const SelectTrigger = ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type='button' role='combobox' aria-expanded='true' {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const SelectValue = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => <span {...props}>{children}</span>;
|
||||
|
||||
const SelectIndicator = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span aria-hidden='true' {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const SelectPopover = ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
|
||||
|
||||
const ListBoxRoot = ({
|
||||
children,
|
||||
selectionMode: _selectionMode,
|
||||
selectedKeys: _selectedKeys,
|
||||
onSelectionChange: _onSelectionChange,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
selectionMode?: string;
|
||||
selectedKeys?: Iterable<React.Key>;
|
||||
onSelectionChange?: (keys: Iterable<React.Key>) => void;
|
||||
}) => (
|
||||
<div role='listbox' {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ListBoxItem = ({
|
||||
children,
|
||||
id,
|
||||
textValue,
|
||||
isDisabled,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLButtonElement> & {
|
||||
id: React.Key;
|
||||
textValue?: string;
|
||||
isDisabled?: boolean;
|
||||
}) => {
|
||||
const { value, onChange } = useContext(SelectContext);
|
||||
const selected = Array.isArray(value)
|
||||
? value.includes(id)
|
||||
: value === id;
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
role='option'
|
||||
aria-label={textValue}
|
||||
aria-selected={selected}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
onClick={() => onChange?.(id)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const Select = Object.assign(SelectRoot, {
|
||||
Root: SelectRoot,
|
||||
Trigger: SelectTrigger,
|
||||
Value: SelectValue,
|
||||
Indicator: SelectIndicator,
|
||||
Popover: SelectPopover,
|
||||
});
|
||||
|
||||
export const ListBox = Object.assign(ListBoxRoot, {
|
||||
Root: ListBoxRoot,
|
||||
Item: Object.assign(ListBoxItem, {
|
||||
Indicator: () => <span aria-hidden='true' />,
|
||||
}),
|
||||
ItemIndicator: () => <span aria-hidden='true' />,
|
||||
Section: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const ScrollShadow = ({
|
||||
children,
|
||||
hideScrollBar: _hideScrollBar,
|
||||
orientation: _orientation,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
hideScrollBar?: boolean;
|
||||
orientation?: string;
|
||||
}) => <div {...props}>{children}</div>;
|
||||
|
||||
export const Spinner = (props: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span role='status' {...props} />
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,6 +3,7 @@
|
|||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card, EmptyState, Spinner } from '@heroui/react';
|
||||
import { Suspense } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -728,7 +729,7 @@ function DoubanPageClient() {
|
|||
|
||||
{/* 选择器组件 */}
|
||||
{type !== 'custom' ? (
|
||||
<div className='app-filter-panel'>
|
||||
<Card>
|
||||
<DoubanSelector
|
||||
type={type as 'movie' | 'tv' | 'show' | 'anime'}
|
||||
primarySelection={primarySelection}
|
||||
|
|
@ -738,9 +739,9 @@ function DoubanPageClient() {
|
|||
onMultiLevelChange={handleMultiLevelChange}
|
||||
onWeekdayChange={handleWeekdayChange}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className='app-filter-panel'>
|
||||
<Card>
|
||||
<DoubanCustomSelector
|
||||
customCategories={customCategories}
|
||||
primarySelection={primarySelection}
|
||||
|
|
@ -748,7 +749,7 @@ function DoubanPageClient() {
|
|||
onPrimaryChange={handlePrimaryChange}
|
||||
onSecondaryChange={handleSecondaryChange}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -792,8 +793,8 @@ function DoubanPageClient() {
|
|||
>
|
||||
{isLoadingMore && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
|
||||
<span className='text-gray-600'>加载中...</span>
|
||||
<Spinner size='sm' />
|
||||
<span className='text-muted'>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -801,12 +802,12 @@ function DoubanPageClient() {
|
|||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && doubanData.length > 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>已加载全部内容</div>
|
||||
<EmptyState className='py-8'>已加载全部内容</EmptyState>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && doubanData.length === 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>暂无相关内容</div>
|
||||
<EmptyState className='py-8'>暂无相关内容</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,57 +8,57 @@
|
|||
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace;
|
||||
|
||||
--background: oklch(0.985 0.006 255);
|
||||
--foreground: oklch(0.21 0.025 258);
|
||||
--surface: oklch(0.998 0.002 255);
|
||||
--background: oklch(0.985 0.002 286);
|
||||
--foreground: oklch(0.19 0.006 286);
|
||||
--surface: oklch(0.998 0.001 286);
|
||||
--surface-foreground: var(--foreground);
|
||||
--surface-secondary: oklch(0.965 0.009 255);
|
||||
--surface-secondary: oklch(0.955 0.004 286);
|
||||
--surface-secondary-foreground: var(--foreground);
|
||||
--surface-tertiary: oklch(0.935 0.015 255);
|
||||
--surface-tertiary: oklch(0.92 0.006 286);
|
||||
--surface-tertiary-foreground: var(--foreground);
|
||||
--overlay: oklch(0.998 0.002 255 / 92%);
|
||||
--overlay: oklch(0.998 0.001 286 / 92%);
|
||||
--overlay-foreground: var(--foreground);
|
||||
--muted: oklch(0.53 0.032 258);
|
||||
--border: oklch(0.85 0.018 255 / 70%);
|
||||
--separator: oklch(0.9 0.012 255 / 72%);
|
||||
--accent: oklch(0.59 0.19 255);
|
||||
--accent-foreground: oklch(0.99 0.004 255);
|
||||
--muted: oklch(0.48 0.01 286);
|
||||
--border: oklch(0.84 0.006 286 / 70%);
|
||||
--separator: oklch(0.89 0.004 286 / 72%);
|
||||
--accent: oklch(0.58 0.22 16);
|
||||
--accent-foreground: oklch(0.99 0.004 20);
|
||||
--success: oklch(0.66 0.16 150);
|
||||
--success-foreground: oklch(0.99 0.004 150);
|
||||
--warning: oklch(0.73 0.15 82);
|
||||
--warning-foreground: oklch(0.22 0.035 70);
|
||||
--danger: oklch(0.62 0.2 28);
|
||||
--danger-foreground: oklch(0.99 0.004 28);
|
||||
--field-background: oklch(0.995 0.003 255);
|
||||
--field-background: oklch(0.995 0.002 286);
|
||||
--field-foreground: var(--foreground);
|
||||
--field-placeholder: var(--muted);
|
||||
--field-border: var(--border);
|
||||
|
||||
--color-background: 248 250 252;
|
||||
--color-foreground: 15 23 42;
|
||||
--color-background: 250 250 250;
|
||||
--color-foreground: 24 24 27;
|
||||
--color-surface: 255 255 255;
|
||||
--color-surface-secondary: 241 245 249;
|
||||
--color-surface-tertiary: 226 232 240;
|
||||
--color-surface-secondary: 244 244 245;
|
||||
--color-surface-tertiary: 228 228 231;
|
||||
--color-overlay: 255 255 255;
|
||||
--color-muted: 100 116 139;
|
||||
--color-border: 203 213 225;
|
||||
--color-accent: 37 99 235;
|
||||
--color-accent-strong: 29 78 216;
|
||||
--color-muted: 113 113 122;
|
||||
--color-border: 212 212 216;
|
||||
--color-accent: 225 29 72;
|
||||
--color-accent-strong: 190 18 60;
|
||||
--color-success: 22 163 74;
|
||||
--color-warning: 217 119 6;
|
||||
--color-danger: 220 38 38;
|
||||
|
||||
--color-primary-50: 239 246 255;
|
||||
--color-primary-100: 219 234 254;
|
||||
--color-primary-200: 191 219 254;
|
||||
--color-primary-300: 147 197 253;
|
||||
--color-primary-400: 96 165 250;
|
||||
--color-primary-500: 59 130 246;
|
||||
--color-primary-600: 37 99 235;
|
||||
--color-primary-700: 29 78 216;
|
||||
--color-primary-800: 30 64 175;
|
||||
--color-primary-900: 30 58 138;
|
||||
--color-dark: 15 23 42;
|
||||
--color-primary-50: 255 241 242;
|
||||
--color-primary-100: 255 228 230;
|
||||
--color-primary-200: 254 205 211;
|
||||
--color-primary-300: 253 164 175;
|
||||
--color-primary-400: 251 113 133;
|
||||
--color-primary-500: 244 63 94;
|
||||
--color-primary-600: 225 29 72;
|
||||
--color-primary-700: 190 18 60;
|
||||
--color-primary-800: 159 18 57;
|
||||
--color-primary-900: 136 19 55;
|
||||
--color-dark: 24 24 27;
|
||||
|
||||
--color-theme-bg: var(--color-background);
|
||||
--color-theme-surface: var(--color-surface);
|
||||
|
|
@ -71,60 +71,47 @@
|
|||
--color-theme-error: var(--color-danger);
|
||||
--color-theme-info: var(--color-accent);
|
||||
|
||||
/* Temporary compatibility aliases. They render as HeroUI Clean colors. */
|
||||
--a2-canvas: var(--color-background);
|
||||
--a2-stage: var(--color-surface);
|
||||
--a2-rail: var(--color-surface-secondary);
|
||||
--a2-text: var(--color-foreground);
|
||||
--a2-text-soft: 51 65 85;
|
||||
--a2-text-muted: var(--color-muted);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
--a2-line: var(--color-border);
|
||||
--a2-signal-stable: var(--color-success);
|
||||
--a2-signal-warn: var(--color-warning);
|
||||
--a2-signal-error: var(--color-danger);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: oklch(0.18 0.022 258);
|
||||
--foreground: oklch(0.94 0.012 255);
|
||||
--surface: oklch(0.225 0.025 258);
|
||||
--background: oklch(0.15 0.006 286);
|
||||
--foreground: oklch(0.94 0.006 286);
|
||||
--surface: oklch(0.205 0.008 286);
|
||||
--surface-foreground: var(--foreground);
|
||||
--surface-secondary: oklch(0.27 0.028 258);
|
||||
--surface-secondary: oklch(0.255 0.01 286);
|
||||
--surface-secondary-foreground: var(--foreground);
|
||||
--surface-tertiary: oklch(0.32 0.031 258);
|
||||
--surface-tertiary: oklch(0.31 0.012 286);
|
||||
--surface-tertiary-foreground: var(--foreground);
|
||||
--overlay: oklch(0.22 0.025 258 / 94%);
|
||||
--overlay: oklch(0.19 0.008 286 / 94%);
|
||||
--overlay-foreground: var(--foreground);
|
||||
--muted: oklch(0.7 0.028 255);
|
||||
--border: oklch(0.42 0.03 258 / 68%);
|
||||
--separator: oklch(0.36 0.025 258 / 70%);
|
||||
--accent: oklch(0.69 0.17 255);
|
||||
--accent-foreground: oklch(0.16 0.03 258);
|
||||
--muted: oklch(0.7 0.012 286);
|
||||
--border: oklch(0.38 0.012 286 / 68%);
|
||||
--separator: oklch(0.33 0.01 286 / 70%);
|
||||
--accent: oklch(0.69 0.18 14);
|
||||
--accent-foreground: oklch(0.16 0.02 14);
|
||||
--success: oklch(0.72 0.16 150);
|
||||
--success-foreground: oklch(0.14 0.025 150);
|
||||
--warning: oklch(0.78 0.15 82);
|
||||
--warning-foreground: oklch(0.18 0.035 70);
|
||||
--danger: oklch(0.7 0.18 28);
|
||||
--danger-foreground: oklch(0.99 0.004 28);
|
||||
--field-background: oklch(0.25 0.025 258);
|
||||
--field-background: oklch(0.235 0.009 286);
|
||||
--field-foreground: var(--foreground);
|
||||
--field-placeholder: var(--muted);
|
||||
--field-border: var(--border);
|
||||
|
||||
--color-background: 15 23 42;
|
||||
--color-foreground: 241 245 249;
|
||||
--color-surface: 30 41 59;
|
||||
--color-surface-secondary: 51 65 85;
|
||||
--color-surface-tertiary: 71 85 105;
|
||||
--color-overlay: 15 23 42;
|
||||
--color-muted: 148 163 184;
|
||||
--color-border: 71 85 105;
|
||||
--color-accent: 96 165 250;
|
||||
--color-accent-strong: 147 197 253;
|
||||
--color-background: 18 18 20;
|
||||
--color-foreground: 244 244 245;
|
||||
--color-surface: 31 31 35;
|
||||
--color-surface-secondary: 39 39 42;
|
||||
--color-surface-tertiary: 63 63 70;
|
||||
--color-overlay: 24 24 27;
|
||||
--color-muted: 161 161 170;
|
||||
--color-border: 82 82 91;
|
||||
--color-accent: 251 113 133;
|
||||
--color-accent-strong: 253 164 175;
|
||||
--color-success: 74 222 128;
|
||||
--color-warning: 251 191 36;
|
||||
--color-danger: 248 113 113;
|
||||
|
|
@ -140,18 +127,6 @@
|
|||
--color-theme-error: var(--color-danger);
|
||||
--color-theme-info: var(--color-accent);
|
||||
|
||||
--a2-canvas: var(--color-background);
|
||||
--a2-stage: var(--color-surface);
|
||||
--a2-rail: var(--color-surface-secondary);
|
||||
--a2-text: var(--color-foreground);
|
||||
--a2-text-soft: 203 213 225;
|
||||
--a2-text-muted: var(--color-muted);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
--a2-line: var(--color-border);
|
||||
--a2-signal-stable: var(--color-success);
|
||||
--a2-signal-warn: var(--color-warning);
|
||||
--a2-signal-error: var(--color-danger);
|
||||
}
|
||||
|
||||
[data-theme="minimal"] {
|
||||
|
|
@ -161,8 +136,6 @@
|
|||
--color-accent-strong: 64 64 64;
|
||||
--color-theme-accent: var(--color-accent);
|
||||
--color-theme-info: var(--color-accent);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
[data-theme="minimal"].dark {
|
||||
|
|
@ -172,8 +145,6 @@
|
|||
--color-accent-strong: 245 245 245;
|
||||
--color-theme-accent: var(--color-accent);
|
||||
--color-theme-info: var(--color-accent);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
[data-theme="warm"] {
|
||||
|
|
@ -183,8 +154,6 @@
|
|||
--color-accent-strong: 180 83 9;
|
||||
--color-theme-accent: var(--color-accent);
|
||||
--color-theme-info: var(--color-accent);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
[data-theme="warm"].dark {
|
||||
|
|
@ -194,8 +163,6 @@
|
|||
--color-accent-strong: 253 224 71;
|
||||
--color-theme-accent: var(--color-accent);
|
||||
--color-theme-info: var(--color-accent);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
[data-theme="fresh"] {
|
||||
|
|
@ -205,8 +172,6 @@
|
|||
--color-accent-strong: 21 128 61;
|
||||
--color-theme-accent: var(--color-accent);
|
||||
--color-theme-info: var(--color-accent);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
[data-theme="fresh"].dark {
|
||||
|
|
@ -216,8 +181,6 @@
|
|||
--color-accent-strong: 134 239 172;
|
||||
--color-theme-accent: var(--color-accent);
|
||||
--color-theme-info: var(--color-accent);
|
||||
--a2-copper: var(--color-accent);
|
||||
--a2-copper-strong: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
html,
|
||||
|
|
@ -296,18 +259,6 @@ body {
|
|||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
@apply rounded-2xl border border-border/70 bg-surface shadow-sm;
|
||||
}
|
||||
|
||||
.theme-button {
|
||||
@apply rounded-xl border border-accent bg-accent px-4 py-2 font-medium text-accent-foreground hover:bg-accent-strong;
|
||||
}
|
||||
|
||||
.theme-button-secondary {
|
||||
@apply rounded-xl border border-border bg-surface px-4 py-2 font-medium text-foreground hover:bg-surface-secondary;
|
||||
}
|
||||
|
||||
.theme-input {
|
||||
@apply rounded-xl border border-border bg-field px-3 py-2 text-foreground placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20;
|
||||
}
|
||||
|
|
@ -324,152 +275,6 @@ body {
|
|||
background: linear-gradient(to bottom, transparent 0%, rgb(15 23 42 / 0.74) 100%);
|
||||
}
|
||||
|
||||
.bg-white,
|
||||
.bg-gray-50,
|
||||
.bg-gray-100 {
|
||||
background-color: rgb(var(--color-surface)) !important;
|
||||
}
|
||||
|
||||
.bg-gray-200,
|
||||
.bg-gray-300,
|
||||
.bg-gray-700,
|
||||
.bg-gray-800,
|
||||
.bg-gray-900 {
|
||||
background-color: rgb(var(--color-surface-secondary)) !important;
|
||||
}
|
||||
|
||||
.bg-blue-50,
|
||||
.bg-blue-100,
|
||||
.bg-blue-900\/20,
|
||||
.bg-blue-900\/30,
|
||||
.bg-blue-900\/40,
|
||||
.bg-purple-100,
|
||||
.bg-purple-900\/40,
|
||||
.from-blue-50,
|
||||
.to-emerald-50 {
|
||||
background-color: rgb(var(--color-accent) / 0.1) !important;
|
||||
}
|
||||
|
||||
.bg-blue-500,
|
||||
.bg-blue-600,
|
||||
.bg-blue-700,
|
||||
.from-blue-500,
|
||||
.to-blue-600,
|
||||
.from-cyan-500,
|
||||
.to-cyan-500,
|
||||
.via-cyan-500,
|
||||
.via-purple-500 {
|
||||
background-color: rgb(var(--color-accent)) !important;
|
||||
}
|
||||
|
||||
.text-gray-300,
|
||||
.text-gray-400,
|
||||
.text-gray-500,
|
||||
.text-gray-600 {
|
||||
color: rgb(var(--color-muted)) !important;
|
||||
}
|
||||
|
||||
.text-gray-700,
|
||||
.text-gray-800,
|
||||
.text-gray-900,
|
||||
.text-blue-200,
|
||||
.text-blue-300,
|
||||
.text-blue-400,
|
||||
.text-blue-500,
|
||||
.text-blue-600,
|
||||
.text-blue-700,
|
||||
.text-blue-800,
|
||||
.text-purple-200,
|
||||
.text-purple-400,
|
||||
.text-purple-700,
|
||||
.text-purple-800 {
|
||||
color: rgb(var(--color-foreground)) !important;
|
||||
}
|
||||
|
||||
.border-gray-200,
|
||||
.border-gray-300,
|
||||
.border-gray-600,
|
||||
.border-gray-700,
|
||||
.border-blue-200,
|
||||
.border-blue-700,
|
||||
.border-blue-800 {
|
||||
border-color: rgb(var(--color-border) / 0.7) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-gray-50:hover,
|
||||
.hover\:bg-gray-100:hover,
|
||||
.hover\:bg-gray-200:hover,
|
||||
.hover\:bg-gray-300:hover,
|
||||
.hover\:bg-gray-700:hover {
|
||||
background-color: rgb(var(--color-surface-tertiary) / 0.8) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-200:hover,
|
||||
.hover\:bg-blue-600:hover,
|
||||
.hover\:bg-blue-700:hover,
|
||||
.hover\:bg-purple-200:hover {
|
||||
background-color: rgb(var(--color-accent) / 0.16) !important;
|
||||
}
|
||||
|
||||
.hover\:text-blue-500:hover,
|
||||
.hover\:text-blue-600:hover,
|
||||
.hover\:text-blue-700:hover,
|
||||
.hover\:text-gray-600:hover,
|
||||
.hover\:text-gray-800:hover {
|
||||
color: rgb(var(--color-accent)) !important;
|
||||
}
|
||||
|
||||
.focus\:ring-blue-400:focus,
|
||||
.focus\:ring-blue-500:focus,
|
||||
.focus\:ring-blue-400\/50:focus {
|
||||
--tw-ring-color: rgb(var(--color-accent) / 0.22) !important;
|
||||
}
|
||||
|
||||
.focus\:border-blue-500:focus {
|
||||
border-color: rgb(var(--color-accent)) !important;
|
||||
}
|
||||
|
||||
.border-t-blue-500 {
|
||||
border-top-color: rgb(var(--color-accent)) !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-gray-600,
|
||||
.dark .dark\:bg-gray-700,
|
||||
.dark .dark\:bg-gray-800,
|
||||
.dark .dark\:bg-gray-900 {
|
||||
background-color: rgb(var(--color-surface)) !important;
|
||||
}
|
||||
|
||||
.dark .dark\:bg-blue-800\/40,
|
||||
.dark .dark\:bg-blue-900\/20,
|
||||
.dark .dark\:bg-blue-900\/30,
|
||||
.dark .dark\:bg-blue-900\/40,
|
||||
.dark .dark\:bg-purple-900\/40 {
|
||||
background-color: rgb(var(--color-accent) / 0.12) !important;
|
||||
}
|
||||
|
||||
.dark .dark\:text-gray-100,
|
||||
.dark .dark\:text-gray-200,
|
||||
.dark .dark\:text-gray-300,
|
||||
.dark .dark\:text-blue-200,
|
||||
.dark .dark\:text-blue-300,
|
||||
.dark .dark\:text-blue-400,
|
||||
.dark .dark\:text-purple-200,
|
||||
.dark .dark\:text-purple-400 {
|
||||
color: rgb(var(--color-foreground)) !important;
|
||||
}
|
||||
|
||||
.dark .dark\:text-gray-400,
|
||||
.dark .dark\:text-gray-500 {
|
||||
color: rgb(var(--color-muted)) !important;
|
||||
}
|
||||
|
||||
.dark .dark\:border-gray-600,
|
||||
.dark .dark\:border-gray-700,
|
||||
.dark .dark\:border-blue-700,
|
||||
.dark .dark\:border-blue-800 {
|
||||
border-color: rgb(var(--color-border) / 0.7) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-from-top {
|
||||
|
|
@ -528,326 +333,3 @@ div[data-media-provider] video {
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.a2-icon-button {
|
||||
@apply inline-flex h-9 w-9 items-center justify-center rounded-xl border border-border bg-surface text-muted shadow-sm hover:border-accent/40 hover:bg-surface-secondary hover:text-foreground active:translate-y-px;
|
||||
}
|
||||
|
||||
.a2-tablist,
|
||||
.a2-filter-tabs {
|
||||
@apply inline-flex min-w-max items-center gap-2 rounded-2xl border border-border bg-surface p-1 shadow-sm;
|
||||
}
|
||||
|
||||
.a2-tab,
|
||||
.a2-filter-tab,
|
||||
.a2-selector-trigger {
|
||||
@apply rounded-xl border border-transparent px-3 py-2 text-sm font-medium tracking-normal text-muted hover:bg-surface-secondary hover:text-foreground;
|
||||
}
|
||||
|
||||
.a2-tab-active,
|
||||
.a2-filter-tab-active,
|
||||
.a2-selector-trigger-active {
|
||||
@apply border-accent/20 bg-accent/10 text-accent shadow-sm;
|
||||
}
|
||||
|
||||
.a2-filter-row {
|
||||
@apply flex flex-col gap-2 sm:flex-row sm:items-center;
|
||||
}
|
||||
|
||||
.a2-filter-label {
|
||||
@apply min-w-[56px] text-sm font-medium tracking-normal text-muted;
|
||||
}
|
||||
|
||||
.a2-selector-menu {
|
||||
@apply fixed z-[9999] flex max-h-[50vh] flex-col overflow-hidden rounded-2xl border border-border bg-overlay p-1 shadow-2xl backdrop-blur-xl;
|
||||
}
|
||||
|
||||
.a2-selector-option {
|
||||
@apply rounded-xl border border-transparent px-3 py-2 text-left text-sm text-muted hover:bg-surface-secondary hover:text-foreground;
|
||||
}
|
||||
|
||||
.a2-selector-option-active {
|
||||
@apply border-accent/20 bg-accent/10 text-accent;
|
||||
}
|
||||
|
||||
.a2-field {
|
||||
@apply w-full rounded-xl border border-border bg-field px-3 py-2.5 text-sm text-foreground placeholder:text-muted hover:border-accent/40 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20;
|
||||
}
|
||||
|
||||
.a2-button {
|
||||
@apply inline-flex items-center justify-center rounded-xl border border-border bg-surface px-4 py-2.5 text-sm font-medium text-foreground shadow-sm hover:border-accent/40 hover:bg-surface-secondary active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.a2-button-accent {
|
||||
@apply border-accent bg-accent text-accent-foreground hover:bg-accent-strong;
|
||||
}
|
||||
|
||||
.a2-button-danger {
|
||||
@apply border-danger/30 bg-danger/10 text-danger hover:bg-danger/15;
|
||||
}
|
||||
|
||||
.a2-panel {
|
||||
@apply rounded-2xl border border-border bg-surface text-foreground shadow-xl;
|
||||
}
|
||||
|
||||
.a2-kicker {
|
||||
@apply text-xs font-medium tracking-normal text-muted;
|
||||
}
|
||||
|
||||
.a2-title {
|
||||
@apply text-balance text-2xl font-semibold tracking-normal text-foreground md:text-[2rem];
|
||||
}
|
||||
|
||||
.a2-muted-copy {
|
||||
@apply text-sm leading-7 text-muted;
|
||||
}
|
||||
|
||||
.a2-link-action {
|
||||
@apply inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-accent hover:bg-accent/10;
|
||||
}
|
||||
|
||||
.app-filter-row {
|
||||
@apply grid min-w-0 gap-2 sm:grid-cols-[3.75rem_minmax(0,1fr)] sm:items-center;
|
||||
}
|
||||
|
||||
.app-filter-panel {
|
||||
@apply rounded-2xl border border-border/40 bg-surface/75 p-4 shadow-sm backdrop-blur sm:p-6;
|
||||
}
|
||||
|
||||
.app-filter-label {
|
||||
@apply text-xs font-semibold uppercase tracking-normal text-muted sm:text-sm;
|
||||
}
|
||||
|
||||
.app-filter-scroll {
|
||||
@apply w-full min-w-0;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.app-filter-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-filter-dropdowns {
|
||||
@apply flex min-w-0 flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.app-filter-trigger {
|
||||
@apply h-9 whitespace-nowrap rounded-xl border border-border bg-surface-secondary px-3 text-sm font-semibold text-muted shadow-none hover:border-accent/30 hover:bg-accent/10 hover:text-foreground;
|
||||
}
|
||||
|
||||
.app-filter-trigger-active {
|
||||
@apply border-accent/30 bg-accent/10 text-accent hover:text-accent;
|
||||
}
|
||||
|
||||
.app-search-filter-bar {
|
||||
@apply inline-flex max-w-full flex-wrap items-center gap-2 rounded-2xl border border-border/70 bg-surface/85 p-1.5 shadow-sm backdrop-blur;
|
||||
}
|
||||
|
||||
.app-search-filter-trigger {
|
||||
@apply h-10 max-w-[11rem] cursor-pointer whitespace-nowrap rounded-xl border border-transparent bg-transparent px-3 text-sm font-semibold tracking-normal text-muted shadow-none hover:bg-surface-secondary hover:text-foreground;
|
||||
}
|
||||
|
||||
.app-search-filter-trigger-active {
|
||||
@apply border-accent/30 bg-accent/10 text-accent hover:text-accent;
|
||||
}
|
||||
|
||||
.app-search-filter-popover {
|
||||
@apply w-[min(88vw,22rem)] rounded-2xl p-1;
|
||||
}
|
||||
|
||||
.app-search-filter-scroll {
|
||||
@apply max-h-[min(60vh,24rem)] overflow-y-auto;
|
||||
}
|
||||
|
||||
.app-search-filter-menu {
|
||||
@apply flex flex-col gap-1 p-1;
|
||||
}
|
||||
|
||||
.app-search-filter-option,
|
||||
.app-search-filter-option-active {
|
||||
@apply min-h-10 rounded-xl border border-transparent px-3 py-2 text-sm font-medium;
|
||||
}
|
||||
|
||||
.app-search-filter-option {
|
||||
@apply text-muted hover:bg-surface-secondary hover:text-foreground;
|
||||
}
|
||||
|
||||
.app-search-filter-option-active {
|
||||
@apply border-accent/20 bg-accent/10 text-accent;
|
||||
}
|
||||
|
||||
.app-search-filter-option-label {
|
||||
@apply block min-w-0 flex-1 truncate whitespace-nowrap text-left leading-5;
|
||||
}
|
||||
|
||||
.a2-rule {
|
||||
border-color: rgb(var(--color-border) / 0.7);
|
||||
}
|
||||
|
||||
.a2-data {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
color 180ms ease,
|
||||
transform 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.button[data-pressed="true"],
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.button--secondary,
|
||||
.button--tertiary,
|
||||
.button--outline {
|
||||
border-color: var(--border);
|
||||
background: color-mix(in oklab, var(--surface) 94%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.button--danger,
|
||||
.button--danger-soft {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card,
|
||||
.modal__container,
|
||||
.drawer__content,
|
||||
.dropdown__popover,
|
||||
.popover__content,
|
||||
.toast {
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, var(--surface) 94%, transparent);
|
||||
color: var(--foreground);
|
||||
box-shadow: 0 18px 60px -42px color-mix(in oklab, var(--foreground) 48%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.tabs__list {
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in oklab, var(--surface) 94%, transparent);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.tabs__tab {
|
||||
min-height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tabs__tab[aria-selected="true"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tabs__indicator {
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in oklab, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.app-filter-tabs {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-filter-tabs .tabs__list {
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
height: 2.25rem;
|
||||
min-height: 2.25rem;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border: 0 !important;
|
||||
border-width: 0 !important;
|
||||
border-bottom-width: 0 !important;
|
||||
border-radius: 999px !important;
|
||||
background-color: rgb(var(--color-surface-secondary) / 0.86) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.app-filter-tabs .tabs__tab {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
min-width: max-content;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
border-radius: 999px !important;
|
||||
padding: 0 1rem;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-filter-tabs .tabs__tab:hover,
|
||||
.app-filter-tabs .tabs__tab[data-hovered="true"] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-filter-tabs .tabs__tab[aria-selected="true"] {
|
||||
background-color: rgb(var(--color-surface)) !important;
|
||||
color: var(--foreground) !important;
|
||||
box-shadow: 0 8px 22px -16px color-mix(in oklab, var(--foreground) 44%, transparent);
|
||||
}
|
||||
|
||||
.app-filter-tabs .tabs__separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-filter-tabs .tabs__indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textfield,
|
||||
.select__trigger,
|
||||
.textarea {
|
||||
border-color: var(--field-border);
|
||||
background: var(--field-background);
|
||||
color: var(--field-foreground);
|
||||
}
|
||||
|
||||
.table-root {
|
||||
border-color: var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.table__header,
|
||||
.table__row {
|
||||
border-color: var(--separator);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import Artplayer from 'artplayer';
|
||||
import Hls from 'hls.js';
|
||||
import { Heart, Radio, Tv } from 'lucide-react';
|
||||
import { Alert, Button, Card, Chip, EmptyState, ProgressBar, ScrollShadow, Spinner, Tabs } from '@heroui/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -1058,68 +1059,26 @@ function LivePageClient() {
|
|||
}, []);
|
||||
|
||||
if (loading) {
|
||||
const progressValue =
|
||||
loadingStage === 'loading' ? 33 : loadingStage === 'fetching' ? 66 : 100;
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/live'>
|
||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
{/* 动画直播图标 */}
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>📺</div>
|
||||
{/* 旋转光环 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子效果 */}
|
||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
|
||||
<div
|
||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '1s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度指示器 */}
|
||||
<div className='mb-6 w-80 mx-auto'>
|
||||
<div className='flex justify-center space-x-2 mb-4'>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'loading' ? 'bg-blue-500 scale-125' : 'bg-blue-500'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'fetching' ? 'bg-blue-500 scale-125' : 'bg-blue-500'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready' ? 'bg-blue-500 scale-125' : 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-gradient-to-r from-blue-300 to-blue-700 rounded-full transition-all duration-1000 ease-out'
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'loading' ? '33%' : loadingStage === 'fetching' ? '66%' : '100%',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载消息 */}
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
|
||||
{loadingMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className='w-full max-w-md text-center'>
|
||||
<Card.Header className='items-center'>
|
||||
<Spinner />
|
||||
<Card.Title>{loadingMessage}</Card.Title>
|
||||
<Card.Description>正在准备直播播放器</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ProgressBar aria-label='加载进度' value={progressValue} color='accent'>
|
||||
<ProgressBar.Track>
|
||||
<ProgressBar.Fill />
|
||||
</ProgressBar.Track>
|
||||
</ProgressBar>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
@ -1129,41 +1088,24 @@ function LivePageClient() {
|
|||
return (
|
||||
<PageLayout activePath='/live'>
|
||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
{/* 错误图标 */}
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>😵</div>
|
||||
{/* 脉冲效果 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<div className='space-y-4 mb-8'>
|
||||
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
哎呀,出现了一些问题
|
||||
</h2>
|
||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
|
||||
<p className='text-red-600 dark:text-red-400 font-medium'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
请检查网络连接或尝试刷新页面
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className='w-full px-6 py-3 bg-gradient-to-r from-blue-300 to-blue-700 text-white rounded-xl font-medium hover:from-blue-400 hover:to-blue-800 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
|
||||
>
|
||||
<Card className='w-full max-w-md'>
|
||||
<Card.Header>
|
||||
<Card.Title>哎呀,出现了一些问题</Card.Title>
|
||||
<Card.Description>请检查网络连接或尝试刷新页面</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Alert status='danger'>
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button fullWidth onPress={() => window.location.reload()}>
|
||||
🔄 重新尝试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
@ -1175,7 +1117,7 @@ function LivePageClient() {
|
|||
{/* 第一行:页面标题 */}
|
||||
<div className='py-1'>
|
||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2 max-w-[80%]'>
|
||||
<Radio className='w-5 h-5 text-blue-500 flex-shrink-0' />
|
||||
<Radio className='w-5 h-5 text-accent flex-shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate'>
|
||||
{currentSource?.name}
|
||||
|
|
@ -1198,17 +1140,16 @@ function LivePageClient() {
|
|||
<div className='space-y-2'>
|
||||
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
||||
<div className='hidden lg:flex justify-end'>
|
||||
<button
|
||||
onClick={() =>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
aria-label={isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'}
|
||||
onPress={() =>
|
||||
setIsChannelListCollapsed(!isChannelListCollapsed)
|
||||
}
|
||||
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
|
||||
title={
|
||||
isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isChannelListCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${isChannelListCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
|
|
@ -1221,18 +1162,8 @@ function LivePageClient() {
|
|||
d='M9 5l7 7-7 7'
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
|
||||
{isChannelListCollapsed ? '显示' : '隐藏'}
|
||||
</span>
|
||||
|
||||
{/* 精致的状态指示点 */}
|
||||
<div
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isChannelListCollapsed
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-blue-400'
|
||||
}`}
|
||||
></div>
|
||||
</button>
|
||||
{isChannelListCollapsed ? '显示' : '隐藏'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isChannelListCollapsed
|
||||
|
|
@ -1249,50 +1180,27 @@ function LivePageClient() {
|
|||
|
||||
{/* 不支持的直播类型提示 */}
|
||||
{unsupportedType && (
|
||||
<div className='absolute inset-0 bg-black/90 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[600] transition-all duration-300'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>⚠️</div>
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl opacity-20 animate-pulse'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-xl font-semibold text-white'>
|
||||
暂不支持的直播流类型
|
||||
</h3>
|
||||
<div className='bg-orange-500/20 border border-orange-500/30 rounded-lg p-4'>
|
||||
<p className='text-orange-300 font-medium'>
|
||||
当前频道直播流类型:<span className='text-white font-bold'>{unsupportedType.toUpperCase()}</span>
|
||||
</p>
|
||||
<p className='text-sm text-orange-200 mt-2'>
|
||||
目前仅支持 M3U8 格式的直播流
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-sm text-gray-300'>
|
||||
请尝试其他频道
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute inset-0 z-[600] flex items-center justify-center bg-black/90'>
|
||||
<Card variant='default' className='max-w-md p-6 text-center'>
|
||||
<Alert status='warning'>
|
||||
<Alert.Content>
|
||||
<Alert.Title>暂不支持的直播流类型</Alert.Title>
|
||||
<Alert.Description>
|
||||
当前频道直播流类型:{unsupportedType.toUpperCase()}。目前仅支持 M3U8 格式的直播流,请尝试其他频道。
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频加载蒙层 */}
|
||||
{isVideoLoading && (
|
||||
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[500] transition-all duration-300'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>📺</div>
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-white animate-pulse'>
|
||||
🔄 IPTV 加载中...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute inset-0 z-[500] flex items-center justify-center bg-black/85'>
|
||||
<Card variant='default' className='max-w-md p-6 text-center'>
|
||||
<Spinner />
|
||||
<p className='mt-4 text-lg font-semibold'>IPTV 加载中...</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1303,42 +1211,28 @@ function LivePageClient() {
|
|||
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||
}`}>
|
||||
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||
<Card variant='default' className='md:ml-2 h-full p-4 flex flex-col overflow-hidden'>
|
||||
{/* 主要的 Tab 切换 */}
|
||||
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||
<div
|
||||
onClick={() => setActiveTab('channels')}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${activeTab === 'channels'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
频道
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${activeTab === 'sources'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
直播源
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
variant='secondary'
|
||||
onSelectionChange={(key) => setActiveTab(String(key) as 'channels' | 'sources')}
|
||||
>
|
||||
<Tabs.List aria-label='直播列表类型'>
|
||||
<Tabs.Tab id='channels'>频道</Tabs.Tab>
|
||||
<Tabs.Tab id='sources'>直播源</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{/* 频道 Tab 内容 */}
|
||||
{activeTab === 'channels' && (
|
||||
<>
|
||||
{/* 分组标签 */}
|
||||
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
|
||||
<div className='flex items-center gap-4 my-4 flex-shrink-0'>
|
||||
{/* 切换状态提示 */}
|
||||
{isSwitchingSource && (
|
||||
<div className='flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400'>
|
||||
<div className='w-2 h-2 bg-amber-500 rounded-full animate-pulse'></div>
|
||||
<Spinner size='sm' />
|
||||
切换直播源中...
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1370,57 +1264,46 @@ function LivePageClient() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-4 min-w-max'>
|
||||
<div className='flex gap-2 min-w-max'>
|
||||
{Object.keys(groupedChannels).map((group, index) => (
|
||||
<button
|
||||
<Button
|
||||
key={group}
|
||||
data-group={group}
|
||||
ref={(el) => {
|
||||
groupButtonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => handleGroupChange(group)}
|
||||
disabled={isSwitchingSource}
|
||||
className={`w-20 relative py-2 text-sm font-medium transition-colors flex-shrink-0 text-center overflow-hidden
|
||||
${isSwitchingSource
|
||||
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
|
||||
: selectedGroup === group
|
||||
? 'text-blue-500 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400'
|
||||
}
|
||||
`.trim()}
|
||||
size='sm'
|
||||
variant={selectedGroup === group ? 'secondary' : 'tertiary'}
|
||||
isDisabled={isSwitchingSource}
|
||||
className='w-20 flex-shrink-0 overflow-hidden'
|
||||
onPress={() => handleGroupChange(group)}
|
||||
>
|
||||
<div className='px-1 overflow-hidden whitespace-nowrap' title={group}>
|
||||
{group}
|
||||
</div>
|
||||
{selectedGroup === group && !isSwitchingSource && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400' />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<div ref={channelListRef} className='flex-1 overflow-y-auto space-y-2 pb-4'>
|
||||
<ScrollShadow hideScrollBar ref={channelListRef} className='flex-1 space-y-2 pb-4'>
|
||||
{filteredChannels.length > 0 ? (
|
||||
filteredChannels.map(channel => {
|
||||
const isActive = channel.id === currentChannel?.id;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={channel.id}
|
||||
data-channel-id={channel.id}
|
||||
onClick={() => handleChannelChange(channel)}
|
||||
disabled={isSwitchingSource}
|
||||
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
fullWidth
|
||||
variant={isActive ? 'secondary' : 'tertiary'}
|
||||
isDisabled={isSwitchingSource}
|
||||
className='h-auto justify-start p-3'
|
||||
onPress={() => handleChannelChange(channel)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
<div className='w-10 h-10 flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
{channel.logo ? (
|
||||
<img
|
||||
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
|
||||
|
|
@ -1433,90 +1316,77 @@ function LivePageClient() {
|
|||
)}
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
|
||||
<div className='text-sm font-medium truncate' title={channel.name}>
|
||||
{channel.name}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
|
||||
<div className='text-xs text-muted mt-1' title={channel.group}>
|
||||
{channel.group}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
|
||||
<Tv className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
||||
</div>
|
||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
||||
暂无可用频道
|
||||
</p>
|
||||
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
|
||||
请选择其他直播源或稍后再试
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState>
|
||||
<Tv className='w-8 h-8' />
|
||||
<p className='font-medium'>暂无可用频道</p>
|
||||
<p className='text-sm text-muted'>请选择其他直播源或稍后再试</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 直播源 Tab 内容 */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className='flex flex-col h-full mt-4'>
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
|
||||
<ScrollShadow hideScrollBar className='flex-1 space-y-2 pb-20'>
|
||||
{liveSources.length > 0 ? (
|
||||
liveSources.map((source) => {
|
||||
const isCurrentSource = source.key === currentSource?.key;
|
||||
return (
|
||||
<div
|
||||
<Button
|
||||
key={source.key}
|
||||
onClick={() => !isCurrentSource && handleSourceChange(source)}
|
||||
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
||||
${isCurrentSource
|
||||
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30 border'
|
||||
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||
}`.trim()}
|
||||
fullWidth
|
||||
variant={isCurrentSource ? 'secondary' : 'tertiary'}
|
||||
className='h-auto justify-start p-3'
|
||||
isDisabled={isCurrentSource}
|
||||
onPress={() => handleSourceChange(source)}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<div className='w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-lg flex items-center justify-center flex-shrink-0'>
|
||||
<div className='w-12 h-12 flex items-center justify-center flex-shrink-0'>
|
||||
<Radio className='w-6 h-6 text-gray-500' />
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||
<div className='text-sm font-medium truncate'>
|
||||
{source.name}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<div className='text-xs text-muted mt-1'>
|
||||
{!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前标识 */}
|
||||
{isCurrentSource && (
|
||||
<div className='absolute top-2 right-2 w-2 h-2 bg-blue-500 rounded-full'></div>
|
||||
<Chip color='accent' size='sm'>当前</Chip>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
|
||||
<Radio className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
||||
</div>
|
||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
||||
暂无可用直播源
|
||||
</p>
|
||||
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
|
||||
请检查网络连接或联系管理员添加直播源
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState>
|
||||
<Radio className='w-8 h-8' />
|
||||
<p className='font-medium'>暂无可用直播源</p>
|
||||
<p className='text-sm text-muted'>请检查网络连接或联系管理员添加直播源</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1545,16 +1415,17 @@ function LivePageClient() {
|
|||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||
{currentChannel.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<Button
|
||||
isIconOnly
|
||||
variant='tertiary'
|
||||
aria-label={favorited ? '取消收藏' : '收藏'}
|
||||
onPress={() => {
|
||||
handleToggleFavorite();
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title={favorited ? '取消收藏' : '收藏'}
|
||||
>
|
||||
<FavoriteIcon filled={favorited} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
|
||||
{currentSource?.name} {' > '} {currentChannel.group}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
'use client';
|
||||
|
||||
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
|
||||
import { Form, Input, Label, TextField } from '@heroui/react';
|
||||
import { Alert, Checkbox, Form, Input, Label, Link, TextField } from '@heroui/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -37,11 +37,10 @@ function VersionDisplay() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
window.open('https://github.com/djteang/OrangeTV', '_blank')
|
||||
}
|
||||
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
|
||||
<Link
|
||||
href='https://github.com/djteang/OrangeTV'
|
||||
target='_blank'
|
||||
className='absolute bottom-4 left-1/2 -translate-x-1/2 text-xs'
|
||||
>
|
||||
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||
|
|
@ -49,7 +48,7 @@ function VersionDisplay() {
|
|||
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
? 'text-success'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
|
|
@ -67,7 +66,7 @@ function VersionDisplay() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +202,7 @@ function LoginPageClient() {
|
|||
<ThemeToggle />
|
||||
</div>
|
||||
<AppSurface className='relative z-10 w-full max-w-md p-8 sm:p-10'>
|
||||
<h1 className='text-blue-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
|
||||
<h1 className='mb-8 text-center text-3xl font-semibold'>
|
||||
{siteName}
|
||||
</h1>
|
||||
<Form onSubmit={handleSubmit} className='space-y-6'>
|
||||
|
|
@ -236,36 +235,35 @@ function LoginPageClient() {
|
|||
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
|
||||
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
|
||||
<div className='space-y-4'>
|
||||
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||
<div className='flex items-center space-x-2 mb-2'>
|
||||
<Shield className='w-4 h-4 text-blue-600 dark:text-blue-400' />
|
||||
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>设备识别码</span>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-xs font-mono text-gray-700 dark:text-gray-300 break-all'>
|
||||
<Alert status='accent'>
|
||||
<Alert.Indicator>
|
||||
<Shield className='w-4 h-4' />
|
||||
</Alert.Indicator>
|
||||
<Alert.Content>
|
||||
<Alert.Title>设备识别码</Alert.Title>
|
||||
<Alert.Description>
|
||||
{MachineCode.formatMachineCode(machineCode)}
|
||||
</div>
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400'>
|
||||
<br />
|
||||
设备信息: {deviceInfo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
|
||||
{/* 绑定选项 */}
|
||||
{!requireMachineCode && (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<input
|
||||
id='bindMachineCode'
|
||||
type='checkbox'
|
||||
checked={bindMachineCode}
|
||||
onChange={(e) => setBindMachineCode(e.target.checked)}
|
||||
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
|
||||
/>
|
||||
<label htmlFor='bindMachineCode' className='text-sm text-gray-700 dark:text-gray-300'>
|
||||
<Checkbox
|
||||
id='bindMachineCode'
|
||||
isSelected={bindMachineCode}
|
||||
onChange={setBindMachineCode}
|
||||
>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Content>
|
||||
绑定此设备(提升账户安全性)
|
||||
</label>
|
||||
</div>
|
||||
</Checkbox.Content>
|
||||
</Checkbox>
|
||||
{/* <p className='text-xs text-gray-500 dark:text-gray-400 ml-7'>
|
||||
// 管理员可选择不绑定机器码直接登录
|
||||
</p> */}
|
||||
|
|
|
|||
215
src/app/page.tsx
215
src/app/page.tsx
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Button, Card, EmptyState, Link as HeroLink, Skeleton } from '@heroui/react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
|
|
@ -26,6 +25,7 @@ import PageLayout from '@/components/PageLayout';
|
|||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
import { AppDialog } from '@/components/ui/HeroPrimitives';
|
||||
|
||||
function HomeClient() {
|
||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
||||
|
|
@ -185,26 +185,24 @@ function HomeClient() {
|
|||
<div className='mx-auto max-w-[1380px] space-y-10'>
|
||||
{activeTab === 'favorites' ? (
|
||||
// 收藏夹视图
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Saved</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
我的收藏
|
||||
</h2>
|
||||
<Card>
|
||||
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||
<div>
|
||||
<Card.Description>Saved</Card.Description>
|
||||
<Card.Title>我的收藏</Card.Title>
|
||||
</div>
|
||||
{favoriteItems.length > 0 && (
|
||||
<button
|
||||
className='a2-link-action'
|
||||
onClick={async () => {
|
||||
<Button
|
||||
variant='danger'
|
||||
onPress={async () => {
|
||||
await clearAllFavorites();
|
||||
setFavoriteItems([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<div className='grid justify-start grid-cols-3 gap-x-3 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20'>
|
||||
{favoriteItems.map((item) => (
|
||||
<div key={item.id + item.source} className='w-full'>
|
||||
|
|
@ -217,12 +215,12 @@ function HomeClient() {
|
|||
</div>
|
||||
))}
|
||||
{favoriteItems.length === 0 && (
|
||||
<div className='col-span-full rounded-2xl border border-dashed border-border bg-surface-secondary/60 py-10 text-center text-sm font-medium tracking-normal text-muted'>
|
||||
<EmptyState className='col-span-full'>
|
||||
暂无收藏内容
|
||||
</div>
|
||||
</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
) : (
|
||||
// 首页视图
|
||||
<>
|
||||
|
|
@ -230,21 +228,18 @@ function HomeClient() {
|
|||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>精选推荐</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
热门电影
|
||||
</h2>
|
||||
<Card>
|
||||
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||
<div>
|
||||
<Card.Description>精选推荐</Card.Description>
|
||||
<Card.Title>热门电影</Card.Title>
|
||||
</div>
|
||||
<Link
|
||||
<HeroLink
|
||||
href='/douban?type=movie'
|
||||
className='a2-link-action'
|
||||
>
|
||||
查看更多
|
||||
</Link>
|
||||
</div>
|
||||
</HeroLink>
|
||||
</Card.Header>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
|
|
@ -253,10 +248,8 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
<Skeleton className='aspect-[2/3] w-full' />
|
||||
<Skeleton className='mt-3 h-4' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -277,21 +270,19 @@ function HomeClient() {
|
|||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
{/* 热门剧集 */}
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Series</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<Card>
|
||||
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||
<div>
|
||||
<Card.Description>Series</Card.Description>
|
||||
<Card.Title>热门剧集</Card.Title>
|
||||
</div>
|
||||
<Link href='/douban?type=tv' className='a2-link-action'>
|
||||
<HeroLink href='/douban?type=tv'>
|
||||
查看更多
|
||||
</Link>
|
||||
</div>
|
||||
</HeroLink>
|
||||
</Card.Header>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
|
|
@ -300,10 +291,8 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
<Skeleton className='aspect-[2/3] w-full' />
|
||||
<Skeleton className='mt-3 h-4' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -323,24 +312,21 @@ function HomeClient() {
|
|||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
{/* 每日新番放送 */}
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Bangumi</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
新番放送
|
||||
</h2>
|
||||
<Card>
|
||||
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||
<div>
|
||||
<Card.Description>Bangumi</Card.Description>
|
||||
<Card.Title>新番放送</Card.Title>
|
||||
</div>
|
||||
<Link
|
||||
<HeroLink
|
||||
href='/douban?type=anime'
|
||||
className='a2-link-action'
|
||||
>
|
||||
查看更多
|
||||
</Link>
|
||||
</div>
|
||||
</HeroLink>
|
||||
</Card.Header>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
|
|
@ -349,10 +335,8 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
<Skeleton className='aspect-[2/3] w-full' />
|
||||
<Skeleton className='mt-3 h-4' />
|
||||
</div>
|
||||
))
|
||||
: // 展示当前日期的番剧
|
||||
|
|
@ -401,24 +385,21 @@ function HomeClient() {
|
|||
));
|
||||
})()}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
{/* 热门综艺 */}
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Shows</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
热门综艺
|
||||
</h2>
|
||||
<Card>
|
||||
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||
<div>
|
||||
<Card.Description>Shows</Card.Description>
|
||||
<Card.Title>热门综艺</Card.Title>
|
||||
</div>
|
||||
<Link
|
||||
<HeroLink
|
||||
href='/douban?type=show'
|
||||
className='a2-link-action'
|
||||
>
|
||||
查看更多
|
||||
</Link>
|
||||
</div>
|
||||
</HeroLink>
|
||||
</Card.Header>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
|
|
@ -427,10 +408,8 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
<Skeleton className='aspect-[2/3] w-full' />
|
||||
<Skeleton className='mt-3 h-4' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -450,75 +429,29 @@ function HomeClient() {
|
|||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{announcement && showAnnouncement && (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${showAnnouncement ? '' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onTouchStart={(e) => {
|
||||
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
{announcement && (
|
||||
<AppDialog
|
||||
isOpen={showAnnouncement}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleCloseAnnouncement(announcement);
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
// 如果触摸的是背景区域,阻止触摸移动,防止背景滚动
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
// 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'none', // 禁用所有触摸操作
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='a2-panel w-full max-w-md p-6 transform transition-all duration-300'
|
||||
onTouchMove={(e) => {
|
||||
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'auto', // 允许内容区域的正常触摸操作
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-between items-start mb-4'>
|
||||
<h3 className='a2-title border-b border-border/70 pb-3 text-[1.75rem]'>
|
||||
提示
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='a2-icon-button h-8 w-8 p-1.5'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<div className='border-l-4 border-accent pl-4'>
|
||||
<p className='a2-muted-copy'>
|
||||
{announcement}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='a2-link-action w-full justify-center border-b-0 border-t border-border/70 px-4 pt-3'
|
||||
title='提示'
|
||||
footer={
|
||||
<Button
|
||||
fullWidth
|
||||
onPress={() => handleCloseAnnouncement(announcement)}
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className='text-sm leading-6 text-muted'>{announcement}</p>
|
||||
</AppDialog>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
// Artplayer 和 Hls 以及弹幕插件将动态加载
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -707,38 +708,6 @@ function PlayPageClient() {
|
|||
return vodTagString.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
};
|
||||
|
||||
// 为标签生成颜色的函数
|
||||
const getTagColor = (tag: string, isClass: boolean = false) => {
|
||||
if (isClass) {
|
||||
// vod_class 使用更显眼的颜色
|
||||
const classColors = [
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
|
||||
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
|
||||
];
|
||||
const hash = tag.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
return classColors[hash % classColors.length];
|
||||
} else {
|
||||
// vod_tag 使用较为柔和的颜色
|
||||
const tagColors = [
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
|
||||
'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
||||
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-800 dark:text-amber-300',
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-800 dark:text-orange-300',
|
||||
'bg-red-100 text-red-700 dark:bg-red-800 dark:text-red-300',
|
||||
'bg-pink-100 text-pink-700 dark:bg-pink-800 dark:text-pink-300',
|
||||
'bg-rose-100 text-rose-700 dark:bg-rose-800 dark:text-rose-300'
|
||||
];
|
||||
const hash = tag.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
return tagColors[hash % tagColors.length];
|
||||
}
|
||||
};
|
||||
|
||||
// 短剧播放地址处理函数 - 参考utils.ts中的代理逻辑
|
||||
const processShortDramaUrl = (originalUrl: string): string => {
|
||||
if (!originalUrl) {
|
||||
|
|
@ -2482,89 +2451,30 @@ function PlayPageClient() {
|
|||
}, []);
|
||||
|
||||
if (loading) {
|
||||
const progressValue =
|
||||
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
? 33
|
||||
: loadingStage === 'preferring'
|
||||
? 66
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/play'>
|
||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
{/* 动画影院图标 */}
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>
|
||||
{loadingStage === 'searching' && '🔍'}
|
||||
{loadingStage === 'preferring' && '⚡'}
|
||||
{loadingStage === 'fetching' && '🎬'}
|
||||
{loadingStage === 'ready' && '✨'}
|
||||
</div>
|
||||
{/* 旋转光环 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子效果 */}
|
||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
|
||||
<div
|
||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute bottom-3 left-6 w-1 h-1 bg-blue-300 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '1s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度指示器 */}
|
||||
<div className='mb-6 w-80 mx-auto'>
|
||||
<div className='flex justify-center space-x-2 mb-4'>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
? 'bg-blue-500 scale-125'
|
||||
: loadingStage === 'preferring' ||
|
||||
loadingStage === 'ready'
|
||||
? 'bg-blue-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'preferring'
|
||||
? 'bg-blue-500 scale-125'
|
||||
: loadingStage === 'ready'
|
||||
? 'bg-blue-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready'
|
||||
? 'bg-blue-500 scale-125'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-1000 ease-out'
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'searching' ||
|
||||
loadingStage === 'fetching'
|
||||
? '33%'
|
||||
: loadingStage === 'preferring'
|
||||
? '66%'
|
||||
: '100%',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载消息 */}
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
|
||||
{loadingMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className='w-full max-w-md text-center'>
|
||||
<Card.Header className='items-center'>
|
||||
<Spinner />
|
||||
<Card.Title>{loadingMessage}</Card.Title>
|
||||
<Card.Description>正在准备播放器</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ProgressBar aria-label='加载进度' value={progressValue} color='accent'>
|
||||
<ProgressBar.Track>
|
||||
<ProgressBar.Fill />
|
||||
</ProgressBar.Track>
|
||||
</ProgressBar>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
@ -2574,65 +2484,39 @@ function PlayPageClient() {
|
|||
return (
|
||||
<PageLayout activePath='/play'>
|
||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
{/* 错误图标 */}
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>😵</div>
|
||||
{/* 脉冲效果 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
|
||||
</div>
|
||||
|
||||
{/* 浮动错误粒子 */}
|
||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
|
||||
<div
|
||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '1s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<div className='space-y-4 mb-8'>
|
||||
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
哎呀,出现了一些问题
|
||||
</h2>
|
||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
|
||||
<p className='text-red-600 dark:text-red-400 font-medium'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
请检查网络连接或尝试刷新页面
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={() =>
|
||||
<Card className='w-full max-w-md'>
|
||||
<Card.Header>
|
||||
<Card.Title>哎呀,出现了一些问题</Card.Title>
|
||||
<Card.Description>请检查网络连接或尝试刷新页面</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Alert status='danger'>
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
</Card.Content>
|
||||
<Card.Footer className='gap-3'>
|
||||
<Button
|
||||
fullWidth
|
||||
onPress={() =>
|
||||
videoTitle
|
||||
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
|
||||
: router.back()
|
||||
}
|
||||
className='w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
|
||||
>
|
||||
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
|
||||
<Button
|
||||
fullWidth
|
||||
variant='tertiary'
|
||||
onPress={() => window.location.reload()}
|
||||
>
|
||||
🔄 重新尝试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
@ -2656,17 +2540,16 @@ function PlayPageClient() {
|
|||
<div className='space-y-2'>
|
||||
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
||||
<div className='hidden lg:flex justify-end'>
|
||||
<button
|
||||
onClick={() =>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
aria-label={isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'}
|
||||
onPress={() =>
|
||||
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
|
||||
}
|
||||
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
|
||||
title={
|
||||
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
|
|
@ -2679,18 +2562,8 @@ function PlayPageClient() {
|
|||
d='M9 5l7 7-7 7'
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
|
||||
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
|
||||
</span>
|
||||
|
||||
{/* 精致的状态指示点 */}
|
||||
<div
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isEpisodeSelectorCollapsed
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-blue-400'
|
||||
}`}
|
||||
></div>
|
||||
</button>
|
||||
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -2712,39 +2585,15 @@ function PlayPageClient() {
|
|||
|
||||
{/* 换源加载蒙层 */}
|
||||
{isVideoLoading && (
|
||||
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
{/* 动画影院图标 */}
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>🎬</div>
|
||||
{/* 旋转光环 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子效果 */}
|
||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
|
||||
<div
|
||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute bottom-3 left-6 w-1 h-1 bg-blue-300 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '1s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 换源消息 */}
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-white animate-pulse'>
|
||||
{videoLoadingStage === 'sourceChanging'
|
||||
? '🔄 切换播放源...'
|
||||
: '🔄 视频加载中...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute inset-0 z-[500] flex items-center justify-center bg-black/85'>
|
||||
<Card variant='default' className='max-w-md p-6 text-center'>
|
||||
<Spinner />
|
||||
<p className='mt-4 text-lg font-semibold'>
|
||||
{videoLoadingStage === 'sourceChanging'
|
||||
? '切换播放源...'
|
||||
: '视频加载中...'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2783,33 +2632,35 @@ function PlayPageClient() {
|
|||
{/* 标题 */}
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||
{videoTitle || '影片标题'}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<Button
|
||||
isIconOnly
|
||||
variant='tertiary'
|
||||
aria-label={favorited ? '取消收藏' : '收藏'}
|
||||
onPress={() => {
|
||||
handleToggleFavorite();
|
||||
}}
|
||||
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
className='ml-3 flex-shrink-0'
|
||||
>
|
||||
<FavoriteIcon filled={favorited} />
|
||||
</button>
|
||||
</Button>
|
||||
</h1>
|
||||
|
||||
{/* 关键信息行 */}
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{detail?.class && (
|
||||
<span className='text-blue-600 font-semibold'>
|
||||
<Chip color='accent' variant='secondary'>
|
||||
{detail.class}
|
||||
</span>
|
||||
</Chip>
|
||||
)}
|
||||
{(detail?.year || videoYear) && (
|
||||
<span>{detail?.year || videoYear}</span>
|
||||
<Chip variant='secondary'>{detail?.year || videoYear}</Chip>
|
||||
)}
|
||||
{detail?.source_name && (
|
||||
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
||||
<Chip variant='secondary'>
|
||||
{detail.source_name}
|
||||
</span>
|
||||
</Chip>
|
||||
)}
|
||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||
{detail?.type_name && <Chip variant='secondary'>{detail.type_name}</Chip>}
|
||||
</div>
|
||||
|
||||
{/* 短剧专用标签展示 */}
|
||||
|
|
@ -2822,11 +2673,9 @@ function PlayPageClient() {
|
|||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||
分类:
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${getTagColor(vodClass, true)}`}
|
||||
>
|
||||
<Chip color='accent' variant='secondary' size='sm'>
|
||||
📂 {vodClass}
|
||||
</span>
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -2837,12 +2686,13 @@ function PlayPageClient() {
|
|||
标签:
|
||||
</span>
|
||||
{parseVodTags(vodTag).map((tag, index) => (
|
||||
<span
|
||||
<Chip
|
||||
key={index}
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getTagColor(tag, false)}`}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
🏷️ {tag}
|
||||
</span>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2881,7 +2731,7 @@ function PlayPageClient() {
|
|||
rel='noopener noreferrer'
|
||||
className='absolute top-3 left-3'
|
||||
>
|
||||
<div className='bg-blue-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-blue-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
|
||||
<Button isIconOnly size='sm' variant='primary' aria-label='打开豆瓣页面'>
|
||||
<svg
|
||||
width='16'
|
||||
height='16'
|
||||
|
|
@ -2895,7 +2745,7 @@ function PlayPageClient() {
|
|||
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
|
||||
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@
|
|||
'use client';
|
||||
|
||||
import { ChevronUp, Search, X } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
EmptyState,
|
||||
Input,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@heroui/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
|
|
@ -1021,7 +1031,7 @@ function SearchPageClient() {
|
|||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted' />
|
||||
<input
|
||||
<Input
|
||||
id='searchInput'
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
|
|
@ -1029,14 +1039,18 @@ function SearchPageClient() {
|
|||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧、短剧...'
|
||||
autoComplete="off"
|
||||
className='w-full h-12 rounded-2xl border border-border bg-surface/90 py-3 pl-10 pr-12 text-sm text-foreground placeholder:text-muted shadow-sm backdrop-blur focus:border-accent focus:bg-surface focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
fullWidth
|
||||
className='pl-10 pr-12'
|
||||
/>
|
||||
|
||||
{/* 清除按钮 */}
|
||||
{searchQuery && (
|
||||
<button
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onPress={() => {
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(false);
|
||||
document.getElementById('searchInput')?.focus();
|
||||
|
|
@ -1045,7 +1059,7 @@ function SearchPageClient() {
|
|||
aria-label='清除搜索内容'
|
||||
>
|
||||
<X className='h-5 w-5' />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 搜索建议 */}
|
||||
|
|
@ -1071,7 +1085,7 @@ function SearchPageClient() {
|
|||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||
{showResults ? (
|
||||
<section className='mb-12 rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<Card className='mb-12'>
|
||||
{/* 标题 */}
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-xl font-semibold tracking-normal text-foreground'>
|
||||
|
|
@ -1082,9 +1096,7 @@ function SearchPageClient() {
|
|||
</span>
|
||||
)}
|
||||
{isLoading && useFluidSearch && (
|
||||
<span className='ml-2 inline-block align-middle'>
|
||||
<span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-border border-t-accent'></span>
|
||||
</span>
|
||||
<Spinner size='sm' className='ml-2 inline-flex align-middle' />
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
|
@ -1106,29 +1118,26 @@ function SearchPageClient() {
|
|||
)}
|
||||
</div>
|
||||
{/* 聚合开关 */}
|
||||
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
|
||||
<span className='text-xs sm:text-sm font-medium text-muted'>聚合</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={viewMode === 'agg'}
|
||||
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
|
||||
/>
|
||||
<div className='w-9 h-5 rounded-full bg-surface-secondary transition-colors peer-checked:bg-accent'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-surface shadow-sm transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
<Switch
|
||||
size='sm'
|
||||
isSelected={viewMode === 'agg'}
|
||||
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
|
||||
>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Content>聚合</Switch.Content>
|
||||
</Switch>
|
||||
</div>
|
||||
{searchResults.length === 0 ? (
|
||||
isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent'></div>
|
||||
<div className='flex h-40 items-center justify-center'>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-border bg-surface-secondary/60 py-8 text-center text-muted'>
|
||||
<EmptyState>
|
||||
未找到相关结果
|
||||
</div>
|
||||
</EmptyState>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
|
|
@ -1196,51 +1205,56 @@ function SearchPageClient() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
<section className='mb-12 rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<h2 className='mb-4 text-left text-xl font-semibold tracking-normal text-foreground'>
|
||||
搜索历史
|
||||
<Card className='mb-12'>
|
||||
<Card.Header className='flex-row items-center justify-between gap-3'>
|
||||
<Card.Title>搜索历史</Card.Title>
|
||||
{searchHistory.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
<Button
|
||||
variant='danger'
|
||||
size='sm'
|
||||
onPress={() => {
|
||||
clearSearchHistory(); // 事件监听会自动更新界面
|
||||
}}
|
||||
className='ml-3 text-sm text-muted transition-colors hover:text-danger'
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</h2>
|
||||
</Card.Header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{searchHistory.map((item) => (
|
||||
<div key={item} className='relative group'>
|
||||
<button
|
||||
<Chip
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
// 直接调用搜索函数
|
||||
performSearch(item.trim());
|
||||
}}
|
||||
className='rounded-full border border-border bg-surface-secondary px-4 py-2 text-sm text-foreground transition-colors duration-200 hover:border-accent/40 hover:bg-accent/10 hover:text-accent'
|
||||
color='accent'
|
||||
variant='soft'
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
<Chip.Label>{item}</Chip.Label>
|
||||
</Chip>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
<Button
|
||||
aria-label='删除搜索历史'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='danger'
|
||||
className='absolute -right-2 -top-2 opacity-0 group-hover:opacity-100'
|
||||
onPress={() => {
|
||||
deleteSearchHistory(item); // 事件监听会自动更新界面
|
||||
}}
|
||||
className='absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] text-white opacity-0 transition-colors hover:bg-danger group-hover:opacity-100'
|
||||
>
|
||||
<X className='w-3 h-3' />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1252,62 +1266,18 @@ function SearchPageClient() {
|
|||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className='group relative h-14 w-14 rounded-2xl border border-border bg-overlay shadow-xl backdrop-blur-xl transition-all duration-300 ease-out hover:scale-105 hover:border-accent/40 focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
|
||||
style={{
|
||||
background: `conic-gradient(from 0deg, rgb(var(--color-accent)) ${scrollProgress * 3.6}deg, rgb(var(--color-accent) / 0.12) ${scrollProgress * 3.6}deg)`
|
||||
}}
|
||||
>
|
||||
{/* 内部发光圆圈 */}
|
||||
<div className='absolute inset-1 flex items-center justify-center rounded-xl bg-surface/90 backdrop-blur-sm transition-all duration-300 group-hover:bg-accent/15'>
|
||||
<ChevronUp className='w-6 h-6 text-accent transition-all duration-300 group-hover:scale-110' />
|
||||
</div>
|
||||
|
||||
{/* 进度环 */}
|
||||
<svg className='absolute inset-0 w-full h-full -rotate-90' viewBox='0 0 56 56'>
|
||||
<circle
|
||||
cx='28'
|
||||
cy='28'
|
||||
r='25'
|
||||
fill='none'
|
||||
stroke='rgba(255, 255, 255, 0.1)'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<circle
|
||||
cx='28'
|
||||
cy='28'
|
||||
r='25'
|
||||
fill='none'
|
||||
stroke='url(#progressGradient)'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={`${(scrollProgress / 100) * 157} 157`}
|
||||
className='transition-all duration-300 ease-out'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id='progressGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
|
||||
<stop offset='0%' stopColor='rgb(var(--color-accent))' />
|
||||
<stop offset='50%' stopColor='rgb(var(--color-accent))' />
|
||||
<stop offset='100%' stopColor='rgb(var(--color-accent-strong))' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* 悬停时的进度提示 */}
|
||||
<div className='absolute -top-12 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none'>
|
||||
<div className='rounded-xl border border-border bg-overlay px-3 py-1.5 text-xs text-foreground shadow-xl backdrop-blur'>
|
||||
<div className='text-center font-medium'>
|
||||
{Math.round(scrollProgress)}%
|
||||
</div>
|
||||
<div className='absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 border-b border-r border-border bg-overlay'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 脉冲动画 */}
|
||||
<div className='absolute inset-0 animate-pulse rounded-2xl bg-accent/10 opacity-0 transition-opacity duration-300 group-hover:opacity-100'></div>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
onPress={scrollToTop}
|
||||
isIconOnly
|
||||
aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
|
||||
>
|
||||
<ChevronUp className='w-6 h-6' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{Math.round(scrollProgress)}%</Tooltip.Content>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
'use client';
|
||||
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Card, EmptyState, Spinner } from '@heroui/react';
|
||||
|
||||
import {
|
||||
getShortDramaList,
|
||||
|
|
@ -250,12 +251,12 @@ function ShortDramaPageClient() {
|
|||
</div>
|
||||
|
||||
{/* 选择器组件 */}
|
||||
<div className='app-filter-panel'>
|
||||
<Card>
|
||||
<ShortDramaSelector
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 内容展示区域 */}
|
||||
|
|
@ -305,8 +306,8 @@ function ShortDramaPageClient() {
|
|||
>
|
||||
{isLoadingMore && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
|
||||
<span className='text-gray-600 dark:text-gray-400'>加载中...</span>
|
||||
<Spinner size='sm' />
|
||||
<span className='text-muted'>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -314,9 +315,9 @@ function ShortDramaPageClient() {
|
|||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && shortDramaData.length > 0 && (
|
||||
<div className='text-center text-gray-500 dark:text-gray-400 py-8'>
|
||||
<EmptyState className='py-8'>
|
||||
已加载全部内容
|
||||
</div>
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Metadata } from 'next';
|
||||
import WarningClient from './warning-client';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '安全警告 - OrangeTV',
|
||||
|
|
@ -6,92 +7,5 @@ export const metadata: Metadata = {
|
|||
};
|
||||
|
||||
export default function WarningPage() {
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4'>
|
||||
<div className='max-w-2xl w-full bg-white rounded-2xl shadow-2xl p-4 sm:p-8 border border-red-200'>
|
||||
{/* 警告图标 */}
|
||||
<div className='flex justify-center mb-4 sm:mb-6'>
|
||||
<div className='w-16 h-16 sm:w-20 sm:h-20 bg-red-100 rounded-full flex items-center justify-center'>
|
||||
<svg
|
||||
className='w-10 h-10 sm:w-12 sm:h-12 text-red-600'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className='text-center mb-6 sm:mb-8'>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-gray-900 mb-2'>
|
||||
安全合规配置警告
|
||||
</h1>
|
||||
<div className='w-12 sm:w-16 h-1 bg-red-500 mx-auto rounded-full'></div>
|
||||
</div>
|
||||
|
||||
{/* 警告内容 */}
|
||||
<div className='space-y-4 sm:space-y-6 text-gray-700'>
|
||||
<div className='bg-red-50 border-l-4 border-red-500 p-3 sm:p-4 rounded-r-lg'>
|
||||
<p className='text-base sm:text-lg font-semibold text-red-800 mb-2'>
|
||||
⚠️ 安全风险提示
|
||||
</p>
|
||||
<p className='text-sm sm:text-base text-red-700'>
|
||||
检测到您的站点未配置访问控制,存在潜在的安全风险和法律合规问题。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<h2 className='text-lg sm:text-xl font-semibold text-gray-900'>
|
||||
主要风险
|
||||
</h2>
|
||||
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-gray-600'>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>未经授权的访问可能导致内容被恶意传播</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务器资源可能被滥用,影响正常服务</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>可能收到相关权利方的法律通知</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务提供商可能因合规问题终止服务</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4'>
|
||||
<h3 className='text-base sm:text-lg font-semibold text-yellow-800 mb-2'>
|
||||
🔒 安全配置建议
|
||||
</h3>
|
||||
<p className='text-sm sm:text-base text-yellow-700'>
|
||||
请立即配置{' '}
|
||||
<code className='bg-yellow-100 px-1.5 py-0.5 rounded text-xs sm:text-sm font-mono'>
|
||||
PASSWORD
|
||||
</code>{' '}
|
||||
环境变量以启用访问控制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部装饰 */}
|
||||
<div className='mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-gray-200'>
|
||||
<div className='text-center text-xs sm:text-sm text-gray-500'>
|
||||
<p>为确保系统安全性和合规性,请及时完成安全配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <WarningClient />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { Alert, Card, Chip } from '@heroui/react';
|
||||
import { AlertTriangle, ShieldAlert } from 'lucide-react';
|
||||
|
||||
export default function WarningClient() {
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center p-4'>
|
||||
<Card variant='default' className='max-w-2xl w-full p-4 sm:p-8'>
|
||||
<Card.Header className='items-center text-center'>
|
||||
<Chip color='danger' variant='secondary' size='lg'>
|
||||
<ShieldAlert className='h-6 w-6' />
|
||||
</Chip>
|
||||
<Card.Title className='mt-4 text-2xl sm:text-3xl'>
|
||||
安全合规配置警告
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content className='space-y-4 sm:space-y-6'>
|
||||
<Alert status='danger'>
|
||||
<Alert.Indicator>
|
||||
<AlertTriangle className='h-5 w-5' />
|
||||
</Alert.Indicator>
|
||||
<Alert.Content>
|
||||
<Alert.Title>安全风险提示</Alert.Title>
|
||||
<Alert.Description>
|
||||
检测到您的站点未配置访问控制,存在潜在的安全风险和法律合规问题。
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<h2 className='text-lg sm:text-xl font-semibold'>主要风险</h2>
|
||||
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-muted'>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Chip color='danger' size='sm'>1</Chip>
|
||||
<span>未经授权的访问可能导致内容被恶意传播</span>
|
||||
</li>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Chip color='danger' size='sm'>2</Chip>
|
||||
<span>服务器资源可能被滥用,影响正常服务</span>
|
||||
</li>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Chip color='danger' size='sm'>3</Chip>
|
||||
<span>可能收到相关权利方的法律通知</span>
|
||||
</li>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Chip color='danger' size='sm'>4</Chip>
|
||||
<span>服务提供商可能因合规问题终止服务</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert status='warning'>
|
||||
<Alert.Title>安全配置建议</Alert.Title>
|
||||
<Alert.Description>
|
||||
请立即配置{' '}
|
||||
<code className='font-mono text-xs sm:text-sm'>PASSWORD</code>{' '}
|
||||
环境变量以启用访问控制。
|
||||
</Alert.Description>
|
||||
</Alert>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer>
|
||||
<div className='text-center text-xs sm:text-sm text-muted w-full'>
|
||||
<p>为确保系统安全性和合规性,请及时完成安全配置</p>
|
||||
</div>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,10 +6,9 @@ export function BackButton() {
|
|||
return (
|
||||
<AppIconButton
|
||||
onPress={() => window.history.back()}
|
||||
className='a2-icon-button'
|
||||
aria-label='Back'
|
||||
>
|
||||
<ArrowLeft className='w-full h-full' />
|
||||
<ArrowLeft className='h-5 w-5' />
|
||||
</AppIconButton>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,7 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Skeleton } from '@heroui/react';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import {
|
||||
|
|
@ -86,26 +87,24 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<section className={`rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6 ${className || ''}`}>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>最近观看</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
继续观看
|
||||
</h2>
|
||||
<Card className={className}>
|
||||
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||
<div>
|
||||
<Card.Description>最近观看</Card.Description>
|
||||
<Card.Title>继续观看</Card.Title>
|
||||
</div>
|
||||
{!loading && playRecords.length > 0 && (
|
||||
<button
|
||||
className='a2-link-action'
|
||||
onClick={async () => {
|
||||
<Button
|
||||
variant='danger'
|
||||
onPress={async () => {
|
||||
await clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
|
|
@ -114,11 +113,9 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
<div className='mt-1 h-3 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
<Skeleton className='aspect-[2/3] w-full' />
|
||||
<Skeleton className='mt-3 h-4' />
|
||||
<Skeleton className='mt-1 h-3' />
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -152,6 +149,6 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||
);
|
||||
})}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Download, FileCheck, Lock, Upload } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Alert, Button, Card, Chip, Input, Label, Spinner, TextField } from '@heroui/react';
|
||||
import { AppDialog } from './ui/HeroPrimitives';
|
||||
|
||||
interface DataMigrationProps {
|
||||
onRefreshConfig?: () => Promise<void>;
|
||||
|
|
@ -34,107 +35,80 @@ const AlertModal = ({
|
|||
showConfirm = false,
|
||||
timer
|
||||
}: AlertModalProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
if (timer) {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, timer);
|
||||
}
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
if (isOpen && timer) {
|
||||
const timeout = setTimeout(onClose, timer);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isOpen, timer, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-12 h-12 text-green-500" />;
|
||||
return <CheckCircle className="h-5 w-5" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-12 h-12 text-red-500" />;
|
||||
return <AlertCircle className="h-5 w-5" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-12 h-12 text-yellow-500" />;
|
||||
return <AlertTriangle className="h-5 w-5" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
const getStatus = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||
return 'danger';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||
return 'warning';
|
||||
default:
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||
return 'accent';
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`} onClick={onClose}>
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border ${getBgColor()} transition-all duration-200 ${isVisible ? 'scale-100' : 'scale-95'}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-6 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{html && (
|
||||
<div
|
||||
className="text-left text-gray-600 dark:text-gray-400 mb-4"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center space-x-3">
|
||||
{showConfirm && onConfirm ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
return (
|
||||
<AppDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
title={title}
|
||||
icon={getIcon()}
|
||||
footer={
|
||||
showConfirm && onConfirm ? (
|
||||
<>
|
||||
<Button variant='secondary' onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onPress={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='primary' onPress={onClose}>
|
||||
确定
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Alert status={getStatus()}>
|
||||
{message ? <p>{message}</p> : null}
|
||||
{html ? (
|
||||
<div
|
||||
className='text-sm leading-6'
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : null}
|
||||
</Alert>
|
||||
</AppDialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -334,51 +308,47 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||
<>
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* 简洁警告提示 */}
|
||||
<div className="flex items-center gap-3 p-4 border border-amber-200 dark:border-amber-700 rounded-lg bg-amber-50/30 dark:bg-amber-900/5">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
数据迁移操作请谨慎,确保已备份重要数据
|
||||
</p>
|
||||
</div>
|
||||
<Alert status='warning'>
|
||||
数据迁移操作请谨慎,确保已备份重要数据
|
||||
</Alert>
|
||||
|
||||
{/* 主要操作区域 - 响应式布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 数据导出 */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-800 hover:shadow-sm transition-shadow flex flex-col">
|
||||
<Card variant='default' className='p-6'>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center">
|
||||
<Download className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<Chip variant='secondary' size='lg'>
|
||||
<Download className="w-4 h-4" />
|
||||
</Chip>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">数据导出</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">创建加密备份文件</p>
|
||||
<h3 className="font-semibold">数据导出</h3>
|
||||
<p className="text-sm text-muted">创建加密备份文件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="space-y-4">
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<TextField>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
加密密码
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="设置强密码保护备份文件"
|
||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
disabled={isExporting}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p className="text-xs text-muted">
|
||||
导入时需要使用相同密码
|
||||
</p>
|
||||
</div>
|
||||
</TextField>
|
||||
|
||||
{/* 备份内容列表 */}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300 mb-2">备份内容:</p>
|
||||
<div className="text-xs text-muted space-y-1">
|
||||
<p className="font-medium text-foreground mb-2">备份内容:</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div>• 管理配置</div>
|
||||
<div>• 用户数据</div>
|
||||
|
|
@ -389,17 +359,16 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||
</div>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !exportPassword.trim()}
|
||||
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isExporting || !exportPassword.trim()
|
||||
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
<Button
|
||||
fullWidth
|
||||
variant='primary'
|
||||
className='mt-10'
|
||||
onPress={handleExport}
|
||||
isDisabled={isExporting || !exportPassword.trim()}
|
||||
>
|
||||
{isExporting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<Spinner size='sm' />
|
||||
导出中...
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -408,74 +377,80 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||
导出数据
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 数据导入 */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-800 hover:shadow-sm transition-shadow flex flex-col">
|
||||
<Card variant='default' className='p-6'>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<Upload className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<Chip color='danger' variant='secondary' size='lg'>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Chip>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">数据导入</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">⚠️ 将清空现有数据</p>
|
||||
<h3 className="font-semibold">数据导入</h3>
|
||||
<p className="text-sm text-danger">将清空现有数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="space-y-4">
|
||||
{/* 文件选择 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<div className='space-y-2'>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileCheck className="w-4 h-4" />
|
||||
备份文件
|
||||
{selectedFile && (
|
||||
<span className="ml-auto text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
<span className="ml-auto text-xs text-success font-normal">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".dat"
|
||||
onChange={handleFileSelect}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-50 dark:file:bg-gray-600 file:text-gray-700 dark:file:text-gray-300 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 transition-colors"
|
||||
className="sr-only"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onPress={() => fileInputRef.current?.click()}
|
||||
isDisabled={isImporting}
|
||||
>
|
||||
<FileCheck className='h-4 w-4' />
|
||||
选择备份文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<TextField>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
解密密码
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="输入导出时的加密密码"
|
||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</div>
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
{/* 导入按钮 */}
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting || !selectedFile || !importPassword.trim()}
|
||||
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isImporting || !selectedFile || !importPassword.trim()
|
||||
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
||||
: 'bg-red-600 hover:bg-red-700 text-white'
|
||||
}`}
|
||||
<Button
|
||||
fullWidth
|
||||
variant='primary'
|
||||
className='mt-10'
|
||||
onPress={handleImport}
|
||||
isDisabled={isImporting || !selectedFile || !importPassword.trim()}
|
||||
>
|
||||
{isImporting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<Spinner size='sm' />
|
||||
导入中...
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -484,9 +459,9 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||
导入数据
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||
|
||||
interface CustomCategory {
|
||||
name: string;
|
||||
|
|
@ -22,18 +22,17 @@ interface DoubanCustomSelectorProps {
|
|||
|
||||
const renderSelector = (
|
||||
label: string,
|
||||
ariaLabel: string,
|
||||
options: { label: string; value: string }[],
|
||||
activeValue: string | undefined,
|
||||
onChange: (value: string) => void
|
||||
) => (
|
||||
<AppFilterTabs
|
||||
ariaLabel={label}
|
||||
selectedKey={activeValue}
|
||||
onSelectionChange={onChange}
|
||||
items={options.map((option) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
<AppFilterSelect
|
||||
ariaLabel={ariaLabel}
|
||||
label={label}
|
||||
options={options}
|
||||
value={activeValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -76,31 +75,23 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
|
|||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>类型</span>
|
||||
<div className='min-w-0'>
|
||||
{renderSelector(
|
||||
'自定义类型',
|
||||
primaryOptions,
|
||||
primarySelection || primaryOptions[0]?.value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||
{renderSelector(
|
||||
'类型',
|
||||
'自定义类型选项',
|
||||
primaryOptions,
|
||||
primarySelection || primaryOptions[0]?.value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
|
||||
{secondaryOptions.length > 0 && (
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>片单</span>
|
||||
<div className='min-w-0'>
|
||||
{renderSelector(
|
||||
'自定义片单',
|
||||
secondaryOptions,
|
||||
secondarySelection || secondaryOptions[0]?.value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
renderSelector(
|
||||
'片单',
|
||||
'自定义片单选项',
|
||||
secondaryOptions,
|
||||
secondarySelection || secondaryOptions[0]?.value,
|
||||
onSecondaryChange
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import MultiLevelSelector from './MultiLevelSelector';
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||
import WeekdaySelector from './WeekdaySelector';
|
||||
|
||||
interface SelectorOption {
|
||||
|
|
@ -73,31 +73,27 @@ const animePrimaryOptions: SelectorOption[] = [
|
|||
|
||||
const renderSelector = (
|
||||
label: string,
|
||||
ariaLabel: string,
|
||||
options: SelectorOption[],
|
||||
activeValue: string | undefined,
|
||||
onChange: (value: string) => void
|
||||
) => (
|
||||
<AppFilterTabs
|
||||
ariaLabel={label}
|
||||
selectedKey={activeValue}
|
||||
onSelectionChange={onChange}
|
||||
items={options.map((option) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
<AppFilterSelect
|
||||
ariaLabel={ariaLabel}
|
||||
label={label}
|
||||
options={options}
|
||||
value={activeValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const FilterRow = ({
|
||||
label,
|
||||
const FilterGrid = ({
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>{label}</span>
|
||||
<div className='min-w-0'>{children}</div>
|
||||
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -117,86 +113,81 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
|||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{type === 'movie' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'电影分类',
|
||||
moviePrimaryOptions,
|
||||
primarySelection || moviePrimaryOptions[0].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
<FilterGrid>
|
||||
{renderSelector(
|
||||
'分类',
|
||||
'电影分类选项',
|
||||
moviePrimaryOptions,
|
||||
primarySelection || moviePrimaryOptions[0].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
|
||||
{primarySelection !== '全部' ? (
|
||||
<FilterRow label='地区'>
|
||||
{renderSelector(
|
||||
'电影地区',
|
||||
movieSecondaryOptions,
|
||||
secondarySelection || movieSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
renderSelector(
|
||||
'地区',
|
||||
'电影地区选项',
|
||||
movieSecondaryOptions,
|
||||
secondarySelection || movieSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)
|
||||
) : (
|
||||
<FilterRow label='筛选'>
|
||||
<div className='col-span-full'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</FilterRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FilterGrid>
|
||||
)}
|
||||
|
||||
{type === 'tv' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'剧集分类',
|
||||
tvPrimaryOptions,
|
||||
primarySelection || tvPrimaryOptions[1].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
<FilterGrid>
|
||||
{renderSelector(
|
||||
'分类',
|
||||
'剧集分类选项',
|
||||
tvPrimaryOptions,
|
||||
primarySelection || tvPrimaryOptions[1].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
|
||||
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<FilterRow label='类型'>
|
||||
{renderSelector(
|
||||
'剧集类型',
|
||||
tvSecondaryOptions,
|
||||
secondarySelection || tvSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
renderSelector(
|
||||
'类型',
|
||||
'剧集类型选项',
|
||||
tvSecondaryOptions,
|
||||
secondarySelection || tvSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)
|
||||
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
|
||||
<FilterRow label='筛选'>
|
||||
<div className='col-span-full'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</FilterRow>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FilterGrid>
|
||||
)}
|
||||
|
||||
{type === 'anime' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'动漫分类',
|
||||
animePrimaryOptions,
|
||||
primarySelection || animePrimaryOptions[0].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
<FilterGrid>
|
||||
{renderSelector(
|
||||
'分类',
|
||||
'动漫分类选项',
|
||||
animePrimaryOptions,
|
||||
primarySelection || animePrimaryOptions[0].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
|
||||
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
|
||||
<FilterRow label='星期'>
|
||||
<div className='min-w-0'>
|
||||
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
|
||||
</FilterRow>
|
||||
</div>
|
||||
) : (
|
||||
<FilterRow label='筛选'>
|
||||
<div className='col-span-full'>
|
||||
{(primarySelection || animePrimaryOptions[0].value) === '番剧' ? (
|
||||
<MultiLevelSelector
|
||||
key={`anime-tv-${primarySelection}`}
|
||||
|
|
@ -210,41 +201,39 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
|||
contentType='anime-movie'
|
||||
/>
|
||||
)}
|
||||
</FilterRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FilterGrid>
|
||||
)}
|
||||
|
||||
{type === 'show' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'综艺分类',
|
||||
showPrimaryOptions,
|
||||
primarySelection || showPrimaryOptions[1].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
<FilterGrid>
|
||||
{renderSelector(
|
||||
'分类',
|
||||
'综艺分类选项',
|
||||
showPrimaryOptions,
|
||||
primarySelection || showPrimaryOptions[1].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
|
||||
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<FilterRow label='类型'>
|
||||
{renderSelector(
|
||||
'综艺类型',
|
||||
showSecondaryOptions,
|
||||
secondarySelection || showSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
renderSelector(
|
||||
'类型',
|
||||
'综艺类型选项',
|
||||
showSecondaryOptions,
|
||||
secondarySelection || showSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)
|
||||
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
|
||||
<FilterRow label='筛选'>
|
||||
<div className='col-span-full'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</FilterRow>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FilterGrid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
import { Clock, Target, Tv } from 'lucide-react';
|
||||
import { Button, Card, Chip, EmptyState, Spinner } from '@heroui/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { formatTimeToHHMM, parseCustomTimeFormat } from '@/lib/time';
|
||||
|
|
@ -147,19 +148,16 @@ export default function EpgScrollableRow({
|
|||
// 加载中状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<div className='pt-4'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
|
||||
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
今日节目单
|
||||
</h4>
|
||||
<div className="w-16 sm:w-20"></div>
|
||||
</div>
|
||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 sm:gap-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="w-5 h-5 sm:w-6 sm:h-6 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<span className="text-sm sm:text-base">加载节目单...</span>
|
||||
</div>
|
||||
<div className='flex min-h-[100px] items-center justify-center sm:min-h-[120px]'>
|
||||
<Spinner />
|
||||
<span className='ml-3 text-sm text-muted sm:text-base'>加载节目单...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -168,41 +166,40 @@ export default function EpgScrollableRow({
|
|||
// 无节目单状态
|
||||
if (!programs || programs.length === 0) {
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<div className='pt-4'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
|
||||
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
今日节目单
|
||||
</h4>
|
||||
<div className="w-16 sm:w-20"></div>
|
||||
</div>
|
||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 sm:gap-3 text-gray-400 dark:text-gray-500">
|
||||
<Tv className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="text-sm sm:text-base">暂无节目单数据</span>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState>
|
||||
<Tv className='mx-auto mb-2 h-5 w-5' />
|
||||
暂无节目单数据
|
||||
<p className='mt-1 text-sm text-muted'>当前频道没有可显示的 EPG 信息</p>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 mt-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<div className='mt-2 pt-4'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
|
||||
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||
今日节目单
|
||||
</h4>
|
||||
{currentPlayingIndex !== -1 && (
|
||||
<button
|
||||
onClick={scrollToCurrentProgram}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
|
||||
title="滚动到当前播放位置"
|
||||
<Button
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
onPress={scrollToCurrentProgram}
|
||||
aria-label='滚动到当前播放位置'
|
||||
>
|
||||
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
|
||||
<span className="hidden sm:inline">当前播放</span>
|
||||
<span className="sm:hidden">当前</span>
|
||||
</button>
|
||||
<Target className='h-3 w-3' />
|
||||
<span className='hidden sm:inline'>当前播放</span>
|
||||
<span className='sm:hidden'>当前</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -213,53 +210,29 @@ export default function EpgScrollableRow({
|
|||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
|
||||
className='scrollbar-hide flex min-h-[100px] gap-3 overflow-x-auto px-2 py-2 pb-4 sm:min-h-[120px] sm:px-4'
|
||||
>
|
||||
{programs.map((program, index) => {
|
||||
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
|
||||
const isPlaying = index === currentPlayingIndex;
|
||||
const isFinishedProgram = index < currentPlayingIndex;
|
||||
const isUpcomingProgram = index > currentPlayingIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
key={index}
|
||||
className={`flex-shrink-0 w-36 sm:w-48 p-2 sm:p-3 rounded-lg border transition-all duration-200 flex flex-col min-h-[100px] sm:min-h-[120px] ${isPlaying
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
|
||||
: isFinishedProgram
|
||||
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
: isUpcomingProgram
|
||||
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
|
||||
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
className='flex min-h-[100px] w-36 flex-shrink-0 flex-col p-2 sm:min-h-[120px] sm:w-48 sm:p-3'
|
||||
>
|
||||
{/* 时间显示在顶部 */}
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium ${isPlaying
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: isFinishedProgram
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: isUpcomingProgram
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}`}>
|
||||
<div className='mb-2 flex flex-shrink-0 items-center justify-between sm:mb-3'>
|
||||
<Chip size='sm' variant={isPlaying ? 'primary' : 'secondary'}>
|
||||
{formatTime(program.start)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
</Chip>
|
||||
<span className='text-xs text-muted'>
|
||||
{formatTime(program.end)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题在中间,占据剩余空间 */}
|
||||
<div
|
||||
className={`text-xs sm:text-sm font-medium flex-1 ${isPlaying
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: isFinishedProgram
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: isUpcomingProgram
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
className='flex-1 text-xs font-medium text-foreground sm:text-sm'
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
|
|
@ -276,14 +249,11 @@ export default function EpgScrollableRow({
|
|||
|
||||
{/* 正在播放状态在底部 */}
|
||||
{isPlaying && (
|
||||
<div className="mt-auto pt-1 sm:pt-2 flex items-center gap-1 sm:gap-1.5 flex-shrink-0">
|
||||
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
正在播放
|
||||
</span>
|
||||
</div>
|
||||
<Chip size='sm' variant='primary' className='mt-auto'>
|
||||
正在播放
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Chip, Spinner } from '@heroui/react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -403,32 +404,29 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
{categories.map((label, idx) => {
|
||||
const isActive = idx === displayPage;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={label}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[idx] = el;
|
||||
}}
|
||||
onClick={() => handleCategoryClick(idx)}
|
||||
className={`relative w-20 flex-shrink-0 py-2 text-center text-[11px] font-medium uppercase tracking-[0.14em] transition-colors whitespace-nowrap
|
||||
${isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted hover:text-muted'
|
||||
}
|
||||
`.trim()}
|
||||
onPress={() => handleCategoryClick(idx)}
|
||||
variant={isActive ? 'primary' : 'ghost'}
|
||||
size='sm'
|
||||
className='w-20 flex-shrink-0'
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-px bg-accent' />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 向上/向下按钮 */}
|
||||
<button
|
||||
className='a2-icon-button h-8 w-8 flex-shrink-0 translate-y-[-4px]'
|
||||
onClick={() => {
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='tertiary'
|
||||
className='flex-shrink-0 translate-y-[-4px]'
|
||||
onPress={() => {
|
||||
// 切换集数排序(正序/倒序)
|
||||
setDescending((prev) => !prev);
|
||||
}}
|
||||
|
|
@ -446,7 +444,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 集数网格 */}
|
||||
|
|
@ -460,14 +458,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
})().map((episodeNumber) => {
|
||||
const isActive = episodeNumber === value;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={episodeNumber}
|
||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
||||
className={`a2-data flex h-10 min-w-10 items-center justify-center border px-3 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap
|
||||
${isActive
|
||||
? 'border-accent bg-accent text-accent-foreground'
|
||||
: 'border-border/70 bg-surface/60 text-muted hover:border-accent/35 hover:text-foreground'
|
||||
}`.trim()}
|
||||
onPress={() => handleEpisodeClick(episodeNumber - 1)}
|
||||
variant={isActive ? 'primary' : 'tertiary'}
|
||||
size='sm'
|
||||
className='min-w-10'
|
||||
>
|
||||
{(() => {
|
||||
const title = episodes_titles?.[episodeNumber - 1];
|
||||
|
|
@ -481,7 +477,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
}
|
||||
return title;
|
||||
})()}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -493,9 +489,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
<div className='flex flex-col h-full mt-4'>
|
||||
{sourceSearchLoading && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='h-px w-24 bg-border/70'>
|
||||
<div className='h-full w-1/2 animate-pulse bg-accent'></div>
|
||||
</div>
|
||||
<Spinner size='sm' />
|
||||
<span className='ml-3 text-xs uppercase tracking-[0.16em] text-muted'>
|
||||
搜索中
|
||||
</span>
|
||||
|
|
@ -630,9 +624,9 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
|
||||
{/* 源名称和集数信息 - 垂直居中 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
|
||||
{source.source_name}
|
||||
</span>
|
||||
<Chip size='sm' variant='secondary'>
|
||||
<Chip.Label>{source.source_name}</Chip.Label>
|
||||
</Chip>
|
||||
{source.episodes.length > 1 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||
{source.episodes.length} 集
|
||||
|
|
@ -649,12 +643,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
if (!videoInfo.hasError) {
|
||||
return (
|
||||
<div className='flex items-end gap-3 text-xs'>
|
||||
<div className='a2-data text-accent font-medium text-xs'>
|
||||
{videoInfo.loadSpeed}
|
||||
</div>
|
||||
<div className='a2-data text-warning font-medium text-xs'>
|
||||
{videoInfo.pingTime}ms
|
||||
</div>
|
||||
<Chip size='sm' color='accent' variant='soft'>
|
||||
<Chip.Label>{videoInfo.loadSpeed}</Chip.Label>
|
||||
</Chip>
|
||||
<Chip size='sm' color='warning' variant='soft'>
|
||||
<Chip.Label>{videoInfo.pingTime}ms</Chip.Label>
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
|
@ -672,18 +666,19 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
);
|
||||
})}
|
||||
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => {
|
||||
<Button
|
||||
variant='tertiary'
|
||||
fullWidth
|
||||
onPress={() => {
|
||||
if (videoTitle) {
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(videoTitle)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className='a2-link-action w-full justify-center border-b-0 pt-2 text-center'
|
||||
>
|
||||
影片匹配有误?点击去搜索
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Alert, Button } from '@heroui/react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ErrorInfo {
|
||||
|
|
@ -60,35 +62,25 @@ export function GlobalErrorIndicator() {
|
|||
|
||||
return (
|
||||
<div className='fixed top-4 right-4 z-[2000]'>
|
||||
{/* 错误卡片 */}
|
||||
<div
|
||||
className={`bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between min-w-[300px] max-w-[400px] transition-all duration-300 ${
|
||||
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
|
||||
} animate-fade-in`}
|
||||
<Alert
|
||||
status='danger'
|
||||
className={`min-w-[300px] max-w-[400px] transition-transform duration-300 ${
|
||||
isReplacing ? 'scale-105' : 'scale-100'
|
||||
}`}
|
||||
>
|
||||
<span className='text-sm font-medium flex-1 mr-3'>
|
||||
{currentError.message}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
|
||||
<Alert.Content>
|
||||
<Alert.Description>{currentError.message}</Alert.Description>
|
||||
</Alert.Content>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onPress={handleClose}
|
||||
aria-label='关闭错误提示'
|
||||
>
|
||||
<svg
|
||||
className='w-5 h-5'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Radio } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { Card, Chip } from '@heroui/react';
|
||||
|
||||
import { AppButton, AppDrawer, AppScrollShadow } from './ui/HeroPrimitives';
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||
>
|
||||
<div className='space-y-4'>
|
||||
{(poster || sourceName) && (
|
||||
<div className='flex items-center gap-3 rounded-lg border border-border/70 bg-surface-secondary/60 p-3'>
|
||||
<Card variant='secondary' className='flex-row items-center gap-3'>
|
||||
{poster && (
|
||||
<div className='relative h-16 w-12 flex-shrink-0 overflow-hidden rounded-md border border-border/70 bg-surface-secondary/60'>
|
||||
<Image
|
||||
|
|
@ -74,25 +75,25 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-base font-semibold text-foreground'>{title}</p>
|
||||
{sourceName ? (
|
||||
<span className='a2-data mt-1 inline-flex max-w-full items-center border border-border/70 px-2 py-1 text-[10px] text-muted'>
|
||||
<Chip size='sm' variant='soft' color='accent' className='mt-1 max-w-full'>
|
||||
{origin === 'live' ? (
|
||||
<Radio size={12} className='mr-1.5 text-accent' />
|
||||
) : null}
|
||||
<span className='truncate'>{sourceName}</span>
|
||||
</span>
|
||||
<Chip.Label>{sourceName}</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className='divide-y divide-border/10 overflow-hidden rounded-lg border border-border/70'>
|
||||
<Card variant='default' className='gap-1 p-1'>
|
||||
{actions.map((action) => (
|
||||
<AppButton
|
||||
key={action.id}
|
||||
variant='tertiary'
|
||||
fullWidth
|
||||
isDisabled={action.disabled}
|
||||
className='h-auto justify-start rounded-none px-3 py-4'
|
||||
className='h-auto justify-start'
|
||||
onPress={() => {
|
||||
action.onClick();
|
||||
onClose();
|
||||
|
|
@ -115,36 +116,30 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||
{action.label}
|
||||
</span>
|
||||
{action.id === 'play' && currentEpisode && totalEpisodes ? (
|
||||
<span className='a2-data text-xs text-muted'>
|
||||
{currentEpisode}/{totalEpisodes}
|
||||
</span>
|
||||
<Chip size='sm' variant='secondary'>
|
||||
<Chip.Label>{currentEpisode}/{totalEpisodes}</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
</AppButton>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isAggregate && sources && sources.length > 0 ? (
|
||||
<div className='rounded-lg border border-border/70 p-3'>
|
||||
<Card variant='secondary'>
|
||||
<div className='mb-3'>
|
||||
<h4 className='mb-1 text-sm font-medium text-foreground'>可用播放源</h4>
|
||||
<p className='a2-kicker'>共 {sources.length} 个播放源</p>
|
||||
<p className='text-sm text-muted'>共 {sources.length} 个播放源</p>
|
||||
</div>
|
||||
<AppScrollShadow className='max-h-32'>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source}
|
||||
className='flex min-w-0 items-center gap-2 border-l border-border/70 px-3 py-2'
|
||||
>
|
||||
<div className='h-1.5 w-1.5 flex-shrink-0 bg-accent/80' />
|
||||
<span className='truncate text-xs text-muted'>
|
||||
{source}
|
||||
</span>
|
||||
</div>
|
||||
<Chip key={source} size='sm' variant='secondary'>
|
||||
<Chip.Label>{source}</Chip.Label>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</AppScrollShadow>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
'use client';
|
||||
|
||||
import { Cat, Clover, Film, Home, Play, Radio, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button, Card, ScrollShadow } from '@heroui/react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
|
|
@ -16,6 +16,7 @@ interface MobileBottomNavProps {
|
|||
|
||||
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
|
||||
const currentActive = activePath ?? pathname;
|
||||
|
|
@ -95,8 +96,8 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className='fixed left-0 right-0 z-[600] overflow-hidden border-t border-border/70 bg-surface/90 shadow-[0_-12px_40px_-30px_rgb(15_23_42)] backdrop-blur-xl md:hidden'
|
||||
<Card
|
||||
className='fixed left-0 right-0 z-[600] rounded-none p-0 md:hidden'
|
||||
style={{
|
||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||
bottom: 0,
|
||||
|
|
@ -104,41 +105,31 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li
|
||||
key={item.href}
|
||||
className='flex-shrink-0'
|
||||
style={{ width: '20vw', minWidth: '20vw' }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
className='theme-transition relative flex h-14 w-full flex-col items-center justify-center gap-1 text-xs font-medium tracking-normal'
|
||||
<ScrollShadow orientation='horizontal' hideScrollBar>
|
||||
<ul className='flex items-center gap-1 px-2'>
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li
|
||||
key={item.href}
|
||||
className='flex-shrink-0'
|
||||
style={{ width: '20vw', minWidth: '20vw' }}
|
||||
>
|
||||
{active && <span className='absolute left-3 right-3 top-1 h-1 rounded-full bg-accent' />}
|
||||
<item.icon
|
||||
className={`h-5 w-5 ${active
|
||||
? 'text-accent'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted'
|
||||
}
|
||||
<Button
|
||||
variant={active ? 'primary' : 'ghost'}
|
||||
fullWidth
|
||||
className='h-14 flex-col gap-1'
|
||||
onPress={() => router.push(item.href)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<item.icon className='h-5 w-5' />
|
||||
<span className='text-xs'>{item.label}</span>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollShadow>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, Link as HeroLink } from '@heroui/react';
|
||||
|
||||
import { BackButton } from './BackButton';
|
||||
import { useSite } from './SiteProvider';
|
||||
|
|
@ -14,16 +14,16 @@ interface MobileHeaderProps {
|
|||
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||
const { siteName } = useSite();
|
||||
return (
|
||||
<header className='fixed left-0 right-0 top-0 z-[999] w-full border-b border-border/70 bg-surface/90 shadow-sm backdrop-blur-xl md:hidden'>
|
||||
<Card className='fixed left-0 right-0 top-0 z-[999] w-full rounded-none p-0 md:hidden'>
|
||||
<div className='flex h-12 items-center justify-between px-4'>
|
||||
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Link
|
||||
<HeroLink
|
||||
href='/search'
|
||||
className='a2-icon-button h-8 w-8 p-1.5'
|
||||
aria-label='搜索'
|
||||
>
|
||||
<svg
|
||||
className='w-full h-full'
|
||||
className='h-5 w-5'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
|
|
@ -36,7 +36,7 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
|||
d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</HeroLink>
|
||||
{showBackButton && <BackButton />}
|
||||
</div>
|
||||
|
||||
|
|
@ -49,14 +49,14 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
|||
|
||||
{/* 中间:Logo(绝对居中) */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Link
|
||||
<HeroLink
|
||||
href='/'
|
||||
className='theme-transition text-lg font-semibold tracking-normal text-foreground hover:text-accent'
|
||||
className='text-lg font-semibold'
|
||||
>
|
||||
{siteName}
|
||||
</Link>
|
||||
</HeroLink>
|
||||
</div>
|
||||
</header>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { Dropdown, Label } from '@heroui/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AppButton } from './ui/HeroPrimitives';
|
||||
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||
|
||||
interface MultiLevelOption {
|
||||
label: string;
|
||||
|
|
@ -367,103 +366,17 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
|||
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = (categoryKey: string) => {
|
||||
const category = categories.find((cat) => cat.key === categoryKey);
|
||||
if (!category) return '';
|
||||
|
||||
const value = values[categoryKey];
|
||||
|
||||
if (
|
||||
!value ||
|
||||
value === 'all' ||
|
||||
(categoryKey === 'sort' && value === 'T')
|
||||
) {
|
||||
return category.label;
|
||||
}
|
||||
const option = category.options.find((opt) => opt.value === value);
|
||||
return option?.label || category.label;
|
||||
};
|
||||
|
||||
// 检查是否为默认值
|
||||
const isDefaultValue = (categoryKey: string) => {
|
||||
const value = values[categoryKey];
|
||||
return (
|
||||
!value || value === 'all' || (categoryKey === 'sort' && value === 'T')
|
||||
);
|
||||
};
|
||||
|
||||
// 检查选项是否被选中
|
||||
const isOptionSelected = (categoryKey: string, optionValue: string) => {
|
||||
let value = values[categoryKey];
|
||||
if (value === undefined) {
|
||||
value = 'all';
|
||||
if (categoryKey === 'sort') {
|
||||
value = 'T';
|
||||
}
|
||||
}
|
||||
return value === optionValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='app-filter-dropdowns'>
|
||||
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||
{categories.map((category) => (
|
||||
<Dropdown key={category.key}>
|
||||
<AppButton
|
||||
aria-label={`${category.label}筛选`}
|
||||
variant='tertiary'
|
||||
className={`app-filter-trigger ${
|
||||
isDefaultValue(category.key)
|
||||
? ''
|
||||
: 'app-filter-trigger-active'
|
||||
}`}
|
||||
>
|
||||
<span>{getDisplayText(category.key)}</span>
|
||||
<svg
|
||||
className='ml-0.5 inline-block h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</AppButton>
|
||||
<Dropdown.Popover className='w-[min(92vw,600px)]'>
|
||||
<Dropdown.Menu
|
||||
aria-label={`${category.label}选项`}
|
||||
selectionMode='single'
|
||||
selectedKeys={
|
||||
new Set([
|
||||
values[category.key] ||
|
||||
(category.key === 'sort' ? 'T' : 'all'),
|
||||
])
|
||||
}
|
||||
onAction={(key) => handleOptionSelect(category.key, String(key))}
|
||||
className='grid grid-cols-3 gap-1 p-2 sm:grid-cols-4 sm:gap-2 md:grid-cols-5'
|
||||
>
|
||||
{category.options.map((option) => (
|
||||
<Dropdown.Item
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={
|
||||
isOptionSelected(category.key, option.value)
|
||||
? 'a2-selector-option-active'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Label>{option.label}</Label>
|
||||
<Dropdown.ItemIndicator />
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
<AppFilterSelect
|
||||
key={category.key}
|
||||
ariaLabel={`${category.label}选项`}
|
||||
label={category.label}
|
||||
options={category.options}
|
||||
value={values[category.key] || (category.key === 'sort' ? 'T' : 'all')}
|
||||
onChange={(value) => handleOptionSelect(category.key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@heroui/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface ScrollableRowProps {
|
||||
|
|
@ -126,12 +127,14 @@ export default function ScrollableRow({
|
|||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollLeftClick}
|
||||
className='theme-transition flex h-11 w-11 items-center justify-center rounded-2xl border border-border bg-overlay/95 text-muted shadow-xl backdrop-blur hover:border-accent/40 hover:text-accent'
|
||||
<Button
|
||||
isIconOnly
|
||||
variant='secondary'
|
||||
onPress={handleScrollLeftClick}
|
||||
aria-label='向左滚动'
|
||||
>
|
||||
<ChevronLeft className='h-5 w-5' />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -155,12 +158,14 @@ export default function ScrollableRow({
|
|||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollRightClick}
|
||||
className='theme-transition flex h-11 w-11 items-center justify-center rounded-2xl border border-border bg-overlay/95 text-muted shadow-xl backdrop-blur hover:border-accent/40 hover:text-accent'
|
||||
<Button
|
||||
isIconOnly
|
||||
variant='secondary'
|
||||
onPress={handleScrollRightClick}
|
||||
aria-label='向右滚动'
|
||||
>
|
||||
<ChevronRight className='h-5 w-5' />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { Dropdown, Label, ScrollShadow } from '@heroui/react';
|
||||
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AppButton } from './ui/HeroPrimitives';
|
||||
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||
|
||||
export type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder';
|
||||
|
||||
|
|
@ -32,6 +30,12 @@ const DEFAULTS: Record<SearchFilterKey, string> = {
|
|||
yearOrder: 'none',
|
||||
};
|
||||
|
||||
const YEAR_ORDER_OPTIONS: SearchFilterOption[] = [
|
||||
{ label: '默认排序', value: 'none' },
|
||||
{ label: '年份降序', value: 'desc' },
|
||||
{ label: '年份升序', value: 'asc' },
|
||||
];
|
||||
|
||||
const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, values, onChange }) => {
|
||||
const mergedValues = useMemo(() => {
|
||||
return {
|
||||
|
|
@ -48,109 +52,25 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
|
|||
onChange(newValues);
|
||||
};
|
||||
|
||||
const getDisplayText = (categoryKey: SearchFilterKey) => {
|
||||
const category = categories.find((cat) => cat.key === categoryKey);
|
||||
if (!category) return '';
|
||||
const value = mergedValues[categoryKey];
|
||||
if (!value || value === DEFAULTS[categoryKey]) return category.label;
|
||||
const option = category.options.find((opt) => opt.value === value);
|
||||
return option?.label || category.label;
|
||||
};
|
||||
|
||||
const isDefaultValue = (categoryKey: SearchFilterKey) => {
|
||||
const value = mergedValues[categoryKey];
|
||||
return !value || value === DEFAULTS[categoryKey];
|
||||
};
|
||||
|
||||
const isOptionSelected = (categoryKey: SearchFilterKey, optionValue: string) => {
|
||||
const value = mergedValues[categoryKey] ?? DEFAULTS[categoryKey];
|
||||
return value === optionValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='app-search-filter-bar'>
|
||||
{categories.map((category) => (
|
||||
<Dropdown key={category.key}>
|
||||
<AppButton
|
||||
variant='tertiary'
|
||||
className={`app-search-filter-trigger ${
|
||||
isDefaultValue(category.key) ? '' : 'app-search-filter-trigger-active'
|
||||
}`}
|
||||
>
|
||||
<span>{getDisplayText(category.key)}</span>
|
||||
<svg className='inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 9l-7 7-7-7' />
|
||||
</svg>
|
||||
</AppButton>
|
||||
<Dropdown.Popover className='app-search-filter-popover'>
|
||||
<ScrollShadow className='app-search-filter-scroll'>
|
||||
<Dropdown.Menu
|
||||
aria-label={`${category.label}筛选`}
|
||||
selectionMode='single'
|
||||
selectedKeys={new Set([mergedValues[category.key]])}
|
||||
onAction={(key) => handleOptionSelect(category.key, String(key))}
|
||||
className='app-search-filter-menu'
|
||||
>
|
||||
{category.options.map((option) => (
|
||||
<Dropdown.Item
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={
|
||||
isOptionSelected(category.key, option.value)
|
||||
? 'app-search-filter-option-active'
|
||||
: 'app-search-filter-option'
|
||||
}
|
||||
>
|
||||
<Label className='app-search-filter-option-label'>
|
||||
{option.label}
|
||||
</Label>
|
||||
<Dropdown.ItemIndicator />
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</ScrollShadow>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
))}
|
||||
{/* 通用年份排序切换按钮 */}
|
||||
<div className='relative'>
|
||||
<AppButton
|
||||
variant='ghost'
|
||||
onPress={() => {
|
||||
let next;
|
||||
switch (mergedValues.yearOrder) {
|
||||
case 'none':
|
||||
next = 'desc';
|
||||
break;
|
||||
case 'desc':
|
||||
next = 'asc';
|
||||
break;
|
||||
case 'asc':
|
||||
next = 'none';
|
||||
break;
|
||||
default:
|
||||
next = 'desc';
|
||||
}
|
||||
onChange({ ...mergedValues, yearOrder: next });
|
||||
}}
|
||||
className={`app-search-filter-trigger ${
|
||||
mergedValues.yearOrder === 'none'
|
||||
? ''
|
||||
: 'app-search-filter-trigger-active'
|
||||
}`}
|
||||
aria-label={`按年份${mergedValues.yearOrder === 'none' ? '排序' : mergedValues.yearOrder === 'desc' ? '降序' : '升序'}排序`}
|
||||
>
|
||||
<span>年份</span>
|
||||
{mergedValues.yearOrder === 'none' ? (
|
||||
<ArrowUpDown className='inline-block ml-1 w-4 h-4 sm:w-4 sm:h-4' />
|
||||
) : mergedValues.yearOrder === 'desc' ? (
|
||||
<ArrowDownWideNarrow className='inline-block ml-1 w-4 h-4 sm:w-4 sm:h-4' />
|
||||
) : (
|
||||
<ArrowUpNarrowWide className='inline-block ml-1 w-4 h-4 sm:w-4 sm:h-4' />
|
||||
)}
|
||||
</AppButton>
|
||||
</div>
|
||||
<div className='grid max-w-full grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4'>
|
||||
{categories.map((category) => (
|
||||
<AppFilterSelect
|
||||
key={category.key}
|
||||
ariaLabel={`${category.label}选项`}
|
||||
label={category.label}
|
||||
options={category.options}
|
||||
value={mergedValues[category.key]}
|
||||
onChange={(value) => handleOptionSelect(category.key, value)}
|
||||
/>
|
||||
))}
|
||||
<AppFilterSelect
|
||||
ariaLabel='排序选项'
|
||||
label='排序'
|
||||
options={YEAR_ORDER_OPTIONS}
|
||||
value={mergedValues.yearOrder}
|
||||
onChange={(value) => onChange({ ...mergedValues, yearOrder: value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Card, Label, ListBox, ScrollShadow } from '@heroui/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SearchSuggestionsProps {
|
||||
|
|
@ -146,21 +147,27 @@ export default function SearchSuggestions({
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
ref={containerRef}
|
||||
className='absolute top-full left-0 right-0 z-[600] mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
|
||||
className='absolute left-0 right-0 top-full z-[600] mt-1 p-0'
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`related-${suggestion.text}`}
|
||||
onClick={() => onSelect(suggestion.text)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3"
|
||||
<ScrollShadow className='max-h-80' hideScrollBar>
|
||||
<ListBox
|
||||
aria-label='搜索建议'
|
||||
selectionMode='none'
|
||||
onAction={(key) => onSelect(String(key))}
|
||||
>
|
||||
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
|
||||
{suggestion.text}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{suggestions.map((suggestion) => (
|
||||
<ListBox.Item
|
||||
key={`related-${suggestion.text}`}
|
||||
id={suggestion.text}
|
||||
textValue={suggestion.text}
|
||||
>
|
||||
<Label className='truncate'>{suggestion.text}</Label>
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</ScrollShadow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Skeleton } from '@heroui/react';
|
||||
|
||||
import { getShortDramaCategories, ShortDramaCategory } from '@/lib/shortdrama.client';
|
||||
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||
|
||||
interface ShortDramaSelectorProps {
|
||||
selectedCategory: string;
|
||||
|
|
@ -49,44 +50,31 @@ const ShortDramaSelector = ({
|
|||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
// 渲染胶囊式选择器
|
||||
const renderCapsuleSelector = () => {
|
||||
const renderCategorySelector = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='inline-flex rounded-full bg-surface-secondary p-1'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='mx-0.5 h-8 w-16 rounded-full bg-surface-tertiary animate-pulse'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppFilterTabs
|
||||
ariaLabel='短剧分类'
|
||||
selectedKey={selectedCategory}
|
||||
onSelectionChange={onCategoryChange}
|
||||
items={categories.map((category) => ({
|
||||
key: category.type_id.toString(),
|
||||
<AppFilterSelect
|
||||
ariaLabel='短剧分类选项'
|
||||
label='分类'
|
||||
options={categories.map((category) => ({
|
||||
value: category.type_id.toString(),
|
||||
label: category.type_name,
|
||||
}))}
|
||||
value={selectedCategory}
|
||||
onChange={onCategoryChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 分类选择 */}
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>
|
||||
分类
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
{renderCapsuleSelector()}
|
||||
</div>
|
||||
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||
{renderCategorySelector()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
Tv,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
useLayoutEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { useSite } from './SiteProvider';
|
||||
|
||||
|
|
@ -50,10 +51,11 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
|||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className='theme-transition flex h-12 w-12 cursor-pointer items-center justify-center hover:opacity-80'
|
||||
title='点击展开侧边栏'
|
||||
<Button
|
||||
onPress={onClick}
|
||||
isIconOnly
|
||||
variant='ghost'
|
||||
aria-label='点击展开侧边栏'
|
||||
>
|
||||
<Image
|
||||
src='/logo.png'
|
||||
|
|
@ -62,28 +64,26 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
|||
height={32}
|
||||
className='rounded-lg'
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
<HeroLink
|
||||
href='/'
|
||||
className='theme-transition flex h-16 items-center justify-center select-none hover:opacity-80'
|
||||
className='flex h-16 items-center justify-center gap-2'
|
||||
>
|
||||
<div className='flex items-center gap'>
|
||||
<Image
|
||||
src='/logo.png'
|
||||
alt={siteName}
|
||||
width={40}
|
||||
height={40}
|
||||
className='rounded-lg'
|
||||
/>
|
||||
<span className='text-xl font-semibold tracking-normal text-foreground'>
|
||||
{siteName}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Image
|
||||
src='/logo.png'
|
||||
alt={siteName}
|
||||
width={40}
|
||||
height={40}
|
||||
className='rounded-lg'
|
||||
/>
|
||||
<span className='text-xl font-semibold'>
|
||||
{siteName}
|
||||
</span>
|
||||
</HeroLink>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -217,25 +217,60 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const getNavClasses = (isActive: boolean) =>
|
||||
`group flex min-h-[42px] items-center gap-3 rounded-xl border px-3 py-2 text-sm font-medium tracking-normal transition-all duration-200 ${
|
||||
isActive
|
||||
? 'border-accent/25 bg-accent/10 text-accent shadow-sm'
|
||||
: 'border-transparent text-muted hover:border-border hover:bg-surface-secondary hover:text-foreground'
|
||||
}`;
|
||||
const renderNavButton = ({
|
||||
href,
|
||||
label,
|
||||
icon: Icon,
|
||||
isActive,
|
||||
onPress,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
isActive: boolean;
|
||||
onPress?: () => void;
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={label}
|
||||
variant={isActive ? 'primary' : 'ghost'}
|
||||
fullWidth={!isCollapsed}
|
||||
isIconOnly={isCollapsed}
|
||||
className={isCollapsed ? '' : 'justify-start'}
|
||||
onPress={() => {
|
||||
setActive(href);
|
||||
if (onPress) {
|
||||
onPress();
|
||||
} else {
|
||||
router.push(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
{!isCollapsed ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return isCollapsed ? (
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>{button}</Tooltip.Trigger>
|
||||
<Tooltip.Content placement='right'>{label}</Tooltip.Content>
|
||||
</Tooltip>
|
||||
) : button;
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
{/* 在移动端隐藏侧边栏 */}
|
||||
<div className='hidden md:flex'>
|
||||
<aside
|
||||
<Card
|
||||
data-sidebar
|
||||
className={`fixed left-0 top-0 z-10 h-screen border-r border-border/70 bg-surface/90 shadow-sm backdrop-blur-xl transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'
|
||||
className={`fixed left-0 top-0 z-10 h-screen rounded-none p-0 transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className='relative h-16 border-b border-border/70'>
|
||||
<div className='relative h-16'>
|
||||
<div className='absolute inset-0 flex items-center justify-center transition-all duration-200'>
|
||||
{isCollapsed ? (
|
||||
<Logo isCollapsed={true} onClick={handleToggle} />
|
||||
|
|
@ -246,57 +281,41 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
)}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className='a2-icon-button absolute right-3 top-1/2 z-10 -translate-y-1/2'
|
||||
title='收起侧边栏'
|
||||
<Button
|
||||
onPress={handleToggle}
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
className='absolute right-3 top-1/2 z-10 -translate-y-1/2'
|
||||
aria-label='收起侧边栏'
|
||||
>
|
||||
<Menu className='h-4 w-4' />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* 首页和搜索导航 */}
|
||||
<nav className='mt-6 space-y-1 px-3'>
|
||||
<Link
|
||||
href='/'
|
||||
onClick={() => setActive('/')}
|
||||
data-active={active === '/'}
|
||||
className={getNavClasses(active === '/')}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Home className='h-4 w-4' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
||||
首页
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href='/search'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearchClick();
|
||||
setActive('/search');
|
||||
}}
|
||||
data-active={active === '/search'}
|
||||
className={getNavClasses(active === '/search')}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Search className='h-4 w-4' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
||||
搜索
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{renderNavButton({
|
||||
href: '/',
|
||||
label: '首页',
|
||||
icon: Home,
|
||||
isActive: active === '/',
|
||||
})}
|
||||
{renderNavButton({
|
||||
href: '/search',
|
||||
label: '搜索',
|
||||
icon: Search,
|
||||
isActive: active === '/search',
|
||||
onPress: handleSearchClick,
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='flex-1 overflow-y-auto px-3 pt-6'>
|
||||
<div className='space-y-1 border-t border-border/70 pt-4'>
|
||||
<Separator className='mb-4' />
|
||||
<div className='space-y-1'>
|
||||
{menuItems.map((item) => {
|
||||
// 检查当前路径是否匹配这个菜单项
|
||||
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||
|
|
@ -312,22 +331,14 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
(item.href === '/shortdrama' && decodedActive.startsWith('/shortdrama'));
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={isActive}
|
||||
className={getNavClasses(isActive)}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Icon className='h-4 w-4' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<div key={item.label}>
|
||||
{renderNavButton({
|
||||
href: item.href,
|
||||
label: item.label,
|
||||
icon: Icon,
|
||||
isActive,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -335,40 +346,49 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
|
||||
{/* 致谢信息 */}
|
||||
<div className='px-3 pb-5'>
|
||||
<div className='border-t border-border/70 pt-4'>
|
||||
<Separator className='mb-4' />
|
||||
<div>
|
||||
{!isCollapsed ? (
|
||||
<div className='px-2 text-center text-xs leading-relaxed text-muted'>
|
||||
<span>本项目基于 </span>
|
||||
<button
|
||||
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
|
||||
className='theme-transition font-medium text-accent hover:text-accent-strong'
|
||||
<HeroLink
|
||||
href='https://github.com/MoonTechLab/LunaTV'
|
||||
target='_blank'
|
||||
>
|
||||
MoonTV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
|
||||
className='theme-transition ml-1 text-accent hover:text-accent-strong'
|
||||
title='访问 MoonTV 项目'
|
||||
</HeroLink>
|
||||
<HeroLink
|
||||
href='https://github.com/MoonTechLab/LunaTV'
|
||||
target='_blank'
|
||||
aria-label='访问 MoonTV 项目'
|
||||
className='ml-1'
|
||||
>
|
||||
<ExternalLink className='h-3 w-3 inline' />
|
||||
</button>
|
||||
</HeroLink>
|
||||
<span> 的二次开发</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex justify-center'>
|
||||
<button
|
||||
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
|
||||
className='theme-transition p-1 text-accent hover:text-accent-strong'
|
||||
title='基于 MoonTV 的二次开发'
|
||||
>
|
||||
<ExternalLink className='h-4 w-4' />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>
|
||||
<HeroLink
|
||||
href='https://github.com/MoonTechLab/LunaTV'
|
||||
target='_blank'
|
||||
aria-label='基于 MoonTV 的二次开发'
|
||||
>
|
||||
<ExternalLink className='h-4 w-4' />
|
||||
</HeroLink>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement='right'>
|
||||
基于 MoonTV 的二次开发
|
||||
</Tooltip.Content>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Card>
|
||||
<div
|
||||
className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Palette, Eye, Check } from 'lucide-react';
|
||||
import { Alert, Button, Card, Chip, TextArea } from '@heroui/react';
|
||||
|
||||
// CSS模板配置
|
||||
const cssTemplates = [
|
||||
|
|
@ -9,10 +10,10 @@ const cssTemplates = [
|
|||
id: 'gradient-bg',
|
||||
name: '渐变背景',
|
||||
description: '为页面添加漂亮的渐变背景',
|
||||
preview: 'body {\n background: linear-gradient(135deg, \n #667eea 0%, #764ba2 100%);\n}',
|
||||
preview: 'body {\n background: linear-gradient(135deg, \n #18181b 0%, #be123c 100%);\n}',
|
||||
css: `/* 渐变背景主题 */
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: linear-gradient(135deg, #18181b 0%, #be123c 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
|
|
@ -67,26 +68,26 @@ body::before {
|
|||
id: 'sidebar-glow',
|
||||
name: '发光侧边栏',
|
||||
description: '为侧边栏添加发光效果',
|
||||
preview: '.sidebar {\n box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);\n border-radius: 15px;\n}',
|
||||
preview: '.sidebar {\n box-shadow: 0 0 20px rgba(225, 29, 72, 0.3);\n border-radius: 15px;\n}',
|
||||
css: `/* 发光侧边栏效果 */
|
||||
.sidebar, [data-sidebar] {
|
||||
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
|
||||
box-shadow: 0 0 20px rgba(225, 29, 72, 0.3);
|
||||
border-radius: 15px;
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border: 1px solid rgba(225, 29, 72, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 侧边栏项目悬停效果 */
|
||||
.sidebar a:hover, [data-sidebar] a:hover {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
background: rgba(225, 29, 72, 0.1);
|
||||
transform: translateX(5px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 活动项目发光 */
|
||||
.sidebar [data-active="true"], [data-sidebar] [data-active="true"] {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
box-shadow: inset 0 0 10px rgba(14, 165, 233, 0.2);
|
||||
background: rgba(225, 29, 72, 0.15);
|
||||
box-shadow: inset 0 0 10px rgba(225, 29, 72, 0.2);
|
||||
border-radius: 8px;
|
||||
}`
|
||||
},
|
||||
|
|
@ -134,9 +135,9 @@ body::before {
|
|||
css: `/* 毛玻璃主题 */
|
||||
body {
|
||||
background: linear-gradient(45deg,
|
||||
rgba(59, 130, 246, 0.1) 0%,
|
||||
rgba(147, 51, 234, 0.1) 50%,
|
||||
rgba(236, 72, 153, 0.1) 100%);
|
||||
rgba(24, 24, 27, 0.1) 0%,
|
||||
rgba(225, 29, 72, 0.1) 50%,
|
||||
rgba(244, 63, 94, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* 所有面板使用毛玻璃效果 */
|
||||
|
|
@ -212,13 +213,13 @@ const themes = [
|
|||
{
|
||||
id: 'default',
|
||||
name: '默认主题',
|
||||
description: '现代蓝色主题,清新优雅',
|
||||
description: '石墨玫瑰,冷静高级',
|
||||
preview: {
|
||||
bg: '#ffffff',
|
||||
surface: '#f9fafb',
|
||||
accent: '#0ea5e9',
|
||||
text: '#111827',
|
||||
border: '#e5e7eb'
|
||||
bg: '#fafafa',
|
||||
surface: '#ffffff',
|
||||
accent: '#e11d48',
|
||||
text: '#18181b',
|
||||
border: '#d4d4d8'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -560,51 +561,49 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
|||
<div className="space-y-6">
|
||||
{/* 管理员控制面板 */}
|
||||
{isAdmin && globalThemeConfig && (
|
||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
全站主题设置
|
||||
</h3>
|
||||
<Card variant='default' className='p-4'>
|
||||
<Card.Header>
|
||||
<Card.Title className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
全站主题设置
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
|
||||
<div className="text-sm text-theme-text">
|
||||
<Card variant='secondary' className='p-3'>
|
||||
<div className="text-sm">
|
||||
<strong>当前全站配置:</strong>
|
||||
</div>
|
||||
<div className="text-xs text-theme-text-secondary mt-1">
|
||||
<div className="text-xs text-muted mt-1">
|
||||
默认主题: {themes.find(t => t.id === globalThemeConfig.defaultTheme)?.name || globalThemeConfig.defaultTheme}
|
||||
{globalThemeConfig.customCSS && ' | 包含自定义CSS'}
|
||||
{!globalThemeConfig.allowUserCustomization && ' | 禁止用户自定义'}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-700">
|
||||
<div className="flex items-center gap-2 text-blue-800 dark:text-blue-200">
|
||||
<span className="text-sm font-medium">ℹ️ 全站主题</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
<Alert status='accent'>
|
||||
<Alert.Title>全站主题</Alert.Title>
|
||||
<Alert.Description>
|
||||
在此设置的主题配置将应用到整个网站,影响所有用户的默认体验
|
||||
</p>
|
||||
</div>
|
||||
</Alert.Description>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 主题选择器 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
全站主题选择
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{themes.map((theme) => (
|
||||
<div
|
||||
<Card
|
||||
key={theme.id}
|
||||
className={`relative p-4 border-2 rounded-xl transition-all ${currentTheme === theme.id
|
||||
? 'border-theme-accent bg-theme-accent/5'
|
||||
: 'border-theme-border bg-theme-surface'
|
||||
} ${isAdmin ? 'cursor-pointer hover:border-theme-accent/50' : 'cursor-not-allowed opacity-60'}`}
|
||||
variant={currentTheme === theme.id ? 'secondary' : 'default'}
|
||||
className={`relative p-4 ${isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
|
||||
onClick={() => isAdmin && handleThemeChange(theme.id)}
|
||||
>
|
||||
{/* 主题预览 */}
|
||||
|
|
@ -615,83 +614,86 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
|||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.accent }} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='tertiary'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isAdmin) handleThemePreview(theme.id);
|
||||
}}
|
||||
className={`p-1 transition-colors ${isAdmin ? 'text-theme-text-secondary hover:text-theme-accent' : 'text-theme-text-secondary opacity-50 cursor-not-allowed'}`}
|
||||
title={isAdmin ? "预览主题" : "仅管理员可预览"}
|
||||
disabled={previewMode || !isAdmin}
|
||||
aria-label={isAdmin ? "预览主题" : "仅管理员可预览"}
|
||||
isDisabled={previewMode || !isAdmin}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
{currentTheme === theme.id && (
|
||||
<Check className="h-4 w-4 text-theme-accent" />
|
||||
<Chip variant='primary' size='sm'>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-theme-text">{theme.name}</h4>
|
||||
<p className="text-sm text-theme-text-secondary mt-1">{theme.description}</p>
|
||||
</div>
|
||||
<h4 className="font-medium">{theme.name}</h4>
|
||||
<p className="text-sm text-muted mt-1">{theme.description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{previewMode && (
|
||||
<div className="mt-4 p-3 bg-theme-info/10 border border-theme-info/20 rounded-lg">
|
||||
<p className="text-sm text-theme-info">正在预览主题,3秒后将自动恢复...</p>
|
||||
</div>
|
||||
<Alert status='accent' className='mt-4'>
|
||||
正在预览主题,3秒后将自动恢复...
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 自定义CSS编辑器 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-theme-text flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
全站自定义样式
|
||||
</h3>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
onClick={() => setShowCustomEditor(!showCustomEditor)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-surface border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors"
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onPress={() => setShowCustomEditor(!showCustomEditor)}
|
||||
>
|
||||
{showCustomEditor ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{showCustomEditor ? '收起编辑器' : '展开编辑器'}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-sm text-theme-text-secondary">
|
||||
<div className="text-sm text-muted">
|
||||
仅管理员可编辑
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-700 mb-4">
|
||||
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||||
<span className="text-sm font-medium">⚠️ 权限限制</span>
|
||||
</div>
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
<Alert status='warning' className='mb-4'>
|
||||
<Alert.Title>权限限制</Alert.Title>
|
||||
<Alert.Description>
|
||||
您当前没有权限修改全站主题设置,请联系管理员。
|
||||
</p>
|
||||
</div>
|
||||
</Alert.Description>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isAdmin && showCustomEditor && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-theme-text-secondary bg-theme-surface p-3 rounded-lg border border-theme-border">
|
||||
<Card variant='secondary' className='p-3 text-sm text-muted'>
|
||||
<p className="mb-2">💡 <strong>使用提示:</strong></p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
<li>• 使用CSS变量覆盖主题颜色:<code className="bg-theme-bg px-1 rounded">--color-theme-accent: 255, 0, 0;</code></li>
|
||||
<li>• 使用Tailwind类名:<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||
<li>• 自定义组件样式:<code className="bg-theme-bg px-1 rounded">{`.admin-panel { border-radius: 20px; }`}</code></li>
|
||||
<li>• 使用CSS变量覆盖主题颜色:<code>--color-theme-accent: 255, 0, 0;</code></li>
|
||||
<li>• 使用Tailwind类名:<code>{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||
<li>• 自定义组件样式:<code>{`.admin-panel { border-radius: 20px; }`}</code></li>
|
||||
<li>• 修改会实时生效,请谨慎使用</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
<TextArea
|
||||
value={customCSS}
|
||||
onChange={(e) => setCustomCSS(e.target.value)}
|
||||
placeholder="/* 在此输入您的自定义CSS */
|
||||
|
|
@ -704,27 +706,22 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
|||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 使用Tailwind类名 */
|
||||
.custom-button {
|
||||
@apply bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-xl;
|
||||
}"
|
||||
className="w-full h-64 p-4 bg-theme-surface border border-theme-border rounded-lg text-sm font-mono text-theme-text placeholder-theme-text-secondary resize-none focus:outline-none focus:ring-2 focus:ring-theme-accent/50"
|
||||
/* 使用Tailwind类名 */
|
||||
.custom-button {
|
||||
@apply bg-accent text-accent-foreground px-6 py-3;
|
||||
}"
|
||||
className="h-64 w-full font-mono text-sm"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCustomCSSApply}
|
||||
className="px-4 py-2 bg-theme-accent text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Button variant='primary' onPress={handleCustomCSSApply}>
|
||||
应用样式
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCustomCSSReset}
|
||||
className="px-4 py-2 bg-theme-surface border border-theme-border text-theme-text rounded-lg hover:bg-theme-accent/5 transition-colors"
|
||||
>
|
||||
</Button>
|
||||
<Button variant='secondary' onPress={handleCustomCSSReset}>
|
||||
重置样式
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -732,68 +729,71 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
|||
|
||||
{/* CSS 模板库 */}
|
||||
{isAdmin && (
|
||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-theme-text mb-3 flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
🎨 全站样式模板库
|
||||
</h4>
|
||||
<p className="text-sm text-theme-text-secondary mb-4">选择预设模板快速应用炫酷效果到全站,也可以在此基础上进行自定义修改</p>
|
||||
<Card variant='default' className='p-4'>
|
||||
<Card.Header>
|
||||
<Card.Title className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
全站样式模板库
|
||||
</Card.Title>
|
||||
<Card.Description>选择预设模板快速应用到全站,也可以在此基础上进行自定义修改</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cssTemplates.map((template) => (
|
||||
<div key={template.id} className="p-3 border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors group">
|
||||
<Card key={template.id} variant='secondary' className='p-3'>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="text-sm font-medium text-theme-text">{template.name}</h5>
|
||||
<button
|
||||
onClick={() => handleApplyTemplate(template.css, template.name)}
|
||||
className="text-xs px-2 py-1 bg-theme-accent text-white rounded hover:opacity-90 transition-opacity opacity-0 group-hover:opacity-100"
|
||||
<h5 className="text-sm font-medium">{template.name}</h5>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='primary'
|
||||
onPress={() => handleApplyTemplate(template.css, template.name)}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-theme-text-secondary mb-2">{template.description}</p>
|
||||
<div className="text-xs bg-theme-bg rounded p-2 max-h-16 overflow-y-auto">
|
||||
<code className="whitespace-pre-wrap text-theme-text-secondary">{template.preview}</code>
|
||||
<p className="text-xs text-muted mb-2">{template.description}</p>
|
||||
<div className="text-xs max-h-16 overflow-y-auto">
|
||||
<code className="whitespace-pre-wrap text-muted">{template.preview}</code>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
|
||||
<p className="text-xs text-theme-text-secondary">
|
||||
<Alert status='accent' className='mt-4'>
|
||||
<Alert.Description>
|
||||
<strong>💡 使用提示:</strong> 点击模板的"应用"按钮将代码复制到自定义CSS编辑器,然后可以在此基础上进行修改。记得点击"应用样式"按钮生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert.Description>
|
||||
</Alert>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
||||
<h4 className="font-medium text-theme-text mb-2">📖 全站主题定制指南</h4>
|
||||
<div className="text-sm text-theme-text-secondary space-y-2">
|
||||
<Card variant='default' className='p-4'>
|
||||
<Card.Title>全站主题定制指南</Card.Title>
|
||||
<div className="text-sm text-muted space-y-2 mt-2">
|
||||
<p><strong>内置主题:</strong>{isAdmin ? '选择预设主题即可一键切换全站整体风格' : '由管理员设置的全站预设主题'}</p>
|
||||
{isAdmin && <p><strong>自定义CSS:</strong>通过CSS变量或直接样式实现全站个性化定制</p>}
|
||||
{isAdmin && <p><strong>样式模板:</strong>使用预设模板快速实现炫酷效果</p>}
|
||||
<p><strong>主题变量:</strong></p>
|
||||
<ul className="text-xs space-y-1 ml-4 mt-1">
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-bg</code> - 背景色</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-surface</code> - 卡片背景</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-accent</code> - 主题色</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-text</code> - 主文本色</li>
|
||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-border</code> - 边框色</li>
|
||||
<li>• <code>--color-theme-bg</code> - 背景色</li>
|
||||
<li>• <code>--color-theme-surface</code> - 卡片背景</li>
|
||||
<li>• <code>--color-theme-accent</code> - 主题色</li>
|
||||
<li>• <code>--color-theme-text</code> - 主文本色</li>
|
||||
<li>• <code>--color-theme-border</code> - 边框色</li>
|
||||
</ul>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<p><strong>常用技巧:</strong></p>
|
||||
<ul className="text-xs space-y-1 ml-4 mt-1">
|
||||
<li>• 修改背景:<code className="bg-theme-bg px-1 rounded">{`body { background: linear-gradient(...); }`}</code></li>
|
||||
<li>• 使用Tailwind:<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||
<li>• 修改背景:<code>{`body { background: linear-gradient(...); }`}</code></li>
|
||||
<li>• 使用Tailwind:<code>{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||
<li>• 组合多个模板效果获得独特样式</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MessageCircle, Moon, Sun } from 'lucide-react';
|
|||
import { usePathname } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Badge } from '@heroui/react';
|
||||
import { ChatModal } from './ChatModal';
|
||||
import { AppIconButton } from './ui/HeroPrimitives';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
|
|
@ -105,28 +106,28 @@ export function ThemeToggle() {
|
|||
{!isLoginPage && (
|
||||
<AppIconButton
|
||||
onPress={() => setIsChatModalOpen(true)}
|
||||
className={`a2-icon-button relative ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
|
||||
size={isMobile ? 'sm' : 'md'}
|
||||
aria-label='Open chat'
|
||||
>
|
||||
<MessageCircle className='w-full h-full' />
|
||||
{messageCount > 0 && (
|
||||
<span className={`absolute ${isMobile ? '-right-0.5 -top-0.5 h-4 w-4 text-[10px]' : '-right-1 -top-1 h-5 w-5 text-[10px]'} flex items-center justify-center border border-border/70 bg-accent text-accent-foreground`}>
|
||||
{messageCount > 99 ? '99+' : messageCount}
|
||||
</span>
|
||||
<Badge size='sm' color='accent' variant='primary' className='absolute -right-1 -top-1'>
|
||||
<Badge.Label>{messageCount > 99 ? '99+' : messageCount}</Badge.Label>
|
||||
</Badge>
|
||||
)}
|
||||
<MessageCircle className='h-5 w-5' />
|
||||
</AppIconButton>
|
||||
)}
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<AppIconButton
|
||||
onPress={toggleTheme}
|
||||
className={`a2-icon-button ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
|
||||
size={isMobile ? 'sm' : 'md'}
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className='w-full h-full' />
|
||||
<Sun className='h-5 w-5' />
|
||||
) : (
|
||||
<Moon className='w-full h-full' />
|
||||
<Moon className='h-5 w-5' />
|
||||
)}
|
||||
</AppIconButton>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,15 +10,16 @@ import {
|
|||
Download,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Alert, Button, Card, Chip, Link as HeroLink } from '@heroui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { changelog, ChangelogEntry } from '@/lib/changelog';
|
||||
import { CURRENT_VERSION } from '@/lib/version';
|
||||
import { compareVersions, UpdateStatus } from '@/lib/version_check';
|
||||
|
||||
import { AppDialog } from './ui/HeroPrimitives';
|
||||
|
||||
interface VersionPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -193,34 +194,27 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
key={entry.version}
|
||||
className={`p-4 rounded-lg border ${isCurrentVersion
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: isUpdate
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
className='p-4'
|
||||
>
|
||||
{/* 版本标题 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
<h4 className='text-lg font-semibold text-foreground'>
|
||||
v{entry.version}
|
||||
</h4>
|
||||
{isCurrentVersion && (
|
||||
<span className='px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
|
||||
当前版本
|
||||
</span>
|
||||
<Chip size='sm' variant='primary'>当前版本</Chip>
|
||||
)}
|
||||
{isUpdate && (
|
||||
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
|
||||
<Chip size='sm' variant='secondary'>
|
||||
<Download className='w-3 h-3' />
|
||||
可更新
|
||||
</span>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||
<div className='flex items-center gap-2 text-sm text-muted'>
|
||||
{entry.date}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -229,7 +223,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
<div className='space-y-3'>
|
||||
{entry.added.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-green-700 dark:text-green-400 mb-2 flex items-center gap-1'>
|
||||
<h5 className='mb-2 flex items-center gap-1 text-sm font-medium text-success'>
|
||||
<Plus className='w-4 h-4' />
|
||||
新增功能
|
||||
</h5>
|
||||
|
|
@ -237,9 +231,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
{entry.added.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
className='text-sm text-muted'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -249,7 +242,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
|
||||
{entry.changed.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-blue-700 dark:text-blue-400 mb-2 flex items-center gap-1'>
|
||||
<h5 className='mb-2 flex items-center gap-1 text-sm font-medium text-accent'>
|
||||
<RefreshCw className='w-4 h-4' />
|
||||
功能改进
|
||||
</h5>
|
||||
|
|
@ -257,9 +250,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
{entry.changed.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
className='text-sm text-muted'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -269,7 +261,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
|
||||
{entry.fixed.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
|
||||
<h5 className='mb-2 flex items-center gap-1 text-sm font-medium text-danger'>
|
||||
<Bug className='w-4 h-4' />
|
||||
问题修复
|
||||
</h5>
|
||||
|
|
@ -277,9 +269,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
{entry.fixed.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
className='text-sm text-muted'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -287,289 +278,110 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 版本面板内容
|
||||
const versionPanelContent = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={onClose}
|
||||
onTouchMove={(e) => {
|
||||
// 只阻止滚动,允许其他触摸事件
|
||||
e.preventDefault();
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
// 阻止滚轮滚动
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
if (!mounted) return null;
|
||||
|
||||
{/* 版本面板 */}
|
||||
<div
|
||||
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
|
||||
onTouchMove={(e) => {
|
||||
// 允许版本面板内部滚动,阻止事件冒泡到外层
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'auto', // 允许面板内的正常触摸操作
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between p-3 sm:p-6 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<h3 className='text-lg sm:text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
版本信息
|
||||
</h3>
|
||||
<div className='flex flex-wrap items-center gap-1 sm:gap-2'>
|
||||
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
|
||||
v{CURRENT_VERSION}
|
||||
</span>
|
||||
{hasUpdate && (
|
||||
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
|
||||
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
|
||||
<span className='hidden sm:inline'>有新版本可用</span>
|
||||
<span className='sm:hidden'>可更新</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-6 h-6 sm:w-8 sm:h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
const remoteUpdates = remoteChangelog
|
||||
.filter((entry) => {
|
||||
const localVersions = changelog.map((local) => local.version);
|
||||
return !localVersions.includes(entry.version);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.date);
|
||||
const dateB = new Date(b.date);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className='p-3 sm:p-6 overflow-y-auto max-h-[calc(95vh-140px)] sm:max-h-[calc(90vh-120px)]'>
|
||||
<div className='space-y-3 sm:space-y-6'>
|
||||
{/* 远程更新信息 */}
|
||||
{hasUpdate && (
|
||||
<div className='bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-yellow-100 dark:bg-yellow-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
|
||||
<Download className='w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-sm sm:text-base font-semibold text-yellow-800 dark:text-yellow-200'>
|
||||
发现新版本
|
||||
</h4>
|
||||
<p className='text-xs sm:text-sm text-yellow-700 dark:text-yellow-300 break-all'>
|
||||
v{CURRENT_VERSION} → v{latestVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/djteang/OrangeTV'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
|
||||
>
|
||||
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
|
||||
前往仓库
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<AppDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
title='版本信息'
|
||||
description={`当前版本 v${CURRENT_VERSION}`}
|
||||
size='lg'
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{hasUpdate ? (
|
||||
<Alert status='warning'>
|
||||
<Alert.Indicator>
|
||||
<Download className='h-4 w-4' />
|
||||
</Alert.Indicator>
|
||||
<Alert.Content>
|
||||
<Alert.Title>发现新版本</Alert.Title>
|
||||
<Alert.Description>
|
||||
v{CURRENT_VERSION} {'->'} v{latestVersion}
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert status='success'>
|
||||
<Alert.Indicator>
|
||||
<CheckCircle className='h-4 w-4' />
|
||||
</Alert.Indicator>
|
||||
<Alert.Content>
|
||||
<Alert.Title>当前为最新版本</Alert.Title>
|
||||
<Alert.Description>已是最新版本 v{CURRENT_VERSION}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 当前为最新版本信息 */}
|
||||
{!hasUpdate && (
|
||||
<div className='bg-gradient-to-r from-blue-50 to-emerald-50 dark:from-blue-900/20 dark:to-emerald-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 sm:p-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2 sm:gap-3'>
|
||||
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 dark:bg-blue-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
|
||||
<CheckCircle className='w-4 h-4 sm:w-5 sm:h-5 text-blue-600 dark:text-blue-400' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-sm sm:text-base font-semibold text-blue-800 dark:text-blue-200'>
|
||||
当前为最新版本
|
||||
</h4>
|
||||
<p className='text-xs sm:text-sm text-blue-700 dark:text-blue-300 break-all'>
|
||||
已是最新版本 v{CURRENT_VERSION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/djteang/OrangeTV'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
|
||||
>
|
||||
<CheckCircle className='w-3 h-3 sm:w-4 sm:h-4' />
|
||||
前往仓库
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HeroLink href='https://github.com/djteang/OrangeTV' target='_blank'>
|
||||
前往仓库
|
||||
</HeroLink>
|
||||
|
||||
{/* 远程可更新内容 */}
|
||||
{hasUpdate && (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-3'>
|
||||
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
|
||||
<Download className='w-5 h-5 text-yellow-500' />
|
||||
远程更新内容
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowRemoteContent(!showRemoteContent)}
|
||||
className='inline-flex items-center justify-center gap-2 px-3 py-1.5 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 dark:bg-yellow-800/30 dark:hover:bg-yellow-800/50 dark:text-yellow-200 rounded-lg transition-colors text-sm w-full sm:w-auto'
|
||||
>
|
||||
{showRemoteContent ? (
|
||||
<>
|
||||
<ChevronUp className='w-4 h-4' />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className='w-4 h-4' />
|
||||
查看更新内容
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRemoteContent && remoteChangelog.length > 0 && (
|
||||
<div className='space-y-4'>
|
||||
{remoteChangelog
|
||||
.filter((entry) => {
|
||||
// 找到第一个本地版本,过滤掉本地已有的版本
|
||||
const localVersions = changelog.map(
|
||||
(local) => local.version
|
||||
);
|
||||
return !localVersions.includes(entry.version);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 按日期排序,确保最新的版本在前面显示
|
||||
const dateA = new Date(a.date);
|
||||
const dateB = new Date(b.date);
|
||||
return dateB.getTime() - dateA.getTime(); // 降序排列,最新的在前
|
||||
})
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-lg border ${entry.version === latestVersion
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
v{entry.version}
|
||||
</h4>
|
||||
{entry.version === latestVersion && (
|
||||
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
|
||||
远程最新
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{entry.date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.added && entry.added.length > 0 && (
|
||||
<div className='mb-3'>
|
||||
<h5 className='text-sm font-medium text-green-600 dark:text-green-400 mb-2 flex items-center gap-1'>
|
||||
<Plus className='w-4 h-4' />
|
||||
新增功能
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.added.map((item, itemIndex) => (
|
||||
<li
|
||||
key={itemIndex}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-green-400 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.changed && entry.changed.length > 0 && (
|
||||
<div className='mb-3'>
|
||||
<h5 className='text-sm font-medium text-blue-600 dark:text-blue-400 mb-2 flex items-center gap-1'>
|
||||
<RefreshCw className='w-4 h-4' />
|
||||
功能改进
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.changed.map((item, itemIndex) => (
|
||||
<li
|
||||
key={itemIndex}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-blue-400 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.fixed && entry.fixed.length > 0 && (
|
||||
<div>
|
||||
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
|
||||
<Bug className='w-4 h-4' />
|
||||
问题修复
|
||||
</h5>
|
||||
<ul className='space-y-1'>
|
||||
{entry.fixed.map((item, itemIndex) => (
|
||||
<li
|
||||
key={itemIndex}
|
||||
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
|
||||
>
|
||||
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变更日志标题 */}
|
||||
<div className='border-b border-gray-200 dark:border-gray-700 pb-4'>
|
||||
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 pb-3 sm:pb-4'>
|
||||
变更日志
|
||||
{hasUpdate ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col justify-between gap-3 sm:flex-row sm:items-center'>
|
||||
<h4 className='flex items-center gap-2 text-lg font-semibold text-foreground'>
|
||||
<Download className='h-5 w-5 text-warning' />
|
||||
远程更新内容
|
||||
</h4>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onPress={() => setShowRemoteContent(!showRemoteContent)}
|
||||
>
|
||||
{showRemoteContent ? (
|
||||
<>
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
查看更新内容
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showRemoteContent && remoteUpdates.length > 0 ? (
|
||||
<div className='space-y-4'>
|
||||
{/* 本地变更日志 */}
|
||||
{changelog.map((entry) =>
|
||||
renderChangelogEntry(
|
||||
entry,
|
||||
entry.version === CURRENT_VERSION,
|
||||
false
|
||||
)
|
||||
{remoteUpdates.map((entry) =>
|
||||
renderChangelogEntry(entry, false, true)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='text-lg font-semibold text-foreground'>变更日志</h4>
|
||||
<Chip size='sm' variant='secondary'>
|
||||
{changelog.length}
|
||||
</Chip>
|
||||
</div>
|
||||
{changelog.map((entry) =>
|
||||
renderChangelogEntry(entry, entry.version === CURRENT_VERSION, false)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</AppDialog>
|
||||
);
|
||||
|
||||
// 使用 Portal 渲染到 document.body
|
||||
if (!mounted || !isOpen) return null;
|
||||
|
||||
return createPortal(versionPanelContent, document.body);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
||||
|
||||
import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Trash2 } from 'lucide-react';
|
||||
import { ExternalLink, Heart, Link as LinkIcon, PlayCircleIcon, Radio, Trash2 } from 'lucide-react';
|
||||
import { Badge, Button, Card, Chip, Link as HeroLink, ProgressBar, Tooltip } from '@heroui/react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
|
|
@ -500,7 +501,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
actions.push({
|
||||
id: 'douban',
|
||||
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
|
||||
icon: <Link size={20} />,
|
||||
icon: <LinkIcon size={20} />,
|
||||
onClick: () => {
|
||||
const url = isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
|
|
@ -530,8 +531,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='group relative z-0 w-full cursor-pointer rounded-2xl bg-transparent p-1 transition-all duration-300 ease-in-out hover:-translate-y-1 hover:z-[500]'
|
||||
<Card
|
||||
variant='transparent'
|
||||
className='group relative z-0 w-full cursor-pointer overflow-visible rounded-none p-0'
|
||||
onClick={handleClick}
|
||||
{...longPressProps}
|
||||
style={{
|
||||
|
|
@ -567,8 +569,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
{/* 海报容器 */}
|
||||
<div
|
||||
className={`relative aspect-[2/3] overflow-hidden rounded-2xl border border-border/70 bg-surface shadow-sm transition-all duration-300 group-hover:border-accent/30 group-hover:shadow-xl ${origin === 'live' ? 'ring-1 ring-accent/20' : ''}`}
|
||||
<Card
|
||||
variant='default'
|
||||
className='relative aspect-[2/3] overflow-hidden rounded-lg p-0'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -615,7 +618,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
{/* 悬浮遮罩 */}
|
||||
<div
|
||||
className='absolute inset-0 bg-gradient-to-t from-slate-950/82 via-slate-950/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100'
|
||||
className='absolute inset-0 bg-black/35 opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -645,7 +648,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
<PlayCircleIcon
|
||||
size={50}
|
||||
strokeWidth={0.8}
|
||||
className='fill-surface/80 text-accent drop-shadow-lg transition-all duration-300 ease-out hover:fill-accent hover:text-accent-foreground hover:scale-[1.06]'
|
||||
className='fill-background text-accent'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -675,47 +678,44 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
{config.showCheckCircle && (
|
||||
<Trash2
|
||||
onClick={handleDeleteRecord}
|
||||
size={20}
|
||||
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='danger'
|
||||
onPress={() =>
|
||||
handleDeleteRecord({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
} as React.MouseEvent)
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{config.showHeart && from !== 'search' && from !== 'shortdrama' && (
|
||||
<Heart
|
||||
onClick={handleToggleFavorite}
|
||||
size={20}
|
||||
className={`transition-all duration-300 ease-out ${favorited
|
||||
? 'fill-danger stroke-danger'
|
||||
: 'fill-transparent stroke-white hover:stroke-accent'
|
||||
} hover:scale-[1.1]`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant={favorited ? 'danger' : 'secondary'}
|
||||
onPress={() =>
|
||||
handleToggleFavorite({
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
} as React.MouseEvent)
|
||||
}
|
||||
>
|
||||
<Heart size={16} className={favorited ? 'fill-current' : ''} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 年份徽章 */}
|
||||
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
|
||||
<div
|
||||
className="absolute left-2 top-2 rounded-lg border border-border/70 bg-overlay/85 px-2 py-1 text-[10px] font-medium tracking-normal text-foreground shadow-sm backdrop-blur transition-all duration-300 ease-out group-hover:opacity-90"
|
||||
<Badge
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='absolute left-2 top-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -726,14 +726,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
{actualYear}
|
||||
</div>
|
||||
<Badge.Label>{actualYear}</Badge.Label>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 徽章 */}
|
||||
{config.showRating && rate && (
|
||||
<div
|
||||
className='absolute right-2 top-2 flex min-w-[2rem] items-center justify-center rounded-lg border border-accent/30 bg-accent px-2 py-1 text-[10px] font-semibold text-accent-foreground shadow-sm transition-all duration-300 ease-out group-hover:scale-105'
|
||||
<Chip
|
||||
size='md'
|
||||
color='accent'
|
||||
variant='primary'
|
||||
className='absolute right-2 top-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -744,13 +747,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
{rate}
|
||||
</div>
|
||||
<Chip.Label>{rate}</Chip.Label>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{actualEpisodes && actualEpisodes > 1 && (
|
||||
<div
|
||||
className='absolute right-2 top-2 rounded-lg border border-border/70 bg-overlay/85 px-2 py-1 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur transition-all duration-300 ease-out group-hover:scale-105'
|
||||
<Badge
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='absolute right-2 top-2'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -761,15 +766,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
{currentEpisode
|
||||
<Badge.Label>{currentEpisode
|
||||
? `${currentEpisode}/${actualEpisodes}`
|
||||
: actualEpisodes}
|
||||
</div>
|
||||
: actualEpisodes}</Badge.Label>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 豆瓣链接 */}
|
||||
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
|
||||
<a
|
||||
<HeroLink
|
||||
href={
|
||||
isBangumi
|
||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||
|
|
@ -789,20 +794,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='theme-transition flex h-7 w-7 items-center justify-center rounded-lg border border-border/70 bg-overlay/90 text-accent shadow-sm backdrop-blur hover:border-accent/40 hover:text-foreground'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
<LinkIcon
|
||||
size={18}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -810,8 +803,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
pointerEvents: 'none',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</HeroLink>
|
||||
)}
|
||||
|
||||
{/* 聚合播放源指示器 */}
|
||||
|
|
@ -840,8 +832,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className='theme-transition flex h-6 w-6 cursor-pointer items-center justify-center rounded-lg border border-border/70 bg-overlay/90 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur hover:border-accent/40 hover:text-accent sm:h-7 sm:w-7'
|
||||
<Badge
|
||||
size='sm'
|
||||
color='accent'
|
||||
variant='secondary'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -852,8 +846,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
{sourceCount}
|
||||
</div>
|
||||
<Badge.Label>{sourceCount}</Badge.Label>
|
||||
</Badge>
|
||||
|
||||
{/* 播放源详情悬浮框 */}
|
||||
{(() => {
|
||||
|
|
@ -931,12 +925,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 进度条 */}
|
||||
{config.showProgress && progress !== undefined && (
|
||||
<div
|
||||
className='mt-2 h-1 w-full overflow-hidden rounded-full bg-surface-secondary'
|
||||
<ProgressBar
|
||||
aria-label='观看进度'
|
||||
value={progress}
|
||||
className='mt-2'
|
||||
size='sm'
|
||||
color='accent'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -947,20 +945,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='h-full rounded-full bg-accent transition-all duration-500 ease-out'
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar.Track>
|
||||
<ProgressBar.Fill />
|
||||
</ProgressBar.Track>
|
||||
</ProgressBar>
|
||||
)}
|
||||
|
||||
{/* 标题与来源 */}
|
||||
|
|
@ -976,16 +964,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>
|
||||
<div
|
||||
className='relative'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
>
|
||||
<span
|
||||
className='peer block truncate text-sm font-semibold text-foreground transition-colors duration-300 ease-in-out group-hover:text-accent'
|
||||
className='block truncate text-sm font-semibold'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -998,33 +988,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
>
|
||||
{actualTitle}
|
||||
</span>
|
||||
{/* 自定义 tooltip */}
|
||||
<div
|
||||
className='invisible pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-xl border border-border/70 bg-overlay/95 px-3 py-1 text-xs text-foreground opacity-0 shadow-xl backdrop-blur transition-all duration-200 ease-out delay-100 peer-hover:visible peer-hover:opacity-100'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{actualTitle}
|
||||
<div
|
||||
className='absolute left-1/2 top-full h-2 w-px -translate-x-1/2 bg-border/70'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement='top'>
|
||||
{actualTitle}
|
||||
</Tooltip.Content>
|
||||
</Tooltip>
|
||||
{config.showSourceName && source_name && (
|
||||
<span
|
||||
className='mt-1 block text-xs font-medium tracking-normal text-muted'
|
||||
<Chip
|
||||
size='sm'
|
||||
color='accent'
|
||||
variant='soft'
|
||||
className='mt-1'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -1035,27 +1010,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return false;
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className='inline-flex items-center gap-1 border-l-2 border-accent/70 pl-2 transition-all duration-300 ease-in-out group-hover:text-foreground'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{origin === 'live' && (
|
||||
<Radio size={12} className="inline-block mr-1 text-muted" />
|
||||
)}
|
||||
{source_name}
|
||||
</span>
|
||||
</span>
|
||||
<Chip.Label>{source_name}</Chip.Label>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 操作菜单 - 支持右键和长按触发 */}
|
||||
<MobileActionSheet
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||
|
||||
interface WeekdaySelectorProps {
|
||||
onWeekdayChange: (weekday: string) => void;
|
||||
|
|
@ -43,15 +43,16 @@ const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
|
|||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
return (
|
||||
<AppFilterTabs
|
||||
ariaLabel='星期筛选'
|
||||
<AppFilterSelect
|
||||
ariaLabel='星期选项'
|
||||
className={className}
|
||||
items={weekdays.map((weekday) => ({
|
||||
key: weekday.value,
|
||||
label='星期'
|
||||
options={weekdays.map((weekday) => ({
|
||||
value: weekday.value,
|
||||
label: weekday.shortLabel,
|
||||
}))}
|
||||
selectedKey={selectedWeekday}
|
||||
onSelectionChange={(value) => {
|
||||
value={selectedWeekday}
|
||||
onChange={(value) => {
|
||||
setSelectedWeekday(value);
|
||||
onWeekdayChange(value);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
|||
import MultiLevelSelector from '../MultiLevelSelector';
|
||||
|
||||
describe('MultiLevelSelector', () => {
|
||||
it('applies selected filter values through accessible menu items', () => {
|
||||
it('applies selected filter values through accessible listbox options', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<MultiLevelSelector contentType='movie' onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '喜剧' }));
|
||||
expect(screen.getByRole('listbox', { name: '类型选项' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: '喜剧' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const categories: SearchFilterCategory[] = [
|
|||
];
|
||||
|
||||
describe('SearchResultFilter', () => {
|
||||
it('opens an options menu and applies the selected option', () => {
|
||||
it('applies selected options through accessible listbox options', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(
|
||||
|
|
@ -35,8 +35,8 @@ describe('SearchResultFilter', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('menu', { name: '来源筛选' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '稳定源' }));
|
||||
expect(screen.getByRole('listbox', { name: '来源选项' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: '稳定源' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
source: 'stable',
|
||||
|
|
@ -46,10 +46,10 @@ describe('SearchResultFilter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('cycles year ordering when the year button is clicked repeatedly', () => {
|
||||
it('applies year ordering from the same select pattern', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<SearchResultFilter
|
||||
categories={categories}
|
||||
values={{ source: 'all', title: 'all', yearOrder: 'none' }}
|
||||
|
|
@ -57,8 +57,7 @@ describe('SearchResultFilter', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const yearButton = screen.getByRole('button', { name: '按年份排序排序' });
|
||||
fireEvent.click(yearButton);
|
||||
fireEvent.click(screen.getByRole('option', { name: '年份降序' }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
source: 'all',
|
||||
|
|
@ -66,22 +65,5 @@ describe('SearchResultFilter', () => {
|
|||
year: 'all',
|
||||
yearOrder: 'desc',
|
||||
});
|
||||
|
||||
rerender(
|
||||
<SearchResultFilter
|
||||
categories={categories}
|
||||
values={{ source: 'all', title: 'all', yearOrder: 'desc' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按年份降序排序' }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
yearOrder: 'asc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@ import { render, screen } from '@testing-library/react';
|
|||
import WeekdaySelector from '../WeekdaySelector';
|
||||
|
||||
describe('WeekdaySelector', () => {
|
||||
it('renders as a tablist and marks the current weekday as selected', () => {
|
||||
it('renders as a select listbox and marks the current weekday as selected', () => {
|
||||
const onWeekdayChange = jest.fn();
|
||||
|
||||
render(<WeekdaySelector onWeekdayChange={onWeekdayChange} />);
|
||||
|
||||
const tablist = screen.getByRole('tablist', { name: '星期筛选' });
|
||||
expect(tablist).toBeInTheDocument();
|
||||
const listbox = screen.getByRole('listbox', { name: '星期选项' });
|
||||
expect(listbox).toBeInTheDocument();
|
||||
|
||||
const today = new Date().getDay();
|
||||
const weekdayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
expect(screen.getByRole('tab', { name: weekdayMap[today] })).toHaveAttribute(
|
||||
expect(screen.getByRole('option', { name: weekdayMap[today] })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import {
|
|||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Label,
|
||||
ListBox,
|
||||
Modal,
|
||||
ScrollShadow,
|
||||
Select,
|
||||
Spinner,
|
||||
Tabs,
|
||||
useOverlayState,
|
||||
|
|
@ -42,6 +45,72 @@ export function AppScrollShadow(props: ScrollShadowProps) {
|
|||
return <ScrollShadow hideScrollBar {...props} />;
|
||||
}
|
||||
|
||||
export interface AppFilterSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface AppFilterSelectProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
options: AppFilterSelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function AppFilterSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
className,
|
||||
isDisabled,
|
||||
}: AppFilterSelectProps) {
|
||||
return (
|
||||
<Select
|
||||
className={className}
|
||||
fullWidth
|
||||
isDisabled={isDisabled}
|
||||
placeholder={placeholder ?? `选择${label}`}
|
||||
value={value ?? null}
|
||||
variant='secondary'
|
||||
onChange={(nextValue) => {
|
||||
if (Array.isArray(nextValue) || nextValue == null) return;
|
||||
onChange(String(nextValue));
|
||||
}}
|
||||
>
|
||||
<Label>{label}</Label>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ScrollShadow className='max-h-[min(52vh,22rem)]' hideScrollBar>
|
||||
<ListBox aria-label={ariaLabel ?? `${label}选项`} selectionMode='single'>
|
||||
{options.map((option) => (
|
||||
<ListBox.Item
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
isDisabled={option.isDisabled}
|
||||
textValue={option.label}
|
||||
>
|
||||
<Label>{option.label}</Label>
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</ScrollShadow>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppLoading({
|
||||
label = '加载中...',
|
||||
...props
|
||||
|
|
@ -214,18 +283,15 @@ export function AppTabs({
|
|||
}
|
||||
|
||||
export function AppFilterTabs({
|
||||
className,
|
||||
...props
|
||||
}: AppTabsProps) {
|
||||
return (
|
||||
<ScrollShadow
|
||||
orientation='horizontal'
|
||||
className='app-filter-scroll'
|
||||
hideScrollBar
|
||||
className='w-full min-w-0'
|
||||
>
|
||||
<AppTabs
|
||||
{...props}
|
||||
className={`app-filter-tabs ${className ?? ''}`.trim()}
|
||||
/>
|
||||
<AppTabs {...props} />
|
||||
</ScrollShadow>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue