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;
|
onAction?: (key: React.Key) => void;
|
||||||
selectedKeys?: Iterable<React.Key>;
|
selectedKeys?: Iterable<React.Key>;
|
||||||
}>({});
|
}>({});
|
||||||
|
const SelectContext = createContext<{
|
||||||
|
value?: React.Key | React.Key[] | null;
|
||||||
|
onChange?: (value: React.Key | React.Key[] | null) => void;
|
||||||
|
}>({});
|
||||||
|
|
||||||
type OverlayStateValue = {
|
type OverlayStateValue = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -370,11 +374,132 @@ export const Dropdown = Object.assign(DropdownRoot, {
|
||||||
ItemIndicator: () => <span aria-hidden='true' />,
|
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,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
|
}: 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>) => (
|
export const Spinner = (props: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||||
<span role='status' {...props} />
|
<span role='status' {...props} />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,6 +3,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Card, EmptyState, Spinner } from '@heroui/react';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -728,7 +729,7 @@ function DoubanPageClient() {
|
||||||
|
|
||||||
{/* 选择器组件 */}
|
{/* 选择器组件 */}
|
||||||
{type !== 'custom' ? (
|
{type !== 'custom' ? (
|
||||||
<div className='app-filter-panel'>
|
<Card>
|
||||||
<DoubanSelector
|
<DoubanSelector
|
||||||
type={type as 'movie' | 'tv' | 'show' | 'anime'}
|
type={type as 'movie' | 'tv' | 'show' | 'anime'}
|
||||||
primarySelection={primarySelection}
|
primarySelection={primarySelection}
|
||||||
|
|
@ -738,9 +739,9 @@ function DoubanPageClient() {
|
||||||
onMultiLevelChange={handleMultiLevelChange}
|
onMultiLevelChange={handleMultiLevelChange}
|
||||||
onWeekdayChange={handleWeekdayChange}
|
onWeekdayChange={handleWeekdayChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className='app-filter-panel'>
|
<Card>
|
||||||
<DoubanCustomSelector
|
<DoubanCustomSelector
|
||||||
customCategories={customCategories}
|
customCategories={customCategories}
|
||||||
primarySelection={primarySelection}
|
primarySelection={primarySelection}
|
||||||
|
|
@ -748,7 +749,7 @@ function DoubanPageClient() {
|
||||||
onPrimaryChange={handlePrimaryChange}
|
onPrimaryChange={handlePrimaryChange}
|
||||||
onSecondaryChange={handleSecondaryChange}
|
onSecondaryChange={handleSecondaryChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -792,8 +793,8 @@ function DoubanPageClient() {
|
||||||
>
|
>
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
|
<Spinner size='sm' />
|
||||||
<span className='text-gray-600'>加载中...</span>
|
<span className='text-muted'>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -801,12 +802,12 @@ function DoubanPageClient() {
|
||||||
|
|
||||||
{/* 没有更多数据提示 */}
|
{/* 没有更多数据提示 */}
|
||||||
{!hasMore && doubanData.length > 0 && (
|
{!hasMore && doubanData.length > 0 && (
|
||||||
<div className='text-center text-gray-500 py-8'>已加载全部内容</div>
|
<EmptyState className='py-8'>已加载全部内容</EmptyState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态 */}
|
{/* 空状态 */}
|
||||||
{!loading && doubanData.length === 0 && (
|
{!loading && doubanData.length === 0 && (
|
||||||
<div className='text-center text-gray-500 py-8'>暂无相关内容</div>
|
<EmptyState className='py-8'>暂无相关内容</EmptyState>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,57 +8,57 @@
|
||||||
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace;
|
--font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace;
|
||||||
|
|
||||||
--background: oklch(0.985 0.006 255);
|
--background: oklch(0.985 0.002 286);
|
||||||
--foreground: oklch(0.21 0.025 258);
|
--foreground: oklch(0.19 0.006 286);
|
||||||
--surface: oklch(0.998 0.002 255);
|
--surface: oklch(0.998 0.001 286);
|
||||||
--surface-foreground: var(--foreground);
|
--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-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);
|
--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);
|
--overlay-foreground: var(--foreground);
|
||||||
--muted: oklch(0.53 0.032 258);
|
--muted: oklch(0.48 0.01 286);
|
||||||
--border: oklch(0.85 0.018 255 / 70%);
|
--border: oklch(0.84 0.006 286 / 70%);
|
||||||
--separator: oklch(0.9 0.012 255 / 72%);
|
--separator: oklch(0.89 0.004 286 / 72%);
|
||||||
--accent: oklch(0.59 0.19 255);
|
--accent: oklch(0.58 0.22 16);
|
||||||
--accent-foreground: oklch(0.99 0.004 255);
|
--accent-foreground: oklch(0.99 0.004 20);
|
||||||
--success: oklch(0.66 0.16 150);
|
--success: oklch(0.66 0.16 150);
|
||||||
--success-foreground: oklch(0.99 0.004 150);
|
--success-foreground: oklch(0.99 0.004 150);
|
||||||
--warning: oklch(0.73 0.15 82);
|
--warning: oklch(0.73 0.15 82);
|
||||||
--warning-foreground: oklch(0.22 0.035 70);
|
--warning-foreground: oklch(0.22 0.035 70);
|
||||||
--danger: oklch(0.62 0.2 28);
|
--danger: oklch(0.62 0.2 28);
|
||||||
--danger-foreground: oklch(0.99 0.004 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-foreground: var(--foreground);
|
||||||
--field-placeholder: var(--muted);
|
--field-placeholder: var(--muted);
|
||||||
--field-border: var(--border);
|
--field-border: var(--border);
|
||||||
|
|
||||||
--color-background: 248 250 252;
|
--color-background: 250 250 250;
|
||||||
--color-foreground: 15 23 42;
|
--color-foreground: 24 24 27;
|
||||||
--color-surface: 255 255 255;
|
--color-surface: 255 255 255;
|
||||||
--color-surface-secondary: 241 245 249;
|
--color-surface-secondary: 244 244 245;
|
||||||
--color-surface-tertiary: 226 232 240;
|
--color-surface-tertiary: 228 228 231;
|
||||||
--color-overlay: 255 255 255;
|
--color-overlay: 255 255 255;
|
||||||
--color-muted: 100 116 139;
|
--color-muted: 113 113 122;
|
||||||
--color-border: 203 213 225;
|
--color-border: 212 212 216;
|
||||||
--color-accent: 37 99 235;
|
--color-accent: 225 29 72;
|
||||||
--color-accent-strong: 29 78 216;
|
--color-accent-strong: 190 18 60;
|
||||||
--color-success: 22 163 74;
|
--color-success: 22 163 74;
|
||||||
--color-warning: 217 119 6;
|
--color-warning: 217 119 6;
|
||||||
--color-danger: 220 38 38;
|
--color-danger: 220 38 38;
|
||||||
|
|
||||||
--color-primary-50: 239 246 255;
|
--color-primary-50: 255 241 242;
|
||||||
--color-primary-100: 219 234 254;
|
--color-primary-100: 255 228 230;
|
||||||
--color-primary-200: 191 219 254;
|
--color-primary-200: 254 205 211;
|
||||||
--color-primary-300: 147 197 253;
|
--color-primary-300: 253 164 175;
|
||||||
--color-primary-400: 96 165 250;
|
--color-primary-400: 251 113 133;
|
||||||
--color-primary-500: 59 130 246;
|
--color-primary-500: 244 63 94;
|
||||||
--color-primary-600: 37 99 235;
|
--color-primary-600: 225 29 72;
|
||||||
--color-primary-700: 29 78 216;
|
--color-primary-700: 190 18 60;
|
||||||
--color-primary-800: 30 64 175;
|
--color-primary-800: 159 18 57;
|
||||||
--color-primary-900: 30 58 138;
|
--color-primary-900: 136 19 55;
|
||||||
--color-dark: 15 23 42;
|
--color-dark: 24 24 27;
|
||||||
|
|
||||||
--color-theme-bg: var(--color-background);
|
--color-theme-bg: var(--color-background);
|
||||||
--color-theme-surface: var(--color-surface);
|
--color-theme-surface: var(--color-surface);
|
||||||
|
|
@ -71,60 +71,47 @@
|
||||||
--color-theme-error: var(--color-danger);
|
--color-theme-error: var(--color-danger);
|
||||||
--color-theme-info: var(--color-accent);
|
--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 {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--background: oklch(0.18 0.022 258);
|
--background: oklch(0.15 0.006 286);
|
||||||
--foreground: oklch(0.94 0.012 255);
|
--foreground: oklch(0.94 0.006 286);
|
||||||
--surface: oklch(0.225 0.025 258);
|
--surface: oklch(0.205 0.008 286);
|
||||||
--surface-foreground: var(--foreground);
|
--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-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);
|
--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);
|
--overlay-foreground: var(--foreground);
|
||||||
--muted: oklch(0.7 0.028 255);
|
--muted: oklch(0.7 0.012 286);
|
||||||
--border: oklch(0.42 0.03 258 / 68%);
|
--border: oklch(0.38 0.012 286 / 68%);
|
||||||
--separator: oklch(0.36 0.025 258 / 70%);
|
--separator: oklch(0.33 0.01 286 / 70%);
|
||||||
--accent: oklch(0.69 0.17 255);
|
--accent: oklch(0.69 0.18 14);
|
||||||
--accent-foreground: oklch(0.16 0.03 258);
|
--accent-foreground: oklch(0.16 0.02 14);
|
||||||
--success: oklch(0.72 0.16 150);
|
--success: oklch(0.72 0.16 150);
|
||||||
--success-foreground: oklch(0.14 0.025 150);
|
--success-foreground: oklch(0.14 0.025 150);
|
||||||
--warning: oklch(0.78 0.15 82);
|
--warning: oklch(0.78 0.15 82);
|
||||||
--warning-foreground: oklch(0.18 0.035 70);
|
--warning-foreground: oklch(0.18 0.035 70);
|
||||||
--danger: oklch(0.7 0.18 28);
|
--danger: oklch(0.7 0.18 28);
|
||||||
--danger-foreground: oklch(0.99 0.004 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-foreground: var(--foreground);
|
||||||
--field-placeholder: var(--muted);
|
--field-placeholder: var(--muted);
|
||||||
--field-border: var(--border);
|
--field-border: var(--border);
|
||||||
|
|
||||||
--color-background: 15 23 42;
|
--color-background: 18 18 20;
|
||||||
--color-foreground: 241 245 249;
|
--color-foreground: 244 244 245;
|
||||||
--color-surface: 30 41 59;
|
--color-surface: 31 31 35;
|
||||||
--color-surface-secondary: 51 65 85;
|
--color-surface-secondary: 39 39 42;
|
||||||
--color-surface-tertiary: 71 85 105;
|
--color-surface-tertiary: 63 63 70;
|
||||||
--color-overlay: 15 23 42;
|
--color-overlay: 24 24 27;
|
||||||
--color-muted: 148 163 184;
|
--color-muted: 161 161 170;
|
||||||
--color-border: 71 85 105;
|
--color-border: 82 82 91;
|
||||||
--color-accent: 96 165 250;
|
--color-accent: 251 113 133;
|
||||||
--color-accent-strong: 147 197 253;
|
--color-accent-strong: 253 164 175;
|
||||||
--color-success: 74 222 128;
|
--color-success: 74 222 128;
|
||||||
--color-warning: 251 191 36;
|
--color-warning: 251 191 36;
|
||||||
--color-danger: 248 113 113;
|
--color-danger: 248 113 113;
|
||||||
|
|
@ -140,18 +127,6 @@
|
||||||
--color-theme-error: var(--color-danger);
|
--color-theme-error: var(--color-danger);
|
||||||
--color-theme-info: var(--color-accent);
|
--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"] {
|
[data-theme="minimal"] {
|
||||||
|
|
@ -161,8 +136,6 @@
|
||||||
--color-accent-strong: 64 64 64;
|
--color-accent-strong: 64 64 64;
|
||||||
--color-theme-accent: var(--color-accent);
|
--color-theme-accent: var(--color-accent);
|
||||||
--color-theme-info: 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 {
|
[data-theme="minimal"].dark {
|
||||||
|
|
@ -172,8 +145,6 @@
|
||||||
--color-accent-strong: 245 245 245;
|
--color-accent-strong: 245 245 245;
|
||||||
--color-theme-accent: var(--color-accent);
|
--color-theme-accent: var(--color-accent);
|
||||||
--color-theme-info: var(--color-accent);
|
--color-theme-info: var(--color-accent);
|
||||||
--a2-copper: var(--color-accent);
|
|
||||||
--a2-copper-strong: var(--color-accent-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="warm"] {
|
[data-theme="warm"] {
|
||||||
|
|
@ -183,8 +154,6 @@
|
||||||
--color-accent-strong: 180 83 9;
|
--color-accent-strong: 180 83 9;
|
||||||
--color-theme-accent: var(--color-accent);
|
--color-theme-accent: var(--color-accent);
|
||||||
--color-theme-info: 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 {
|
[data-theme="warm"].dark {
|
||||||
|
|
@ -194,8 +163,6 @@
|
||||||
--color-accent-strong: 253 224 71;
|
--color-accent-strong: 253 224 71;
|
||||||
--color-theme-accent: var(--color-accent);
|
--color-theme-accent: var(--color-accent);
|
||||||
--color-theme-info: var(--color-accent);
|
--color-theme-info: var(--color-accent);
|
||||||
--a2-copper: var(--color-accent);
|
|
||||||
--a2-copper-strong: var(--color-accent-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="fresh"] {
|
[data-theme="fresh"] {
|
||||||
|
|
@ -205,8 +172,6 @@
|
||||||
--color-accent-strong: 21 128 61;
|
--color-accent-strong: 21 128 61;
|
||||||
--color-theme-accent: var(--color-accent);
|
--color-theme-accent: var(--color-accent);
|
||||||
--color-theme-info: 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 {
|
[data-theme="fresh"].dark {
|
||||||
|
|
@ -216,8 +181,6 @@
|
||||||
--color-accent-strong: 134 239 172;
|
--color-accent-strong: 134 239 172;
|
||||||
--color-theme-accent: var(--color-accent);
|
--color-theme-accent: var(--color-accent);
|
||||||
--color-theme-info: var(--color-accent);
|
--color-theme-info: var(--color-accent);
|
||||||
--a2-copper: var(--color-accent);
|
|
||||||
--a2-copper-strong: var(--color-accent-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|
@ -296,18 +259,6 @@ body {
|
||||||
transform 180ms ease;
|
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 {
|
.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;
|
@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%);
|
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 {
|
@keyframes slide-from-top {
|
||||||
|
|
@ -528,326 +333,3 @@ div[data-media-provider] video {
|
||||||
display: none !important;
|
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 Artplayer from 'artplayer';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import { Heart, Radio, Tv } from 'lucide-react';
|
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -1058,68 +1059,26 @@ function LivePageClient() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
const progressValue =
|
||||||
|
loadingStage === 'loading' ? 33 : loadingStage === 'fetching' ? 66 : 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout activePath='/live'>
|
<PageLayout activePath='/live'>
|
||||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card className='w-full max-w-md text-center'>
|
||||||
{/* 动画直播图标 */}
|
<Card.Header className='items-center'>
|
||||||
<div className='relative mb-8'>
|
<Spinner />
|
||||||
<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'>
|
<Card.Title>{loadingMessage}</Card.Title>
|
||||||
<div className='text-white text-4xl'>📺</div>
|
<Card.Description>正在准备直播播放器</Card.Description>
|
||||||
{/* 旋转光环 */}
|
</Card.Header>
|
||||||
<div className='absolute -inset-2 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl opacity-20 animate-spin'></div>
|
<Card.Content>
|
||||||
</div>
|
<ProgressBar aria-label='加载进度' value={progressValue} color='accent'>
|
||||||
|
<ProgressBar.Track>
|
||||||
{/* 浮动粒子效果 */}
|
<ProgressBar.Fill />
|
||||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
</ProgressBar.Track>
|
||||||
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
|
</ProgressBar>
|
||||||
<div
|
</Card.Content>
|
||||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
|
</Card>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -1129,41 +1088,24 @@ function LivePageClient() {
|
||||||
return (
|
return (
|
||||||
<PageLayout activePath='/live'>
|
<PageLayout activePath='/live'>
|
||||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card className='w-full max-w-md'>
|
||||||
{/* 错误图标 */}
|
<Card.Header>
|
||||||
<div className='relative mb-8'>
|
<Card.Title>哎呀,出现了一些问题</Card.Title>
|
||||||
<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'>
|
<Card.Description>请检查网络连接或尝试刷新页面</Card.Description>
|
||||||
<div className='text-white text-4xl'>😵</div>
|
</Card.Header>
|
||||||
{/* 脉冲效果 */}
|
<Card.Content>
|
||||||
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
|
<Alert status='danger'>
|
||||||
</div>
|
<Alert.Content>
|
||||||
</div>
|
<Alert.Description>{error}</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
{/* 错误信息 */}
|
</Alert>
|
||||||
<div className='space-y-4 mb-8'>
|
</Card.Content>
|
||||||
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
|
<Card.Footer>
|
||||||
哎呀,出现了一些问题
|
<Button fullWidth onPress={() => window.location.reload()}>
|
||||||
</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'
|
|
||||||
>
|
|
||||||
🔄 重新尝试
|
🔄 重新尝试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Card.Footer>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -1175,7 +1117,7 @@ function LivePageClient() {
|
||||||
{/* 第一行:页面标题 */}
|
{/* 第一行:页面标题 */}
|
||||||
<div className='py-1'>
|
<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%]'>
|
<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='min-w-0 flex-1'>
|
||||||
<div className='truncate'>
|
<div className='truncate'>
|
||||||
{currentSource?.name}
|
{currentSource?.name}
|
||||||
|
|
@ -1198,17 +1140,16 @@ function LivePageClient() {
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
||||||
<div className='hidden lg:flex justify-end'>
|
<div className='hidden lg:flex justify-end'>
|
||||||
<button
|
<Button
|
||||||
onClick={() =>
|
variant='secondary'
|
||||||
|
size='sm'
|
||||||
|
aria-label={isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'}
|
||||||
|
onPress={() =>
|
||||||
setIsChannelListCollapsed(!isChannelListCollapsed)
|
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
|
<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'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
|
|
@ -1221,18 +1162,8 @@ function LivePageClient() {
|
||||||
d='M9 5l7 7-7 7'
|
d='M9 5l7 7-7 7'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
|
{isChannelListCollapsed ? '显示' : '隐藏'}
|
||||||
{isChannelListCollapsed ? '显示' : '隐藏'}
|
</Button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isChannelListCollapsed
|
<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 && (
|
{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='absolute inset-0 z-[600] flex items-center justify-center bg-black/90'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card variant='default' className='max-w-md p-6 text-center'>
|
||||||
<div className='relative mb-8'>
|
<Alert status='warning'>
|
||||||
<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'>
|
<Alert.Content>
|
||||||
<div className='text-white text-4xl'>⚠️</div>
|
<Alert.Title>暂不支持的直播流类型</Alert.Title>
|
||||||
<div className='absolute -inset-2 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl opacity-20 animate-pulse'></div>
|
<Alert.Description>
|
||||||
</div>
|
当前频道直播流类型:{unsupportedType.toUpperCase()}。目前仅支持 M3U8 格式的直播流,请尝试其他频道。
|
||||||
</div>
|
</Alert.Description>
|
||||||
<div className='space-y-4'>
|
</Alert.Content>
|
||||||
<h3 className='text-xl font-semibold text-white'>
|
</Alert>
|
||||||
暂不支持的直播流类型
|
</Card>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 视频加载蒙层 */}
|
{/* 视频加载蒙层 */}
|
||||||
{isVideoLoading && (
|
{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='absolute inset-0 z-[500] flex items-center justify-center bg-black/85'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card variant='default' className='max-w-md p-6 text-center'>
|
||||||
<div className='relative mb-8'>
|
<Spinner />
|
||||||
<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'>
|
<p className='mt-4 text-lg font-semibold'>IPTV 加载中...</p>
|
||||||
<div className='text-white text-4xl'>📺</div>
|
</Card>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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:hidden lg:opacity-0 lg:scale-95'
|
||||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
: '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 切换 */}
|
{/* 主要的 Tab 切换 */}
|
||||||
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
<Tabs
|
||||||
<div
|
selectedKey={activeTab}
|
||||||
onClick={() => setActiveTab('channels')}
|
variant='secondary'
|
||||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
onSelectionChange={(key) => setActiveTab(String(key) as 'channels' | 'sources')}
|
||||||
${activeTab === 'channels'
|
>
|
||||||
? 'text-blue-600 dark:text-blue-400'
|
<Tabs.List aria-label='直播列表类型'>
|
||||||
: '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'
|
<Tabs.Tab id='channels'>频道</Tabs.Tab>
|
||||||
}
|
<Tabs.Tab id='sources'>直播源</Tabs.Tab>
|
||||||
`.trim()}
|
</Tabs.List>
|
||||||
>
|
</Tabs>
|
||||||
频道
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 频道 Tab 内容 */}
|
{/* 频道 Tab 内容 */}
|
||||||
{activeTab === 'channels' && (
|
{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 && (
|
{isSwitchingSource && (
|
||||||
<div className='flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400'>
|
<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>
|
</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) => (
|
{Object.keys(groupedChannels).map((group, index) => (
|
||||||
<button
|
<Button
|
||||||
key={group}
|
key={group}
|
||||||
data-group={group}
|
data-group={group}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
groupButtonRefs.current[index] = el;
|
groupButtonRefs.current[index] = el;
|
||||||
}}
|
}}
|
||||||
onClick={() => handleGroupChange(group)}
|
size='sm'
|
||||||
disabled={isSwitchingSource}
|
variant={selectedGroup === group ? 'secondary' : 'tertiary'}
|
||||||
className={`w-20 relative py-2 text-sm font-medium transition-colors flex-shrink-0 text-center overflow-hidden
|
isDisabled={isSwitchingSource}
|
||||||
${isSwitchingSource
|
className='w-20 flex-shrink-0 overflow-hidden'
|
||||||
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
|
onPress={() => handleGroupChange(group)}
|
||||||
: 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()}
|
|
||||||
>
|
>
|
||||||
<div className='px-1 overflow-hidden whitespace-nowrap' title={group}>
|
<div className='px-1 overflow-hidden whitespace-nowrap' title={group}>
|
||||||
{group}
|
{group}
|
||||||
</div>
|
</div>
|
||||||
{selectedGroup === group && !isSwitchingSource && (
|
</Button>
|
||||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400' />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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.length > 0 ? (
|
||||||
filteredChannels.map(channel => {
|
filteredChannels.map(channel => {
|
||||||
const isActive = channel.id === currentChannel?.id;
|
const isActive = channel.id === currentChannel?.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
data-channel-id={channel.id}
|
data-channel-id={channel.id}
|
||||||
onClick={() => handleChannelChange(channel)}
|
fullWidth
|
||||||
disabled={isSwitchingSource}
|
variant={isActive ? 'secondary' : 'tertiary'}
|
||||||
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
|
isDisabled={isSwitchingSource}
|
||||||
? 'opacity-50 cursor-not-allowed'
|
className='h-auto justify-start p-3'
|
||||||
: isActive
|
onPress={() => handleChannelChange(channel)}
|
||||||
? '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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-3'>
|
<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 ? (
|
{channel.logo ? (
|
||||||
<img
|
<img
|
||||||
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
|
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
|
||||||
|
|
@ -1433,90 +1316,77 @@ function LivePageClient() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1 min-w-0'>
|
<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}
|
{channel.name}
|
||||||
</div>
|
</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}
|
{channel.group}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
<EmptyState>
|
||||||
<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' />
|
||||||
<Tv className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
<p className='font-medium'>暂无可用频道</p>
|
||||||
</div>
|
<p className='text-sm text-muted'>请选择其他直播源或稍后再试</p>
|
||||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
</EmptyState>
|
||||||
暂无可用频道
|
|
||||||
</p>
|
|
||||||
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
|
|
||||||
请选择其他直播源或稍后再试
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollShadow>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 直播源 Tab 内容 */}
|
{/* 直播源 Tab 内容 */}
|
||||||
{activeTab === 'sources' && (
|
{activeTab === 'sources' && (
|
||||||
<div className='flex flex-col h-full mt-4'>
|
<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.length > 0 ? (
|
||||||
liveSources.map((source) => {
|
liveSources.map((source) => {
|
||||||
const isCurrentSource = source.key === currentSource?.key;
|
const isCurrentSource = source.key === currentSource?.key;
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
key={source.key}
|
key={source.key}
|
||||||
onClick={() => !isCurrentSource && handleSourceChange(source)}
|
fullWidth
|
||||||
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
variant={isCurrentSource ? 'secondary' : 'tertiary'}
|
||||||
${isCurrentSource
|
className='h-auto justify-start p-3'
|
||||||
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30 border'
|
isDisabled={isCurrentSource}
|
||||||
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
onPress={() => handleSourceChange(source)}
|
||||||
}`.trim()}
|
|
||||||
>
|
>
|
||||||
{/* 图标 */}
|
{/* 图标 */}
|
||||||
<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' />
|
<Radio className='w-6 h-6 text-gray-500' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信息 */}
|
{/* 信息 */}
|
||||||
<div className='flex-1 min-w-0'>
|
<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}
|
{source.name}
|
||||||
</div>
|
</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} 个频道`}
|
{!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 当前标识 */}
|
{/* 当前标识 */}
|
||||||
{isCurrentSource && (
|
{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'>
|
<EmptyState>
|
||||||
<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' />
|
||||||
<Radio className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
<p className='font-medium'>暂无可用直播源</p>
|
||||||
</div>
|
<p className='text-sm text-muted'>请检查网络连接或联系管理员添加直播源</p>
|
||||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
</EmptyState>
|
||||||
暂无可用直播源
|
|
||||||
</p>
|
|
||||||
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
|
|
||||||
请检查网络连接或联系管理员添加直播源
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollShadow>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1545,16 +1415,17 @@ function LivePageClient() {
|
||||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||||
{currentChannel.name}
|
{currentChannel.name}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
isIconOnly
|
||||||
e.stopPropagation();
|
variant='tertiary'
|
||||||
|
aria-label={favorited ? '取消收藏' : '收藏'}
|
||||||
|
onPress={() => {
|
||||||
handleToggleFavorite();
|
handleToggleFavorite();
|
||||||
}}
|
}}
|
||||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
title={favorited ? '取消收藏' : '收藏'}
|
|
||||||
>
|
>
|
||||||
<FavoriteIcon filled={favorited} />
|
<FavoriteIcon filled={favorited} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
|
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
|
||||||
{currentSource?.name} {' > '} {currentChannel.group}
|
{currentSource?.name} {' > '} {currentChannel.group}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
|
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -37,11 +37,10 @@ function VersionDisplay() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
onClick={() =>
|
href='https://github.com/djteang/OrangeTV'
|
||||||
window.open('https://github.com/djteang/OrangeTV', '_blank')
|
target='_blank'
|
||||||
}
|
className='absolute bottom-4 left-1/2 -translate-x-1/2 text-xs'
|
||||||
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'
|
|
||||||
>
|
>
|
||||||
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||||
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
|
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||||
|
|
@ -49,7 +48,7 @@ function VersionDisplay() {
|
||||||
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
|
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
|
||||||
? 'text-yellow-600 dark:text-yellow-400'
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
: updateStatus === UpdateStatus.NO_UPDATE
|
: updateStatus === UpdateStatus.NO_UPDATE
|
||||||
? 'text-blue-600 dark:text-blue-400'
|
? 'text-success'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -67,7 +66,7 @@ function VersionDisplay() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +202,7 @@ function LoginPageClient() {
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<AppSurface className='relative z-10 w-full max-w-md p-8 sm:p-10'>
|
<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}
|
{siteName}
|
||||||
</h1>
|
</h1>
|
||||||
<Form onSubmit={handleSubmit} className='space-y-6'>
|
<Form onSubmit={handleSubmit} className='space-y-6'>
|
||||||
|
|
@ -236,36 +235,35 @@ function LoginPageClient() {
|
||||||
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
|
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
|
||||||
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
|
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
|
||||||
<div className='space-y-4'>
|
<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'>
|
<Alert status='accent'>
|
||||||
<div className='flex items-center space-x-2 mb-2'>
|
<Alert.Indicator>
|
||||||
<Shield className='w-4 h-4 text-blue-600 dark:text-blue-400' />
|
<Shield className='w-4 h-4' />
|
||||||
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>设备识别码</span>
|
</Alert.Indicator>
|
||||||
</div>
|
<Alert.Content>
|
||||||
<div className='space-y-2'>
|
<Alert.Title>设备识别码</Alert.Title>
|
||||||
<div className='text-xs font-mono text-gray-700 dark:text-gray-300 break-all'>
|
<Alert.Description>
|
||||||
{MachineCode.formatMachineCode(machineCode)}
|
{MachineCode.formatMachineCode(machineCode)}
|
||||||
</div>
|
<br />
|
||||||
<div className='text-xs text-gray-600 dark:text-gray-400'>
|
|
||||||
设备信息: {deviceInfo}
|
设备信息: {deviceInfo}
|
||||||
</div>
|
</Alert.Description>
|
||||||
</div>
|
</Alert.Content>
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{/* 绑定选项 */}
|
{/* 绑定选项 */}
|
||||||
{!requireMachineCode && (
|
{!requireMachineCode && (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='flex items-center space-x-3'>
|
<Checkbox
|
||||||
<input
|
id='bindMachineCode'
|
||||||
id='bindMachineCode'
|
isSelected={bindMachineCode}
|
||||||
type='checkbox'
|
onChange={setBindMachineCode}
|
||||||
checked={bindMachineCode}
|
>
|
||||||
onChange={(e) => setBindMachineCode(e.target.checked)}
|
<Checkbox.Control>
|
||||||
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'
|
<Checkbox.Indicator />
|
||||||
/>
|
</Checkbox.Control>
|
||||||
<label htmlFor='bindMachineCode' className='text-sm text-gray-700 dark:text-gray-300'>
|
<Checkbox.Content>
|
||||||
绑定此设备(提升账户安全性)
|
绑定此设备(提升账户安全性)
|
||||||
</label>
|
</Checkbox.Content>
|
||||||
</div>
|
</Checkbox>
|
||||||
{/* <p className='text-xs text-gray-500 dark:text-gray-400 ml-7'>
|
{/* <p className='text-xs text-gray-500 dark:text-gray-400 ml-7'>
|
||||||
// 管理员可选择不绑定机器码直接登录
|
// 管理员可选择不绑定机器码直接登录
|
||||||
</p> */}
|
</p> */}
|
||||||
|
|
|
||||||
215
src/app/page.tsx
215
src/app/page.tsx
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { Button, Card, EmptyState, Link as HeroLink, Skeleton } from '@heroui/react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +25,7 @@ import PageLayout from '@/components/PageLayout';
|
||||||
import ScrollableRow from '@/components/ScrollableRow';
|
import ScrollableRow from '@/components/ScrollableRow';
|
||||||
import { useSite } from '@/components/SiteProvider';
|
import { useSite } from '@/components/SiteProvider';
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
import { AppDialog } from '@/components/ui/HeroPrimitives';
|
||||||
|
|
||||||
function HomeClient() {
|
function HomeClient() {
|
||||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
||||||
|
|
@ -185,26 +185,24 @@ function HomeClient() {
|
||||||
<div className='mx-auto max-w-[1380px] space-y-10'>
|
<div className='mx-auto max-w-[1380px] space-y-10'>
|
||||||
{activeTab === 'favorites' ? (
|
{activeTab === 'favorites' ? (
|
||||||
// 收藏夹视图
|
// 收藏夹视图
|
||||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
<Card>
|
||||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||||
<div className='space-y-1'>
|
<div>
|
||||||
<p className='a2-kicker'>Saved</p>
|
<Card.Description>Saved</Card.Description>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
<Card.Title>我的收藏</Card.Title>
|
||||||
我的收藏
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
{favoriteItems.length > 0 && (
|
{favoriteItems.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
className='a2-link-action'
|
variant='danger'
|
||||||
onClick={async () => {
|
onPress={async () => {
|
||||||
await clearAllFavorites();
|
await clearAllFavorites();
|
||||||
setFavoriteItems([]);
|
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'>
|
<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) => (
|
{favoriteItems.map((item) => (
|
||||||
<div key={item.id + item.source} className='w-full'>
|
<div key={item.id + item.source} className='w-full'>
|
||||||
|
|
@ -217,12 +215,12 @@ function HomeClient() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{favoriteItems.length === 0 && (
|
{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>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
// 首页视图
|
// 首页视图
|
||||||
<>
|
<>
|
||||||
|
|
@ -230,21 +228,18 @@ function HomeClient() {
|
||||||
<ContinueWatching />
|
<ContinueWatching />
|
||||||
|
|
||||||
{/* 热门电影 */}
|
{/* 热门电影 */}
|
||||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
<Card>
|
||||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||||
<div className='space-y-1'>
|
<div>
|
||||||
<p className='a2-kicker'>精选推荐</p>
|
<Card.Description>精选推荐</Card.Description>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
<Card.Title>热门电影</Card.Title>
|
||||||
热门电影
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<HeroLink
|
||||||
href='/douban?type=movie'
|
href='/douban?type=movie'
|
||||||
className='a2-link-action'
|
|
||||||
>
|
>
|
||||||
查看更多
|
查看更多
|
||||||
</Link>
|
</HeroLink>
|
||||||
</div>
|
</Card.Header>
|
||||||
<ScrollableRow>
|
<ScrollableRow>
|
||||||
{loading
|
{loading
|
||||||
? // 加载状态显示灰色占位数据
|
? // 加载状态显示灰色占位数据
|
||||||
|
|
@ -253,10 +248,8 @@ function HomeClient() {
|
||||||
key={index}
|
key={index}
|
||||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
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'>
|
<Skeleton className='aspect-[2/3] w-full' />
|
||||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
<Skeleton className='mt-3 h-4' />
|
||||||
</div>
|
|
||||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: // 显示真实数据
|
: // 显示真实数据
|
||||||
|
|
@ -277,21 +270,19 @@ function HomeClient() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
{/* 热门剧集 */}
|
{/* 热门剧集 */}
|
||||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
<Card>
|
||||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||||
<div className='space-y-1'>
|
<div>
|
||||||
<p className='a2-kicker'>Series</p>
|
<Card.Description>Series</Card.Description>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
<Card.Title>热门剧集</Card.Title>
|
||||||
热门剧集
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<Link href='/douban?type=tv' className='a2-link-action'>
|
<HeroLink href='/douban?type=tv'>
|
||||||
查看更多
|
查看更多
|
||||||
</Link>
|
</HeroLink>
|
||||||
</div>
|
</Card.Header>
|
||||||
<ScrollableRow>
|
<ScrollableRow>
|
||||||
{loading
|
{loading
|
||||||
? // 加载状态显示灰色占位数据
|
? // 加载状态显示灰色占位数据
|
||||||
|
|
@ -300,10 +291,8 @@ function HomeClient() {
|
||||||
key={index}
|
key={index}
|
||||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
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'>
|
<Skeleton className='aspect-[2/3] w-full' />
|
||||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
<Skeleton className='mt-3 h-4' />
|
||||||
</div>
|
|
||||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: // 显示真实数据
|
: // 显示真实数据
|
||||||
|
|
@ -323,24 +312,21 @@ function HomeClient() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
{/* 每日新番放送 */}
|
{/* 每日新番放送 */}
|
||||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
<Card>
|
||||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||||
<div className='space-y-1'>
|
<div>
|
||||||
<p className='a2-kicker'>Bangumi</p>
|
<Card.Description>Bangumi</Card.Description>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
<Card.Title>新番放送</Card.Title>
|
||||||
新番放送
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<HeroLink
|
||||||
href='/douban?type=anime'
|
href='/douban?type=anime'
|
||||||
className='a2-link-action'
|
|
||||||
>
|
>
|
||||||
查看更多
|
查看更多
|
||||||
</Link>
|
</HeroLink>
|
||||||
</div>
|
</Card.Header>
|
||||||
<ScrollableRow>
|
<ScrollableRow>
|
||||||
{loading
|
{loading
|
||||||
? // 加载状态显示灰色占位数据
|
? // 加载状态显示灰色占位数据
|
||||||
|
|
@ -349,10 +335,8 @@ function HomeClient() {
|
||||||
key={index}
|
key={index}
|
||||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
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'>
|
<Skeleton className='aspect-[2/3] w-full' />
|
||||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
<Skeleton className='mt-3 h-4' />
|
||||||
</div>
|
|
||||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: // 展示当前日期的番剧
|
: // 展示当前日期的番剧
|
||||||
|
|
@ -401,24 +385,21 @@ function HomeClient() {
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
{/* 热门综艺 */}
|
{/* 热门综艺 */}
|
||||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
<Card>
|
||||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||||
<div className='space-y-1'>
|
<div>
|
||||||
<p className='a2-kicker'>Shows</p>
|
<Card.Description>Shows</Card.Description>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
<Card.Title>热门综艺</Card.Title>
|
||||||
热门综艺
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<HeroLink
|
||||||
href='/douban?type=show'
|
href='/douban?type=show'
|
||||||
className='a2-link-action'
|
|
||||||
>
|
>
|
||||||
查看更多
|
查看更多
|
||||||
</Link>
|
</HeroLink>
|
||||||
</div>
|
</Card.Header>
|
||||||
<ScrollableRow>
|
<ScrollableRow>
|
||||||
{loading
|
{loading
|
||||||
? // 加载状态显示灰色占位数据
|
? // 加载状态显示灰色占位数据
|
||||||
|
|
@ -427,10 +408,8 @@ function HomeClient() {
|
||||||
key={index}
|
key={index}
|
||||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
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'>
|
<Skeleton className='aspect-[2/3] w-full' />
|
||||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
<Skeleton className='mt-3 h-4' />
|
||||||
</div>
|
|
||||||
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: // 显示真实数据
|
: // 显示真实数据
|
||||||
|
|
@ -450,75 +429,29 @@ function HomeClient() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{announcement && showAnnouncement && (
|
{announcement && (
|
||||||
<div
|
<AppDialog
|
||||||
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'
|
isOpen={showAnnouncement}
|
||||||
}`}
|
onOpenChange={(isOpen) => {
|
||||||
onTouchStart={(e) => {
|
if (!isOpen) handleCloseAnnouncement(announcement);
|
||||||
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onTouchMove={(e) => {
|
title='提示'
|
||||||
// 如果触摸的是背景区域,阻止触摸移动,防止背景滚动
|
footer={
|
||||||
if (e.target === e.currentTarget) {
|
<Button
|
||||||
e.preventDefault();
|
fullWidth
|
||||||
e.stopPropagation();
|
onPress={() => handleCloseAnnouncement(announcement)}
|
||||||
}
|
|
||||||
}}
|
|
||||||
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'
|
|
||||||
>
|
>
|
||||||
我知道了
|
我知道了
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
<p className='text-sm leading-6 text-muted'>{announcement}</p>
|
||||||
|
</AppDialog>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
// Artplayer 和 Hls 以及弹幕插件将动态加载
|
// Artplayer 和 Hls 以及弹幕插件将动态加载
|
||||||
import { Heart } from 'lucide-react';
|
import { Heart } from 'lucide-react';
|
||||||
|
import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
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);
|
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中的代理逻辑
|
// 短剧播放地址处理函数 - 参考utils.ts中的代理逻辑
|
||||||
const processShortDramaUrl = (originalUrl: string): string => {
|
const processShortDramaUrl = (originalUrl: string): string => {
|
||||||
if (!originalUrl) {
|
if (!originalUrl) {
|
||||||
|
|
@ -2482,89 +2451,30 @@ function PlayPageClient() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
const progressValue =
|
||||||
|
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||||
|
? 33
|
||||||
|
: loadingStage === 'preferring'
|
||||||
|
? 66
|
||||||
|
: 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout activePath='/play'>
|
<PageLayout activePath='/play'>
|
||||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card className='w-full max-w-md text-center'>
|
||||||
{/* 动画影院图标 */}
|
<Card.Header className='items-center'>
|
||||||
<div className='relative mb-8'>
|
<Spinner />
|
||||||
<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'>
|
<Card.Title>{loadingMessage}</Card.Title>
|
||||||
<div className='text-white text-4xl'>
|
<Card.Description>正在准备播放器</Card.Description>
|
||||||
{loadingStage === 'searching' && '🔍'}
|
</Card.Header>
|
||||||
{loadingStage === 'preferring' && '⚡'}
|
<Card.Content>
|
||||||
{loadingStage === 'fetching' && '🎬'}
|
<ProgressBar aria-label='加载进度' value={progressValue} color='accent'>
|
||||||
{loadingStage === 'ready' && '✨'}
|
<ProgressBar.Track>
|
||||||
</div>
|
<ProgressBar.Fill />
|
||||||
{/* 旋转光环 */}
|
</ProgressBar.Track>
|
||||||
<div className='absolute -inset-2 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl opacity-20 animate-spin'></div>
|
</ProgressBar>
|
||||||
</div>
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
{/* 浮动粒子效果 */}
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -2574,65 +2484,39 @@ function PlayPageClient() {
|
||||||
return (
|
return (
|
||||||
<PageLayout activePath='/play'>
|
<PageLayout activePath='/play'>
|
||||||
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card className='w-full max-w-md'>
|
||||||
{/* 错误图标 */}
|
<Card.Header>
|
||||||
<div className='relative mb-8'>
|
<Card.Title>哎呀,出现了一些问题</Card.Title>
|
||||||
<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'>
|
<Card.Description>请检查网络连接或尝试刷新页面</Card.Description>
|
||||||
<div className='text-white text-4xl'>😵</div>
|
</Card.Header>
|
||||||
{/* 脉冲效果 */}
|
<Card.Content>
|
||||||
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
|
<Alert status='danger'>
|
||||||
</div>
|
<Alert.Content>
|
||||||
|
<Alert.Description>{error}</Alert.Description>
|
||||||
{/* 浮动错误粒子 */}
|
</Alert.Content>
|
||||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
</Alert>
|
||||||
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
|
</Card.Content>
|
||||||
<div
|
<Card.Footer className='gap-3'>
|
||||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
|
<Button
|
||||||
style={{ animationDelay: '0.5s' }}
|
fullWidth
|
||||||
></div>
|
onPress={() =>
|
||||||
<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={() =>
|
|
||||||
videoTitle
|
videoTitle
|
||||||
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
|
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
|
||||||
: router.back()
|
: 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 ? '🔍 返回搜索' : '← 返回上页'}
|
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onClick={() => window.location.reload()}
|
fullWidth
|
||||||
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'
|
variant='tertiary'
|
||||||
|
onPress={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
🔄 重新尝试
|
🔄 重新尝试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Card.Footer>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -2656,17 +2540,16 @@ function PlayPageClient() {
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
||||||
<div className='hidden lg:flex justify-end'>
|
<div className='hidden lg:flex justify-end'>
|
||||||
<button
|
<Button
|
||||||
onClick={() =>
|
variant='secondary'
|
||||||
|
size='sm'
|
||||||
|
aria-label={isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'}
|
||||||
|
onPress={() =>
|
||||||
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
|
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
|
<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'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
|
|
@ -2679,18 +2562,8 @@ function PlayPageClient() {
|
||||||
d='M9 5l7 7-7 7'
|
d='M9 5l7 7-7 7'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
|
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
|
||||||
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
|
</Button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -2712,39 +2585,15 @@ function PlayPageClient() {
|
||||||
|
|
||||||
{/* 换源加载蒙层 */}
|
{/* 换源加载蒙层 */}
|
||||||
{isVideoLoading && (
|
{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='absolute inset-0 z-[500] flex items-center justify-center bg-black/85'>
|
||||||
<div className='text-center max-w-md mx-auto px-6'>
|
<Card variant='default' className='max-w-md p-6 text-center'>
|
||||||
{/* 动画影院图标 */}
|
<Spinner />
|
||||||
<div className='relative mb-8'>
|
<p className='mt-4 text-lg font-semibold'>
|
||||||
<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'>
|
{videoLoadingStage === 'sourceChanging'
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* 浮动粒子效果 */}
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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'>
|
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||||
{videoTitle || '影片标题'}
|
{videoTitle || '影片标题'}
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
isIconOnly
|
||||||
e.stopPropagation();
|
variant='tertiary'
|
||||||
|
aria-label={favorited ? '取消收藏' : '收藏'}
|
||||||
|
onPress={() => {
|
||||||
handleToggleFavorite();
|
handleToggleFavorite();
|
||||||
}}
|
}}
|
||||||
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
|
className='ml-3 flex-shrink-0'
|
||||||
>
|
>
|
||||||
<FavoriteIcon filled={favorited} />
|
<FavoriteIcon filled={favorited} />
|
||||||
</button>
|
</Button>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* 关键信息行 */}
|
{/* 关键信息行 */}
|
||||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||||
{detail?.class && (
|
{detail?.class && (
|
||||||
<span className='text-blue-600 font-semibold'>
|
<Chip color='accent' variant='secondary'>
|
||||||
{detail.class}
|
{detail.class}
|
||||||
</span>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{(detail?.year || videoYear) && (
|
{(detail?.year || videoYear) && (
|
||||||
<span>{detail?.year || videoYear}</span>
|
<Chip variant='secondary'>{detail?.year || videoYear}</Chip>
|
||||||
)}
|
)}
|
||||||
{detail?.source_name && (
|
{detail?.source_name && (
|
||||||
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
<Chip variant='secondary'>
|
||||||
{detail.source_name}
|
{detail.source_name}
|
||||||
</span>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
{detail?.type_name && <Chip variant='secondary'>{detail.type_name}</Chip>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 短剧专用标签展示 */}
|
{/* 短剧专用标签展示 */}
|
||||||
|
|
@ -2822,11 +2673,9 @@ function PlayPageClient() {
|
||||||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||||
分类:
|
分类:
|
||||||
</span>
|
</span>
|
||||||
<span
|
<Chip color='accent' variant='secondary' size='sm'>
|
||||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${getTagColor(vodClass, true)}`}
|
|
||||||
>
|
|
||||||
📂 {vodClass}
|
📂 {vodClass}
|
||||||
</span>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -2837,12 +2686,13 @@ function PlayPageClient() {
|
||||||
标签:
|
标签:
|
||||||
</span>
|
</span>
|
||||||
{parseVodTags(vodTag).map((tag, index) => (
|
{parseVodTags(vodTag).map((tag, index) => (
|
||||||
<span
|
<Chip
|
||||||
key={index}
|
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}
|
🏷️ {tag}
|
||||||
</span>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2881,7 +2731,7 @@ function PlayPageClient() {
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='absolute top-3 left-3'
|
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
|
<svg
|
||||||
width='16'
|
width='16'
|
||||||
height='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='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>
|
<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>
|
</svg>
|
||||||
</div>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronUp, Search, X } from 'lucide-react';
|
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
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'>
|
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted' />
|
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted' />
|
||||||
<input
|
<Input
|
||||||
id='searchInput'
|
id='searchInput'
|
||||||
type='text'
|
type='text'
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|
@ -1029,14 +1039,18 @@ function SearchPageClient() {
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
placeholder='搜索电影、电视剧、短剧...'
|
placeholder='搜索电影、电视剧、短剧...'
|
||||||
autoComplete="off"
|
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 && (
|
{searchQuery && (
|
||||||
<button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => {
|
isIconOnly
|
||||||
|
size='sm'
|
||||||
|
variant='ghost'
|
||||||
|
onPress={() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
document.getElementById('searchInput')?.focus();
|
document.getElementById('searchInput')?.focus();
|
||||||
|
|
@ -1045,7 +1059,7 @@ function SearchPageClient() {
|
||||||
aria-label='清除搜索内容'
|
aria-label='清除搜索内容'
|
||||||
>
|
>
|
||||||
<X className='h-5 w-5' />
|
<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'>
|
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||||
{showResults ? (
|
{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'>
|
<div className='mb-4'>
|
||||||
<h2 className='text-xl font-semibold tracking-normal text-foreground'>
|
<h2 className='text-xl font-semibold tracking-normal text-foreground'>
|
||||||
|
|
@ -1082,9 +1096,7 @@ function SearchPageClient() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isLoading && useFluidSearch && (
|
{isLoading && useFluidSearch && (
|
||||||
<span className='ml-2 inline-block align-middle'>
|
<Spinner size='sm' className='ml-2 inline-flex align-middle' />
|
||||||
<span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-border border-t-accent'></span>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1106,29 +1118,26 @@ function SearchPageClient() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 聚合开关 */}
|
{/* 聚合开关 */}
|
||||||
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
|
<Switch
|
||||||
<span className='text-xs sm:text-sm font-medium text-muted'>聚合</span>
|
size='sm'
|
||||||
<div className='relative'>
|
isSelected={viewMode === 'agg'}
|
||||||
<input
|
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
|
||||||
type='checkbox'
|
>
|
||||||
className='sr-only peer'
|
<Switch.Control>
|
||||||
checked={viewMode === 'agg'}
|
<Switch.Thumb />
|
||||||
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
|
</Switch.Control>
|
||||||
/>
|
<Switch.Content>聚合</Switch.Content>
|
||||||
<div className='w-9 h-5 rounded-full bg-surface-secondary transition-colors peer-checked:bg-accent'></div>
|
</Switch>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{searchResults.length === 0 ? (
|
{searchResults.length === 0 ? (
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<div className='flex justify-center items-center h-40'>
|
<div className='flex h-40 items-center justify-center'>
|
||||||
<div className='h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent'></div>
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='rounded-2xl border border-dashed border-border bg-surface-secondary/60 py-8 text-center text-muted'>
|
<EmptyState>
|
||||||
未找到相关结果
|
未找到相关结果
|
||||||
</div>
|
</EmptyState>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1196,51 +1205,56 @@ function SearchPageClient() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
) : searchHistory.length > 0 ? (
|
) : 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'>
|
<Card className='mb-12'>
|
||||||
<h2 className='mb-4 text-left text-xl font-semibold tracking-normal text-foreground'>
|
<Card.Header className='flex-row items-center justify-between gap-3'>
|
||||||
搜索历史
|
<Card.Title>搜索历史</Card.Title>
|
||||||
{searchHistory.length > 0 && (
|
{searchHistory.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
variant='danger'
|
||||||
|
size='sm'
|
||||||
|
onPress={() => {
|
||||||
clearSearchHistory(); // 事件监听会自动更新界面
|
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'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{searchHistory.map((item) => (
|
{searchHistory.map((item) => (
|
||||||
<div key={item} className='relative group'>
|
<div key={item} className='relative group'>
|
||||||
<button
|
<Chip
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 直接调用搜索函数
|
// 直接调用搜索函数
|
||||||
performSearch(item.trim());
|
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}
|
<Chip.Label>{item}</Chip.Label>
|
||||||
</button>
|
</Chip>
|
||||||
{/* 删除按钮 */}
|
{/* 删除按钮 */}
|
||||||
<button
|
<Button
|
||||||
aria-label='删除搜索历史'
|
aria-label='删除搜索历史'
|
||||||
onClick={(e) => {
|
isIconOnly
|
||||||
e.stopPropagation();
|
size='sm'
|
||||||
e.preventDefault();
|
variant='danger'
|
||||||
|
className='absolute -right-2 -top-2 opacity-0 group-hover:opacity-100'
|
||||||
|
onPress={() => {
|
||||||
deleteSearchHistory(item); // 事件监听会自动更新界面
|
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' />
|
<X className='w-3 h-3' />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1252,62 +1266,18 @@ function SearchPageClient() {
|
||||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={scrollToTop}
|
<Tooltip.Trigger>
|
||||||
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'
|
<Button
|
||||||
aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
|
onPress={scrollToTop}
|
||||||
style={{
|
isIconOnly
|
||||||
background: `conic-gradient(from 0deg, rgb(var(--color-accent)) ${scrollProgress * 3.6}deg, rgb(var(--color-accent) / 0.12) ${scrollProgress * 3.6}deg)`
|
aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
|
||||||
}}
|
>
|
||||||
>
|
<ChevronUp className='w-6 h-6' />
|
||||||
{/* 内部发光圆圈 */}
|
</Button>
|
||||||
<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'>
|
</Tooltip.Trigger>
|
||||||
<ChevronUp className='w-6 h-6 text-accent transition-all duration-300 group-hover:scale-110' />
|
<Tooltip.Content>{Math.round(scrollProgress)}%</Tooltip.Content>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
|
||||||
{/* 进度环 */}
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Card, EmptyState, Spinner } from '@heroui/react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getShortDramaList,
|
getShortDramaList,
|
||||||
|
|
@ -250,12 +251,12 @@ function ShortDramaPageClient() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 选择器组件 */}
|
{/* 选择器组件 */}
|
||||||
<div className='app-filter-panel'>
|
<Card>
|
||||||
<ShortDramaSelector
|
<ShortDramaSelector
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容展示区域 */}
|
{/* 内容展示区域 */}
|
||||||
|
|
@ -305,8 +306,8 @@ function ShortDramaPageClient() {
|
||||||
>
|
>
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
|
<Spinner size='sm' />
|
||||||
<span className='text-gray-600 dark:text-gray-400'>加载中...</span>
|
<span className='text-muted'>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,9 +315,9 @@ function ShortDramaPageClient() {
|
||||||
|
|
||||||
{/* 没有更多数据提示 */}
|
{/* 没有更多数据提示 */}
|
||||||
{!hasMore && shortDramaData.length > 0 && (
|
{!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 { Metadata } from 'next';
|
||||||
|
import WarningClient from './warning-client';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '安全警告 - OrangeTV',
|
title: '安全警告 - OrangeTV',
|
||||||
|
|
@ -6,92 +7,5 @@ export const metadata: Metadata = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function WarningPage() {
|
export default function WarningPage() {
|
||||||
return (
|
return <WarningClient />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<AppIconButton
|
<AppIconButton
|
||||||
onPress={() => window.history.back()}
|
onPress={() => window.history.back()}
|
||||||
className='a2-icon-button'
|
|
||||||
aria-label='Back'
|
aria-label='Back'
|
||||||
>
|
>
|
||||||
<ArrowLeft className='w-full h-full' />
|
<ArrowLeft className='h-5 w-5' />
|
||||||
</AppIconButton>
|
</AppIconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Skeleton } from '@heroui/react';
|
||||||
|
|
||||||
import type { PlayRecord } from '@/lib/db.client';
|
import type { PlayRecord } from '@/lib/db.client';
|
||||||
import {
|
import {
|
||||||
|
|
@ -86,26 +87,24 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6 ${className || ''}`}>
|
<Card className={className}>
|
||||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
<Card.Header className='flex-row items-end justify-between gap-4'>
|
||||||
<div className='space-y-1'>
|
<div>
|
||||||
<p className='a2-kicker'>最近观看</p>
|
<Card.Description>最近观看</Card.Description>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
<Card.Title>继续观看</Card.Title>
|
||||||
继续观看
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
{!loading && playRecords.length > 0 && (
|
{!loading && playRecords.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
className='a2-link-action'
|
variant='danger'
|
||||||
onClick={async () => {
|
onPress={async () => {
|
||||||
await clearAllPlayRecords();
|
await clearAllPlayRecords();
|
||||||
setPlayRecords([]);
|
setPlayRecords([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card.Header>
|
||||||
<ScrollableRow>
|
<ScrollableRow>
|
||||||
{loading
|
{loading
|
||||||
? // 加载状态显示灰色占位数据
|
? // 加载状态显示灰色占位数据
|
||||||
|
|
@ -114,11 +113,9 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||||
key={index}
|
key={index}
|
||||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
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'>
|
<Skeleton className='aspect-[2/3] w-full' />
|
||||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
<Skeleton className='mt-3 h-4' />
|
||||||
</div>
|
<Skeleton className='mt-1 h-3' />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: // 显示真实数据
|
: // 显示真实数据
|
||||||
|
|
@ -152,6 +149,6 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
import { AlertCircle, AlertTriangle, CheckCircle, Download, FileCheck, Lock, Upload } from 'lucide-react';
|
import { AlertCircle, AlertTriangle, CheckCircle, Download, FileCheck, Lock, Upload } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from '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 {
|
interface DataMigrationProps {
|
||||||
onRefreshConfig?: () => Promise<void>;
|
onRefreshConfig?: () => Promise<void>;
|
||||||
|
|
@ -34,107 +35,80 @@ const AlertModal = ({
|
||||||
showConfirm = false,
|
showConfirm = false,
|
||||||
timer
|
timer
|
||||||
}: AlertModalProps) => {
|
}: AlertModalProps) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
// 控制动画状态
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen && timer) {
|
||||||
setIsVisible(true);
|
const timeout = setTimeout(onClose, timer);
|
||||||
if (timer) {
|
return () => clearTimeout(timeout);
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, timer);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
}
|
||||||
}, [isOpen, timer, onClose]);
|
}, [isOpen, timer, onClose]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return <CheckCircle className="w-12 h-12 text-green-500" />;
|
return <CheckCircle className="h-5 w-5" />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <AlertCircle className="w-12 h-12 text-red-500" />;
|
return <AlertCircle className="h-5 w-5" />;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return <AlertTriangle className="w-12 h-12 text-yellow-500" />;
|
return <AlertTriangle className="h-5 w-5" />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBgColor = () => {
|
const getStatus = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
return 'success';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
return 'danger';
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
return 'warning';
|
||||||
default:
|
default:
|
||||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
return 'accent';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<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}>
|
<AppDialog
|
||||||
<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()}>
|
isOpen={isOpen}
|
||||||
<div className="p-6 text-center">
|
onOpenChange={(open) => {
|
||||||
<div className="flex justify-center mb-4">
|
if (!open) onClose();
|
||||||
{getIcon()}
|
}}
|
||||||
</div>
|
title={title}
|
||||||
|
icon={getIcon()}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
footer={
|
||||||
{title}
|
showConfirm && onConfirm ? (
|
||||||
</h3>
|
<>
|
||||||
|
<Button variant='secondary' onPress={onClose}>
|
||||||
{message && (
|
取消
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
</Button>
|
||||||
{message}
|
<Button
|
||||||
</p>
|
variant='primary'
|
||||||
)}
|
onPress={() => {
|
||||||
|
onConfirm();
|
||||||
{html && (
|
onClose();
|
||||||
<div
|
}}
|
||||||
className="text-left text-gray-600 dark:text-gray-400 mb-4"
|
>
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
{confirmText}
|
||||||
/>
|
</Button>
|
||||||
)}
|
</>
|
||||||
|
) : (
|
||||||
<div className="flex justify-center space-x-3">
|
<Button variant='primary' onPress={onClose}>
|
||||||
{showConfirm && onConfirm ? (
|
确定
|
||||||
<>
|
</Button>
|
||||||
<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"
|
>
|
||||||
>
|
<Alert status={getStatus()}>
|
||||||
取消
|
{message ? <p>{message}</p> : null}
|
||||||
</button>
|
{html ? (
|
||||||
<button
|
<div
|
||||||
onClick={() => {
|
className='text-sm leading-6'
|
||||||
onConfirm();
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
onClose();
|
/>
|
||||||
}}
|
) : null}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
</Alert>
|
||||||
>
|
</AppDialog>
|
||||||
{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
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -334,51 +308,47 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
||||||
<>
|
<>
|
||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<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">
|
<Alert status='warning'>
|
||||||
<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">
|
</Alert>
|
||||||
数据迁移操作请谨慎,确保已备份重要数据
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 主要操作区域 - 响应式布局 */}
|
{/* 主要操作区域 - 响应式布局 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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="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">
|
<Chip variant='secondary' size='lg'>
|
||||||
<Download className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
<Download className="w-4 h-4" />
|
||||||
</div>
|
</Chip>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">数据导出</h3>
|
<h3 className="font-semibold">数据导出</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">创建加密备份文件</p>
|
<p className="text-sm text-muted">创建加密备份文件</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 密码输入 */}
|
{/* 密码输入 */}
|
||||||
<div>
|
<TextField>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Lock className="w-4 h-4" />
|
<Lock className="w-4 h-4" />
|
||||||
加密密码
|
加密密码
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={exportPassword}
|
value={exportPassword}
|
||||||
onChange={(e) => setExportPassword(e.target.value)}
|
onChange={(e) => setExportPassword(e.target.value)}
|
||||||
placeholder="设置强密码保护备份文件"
|
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}
|
disabled={isExporting}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-muted">
|
||||||
导入时需要使用相同密码
|
导入时需要使用相同密码
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</TextField>
|
||||||
|
|
||||||
{/* 备份内容列表 */}
|
{/* 备份内容列表 */}
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
<div className="text-xs text-muted space-y-1">
|
||||||
<p className="font-medium text-gray-700 dark:text-gray-300 mb-2">备份内容:</p>
|
<p className="font-medium text-foreground mb-2">备份内容:</p>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
<div>• 管理配置</div>
|
<div>• 管理配置</div>
|
||||||
<div>• 用户数据</div>
|
<div>• 用户数据</div>
|
||||||
|
|
@ -389,17 +359,16 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导出按钮 */}
|
{/* 导出按钮 */}
|
||||||
<button
|
<Button
|
||||||
onClick={handleExport}
|
fullWidth
|
||||||
disabled={isExporting || !exportPassword.trim()}
|
variant='primary'
|
||||||
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isExporting || !exportPassword.trim()
|
className='mt-10'
|
||||||
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
onPress={handleExport}
|
||||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
isDisabled={isExporting || !exportPassword.trim()}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -408,74 +377,80 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
||||||
导出数据
|
导出数据
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</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="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">
|
<Chip color='danger' variant='secondary' size='lg'>
|
||||||
<Upload className="w-4 h-4 text-red-600 dark:text-red-400" />
|
<Upload className="w-4 h-4" />
|
||||||
</div>
|
</Chip>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">数据导入</h3>
|
<h3 className="font-semibold">数据导入</h3>
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">⚠️ 将清空现有数据</p>
|
<p className="text-sm text-danger">将清空现有数据</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 文件选择 */}
|
{/* 文件选择 */}
|
||||||
<div>
|
<div className='space-y-2'>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
<FileCheck className="w-4 h-4" />
|
<FileCheck className="w-4 h-4" />
|
||||||
备份文件
|
备份文件
|
||||||
{selectedFile && (
|
{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)
|
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".dat"
|
accept=".dat"
|
||||||
onChange={handleFileSelect}
|
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}
|
disabled={isImporting}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
onPress={() => fileInputRef.current?.click()}
|
||||||
|
isDisabled={isImporting}
|
||||||
|
>
|
||||||
|
<FileCheck className='h-4 w-4' />
|
||||||
|
选择备份文件
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 密码输入 */}
|
{/* 密码输入 */}
|
||||||
<div>
|
<TextField>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Lock className="w-4 h-4" />
|
<Lock className="w-4 h-4" />
|
||||||
解密密码
|
解密密码
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={importPassword}
|
value={importPassword}
|
||||||
onChange={(e) => setImportPassword(e.target.value)}
|
onChange={(e) => setImportPassword(e.target.value)}
|
||||||
placeholder="输入导出时的加密密码"
|
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}
|
disabled={isImporting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TextField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导入按钮 */}
|
{/* 导入按钮 */}
|
||||||
<button
|
<Button
|
||||||
onClick={handleImport}
|
fullWidth
|
||||||
disabled={isImporting || !selectedFile || !importPassword.trim()}
|
variant='primary'
|
||||||
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isImporting || !selectedFile || !importPassword.trim()
|
className='mt-10'
|
||||||
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
onPress={handleImport}
|
||||||
: 'bg-red-600 hover:bg-red-700 text-white'
|
isDisabled={isImporting || !selectedFile || !importPassword.trim()}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -484,9 +459,9 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
||||||
导入数据
|
导入数据
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
interface CustomCategory {
|
interface CustomCategory {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -22,18 +22,17 @@ interface DoubanCustomSelectorProps {
|
||||||
|
|
||||||
const renderSelector = (
|
const renderSelector = (
|
||||||
label: string,
|
label: string,
|
||||||
|
ariaLabel: string,
|
||||||
options: { label: string; value: string }[],
|
options: { label: string; value: string }[],
|
||||||
activeValue: string | undefined,
|
activeValue: string | undefined,
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
) => (
|
) => (
|
||||||
<AppFilterTabs
|
<AppFilterSelect
|
||||||
ariaLabel={label}
|
ariaLabel={ariaLabel}
|
||||||
selectedKey={activeValue}
|
label={label}
|
||||||
onSelectionChange={onChange}
|
options={options}
|
||||||
items={options.map((option) => ({
|
value={activeValue}
|
||||||
key: option.value,
|
onChange={onChange}
|
||||||
label: option.label,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -76,31 +75,23 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4 sm:space-y-6'>
|
<div className='space-y-4 sm:space-y-6'>
|
||||||
<div className='space-y-3 sm:space-y-4'>
|
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||||
<div className='app-filter-row'>
|
{renderSelector(
|
||||||
<span className='app-filter-label'>类型</span>
|
'类型',
|
||||||
<div className='min-w-0'>
|
'自定义类型选项',
|
||||||
{renderSelector(
|
primaryOptions,
|
||||||
'自定义类型',
|
primarySelection || primaryOptions[0]?.value,
|
||||||
primaryOptions,
|
onPrimaryChange
|
||||||
primarySelection || primaryOptions[0]?.value,
|
)}
|
||||||
onPrimaryChange
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{secondaryOptions.length > 0 && (
|
{secondaryOptions.length > 0 && (
|
||||||
<div className='app-filter-row'>
|
renderSelector(
|
||||||
<span className='app-filter-label'>片单</span>
|
'片单',
|
||||||
<div className='min-w-0'>
|
'自定义片单选项',
|
||||||
{renderSelector(
|
secondaryOptions,
|
||||||
'自定义片单',
|
secondarySelection || secondaryOptions[0]?.value,
|
||||||
secondaryOptions,
|
onSecondaryChange
|
||||||
secondarySelection || secondaryOptions[0]?.value,
|
)
|
||||||
onSecondaryChange
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import MultiLevelSelector from './MultiLevelSelector';
|
import MultiLevelSelector from './MultiLevelSelector';
|
||||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||||
import WeekdaySelector from './WeekdaySelector';
|
import WeekdaySelector from './WeekdaySelector';
|
||||||
|
|
||||||
interface SelectorOption {
|
interface SelectorOption {
|
||||||
|
|
@ -73,31 +73,27 @@ const animePrimaryOptions: SelectorOption[] = [
|
||||||
|
|
||||||
const renderSelector = (
|
const renderSelector = (
|
||||||
label: string,
|
label: string,
|
||||||
|
ariaLabel: string,
|
||||||
options: SelectorOption[],
|
options: SelectorOption[],
|
||||||
activeValue: string | undefined,
|
activeValue: string | undefined,
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
) => (
|
) => (
|
||||||
<AppFilterTabs
|
<AppFilterSelect
|
||||||
ariaLabel={label}
|
ariaLabel={ariaLabel}
|
||||||
selectedKey={activeValue}
|
label={label}
|
||||||
onSelectionChange={onChange}
|
options={options}
|
||||||
items={options.map((option) => ({
|
value={activeValue}
|
||||||
key: option.value,
|
onChange={onChange}
|
||||||
label: option.label,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FilterRow = ({
|
const FilterGrid = ({
|
||||||
label,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => (
|
}) => (
|
||||||
<div className='app-filter-row'>
|
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||||
<span className='app-filter-label'>{label}</span>
|
{children}
|
||||||
<div className='min-w-0'>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -117,86 +113,81 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4 sm:space-y-6'>
|
<div className='space-y-4 sm:space-y-6'>
|
||||||
{type === 'movie' && (
|
{type === 'movie' && (
|
||||||
<div className='space-y-3 sm:space-y-4'>
|
<FilterGrid>
|
||||||
<FilterRow label='分类'>
|
{renderSelector(
|
||||||
{renderSelector(
|
'分类',
|
||||||
'电影分类',
|
'电影分类选项',
|
||||||
moviePrimaryOptions,
|
moviePrimaryOptions,
|
||||||
primarySelection || moviePrimaryOptions[0].value,
|
primarySelection || moviePrimaryOptions[0].value,
|
||||||
onPrimaryChange
|
onPrimaryChange
|
||||||
)}
|
)}
|
||||||
</FilterRow>
|
|
||||||
|
|
||||||
{primarySelection !== '全部' ? (
|
{primarySelection !== '全部' ? (
|
||||||
<FilterRow label='地区'>
|
renderSelector(
|
||||||
{renderSelector(
|
'地区',
|
||||||
'电影地区',
|
'电影地区选项',
|
||||||
movieSecondaryOptions,
|
movieSecondaryOptions,
|
||||||
secondarySelection || movieSecondaryOptions[0].value,
|
secondarySelection || movieSecondaryOptions[0].value,
|
||||||
onSecondaryChange
|
onSecondaryChange
|
||||||
)}
|
)
|
||||||
</FilterRow>
|
|
||||||
) : (
|
) : (
|
||||||
<FilterRow label='筛选'>
|
<div className='col-span-full'>
|
||||||
<MultiLevelSelector
|
<MultiLevelSelector
|
||||||
key={`${type}-${primarySelection}`}
|
key={`${type}-${primarySelection}`}
|
||||||
onChange={handleMultiLevelChange}
|
onChange={handleMultiLevelChange}
|
||||||
contentType={type}
|
contentType={type}
|
||||||
/>
|
/>
|
||||||
</FilterRow>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FilterGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'tv' && (
|
{type === 'tv' && (
|
||||||
<div className='space-y-3 sm:space-y-4'>
|
<FilterGrid>
|
||||||
<FilterRow label='分类'>
|
{renderSelector(
|
||||||
{renderSelector(
|
'分类',
|
||||||
'剧集分类',
|
'剧集分类选项',
|
||||||
tvPrimaryOptions,
|
tvPrimaryOptions,
|
||||||
primarySelection || tvPrimaryOptions[1].value,
|
primarySelection || tvPrimaryOptions[1].value,
|
||||||
onPrimaryChange
|
onPrimaryChange
|
||||||
)}
|
)}
|
||||||
</FilterRow>
|
|
||||||
|
|
||||||
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
|
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
|
||||||
<FilterRow label='类型'>
|
renderSelector(
|
||||||
{renderSelector(
|
'类型',
|
||||||
'剧集类型',
|
'剧集类型选项',
|
||||||
tvSecondaryOptions,
|
tvSecondaryOptions,
|
||||||
secondarySelection || tvSecondaryOptions[0].value,
|
secondarySelection || tvSecondaryOptions[0].value,
|
||||||
onSecondaryChange
|
onSecondaryChange
|
||||||
)}
|
)
|
||||||
</FilterRow>
|
|
||||||
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
|
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
|
||||||
<FilterRow label='筛选'>
|
<div className='col-span-full'>
|
||||||
<MultiLevelSelector
|
<MultiLevelSelector
|
||||||
key={`${type}-${primarySelection}`}
|
key={`${type}-${primarySelection}`}
|
||||||
onChange={handleMultiLevelChange}
|
onChange={handleMultiLevelChange}
|
||||||
contentType={type}
|
contentType={type}
|
||||||
/>
|
/>
|
||||||
</FilterRow>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</FilterGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'anime' && (
|
{type === 'anime' && (
|
||||||
<div className='space-y-3 sm:space-y-4'>
|
<FilterGrid>
|
||||||
<FilterRow label='分类'>
|
{renderSelector(
|
||||||
{renderSelector(
|
'分类',
|
||||||
'动漫分类',
|
'动漫分类选项',
|
||||||
animePrimaryOptions,
|
animePrimaryOptions,
|
||||||
primarySelection || animePrimaryOptions[0].value,
|
primarySelection || animePrimaryOptions[0].value,
|
||||||
onPrimaryChange
|
onPrimaryChange
|
||||||
)}
|
)}
|
||||||
</FilterRow>
|
|
||||||
|
|
||||||
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
|
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
|
||||||
<FilterRow label='星期'>
|
<div className='min-w-0'>
|
||||||
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
|
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
|
||||||
</FilterRow>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FilterRow label='筛选'>
|
<div className='col-span-full'>
|
||||||
{(primarySelection || animePrimaryOptions[0].value) === '番剧' ? (
|
{(primarySelection || animePrimaryOptions[0].value) === '番剧' ? (
|
||||||
<MultiLevelSelector
|
<MultiLevelSelector
|
||||||
key={`anime-tv-${primarySelection}`}
|
key={`anime-tv-${primarySelection}`}
|
||||||
|
|
@ -210,41 +201,39 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||||
contentType='anime-movie'
|
contentType='anime-movie'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FilterRow>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FilterGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'show' && (
|
{type === 'show' && (
|
||||||
<div className='space-y-3 sm:space-y-4'>
|
<FilterGrid>
|
||||||
<FilterRow label='分类'>
|
{renderSelector(
|
||||||
{renderSelector(
|
'分类',
|
||||||
'综艺分类',
|
'综艺分类选项',
|
||||||
showPrimaryOptions,
|
showPrimaryOptions,
|
||||||
primarySelection || showPrimaryOptions[1].value,
|
primarySelection || showPrimaryOptions[1].value,
|
||||||
onPrimaryChange
|
onPrimaryChange
|
||||||
)}
|
)}
|
||||||
</FilterRow>
|
|
||||||
|
|
||||||
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
|
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
|
||||||
<FilterRow label='类型'>
|
renderSelector(
|
||||||
{renderSelector(
|
'类型',
|
||||||
'综艺类型',
|
'综艺类型选项',
|
||||||
showSecondaryOptions,
|
showSecondaryOptions,
|
||||||
secondarySelection || showSecondaryOptions[0].value,
|
secondarySelection || showSecondaryOptions[0].value,
|
||||||
onSecondaryChange
|
onSecondaryChange
|
||||||
)}
|
)
|
||||||
</FilterRow>
|
|
||||||
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
|
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
|
||||||
<FilterRow label='筛选'>
|
<div className='col-span-full'>
|
||||||
<MultiLevelSelector
|
<MultiLevelSelector
|
||||||
key={`${type}-${primarySelection}`}
|
key={`${type}-${primarySelection}`}
|
||||||
onChange={handleMultiLevelChange}
|
onChange={handleMultiLevelChange}
|
||||||
contentType={type}
|
contentType={type}
|
||||||
/>
|
/>
|
||||||
</FilterRow>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</FilterGrid>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
import { Clock, Target, Tv } from 'lucide-react';
|
import { Clock, Target, Tv } from 'lucide-react';
|
||||||
|
import { Button, Card, Chip, EmptyState, Spinner } from '@heroui/react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { formatTimeToHHMM, parseCustomTimeFormat } from '@/lib/time';
|
import { formatTimeToHHMM, parseCustomTimeFormat } from '@/lib/time';
|
||||||
|
|
@ -147,19 +148,16 @@ export default function EpgScrollableRow({
|
||||||
// 加载中状态
|
// 加载中状态
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-4">
|
<div className='pt-4'>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<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">
|
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
|
||||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||||
今日节目单
|
今日节目单
|
||||||
</h4>
|
</h4>
|
||||||
<div className="w-16 sm:w-20"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
<div className='flex min-h-[100px] items-center justify-center sm:min-h-[120px]'>
|
||||||
<div className="flex items-center gap-3 sm:gap-4 text-gray-500 dark:text-gray-400">
|
<Spinner />
|
||||||
<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='ml-3 text-sm text-muted sm:text-base'>加载节目单...</span>
|
||||||
<span className="text-sm sm:text-base">加载节目单...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -168,41 +166,40 @@ export default function EpgScrollableRow({
|
||||||
// 无节目单状态
|
// 无节目单状态
|
||||||
if (!programs || programs.length === 0) {
|
if (!programs || programs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-4">
|
<div className='pt-4'>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<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">
|
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
|
||||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||||
今日节目单
|
今日节目单
|
||||||
</h4>
|
</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>
|
</div>
|
||||||
|
<EmptyState>
|
||||||
|
<Tv className='mx-auto mb-2 h-5 w-5' />
|
||||||
|
暂无节目单数据
|
||||||
|
<p className='mt-1 text-sm text-muted'>当前频道没有可显示的 EPG 信息</p>
|
||||||
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 mt-2">
|
<div className='mt-2 pt-4'>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<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">
|
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
|
||||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
|
||||||
今日节目单
|
今日节目单
|
||||||
</h4>
|
</h4>
|
||||||
{currentPlayingIndex !== -1 && (
|
{currentPlayingIndex !== -1 && (
|
||||||
<button
|
<Button
|
||||||
onClick={scrollToCurrentProgram}
|
size='sm'
|
||||||
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"
|
variant='secondary'
|
||||||
title="滚动到当前播放位置"
|
onPress={scrollToCurrentProgram}
|
||||||
|
aria-label='滚动到当前播放位置'
|
||||||
>
|
>
|
||||||
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
|
<Target className='h-3 w-3' />
|
||||||
<span className="hidden sm:inline">当前播放</span>
|
<span className='hidden sm:inline'>当前播放</span>
|
||||||
<span className="sm:hidden">当前</span>
|
<span className='sm:hidden'>当前</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -213,53 +210,29 @@ export default function EpgScrollableRow({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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) => {
|
{programs.map((program, index) => {
|
||||||
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
|
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
|
||||||
const isPlaying = index === currentPlayingIndex;
|
const isPlaying = index === currentPlayingIndex;
|
||||||
const isFinishedProgram = index < currentPlayingIndex;
|
|
||||||
const isUpcomingProgram = index > currentPlayingIndex;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={index}
|
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
|
className='flex min-h-[100px] w-36 flex-shrink-0 flex-col p-2 sm:min-h-[120px] sm:w-48 sm:p-3'
|
||||||
? '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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* 时间显示在顶部 */}
|
{/* 时间显示在顶部 */}
|
||||||
<div className="flex items-center justify-between mb-2 sm:mb-3 flex-shrink-0">
|
<div className='mb-2 flex flex-shrink-0 items-center justify-between sm:mb-3'>
|
||||||
<span className={`text-xs font-medium ${isPlaying
|
<Chip size='sm' variant={isPlaying ? 'primary' : 'secondary'}>
|
||||||
? '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'
|
|
||||||
}`}>
|
|
||||||
{formatTime(program.start)}
|
{formatTime(program.start)}
|
||||||
</span>
|
</Chip>
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
<span className='text-xs text-muted'>
|
||||||
{formatTime(program.end)}
|
{formatTime(program.end)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标题在中间,占据剩余空间 */}
|
{/* 标题在中间,占据剩余空间 */}
|
||||||
<div
|
<div
|
||||||
className={`text-xs sm:text-sm font-medium flex-1 ${isPlaying
|
className='flex-1 text-xs font-medium text-foreground sm:text-sm'
|
||||||
? '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'
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
WebkitLineClamp: 2,
|
WebkitLineClamp: 2,
|
||||||
|
|
@ -276,14 +249,11 @@ export default function EpgScrollableRow({
|
||||||
|
|
||||||
{/* 正在播放状态在底部 */}
|
{/* 正在播放状态在底部 */}
|
||||||
{isPlaying && (
|
{isPlaying && (
|
||||||
<div className="mt-auto pt-1 sm:pt-2 flex items-center gap-1 sm:gap-1.5 flex-shrink-0">
|
<Chip size='sm' variant='primary' className='mt-auto'>
|
||||||
<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">
|
</Chip>
|
||||||
正在播放
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, Chip, Spinner } from '@heroui/react';
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -403,32 +404,29 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
{categories.map((label, idx) => {
|
{categories.map((label, idx) => {
|
||||||
const isActive = idx === displayPage;
|
const isActive = idx === displayPage;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={label}
|
key={label}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
buttonRefs.current[idx] = el;
|
buttonRefs.current[idx] = el;
|
||||||
}}
|
}}
|
||||||
onClick={() => handleCategoryClick(idx)}
|
onPress={() => 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
|
variant={isActive ? 'primary' : 'ghost'}
|
||||||
${isActive
|
size='sm'
|
||||||
? 'text-foreground'
|
className='w-20 flex-shrink-0'
|
||||||
: 'text-muted hover:text-muted'
|
|
||||||
}
|
|
||||||
`.trim()}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{isActive && (
|
</Button>
|
||||||
<div className='absolute bottom-0 left-0 right-0 h-px bg-accent' />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 向上/向下按钮 */}
|
{/* 向上/向下按钮 */}
|
||||||
<button
|
<Button
|
||||||
className='a2-icon-button h-8 w-8 flex-shrink-0 translate-y-[-4px]'
|
isIconOnly
|
||||||
onClick={() => {
|
size='sm'
|
||||||
|
variant='tertiary'
|
||||||
|
className='flex-shrink-0 translate-y-[-4px]'
|
||||||
|
onPress={() => {
|
||||||
// 切换集数排序(正序/倒序)
|
// 切换集数排序(正序/倒序)
|
||||||
setDescending((prev) => !prev);
|
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'
|
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 集数网格 */}
|
{/* 集数网格 */}
|
||||||
|
|
@ -460,14 +458,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
})().map((episodeNumber) => {
|
})().map((episodeNumber) => {
|
||||||
const isActive = episodeNumber === value;
|
const isActive = episodeNumber === value;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={episodeNumber}
|
key={episodeNumber}
|
||||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
onPress={() => 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
|
variant={isActive ? 'primary' : 'tertiary'}
|
||||||
${isActive
|
size='sm'
|
||||||
? 'border-accent bg-accent text-accent-foreground'
|
className='min-w-10'
|
||||||
: 'border-border/70 bg-surface/60 text-muted hover:border-accent/35 hover:text-foreground'
|
|
||||||
}`.trim()}
|
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const title = episodes_titles?.[episodeNumber - 1];
|
const title = episodes_titles?.[episodeNumber - 1];
|
||||||
|
|
@ -481,7 +477,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
}
|
}
|
||||||
return title;
|
return title;
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -493,9 +489,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
<div className='flex flex-col h-full mt-4'>
|
<div className='flex flex-col h-full mt-4'>
|
||||||
{sourceSearchLoading && (
|
{sourceSearchLoading && (
|
||||||
<div className='flex items-center justify-center py-8'>
|
<div className='flex items-center justify-center py-8'>
|
||||||
<div className='h-px w-24 bg-border/70'>
|
<Spinner size='sm' />
|
||||||
<div className='h-full w-1/2 animate-pulse bg-accent'></div>
|
|
||||||
</div>
|
|
||||||
<span className='ml-3 text-xs uppercase tracking-[0.16em] text-muted'>
|
<span className='ml-3 text-xs uppercase tracking-[0.16em] text-muted'>
|
||||||
搜索中
|
搜索中
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -630,9 +624,9 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
|
|
||||||
{/* 源名称和集数信息 - 垂直居中 */}
|
{/* 源名称和集数信息 - 垂直居中 */}
|
||||||
<div className='flex items-center justify-between'>
|
<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'>
|
<Chip size='sm' variant='secondary'>
|
||||||
{source.source_name}
|
<Chip.Label>{source.source_name}</Chip.Label>
|
||||||
</span>
|
</Chip>
|
||||||
{source.episodes.length > 1 && (
|
{source.episodes.length > 1 && (
|
||||||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||||
{source.episodes.length} 集
|
{source.episodes.length} 集
|
||||||
|
|
@ -649,12 +643,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
if (!videoInfo.hasError) {
|
if (!videoInfo.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className='flex items-end gap-3 text-xs'>
|
<div className='flex items-end gap-3 text-xs'>
|
||||||
<div className='a2-data text-accent font-medium text-xs'>
|
<Chip size='sm' color='accent' variant='soft'>
|
||||||
{videoInfo.loadSpeed}
|
<Chip.Label>{videoInfo.loadSpeed}</Chip.Label>
|
||||||
</div>
|
</Chip>
|
||||||
<div className='a2-data text-warning font-medium text-xs'>
|
<Chip size='sm' color='warning' variant='soft'>
|
||||||
{videoInfo.pingTime}ms
|
<Chip.Label>{videoInfo.pingTime}ms</Chip.Label>
|
||||||
</div>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} 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'>
|
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
variant='tertiary'
|
||||||
|
fullWidth
|
||||||
|
onPress={() => {
|
||||||
if (videoTitle) {
|
if (videoTitle) {
|
||||||
router.push(
|
router.push(
|
||||||
`/search?q=${encodeURIComponent(videoTitle)}`
|
`/search?q=${encodeURIComponent(videoTitle)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className='a2-link-action w-full justify-center border-b-0 pt-2 text-center'
|
|
||||||
>
|
>
|
||||||
影片匹配有误?点击去搜索
|
影片匹配有误?点击去搜索
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Alert, Button } from '@heroui/react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface ErrorInfo {
|
interface ErrorInfo {
|
||||||
|
|
@ -60,35 +62,25 @@ export function GlobalErrorIndicator() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='fixed top-4 right-4 z-[2000]'>
|
<div className='fixed top-4 right-4 z-[2000]'>
|
||||||
{/* 错误卡片 */}
|
<Alert
|
||||||
<div
|
status='danger'
|
||||||
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 ${
|
className={`min-w-[300px] max-w-[400px] transition-transform duration-300 ${
|
||||||
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
|
isReplacing ? 'scale-105' : 'scale-100'
|
||||||
} animate-fade-in`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className='text-sm font-medium flex-1 mr-3'>
|
<Alert.Content>
|
||||||
{currentError.message}
|
<Alert.Description>{currentError.message}</Alert.Description>
|
||||||
</span>
|
</Alert.Content>
|
||||||
<button
|
<Button
|
||||||
onClick={handleClose}
|
isIconOnly
|
||||||
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
|
size='sm'
|
||||||
|
variant='ghost'
|
||||||
|
onPress={handleClose}
|
||||||
aria-label='关闭错误提示'
|
aria-label='关闭错误提示'
|
||||||
>
|
>
|
||||||
<svg
|
<X className='h-4 w-4' />
|
||||||
className='w-5 h-5'
|
</Button>
|
||||||
fill='none'
|
</Alert>
|
||||||
stroke='currentColor'
|
|
||||||
viewBox='0 0 24 24'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap='round'
|
|
||||||
strokeLinejoin='round'
|
|
||||||
strokeWidth={2}
|
|
||||||
d='M6 18L18 6M6 6l12 12'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Radio } from 'lucide-react';
|
import { Radio } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Card, Chip } from '@heroui/react';
|
||||||
|
|
||||||
import { AppButton, AppDrawer, AppScrollShadow } from './ui/HeroPrimitives';
|
import { AppButton, AppDrawer, AppScrollShadow } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||||
>
|
>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
{(poster || sourceName) && (
|
{(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 && (
|
{poster && (
|
||||||
<div className='relative h-16 w-12 flex-shrink-0 overflow-hidden rounded-md border border-border/70 bg-surface-secondary/60'>
|
<div className='relative h-16 w-12 flex-shrink-0 overflow-hidden rounded-md border border-border/70 bg-surface-secondary/60'>
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -74,25 +75,25 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<p className='truncate text-base font-semibold text-foreground'>{title}</p>
|
<p className='truncate text-base font-semibold text-foreground'>{title}</p>
|
||||||
{sourceName ? (
|
{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' ? (
|
{origin === 'live' ? (
|
||||||
<Radio size={12} className='mr-1.5 text-accent' />
|
<Radio size={12} className='mr-1.5 text-accent' />
|
||||||
) : null}
|
) : null}
|
||||||
<span className='truncate'>{sourceName}</span>
|
<Chip.Label>{sourceName}</Chip.Label>
|
||||||
</span>
|
</Chip>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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) => (
|
{actions.map((action) => (
|
||||||
<AppButton
|
<AppButton
|
||||||
key={action.id}
|
key={action.id}
|
||||||
variant='tertiary'
|
variant='tertiary'
|
||||||
fullWidth
|
fullWidth
|
||||||
isDisabled={action.disabled}
|
isDisabled={action.disabled}
|
||||||
className='h-auto justify-start rounded-none px-3 py-4'
|
className='h-auto justify-start'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
action.onClick();
|
action.onClick();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -115,36 +116,30 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||||
{action.label}
|
{action.label}
|
||||||
</span>
|
</span>
|
||||||
{action.id === 'play' && currentEpisode && totalEpisodes ? (
|
{action.id === 'play' && currentEpisode && totalEpisodes ? (
|
||||||
<span className='a2-data text-xs text-muted'>
|
<Chip size='sm' variant='secondary'>
|
||||||
{currentEpisode}/{totalEpisodes}
|
<Chip.Label>{currentEpisode}/{totalEpisodes}</Chip.Label>
|
||||||
</span>
|
</Chip>
|
||||||
) : null}
|
) : null}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{isAggregate && sources && sources.length > 0 ? (
|
{isAggregate && sources && sources.length > 0 ? (
|
||||||
<div className='rounded-lg border border-border/70 p-3'>
|
<Card variant='secondary'>
|
||||||
<div className='mb-3'>
|
<div className='mb-3'>
|
||||||
<h4 className='mb-1 text-sm font-medium text-foreground'>可用播放源</h4>
|
<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>
|
</div>
|
||||||
<AppScrollShadow className='max-h-32'>
|
<AppScrollShadow className='max-h-32'>
|
||||||
<div className='grid grid-cols-2 gap-2'>
|
<div className='grid grid-cols-2 gap-2'>
|
||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<div
|
<Chip key={source} size='sm' variant='secondary'>
|
||||||
key={source}
|
<Chip.Label>{source}</Chip.Label>
|
||||||
className='flex min-w-0 items-center gap-2 border-l border-border/70 px-3 py-2'
|
</Chip>
|
||||||
>
|
|
||||||
<div className='h-1.5 w-1.5 flex-shrink-0 bg-accent/80' />
|
|
||||||
<span className='truncate text-xs text-muted'>
|
|
||||||
{source}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AppScrollShadow>
|
</AppScrollShadow>
|
||||||
</div>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, Home, Play, Radio, Star, Tv } from 'lucide-react';
|
import { Cat, Clover, Film, Home, Play, Radio, Star, Tv } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import { Button, Card, ScrollShadow } from '@heroui/react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
interface MobileBottomNavProps {
|
||||||
|
|
@ -16,6 +16,7 @@ interface MobileBottomNavProps {
|
||||||
|
|
||||||
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
|
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
|
||||||
const currentActive = activePath ?? pathname;
|
const currentActive = activePath ?? pathname;
|
||||||
|
|
@ -95,8 +96,8 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<Card
|
||||||
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'
|
className='fixed left-0 right-0 z-[600] rounded-none p-0 md:hidden'
|
||||||
style={{
|
style={{
|
||||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -104,41 +105,31 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||||
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
|
<ScrollShadow orientation='horizontal' hideScrollBar>
|
||||||
{navItems.map((item) => {
|
<ul className='flex items-center gap-1 px-2'>
|
||||||
const active = isActive(item.href);
|
{navItems.map((item) => {
|
||||||
return (
|
const active = isActive(item.href);
|
||||||
<li
|
return (
|
||||||
key={item.href}
|
<li
|
||||||
className='flex-shrink-0'
|
key={item.href}
|
||||||
style={{ width: '20vw', minWidth: '20vw' }}
|
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'
|
|
||||||
>
|
>
|
||||||
{active && <span className='absolute left-3 right-3 top-1 h-1 rounded-full bg-accent' />}
|
<Button
|
||||||
<item.icon
|
variant={active ? 'primary' : 'ghost'}
|
||||||
className={`h-5 w-5 ${active
|
fullWidth
|
||||||
? 'text-accent'
|
className='h-14 flex-col gap-1'
|
||||||
: 'text-muted'
|
onPress={() => router.push(item.href)}
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? 'text-foreground'
|
|
||||||
: 'text-muted'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
<item.icon className='h-5 w-5' />
|
||||||
</span>
|
<span className='text-xs'>{item.label}</span>
|
||||||
</Link>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</ScrollShadow>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Card, Link as HeroLink } from '@heroui/react';
|
||||||
|
|
||||||
import { BackButton } from './BackButton';
|
import { BackButton } from './BackButton';
|
||||||
import { useSite } from './SiteProvider';
|
import { useSite } from './SiteProvider';
|
||||||
|
|
@ -14,16 +14,16 @@ interface MobileHeaderProps {
|
||||||
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||||
const { siteName } = useSite();
|
const { siteName } = useSite();
|
||||||
return (
|
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 h-12 items-center justify-between px-4'>
|
||||||
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Link
|
<HeroLink
|
||||||
href='/search'
|
href='/search'
|
||||||
className='a2-icon-button h-8 w-8 p-1.5'
|
aria-label='搜索'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className='w-full h-full'
|
className='h-5 w-5'
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
viewBox='0 0 24 24'
|
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'
|
d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</HeroLink>
|
||||||
{showBackButton && <BackButton />}
|
{showBackButton && <BackButton />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -49,14 +49,14 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||||
|
|
||||||
{/* 中间:Logo(绝对居中) */}
|
{/* 中间:Logo(绝对居中) */}
|
||||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||||
<Link
|
<HeroLink
|
||||||
href='/'
|
href='/'
|
||||||
className='theme-transition text-lg font-semibold tracking-normal text-foreground hover:text-accent'
|
className='text-lg font-semibold'
|
||||||
>
|
>
|
||||||
{siteName}
|
{siteName}
|
||||||
</Link>
|
</HeroLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dropdown, Label } from '@heroui/react';
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { AppButton } from './ui/HeroPrimitives';
|
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
interface MultiLevelOption {
|
interface MultiLevelOption {
|
||||||
label: string;
|
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 (
|
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) => (
|
{categories.map((category) => (
|
||||||
<Dropdown key={category.key}>
|
<AppFilterSelect
|
||||||
<AppButton
|
key={category.key}
|
||||||
aria-label={`${category.label}筛选`}
|
ariaLabel={`${category.label}选项`}
|
||||||
variant='tertiary'
|
label={category.label}
|
||||||
className={`app-filter-trigger ${
|
options={category.options}
|
||||||
isDefaultValue(category.key)
|
value={values[category.key] || (category.key === 'sort' ? 'T' : 'all')}
|
||||||
? ''
|
onChange={(value) => handleOptionSelect(category.key, value)}
|
||||||
: '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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@heroui/react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface ScrollableRowProps {
|
interface ScrollableRowProps {
|
||||||
|
|
@ -126,12 +127,14 @@ export default function ScrollableRow({
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
onClick={handleScrollLeftClick}
|
isIconOnly
|
||||||
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'
|
variant='secondary'
|
||||||
|
onPress={handleScrollLeftClick}
|
||||||
|
aria-label='向左滚动'
|
||||||
>
|
>
|
||||||
<ChevronLeft className='h-5 w-5' />
|
<ChevronLeft className='h-5 w-5' />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -155,12 +158,14 @@ export default function ScrollableRow({
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
onClick={handleScrollRightClick}
|
isIconOnly
|
||||||
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'
|
variant='secondary'
|
||||||
|
onPress={handleScrollRightClick}
|
||||||
|
aria-label='向右滚动'
|
||||||
>
|
>
|
||||||
<ChevronRight className='h-5 w-5' />
|
<ChevronRight className='h-5 w-5' />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dropdown, Label, ScrollShadow } from '@heroui/react';
|
|
||||||
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
import { AppButton } from './ui/HeroPrimitives';
|
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
export type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder';
|
export type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder';
|
||||||
|
|
||||||
|
|
@ -32,6 +30,12 @@ const DEFAULTS: Record<SearchFilterKey, string> = {
|
||||||
yearOrder: 'none',
|
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 SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, values, onChange }) => {
|
||||||
const mergedValues = useMemo(() => {
|
const mergedValues = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -48,109 +52,25 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
|
||||||
onChange(newValues);
|
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 (
|
return (
|
||||||
<div className='app-search-filter-bar'>
|
<div className='grid max-w-full grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4'>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Dropdown key={category.key}>
|
<AppFilterSelect
|
||||||
<AppButton
|
key={category.key}
|
||||||
variant='tertiary'
|
ariaLabel={`${category.label}选项`}
|
||||||
className={`app-search-filter-trigger ${
|
label={category.label}
|
||||||
isDefaultValue(category.key) ? '' : 'app-search-filter-trigger-active'
|
options={category.options}
|
||||||
}`}
|
value={mergedValues[category.key]}
|
||||||
>
|
onChange={(value) => handleOptionSelect(category.key, value)}
|
||||||
<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' />
|
<AppFilterSelect
|
||||||
</svg>
|
ariaLabel='排序选项'
|
||||||
</AppButton>
|
label='排序'
|
||||||
<Dropdown.Popover className='app-search-filter-popover'>
|
options={YEAR_ORDER_OPTIONS}
|
||||||
<ScrollShadow className='app-search-filter-scroll'>
|
value={mergedValues.yearOrder}
|
||||||
<Dropdown.Menu
|
onChange={(value) => onChange({ ...mergedValues, yearOrder: value })}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Card, Label, ListBox, ScrollShadow } from '@heroui/react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface SearchSuggestionsProps {
|
interface SearchSuggestionsProps {
|
||||||
|
|
@ -146,21 +147,27 @@ export default function SearchSuggestions({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
ref={containerRef}
|
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) => (
|
<ScrollShadow className='max-h-80' hideScrollBar>
|
||||||
<button
|
<ListBox
|
||||||
key={`related-${suggestion.text}`}
|
aria-label='搜索建议'
|
||||||
onClick={() => onSelect(suggestion.text)}
|
selectionMode='none'
|
||||||
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"
|
onAction={(key) => onSelect(String(key))}
|
||||||
>
|
>
|
||||||
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
|
{suggestions.map((suggestion) => (
|
||||||
{suggestion.text}
|
<ListBox.Item
|
||||||
</span>
|
key={`related-${suggestion.text}`}
|
||||||
</button>
|
id={suggestion.text}
|
||||||
))}
|
textValue={suggestion.text}
|
||||||
</div>
|
>
|
||||||
|
<Label className='truncate'>{suggestion.text}</Label>
|
||||||
|
</ListBox.Item>
|
||||||
|
))}
|
||||||
|
</ListBox>
|
||||||
|
</ScrollShadow>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Skeleton } from '@heroui/react';
|
||||||
|
|
||||||
import { getShortDramaCategories, ShortDramaCategory } from '@/lib/shortdrama.client';
|
import { getShortDramaCategories, ShortDramaCategory } from '@/lib/shortdrama.client';
|
||||||
|
|
||||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
interface ShortDramaSelectorProps {
|
interface ShortDramaSelectorProps {
|
||||||
selectedCategory: string;
|
selectedCategory: string;
|
||||||
|
|
@ -49,44 +50,31 @@ const ShortDramaSelector = ({
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 渲染胶囊式选择器
|
const renderCategorySelector = () => {
|
||||||
const renderCapsuleSelector = () => {
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className='inline-flex rounded-full bg-surface-secondary p-1'>
|
<Skeleton className='h-16 w-full' />
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppFilterTabs
|
<AppFilterSelect
|
||||||
ariaLabel='短剧分类'
|
ariaLabel='短剧分类选项'
|
||||||
selectedKey={selectedCategory}
|
label='分类'
|
||||||
onSelectionChange={onCategoryChange}
|
options={categories.map((category) => ({
|
||||||
items={categories.map((category) => ({
|
value: category.type_id.toString(),
|
||||||
key: category.type_id.toString(),
|
|
||||||
label: category.type_name,
|
label: category.type_name,
|
||||||
}))}
|
}))}
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={onCategoryChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4 sm:space-y-6'>
|
<div className='space-y-4 sm:space-y-6'>
|
||||||
{/* 分类选择 */}
|
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
|
||||||
<div className='app-filter-row'>
|
{renderCategorySelector()}
|
||||||
<span className='app-filter-label'>
|
|
||||||
分类
|
|
||||||
</span>
|
|
||||||
<div className='min-w-0'>
|
|
||||||
{renderCapsuleSelector()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import {
|
||||||
Tv,
|
Tv,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
import { useSite } from './SiteProvider';
|
import { useSite } from './SiteProvider';
|
||||||
|
|
||||||
|
|
@ -50,10 +51,11 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onClick={onClick}
|
onPress={onClick}
|
||||||
className='theme-transition flex h-12 w-12 cursor-pointer items-center justify-center hover:opacity-80'
|
isIconOnly
|
||||||
title='点击展开侧边栏'
|
variant='ghost'
|
||||||
|
aria-label='点击展开侧边栏'
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src='/logo.png'
|
src='/logo.png'
|
||||||
|
|
@ -62,28 +64,26 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
||||||
height={32}
|
height={32}
|
||||||
className='rounded-lg'
|
className='rounded-lg'
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<HeroLink
|
||||||
href='/'
|
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
|
||||||
<Image
|
src='/logo.png'
|
||||||
src='/logo.png'
|
alt={siteName}
|
||||||
alt={siteName}
|
width={40}
|
||||||
width={40}
|
height={40}
|
||||||
height={40}
|
className='rounded-lg'
|
||||||
className='rounded-lg'
|
/>
|
||||||
/>
|
<span className='text-xl font-semibold'>
|
||||||
<span className='text-xl font-semibold tracking-normal text-foreground'>
|
{siteName}
|
||||||
{siteName}
|
</span>
|
||||||
</span>
|
</HeroLink>
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -217,25 +217,60 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getNavClasses = (isActive: boolean) =>
|
const renderNavButton = ({
|
||||||
`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 ${
|
href,
|
||||||
isActive
|
label,
|
||||||
? 'border-accent/25 bg-accent/10 text-accent shadow-sm'
|
icon: Icon,
|
||||||
: 'border-transparent text-muted hover:border-border hover:bg-surface-secondary hover:text-foreground'
|
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 (
|
return (
|
||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
{/* 在移动端隐藏侧边栏 */}
|
{/* 在移动端隐藏侧边栏 */}
|
||||||
<div className='hidden md:flex'>
|
<div className='hidden md:flex'>
|
||||||
<aside
|
<Card
|
||||||
data-sidebar
|
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'>
|
<div className='flex h-full flex-col'>
|
||||||
{/* 顶部 Logo 区域 */}
|
{/* 顶部 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'>
|
<div className='absolute inset-0 flex items-center justify-center transition-all duration-200'>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Logo isCollapsed={true} onClick={handleToggle} />
|
<Logo isCollapsed={true} onClick={handleToggle} />
|
||||||
|
|
@ -246,57 +281,41 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<button
|
<Button
|
||||||
onClick={handleToggle}
|
onPress={handleToggle}
|
||||||
className='a2-icon-button absolute right-3 top-1/2 z-10 -translate-y-1/2'
|
isIconOnly
|
||||||
title='收起侧边栏'
|
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' />
|
<Menu className='h-4 w-4' />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* 首页和搜索导航 */}
|
{/* 首页和搜索导航 */}
|
||||||
<nav className='mt-6 space-y-1 px-3'>
|
<nav className='mt-6 space-y-1 px-3'>
|
||||||
<Link
|
{renderNavButton({
|
||||||
href='/'
|
href: '/',
|
||||||
onClick={() => setActive('/')}
|
label: '首页',
|
||||||
data-active={active === '/'}
|
icon: Home,
|
||||||
className={getNavClasses(active === '/')}
|
isActive: active === '/',
|
||||||
>
|
})}
|
||||||
<div className='w-4 h-4 flex items-center justify-center'>
|
{renderNavButton({
|
||||||
<Home className='h-4 w-4' />
|
href: '/search',
|
||||||
</div>
|
label: '搜索',
|
||||||
{!isCollapsed && (
|
icon: Search,
|
||||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
isActive: active === '/search',
|
||||||
首页
|
onPress: handleSearchClick,
|
||||||
</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>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 菜单项 */}
|
{/* 菜单项 */}
|
||||||
<div className='flex-1 overflow-y-auto px-3 pt-6'>
|
<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) => {
|
{menuItems.map((item) => {
|
||||||
// 检查当前路径是否匹配这个菜单项
|
// 检查当前路径是否匹配这个菜单项
|
||||||
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||||
|
|
@ -312,22 +331,14 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||||
(item.href === '/shortdrama' && decodedActive.startsWith('/shortdrama'));
|
(item.href === '/shortdrama' && decodedActive.startsWith('/shortdrama'));
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<div key={item.label}>
|
||||||
key={item.label}
|
{renderNavButton({
|
||||||
href={item.href}
|
href: item.href,
|
||||||
onClick={() => setActive(item.href)}
|
label: item.label,
|
||||||
data-active={isActive}
|
icon: Icon,
|
||||||
className={getNavClasses(isActive)}
|
isActive,
|
||||||
>
|
})}
|
||||||
<div className='w-4 h-4 flex items-center justify-center'>
|
</div>
|
||||||
<Icon className='h-4 w-4' />
|
|
||||||
</div>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -335,40 +346,49 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||||
|
|
||||||
{/* 致谢信息 */}
|
{/* 致谢信息 */}
|
||||||
<div className='px-3 pb-5'>
|
<div className='px-3 pb-5'>
|
||||||
<div className='border-t border-border/70 pt-4'>
|
<Separator className='mb-4' />
|
||||||
|
<div>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className='px-2 text-center text-xs leading-relaxed text-muted'>
|
<div className='px-2 text-center text-xs leading-relaxed text-muted'>
|
||||||
<span>本项目基于 </span>
|
<span>本项目基于 </span>
|
||||||
<button
|
<HeroLink
|
||||||
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
|
href='https://github.com/MoonTechLab/LunaTV'
|
||||||
className='theme-transition font-medium text-accent hover:text-accent-strong'
|
target='_blank'
|
||||||
>
|
>
|
||||||
MoonTV
|
MoonTV
|
||||||
</button>
|
</HeroLink>
|
||||||
<button
|
<HeroLink
|
||||||
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
|
href='https://github.com/MoonTechLab/LunaTV'
|
||||||
className='theme-transition ml-1 text-accent hover:text-accent-strong'
|
target='_blank'
|
||||||
title='访问 MoonTV 项目'
|
aria-label='访问 MoonTV 项目'
|
||||||
|
className='ml-1'
|
||||||
>
|
>
|
||||||
<ExternalLink className='h-3 w-3 inline' />
|
<ExternalLink className='h-3 w-3 inline' />
|
||||||
</button>
|
</HeroLink>
|
||||||
<span> 的二次开发</span>
|
<span> 的二次开发</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
|
<Tooltip.Trigger>
|
||||||
className='theme-transition p-1 text-accent hover:text-accent-strong'
|
<HeroLink
|
||||||
title='基于 MoonTV 的二次开发'
|
href='https://github.com/MoonTechLab/LunaTV'
|
||||||
>
|
target='_blank'
|
||||||
<ExternalLink className='h-4 w-4' />
|
aria-label='基于 MoonTV 的二次开发'
|
||||||
</button>
|
>
|
||||||
|
<ExternalLink className='h-4 w-4' />
|
||||||
|
</HeroLink>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content placement='right'>
|
||||||
|
基于 MoonTV 的二次开发
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</Card>
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
|
className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ChevronDown, ChevronUp, Palette, Eye, Check } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Palette, Eye, Check } from 'lucide-react';
|
||||||
|
import { Alert, Button, Card, Chip, TextArea } from '@heroui/react';
|
||||||
|
|
||||||
// CSS模板配置
|
// CSS模板配置
|
||||||
const cssTemplates = [
|
const cssTemplates = [
|
||||||
|
|
@ -9,10 +10,10 @@ const cssTemplates = [
|
||||||
id: 'gradient-bg',
|
id: 'gradient-bg',
|
||||||
name: '渐变背景',
|
name: '渐变背景',
|
||||||
description: '为页面添加漂亮的渐变背景',
|
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: `/* 渐变背景主题 */
|
css: `/* 渐变背景主题 */
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #18181b 0%, #be123c 100%);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,26 +68,26 @@ body::before {
|
||||||
id: 'sidebar-glow',
|
id: 'sidebar-glow',
|
||||||
name: '发光侧边栏',
|
name: '发光侧边栏',
|
||||||
description: '为侧边栏添加发光效果',
|
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: `/* 发光侧边栏效果 */
|
css: `/* 发光侧边栏效果 */
|
||||||
.sidebar, [data-sidebar] {
|
.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-radius: 15px;
|
||||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
border: 1px solid rgba(225, 29, 72, 0.2);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边栏项目悬停效果 */
|
/* 侧边栏项目悬停效果 */
|
||||||
.sidebar a:hover, [data-sidebar] a:hover {
|
.sidebar a:hover, [data-sidebar] a:hover {
|
||||||
background: rgba(14, 165, 233, 0.1);
|
background: rgba(225, 29, 72, 0.1);
|
||||||
transform: translateX(5px);
|
transform: translateX(5px);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 活动项目发光 */
|
/* 活动项目发光 */
|
||||||
.sidebar [data-active="true"], [data-sidebar] [data-active="true"] {
|
.sidebar [data-active="true"], [data-sidebar] [data-active="true"] {
|
||||||
background: rgba(14, 165, 233, 0.15);
|
background: rgba(225, 29, 72, 0.15);
|
||||||
box-shadow: inset 0 0 10px rgba(14, 165, 233, 0.2);
|
box-shadow: inset 0 0 10px rgba(225, 29, 72, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}`
|
}`
|
||||||
},
|
},
|
||||||
|
|
@ -134,9 +135,9 @@ body::before {
|
||||||
css: `/* 毛玻璃主题 */
|
css: `/* 毛玻璃主题 */
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(45deg,
|
background: linear-gradient(45deg,
|
||||||
rgba(59, 130, 246, 0.1) 0%,
|
rgba(24, 24, 27, 0.1) 0%,
|
||||||
rgba(147, 51, 234, 0.1) 50%,
|
rgba(225, 29, 72, 0.1) 50%,
|
||||||
rgba(236, 72, 153, 0.1) 100%);
|
rgba(244, 63, 94, 0.1) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 所有面板使用毛玻璃效果 */
|
/* 所有面板使用毛玻璃效果 */
|
||||||
|
|
@ -212,13 +213,13 @@ const themes = [
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: '默认主题',
|
name: '默认主题',
|
||||||
description: '现代蓝色主题,清新优雅',
|
description: '石墨玫瑰,冷静高级',
|
||||||
preview: {
|
preview: {
|
||||||
bg: '#ffffff',
|
bg: '#fafafa',
|
||||||
surface: '#f9fafb',
|
surface: '#ffffff',
|
||||||
accent: '#0ea5e9',
|
accent: '#e11d48',
|
||||||
text: '#111827',
|
text: '#18181b',
|
||||||
border: '#e5e7eb'
|
border: '#d4d4d8'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -560,51 +561,49 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 管理员控制面板 */}
|
{/* 管理员控制面板 */}
|
||||||
{isAdmin && globalThemeConfig && (
|
{isAdmin && globalThemeConfig && (
|
||||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
<Card variant='default' className='p-4'>
|
||||||
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
|
<Card.Header>
|
||||||
<Palette className="h-5 w-5" />
|
<Card.Title className="flex items-center gap-2">
|
||||||
全站主题设置
|
<Palette className="h-5 w-5" />
|
||||||
</h3>
|
全站主题设置
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
|
<Card variant='secondary' className='p-3'>
|
||||||
<div className="text-sm text-theme-text">
|
<div className="text-sm">
|
||||||
<strong>当前全站配置:</strong>
|
<strong>当前全站配置:</strong>
|
||||||
</div>
|
</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}
|
默认主题: {themes.find(t => t.id === globalThemeConfig.defaultTheme)?.name || globalThemeConfig.defaultTheme}
|
||||||
{globalThemeConfig.customCSS && ' | 包含自定义CSS'}
|
{globalThemeConfig.customCSS && ' | 包含自定义CSS'}
|
||||||
{!globalThemeConfig.allowUserCustomization && ' | 禁止用户自定义'}
|
{!globalThemeConfig.allowUserCustomization && ' | 禁止用户自定义'}
|
||||||
</div>
|
</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">
|
<Alert status='accent'>
|
||||||
<div className="flex items-center gap-2 text-blue-800 dark:text-blue-200">
|
<Alert.Title>全站主题</Alert.Title>
|
||||||
<span className="text-sm font-medium">ℹ️ 全站主题</span>
|
<Alert.Description>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
|
||||||
在此设置的主题配置将应用到整个网站,影响所有用户的默认体验
|
在此设置的主题配置将应用到整个网站,影响所有用户的默认体验
|
||||||
</p>
|
</Alert.Description>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主题选择器 */}
|
{/* 主题选择器 */}
|
||||||
<div>
|
<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" />
|
<Palette className="h-5 w-5" />
|
||||||
全站主题选择
|
全站主题选择
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<div
|
<Card
|
||||||
key={theme.id}
|
key={theme.id}
|
||||||
className={`relative p-4 border-2 rounded-xl transition-all ${currentTheme === theme.id
|
variant={currentTheme === theme.id ? 'secondary' : 'default'}
|
||||||
? 'border-theme-accent bg-theme-accent/5'
|
className={`relative p-4 ${isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
|
||||||
: 'border-theme-border bg-theme-surface'
|
|
||||||
} ${isAdmin ? 'cursor-pointer hover:border-theme-accent/50' : 'cursor-not-allowed opacity-60'}`}
|
|
||||||
onClick={() => isAdmin && handleThemeChange(theme.id)}
|
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 className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.accent }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size='sm'
|
||||||
|
variant='tertiary'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isAdmin) handleThemePreview(theme.id);
|
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'}`}
|
aria-label={isAdmin ? "预览主题" : "仅管理员可预览"}
|
||||||
title={isAdmin ? "预览主题" : "仅管理员可预览"}
|
isDisabled={previewMode || !isAdmin}
|
||||||
disabled={previewMode || !isAdmin}
|
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
{currentTheme === theme.id && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 className="font-medium text-theme-text">{theme.name}</h4>
|
<h4 className="font-medium">{theme.name}</h4>
|
||||||
<p className="text-sm text-theme-text-secondary mt-1">{theme.description}</p>
|
<p className="text-sm text-muted mt-1">{theme.description}</p>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previewMode && (
|
{previewMode && (
|
||||||
<div className="mt-4 p-3 bg-theme-info/10 border border-theme-info/20 rounded-lg">
|
<Alert status='accent' className='mt-4'>
|
||||||
<p className="text-sm text-theme-info">正在预览主题,3秒后将自动恢复...</p>
|
正在预览主题,3秒后将自动恢复...
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 自定义CSS编辑器 */}
|
{/* 自定义CSS编辑器 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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" />
|
<Palette className="h-5 w-5" />
|
||||||
全站自定义样式
|
全站自定义样式
|
||||||
</h3>
|
</h3>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<button
|
<Button
|
||||||
onClick={() => setShowCustomEditor(!showCustomEditor)}
|
variant='secondary'
|
||||||
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"
|
size='sm'
|
||||||
|
onPress={() => setShowCustomEditor(!showCustomEditor)}
|
||||||
>
|
>
|
||||||
{showCustomEditor ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{showCustomEditor ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
{showCustomEditor ? '收起编辑器' : '展开编辑器'}
|
{showCustomEditor ? '收起编辑器' : '展开编辑器'}
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-theme-text-secondary">
|
<div className="text-sm text-muted">
|
||||||
仅管理员可编辑
|
仅管理员可编辑
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAdmin && (
|
{!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">
|
<Alert status='warning' className='mb-4'>
|
||||||
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
<Alert.Title>权限限制</Alert.Title>
|
||||||
<span className="text-sm font-medium">⚠️ 权限限制</span>
|
<Alert.Description>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
|
||||||
您当前没有权限修改全站主题设置,请联系管理员。
|
您当前没有权限修改全站主题设置,请联系管理员。
|
||||||
</p>
|
</Alert.Description>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && showCustomEditor && (
|
{isAdmin && showCustomEditor && (
|
||||||
<div className="space-y-4">
|
<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>
|
<p className="mb-2">💡 <strong>使用提示:</strong></p>
|
||||||
<ul className="space-y-1 text-xs">
|
<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>• 使用CSS变量覆盖主题颜色:<code>--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>• 使用Tailwind类名:<code>{`.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>• 自定义组件样式:<code>{`.admin-panel { border-radius: 20px; }`}</code></li>
|
||||||
<li>• 修改会实时生效,请谨慎使用</li>
|
<li>• 修改会实时生效,请谨慎使用</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<TextArea
|
||||||
value={customCSS}
|
value={customCSS}
|
||||||
onChange={(e) => setCustomCSS(e.target.value)}
|
onChange={(e) => setCustomCSS(e.target.value)}
|
||||||
placeholder="/* 在此输入您的自定义CSS */
|
placeholder="/* 在此输入您的自定义CSS */
|
||||||
|
|
@ -704,27 +706,22 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
||||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 使用Tailwind类名 */
|
/* 使用Tailwind类名 */
|
||||||
.custom-button {
|
.custom-button {
|
||||||
@apply bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-xl;
|
@apply bg-accent text-accent-foreground px-6 py-3;
|
||||||
}"
|
}"
|
||||||
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"
|
className="h-64 w-full font-mono text-sm"
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<Button variant='primary' onPress={handleCustomCSSApply}>
|
||||||
onClick={handleCustomCSSApply}
|
|
||||||
className="px-4 py-2 bg-theme-accent text-white rounded-lg hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
应用样式
|
应用样式
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant='secondary' onPress={handleCustomCSSReset}>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -732,68 +729,71 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
|
||||||
|
|
||||||
{/* CSS 模板库 */}
|
{/* CSS 模板库 */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
<Card variant='default' className='p-4'>
|
||||||
<h4 className="font-medium text-theme-text mb-3 flex items-center gap-2">
|
<Card.Header>
|
||||||
<Palette className="h-4 w-4" />
|
<Card.Title className="flex items-center gap-2">
|
||||||
🎨 全站样式模板库
|
<Palette className="h-4 w-4" />
|
||||||
</h4>
|
全站样式模板库
|
||||||
<p className="text-sm text-theme-text-secondary mb-4">选择预设模板快速应用炫酷效果到全站,也可以在此基础上进行自定义修改</p>
|
</Card.Title>
|
||||||
|
<Card.Description>选择预设模板快速应用到全站,也可以在此基础上进行自定义修改</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{cssTemplates.map((template) => (
|
{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">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h5 className="text-sm font-medium text-theme-text">{template.name}</h5>
|
<h5 className="text-sm font-medium">{template.name}</h5>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleApplyTemplate(template.css, template.name)}
|
size='sm'
|
||||||
className="text-xs px-2 py-1 bg-theme-accent text-white rounded hover:opacity-90 transition-opacity opacity-0 group-hover:opacity-100"
|
variant='primary'
|
||||||
|
onPress={() => handleApplyTemplate(template.css, template.name)}
|
||||||
>
|
>
|
||||||
应用
|
应用
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-theme-text-secondary mb-2">{template.description}</p>
|
<p className="text-xs text-muted mb-2">{template.description}</p>
|
||||||
<div className="text-xs bg-theme-bg rounded p-2 max-h-16 overflow-y-auto">
|
<div className="text-xs max-h-16 overflow-y-auto">
|
||||||
<code className="whitespace-pre-wrap text-theme-text-secondary">{template.preview}</code>
|
<code className="whitespace-pre-wrap text-muted">{template.preview}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
|
<Alert status='accent' className='mt-4'>
|
||||||
<p className="text-xs text-theme-text-secondary">
|
<Alert.Description>
|
||||||
<strong>💡 使用提示:</strong> 点击模板的"应用"按钮将代码复制到自定义CSS编辑器,然后可以在此基础上进行修改。记得点击"应用样式"按钮生效。
|
<strong>💡 使用提示:</strong> 点击模板的"应用"按钮将代码复制到自定义CSS编辑器,然后可以在此基础上进行修改。记得点击"应用样式"按钮生效。
|
||||||
</p>
|
</Alert.Description>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 使用说明 */}
|
{/* 使用说明 */}
|
||||||
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
|
<Card variant='default' className='p-4'>
|
||||||
<h4 className="font-medium text-theme-text mb-2">📖 全站主题定制指南</h4>
|
<Card.Title>全站主题定制指南</Card.Title>
|
||||||
<div className="text-sm text-theme-text-secondary space-y-2">
|
<div className="text-sm text-muted space-y-2 mt-2">
|
||||||
<p><strong>内置主题:</strong>{isAdmin ? '选择预设主题即可一键切换全站整体风格' : '由管理员设置的全站预设主题'}</p>
|
<p><strong>内置主题:</strong>{isAdmin ? '选择预设主题即可一键切换全站整体风格' : '由管理员设置的全站预设主题'}</p>
|
||||||
{isAdmin && <p><strong>自定义CSS:</strong>通过CSS变量或直接样式实现全站个性化定制</p>}
|
{isAdmin && <p><strong>自定义CSS:</strong>通过CSS变量或直接样式实现全站个性化定制</p>}
|
||||||
{isAdmin && <p><strong>样式模板:</strong>使用预设模板快速实现炫酷效果</p>}
|
{isAdmin && <p><strong>样式模板:</strong>使用预设模板快速实现炫酷效果</p>}
|
||||||
<p><strong>主题变量:</strong></p>
|
<p><strong>主题变量:</strong></p>
|
||||||
<ul className="text-xs space-y-1 ml-4 mt-1">
|
<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>--color-theme-bg</code> - 背景色</li>
|
||||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-surface</code> - 卡片背景</li>
|
<li>• <code>--color-theme-surface</code> - 卡片背景</li>
|
||||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-accent</code> - 主题色</li>
|
<li>• <code>--color-theme-accent</code> - 主题色</li>
|
||||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-text</code> - 主文本色</li>
|
<li>• <code>--color-theme-text</code> - 主文本色</li>
|
||||||
<li>• <code className="bg-theme-bg px-1 rounded">--color-theme-border</code> - 边框色</li>
|
<li>• <code>--color-theme-border</code> - 边框色</li>
|
||||||
</ul>
|
</ul>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<p><strong>常用技巧:</strong></p>
|
<p><strong>常用技巧:</strong></p>
|
||||||
<ul className="text-xs space-y-1 ml-4 mt-1">
|
<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>• 修改背景:<code>{`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>• 使用Tailwind:<code>{`.my-class { @apply bg-red-500; }`}</code></li>
|
||||||
<li>• 组合多个模板效果获得独特样式</li>
|
<li>• 组合多个模板效果获得独特样式</li>
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { MessageCircle, Moon, Sun } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Badge } from '@heroui/react';
|
||||||
import { ChatModal } from './ChatModal';
|
import { ChatModal } from './ChatModal';
|
||||||
import { AppIconButton } from './ui/HeroPrimitives';
|
import { AppIconButton } from './ui/HeroPrimitives';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
|
@ -105,28 +106,28 @@ export function ThemeToggle() {
|
||||||
{!isLoginPage && (
|
{!isLoginPage && (
|
||||||
<AppIconButton
|
<AppIconButton
|
||||||
onPress={() => setIsChatModalOpen(true)}
|
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'
|
aria-label='Open chat'
|
||||||
>
|
>
|
||||||
<MessageCircle className='w-full h-full' />
|
|
||||||
{messageCount > 0 && (
|
{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`}>
|
<Badge size='sm' color='accent' variant='primary' className='absolute -right-1 -top-1'>
|
||||||
{messageCount > 99 ? '99+' : messageCount}
|
<Badge.Label>{messageCount > 99 ? '99+' : messageCount}</Badge.Label>
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
<MessageCircle className='h-5 w-5' />
|
||||||
</AppIconButton>
|
</AppIconButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主题切换按钮 */}
|
{/* 主题切换按钮 */}
|
||||||
<AppIconButton
|
<AppIconButton
|
||||||
onPress={toggleTheme}
|
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'
|
aria-label='Toggle theme'
|
||||||
>
|
>
|
||||||
{resolvedTheme === 'dark' ? (
|
{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>
|
</AppIconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,15 +10,16 @@ import {
|
||||||
Download,
|
Download,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Alert, Button, Card, Chip, Link as HeroLink } from '@heroui/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
import { changelog, ChangelogEntry } from '@/lib/changelog';
|
import { changelog, ChangelogEntry } from '@/lib/changelog';
|
||||||
import { CURRENT_VERSION } from '@/lib/version';
|
import { CURRENT_VERSION } from '@/lib/version';
|
||||||
import { compareVersions, UpdateStatus } from '@/lib/version_check';
|
import { compareVersions, UpdateStatus } from '@/lib/version_check';
|
||||||
|
|
||||||
|
import { AppDialog } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
interface VersionPanelProps {
|
interface VersionPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -193,34 +194,27 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
|
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={entry.version}
|
key={entry.version}
|
||||||
className={`p-4 rounded-lg border ${isCurrentVersion
|
className='p-4'
|
||||||
? '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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* 版本标题 */}
|
{/* 版本标题 */}
|
||||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
|
<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'>
|
<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}
|
v{entry.version}
|
||||||
</h4>
|
</h4>
|
||||||
{isCurrentVersion && (
|
{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'>
|
<Chip size='sm' variant='primary'>当前版本</Chip>
|
||||||
当前版本
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{isUpdate && (
|
{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' />
|
<Download className='w-3 h-3' />
|
||||||
可更新
|
可更新
|
||||||
</span>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}
|
{entry.date}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +223,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{entry.added.length > 0 && (
|
{entry.added.length > 0 && (
|
||||||
<div>
|
<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' />
|
<Plus className='w-4 h-4' />
|
||||||
新增功能
|
新增功能
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -237,9 +231,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
{entry.added.map((item, index) => (
|
{entry.added.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
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}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -249,7 +242,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
|
|
||||||
{entry.changed.length > 0 && (
|
{entry.changed.length > 0 && (
|
||||||
<div>
|
<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' />
|
<RefreshCw className='w-4 h-4' />
|
||||||
功能改进
|
功能改进
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -257,9 +250,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
{entry.changed.map((item, index) => (
|
{entry.changed.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
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}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -269,7 +261,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
|
|
||||||
{entry.fixed.length > 0 && (
|
{entry.fixed.length > 0 && (
|
||||||
<div>
|
<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' />
|
<Bug className='w-4 h-4' />
|
||||||
问题修复
|
问题修复
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -277,9 +269,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
{entry.fixed.map((item, index) => (
|
{entry.fixed.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
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}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -287,289 +278,110 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 版本面板内容
|
if (!mounted) return null;
|
||||||
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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 版本面板 */}
|
const remoteUpdates = remoteChangelog
|
||||||
<div
|
.filter((entry) => {
|
||||||
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'
|
const localVersions = changelog.map((local) => local.version);
|
||||||
onTouchMove={(e) => {
|
return !localVersions.includes(entry.version);
|
||||||
// 允许版本面板内部滚动,阻止事件冒泡到外层
|
})
|
||||||
e.stopPropagation();
|
.sort((a, b) => {
|
||||||
}}
|
const dateA = new Date(a.date);
|
||||||
style={{
|
const dateB = new Date(b.date);
|
||||||
touchAction: 'auto', // 允许面板内的正常触摸操作
|
return dateB.getTime() - dateA.getTime();
|
||||||
}}
|
});
|
||||||
>
|
|
||||||
{/* 标题栏 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
return (
|
||||||
<div className='p-3 sm:p-6 overflow-y-auto max-h-[calc(95vh-140px)] sm:max-h-[calc(90vh-120px)]'>
|
<AppDialog
|
||||||
<div className='space-y-3 sm:space-y-6'>
|
isOpen={isOpen}
|
||||||
{/* 远程更新信息 */}
|
onOpenChange={(open) => {
|
||||||
{hasUpdate && (
|
if (!open) onClose();
|
||||||
<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'>
|
title='版本信息'
|
||||||
<div className='flex items-center gap-2 sm:gap-3'>
|
description={`当前版本 v${CURRENT_VERSION}`}
|
||||||
<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'>
|
size='lg'
|
||||||
<Download className='w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400' />
|
>
|
||||||
</div>
|
<div className='space-y-6'>
|
||||||
<div className='min-w-0 flex-1'>
|
{hasUpdate ? (
|
||||||
<h4 className='text-sm sm:text-base font-semibold text-yellow-800 dark:text-yellow-200'>
|
<Alert status='warning'>
|
||||||
发现新版本
|
<Alert.Indicator>
|
||||||
</h4>
|
<Download className='h-4 w-4' />
|
||||||
<p className='text-xs sm:text-sm text-yellow-700 dark:text-yellow-300 break-all'>
|
</Alert.Indicator>
|
||||||
v{CURRENT_VERSION} → v{latestVersion}
|
<Alert.Content>
|
||||||
</p>
|
<Alert.Title>发现新版本</Alert.Title>
|
||||||
</div>
|
<Alert.Description>
|
||||||
</div>
|
v{CURRENT_VERSION} {'->'} v{latestVersion}
|
||||||
<a
|
</Alert.Description>
|
||||||
href='https://github.com/djteang/OrangeTV'
|
</Alert.Content>
|
||||||
target='_blank'
|
</Alert>
|
||||||
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'
|
<Alert status='success'>
|
||||||
>
|
<Alert.Indicator>
|
||||||
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
|
<CheckCircle className='h-4 w-4' />
|
||||||
前往仓库
|
</Alert.Indicator>
|
||||||
</a>
|
<Alert.Content>
|
||||||
</div>
|
<Alert.Title>当前为最新版本</Alert.Title>
|
||||||
</div>
|
<Alert.Description>已是最新版本 v{CURRENT_VERSION}</Alert.Description>
|
||||||
)}
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 当前为最新版本信息 */}
|
<HeroLink href='https://github.com/djteang/OrangeTV' target='_blank'>
|
||||||
{!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'>
|
</HeroLink>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 远程可更新内容 */}
|
{hasUpdate ? (
|
||||||
{hasUpdate && (
|
<div className='space-y-4'>
|
||||||
<div className='space-y-4'>
|
<div className='flex flex-col justify-between gap-3 sm:flex-row sm:items-center'>
|
||||||
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-3'>
|
<h4 className='flex items-center gap-2 text-lg font-semibold text-foreground'>
|
||||||
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
|
<Download className='h-5 w-5 text-warning' />
|
||||||
<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'>
|
|
||||||
变更日志
|
|
||||||
</h4>
|
</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'>
|
<div className='space-y-4'>
|
||||||
{/* 本地变更日志 */}
|
{remoteUpdates.map((entry) =>
|
||||||
{changelog.map((entry) =>
|
renderChangelogEntry(entry, false, true)
|
||||||
renderChangelogEntry(
|
|
||||||
entry,
|
|
||||||
entry.version === CURRENT_VERSION,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
</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 */
|
/* 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 Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, {
|
import React, {
|
||||||
|
|
@ -500,7 +501,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
actions.push({
|
actions.push({
|
||||||
id: 'douban',
|
id: 'douban',
|
||||||
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
|
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
|
||||||
icon: <Link size={20} />,
|
icon: <LinkIcon size={20} />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const url = isBangumi
|
const url = isBangumi
|
||||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||||
|
|
@ -530,8 +531,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<Card
|
||||||
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]'
|
variant='transparent'
|
||||||
|
className='group relative z-0 w-full cursor-pointer overflow-visible rounded-none p-0'
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...longPressProps}
|
{...longPressProps}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -567,8 +569,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 海报容器 */}
|
{/* 海报容器 */}
|
||||||
<div
|
<Card
|
||||||
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' : ''}`}
|
variant='default'
|
||||||
|
className='relative aspect-[2/3] overflow-hidden rounded-lg p-0'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -615,7 +618,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
|
|
||||||
{/* 悬浮遮罩 */}
|
{/* 悬浮遮罩 */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -645,7 +648,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
<PlayCircleIcon
|
<PlayCircleIcon
|
||||||
size={50}
|
size={50}
|
||||||
strokeWidth={0.8}
|
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={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -675,47 +678,44 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config.showCheckCircle && (
|
{config.showCheckCircle && (
|
||||||
<Trash2
|
<Button
|
||||||
onClick={handleDeleteRecord}
|
isIconOnly
|
||||||
size={20}
|
size='sm'
|
||||||
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
|
variant='danger'
|
||||||
style={{
|
onPress={() =>
|
||||||
WebkitUserSelect: 'none',
|
handleDeleteRecord({
|
||||||
userSelect: 'none',
|
preventDefault: () => undefined,
|
||||||
WebkitTouchCallout: 'none',
|
stopPropagation: () => undefined,
|
||||||
} as React.CSSProperties}
|
} as React.MouseEvent)
|
||||||
onContextMenu={(e) => {
|
}
|
||||||
e.preventDefault();
|
>
|
||||||
return false;
|
<Trash2 size={16} />
|
||||||
}}
|
</Button>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{config.showHeart && from !== 'search' && from !== 'shortdrama' && (
|
{config.showHeart && from !== 'search' && from !== 'shortdrama' && (
|
||||||
<Heart
|
<Button
|
||||||
onClick={handleToggleFavorite}
|
isIconOnly
|
||||||
size={20}
|
size='sm'
|
||||||
className={`transition-all duration-300 ease-out ${favorited
|
variant={favorited ? 'danger' : 'secondary'}
|
||||||
? 'fill-danger stroke-danger'
|
onPress={() =>
|
||||||
: 'fill-transparent stroke-white hover:stroke-accent'
|
handleToggleFavorite({
|
||||||
} hover:scale-[1.1]`}
|
preventDefault: () => undefined,
|
||||||
style={{
|
stopPropagation: () => undefined,
|
||||||
WebkitUserSelect: 'none',
|
} as React.MouseEvent)
|
||||||
userSelect: 'none',
|
}
|
||||||
WebkitTouchCallout: 'none',
|
>
|
||||||
} as React.CSSProperties}
|
<Heart size={16} className={favorited ? 'fill-current' : ''} />
|
||||||
onContextMenu={(e) => {
|
</Button>
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 年份徽章 */}
|
{/* 年份徽章 */}
|
||||||
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
|
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
|
||||||
<div
|
<Badge
|
||||||
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"
|
size='sm'
|
||||||
|
variant='secondary'
|
||||||
|
className='absolute left-2 top-2'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -726,14 +726,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{actualYear}
|
<Badge.Label>{actualYear}</Badge.Label>
|
||||||
</div>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 徽章 */}
|
{/* 徽章 */}
|
||||||
{config.showRating && rate && (
|
{config.showRating && rate && (
|
||||||
<div
|
<Chip
|
||||||
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'
|
size='md'
|
||||||
|
color='accent'
|
||||||
|
variant='primary'
|
||||||
|
className='absolute right-2 top-2'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -744,13 +747,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rate}
|
<Chip.Label>{rate}</Chip.Label>
|
||||||
</div>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actualEpisodes && actualEpisodes > 1 && (
|
{actualEpisodes && actualEpisodes > 1 && (
|
||||||
<div
|
<Badge
|
||||||
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'
|
size='sm'
|
||||||
|
variant='secondary'
|
||||||
|
className='absolute right-2 top-2'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -761,15 +766,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentEpisode
|
<Badge.Label>{currentEpisode
|
||||||
? `${currentEpisode}/${actualEpisodes}`
|
? `${currentEpisode}/${actualEpisodes}`
|
||||||
: actualEpisodes}
|
: actualEpisodes}</Badge.Label>
|
||||||
</div>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 豆瓣链接 */}
|
{/* 豆瓣链接 */}
|
||||||
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
|
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
|
||||||
<a
|
<HeroLink
|
||||||
href={
|
href={
|
||||||
isBangumi
|
isBangumi
|
||||||
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
|
||||||
|
|
@ -789,20 +794,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<LinkIcon
|
||||||
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'
|
size={18}
|
||||||
style={{
|
|
||||||
WebkitUserSelect: 'none',
|
|
||||||
userSelect: 'none',
|
|
||||||
WebkitTouchCallout: 'none',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
size={16}
|
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -810,8 +803,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</HeroLink>
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 聚合播放源指示器 */}
|
{/* 聚合播放源指示器 */}
|
||||||
|
|
@ -840,8 +832,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div
|
<Badge
|
||||||
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'
|
size='sm'
|
||||||
|
color='accent'
|
||||||
|
variant='secondary'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -852,8 +846,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sourceCount}
|
<Badge.Label>{sourceCount}</Badge.Label>
|
||||||
</div>
|
</Badge>
|
||||||
|
|
||||||
{/* 播放源详情悬浮框 */}
|
{/* 播放源详情悬浮框 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
@ -931,12 +925,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
{config.showProgress && progress !== undefined && (
|
{config.showProgress && progress !== undefined && (
|
||||||
<div
|
<ProgressBar
|
||||||
className='mt-2 h-1 w-full overflow-hidden rounded-full bg-surface-secondary'
|
aria-label='观看进度'
|
||||||
|
value={progress}
|
||||||
|
className='mt-2'
|
||||||
|
size='sm'
|
||||||
|
color='accent'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -947,20 +945,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<ProgressBar.Track>
|
||||||
className='h-full rounded-full bg-accent transition-all duration-500 ease-out'
|
<ProgressBar.Fill />
|
||||||
style={{
|
</ProgressBar.Track>
|
||||||
width: `${progress}%`,
|
</ProgressBar>
|
||||||
WebkitUserSelect: 'none',
|
|
||||||
userSelect: 'none',
|
|
||||||
WebkitTouchCallout: 'none',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 标题与来源 */}
|
{/* 标题与来源 */}
|
||||||
|
|
@ -976,16 +964,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Tooltip>
|
||||||
className='relative'
|
<Tooltip.Trigger>
|
||||||
|
<div
|
||||||
|
className='relative'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -998,33 +988,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
>
|
>
|
||||||
{actualTitle}
|
{actualTitle}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content placement='top'>
|
||||||
|
{actualTitle}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip>
|
||||||
{config.showSourceName && source_name && (
|
{config.showSourceName && source_name && (
|
||||||
<span
|
<Chip
|
||||||
className='mt-1 block text-xs font-medium tracking-normal text-muted'
|
size='sm'
|
||||||
|
color='accent'
|
||||||
|
variant='soft'
|
||||||
|
className='mt-1'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -1035,27 +1010,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||||
return false;
|
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' && (
|
{origin === 'live' && (
|
||||||
<Radio size={12} className="inline-block mr-1 text-muted" />
|
<Radio size={12} className="inline-block mr-1 text-muted" />
|
||||||
)}
|
)}
|
||||||
{source_name}
|
<Chip.Label>{source_name}</Chip.Label>
|
||||||
</span>
|
</Chip>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* 操作菜单 - 支持右键和长按触发 */}
|
{/* 操作菜单 - 支持右键和长按触发 */}
|
||||||
<MobileActionSheet
|
<MobileActionSheet
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
import { AppFilterSelect } from './ui/HeroPrimitives';
|
||||||
|
|
||||||
interface WeekdaySelectorProps {
|
interface WeekdaySelectorProps {
|
||||||
onWeekdayChange: (weekday: string) => void;
|
onWeekdayChange: (weekday: string) => void;
|
||||||
|
|
@ -43,15 +43,16 @@ const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
|
||||||
}, []); // 只在组件挂载时执行一次
|
}, []); // 只在组件挂载时执行一次
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppFilterTabs
|
<AppFilterSelect
|
||||||
ariaLabel='星期筛选'
|
ariaLabel='星期选项'
|
||||||
className={className}
|
className={className}
|
||||||
items={weekdays.map((weekday) => ({
|
label='星期'
|
||||||
key: weekday.value,
|
options={weekdays.map((weekday) => ({
|
||||||
|
value: weekday.value,
|
||||||
label: weekday.shortLabel,
|
label: weekday.shortLabel,
|
||||||
}))}
|
}))}
|
||||||
selectedKey={selectedWeekday}
|
value={selectedWeekday}
|
||||||
onSelectionChange={(value) => {
|
onChange={(value) => {
|
||||||
setSelectedWeekday(value);
|
setSelectedWeekday(value);
|
||||||
onWeekdayChange(value);
|
onWeekdayChange(value);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import MultiLevelSelector from '../MultiLevelSelector';
|
import MultiLevelSelector from '../MultiLevelSelector';
|
||||||
|
|
||||||
describe('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();
|
const onChange = jest.fn();
|
||||||
|
|
||||||
render(<MultiLevelSelector contentType='movie' onChange={onChange} />);
|
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(onChange).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const categories: SearchFilterCategory[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('SearchResultFilter', () => {
|
describe('SearchResultFilter', () => {
|
||||||
it('opens an options menu and applies the selected option', () => {
|
it('applies selected options through accessible listbox options', () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -35,8 +35,8 @@ describe('SearchResultFilter', () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole('menu', { name: '来源筛选' })).toBeInTheDocument();
|
expect(screen.getByRole('listbox', { name: '来源选项' })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: '稳定源' }));
|
fireEvent.click(screen.getByRole('option', { name: '稳定源' }));
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith({
|
expect(onChange).toHaveBeenCalledWith({
|
||||||
source: 'stable',
|
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 onChange = jest.fn();
|
||||||
|
|
||||||
const { rerender } = render(
|
render(
|
||||||
<SearchResultFilter
|
<SearchResultFilter
|
||||||
categories={categories}
|
categories={categories}
|
||||||
values={{ source: 'all', title: 'all', yearOrder: 'none' }}
|
values={{ source: 'all', title: 'all', yearOrder: 'none' }}
|
||||||
|
|
@ -57,8 +57,7 @@ describe('SearchResultFilter', () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const yearButton = screen.getByRole('button', { name: '按年份排序排序' });
|
fireEvent.click(screen.getByRole('option', { name: '年份降序' }));
|
||||||
fireEvent.click(yearButton);
|
|
||||||
|
|
||||||
expect(onChange).toHaveBeenLastCalledWith({
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
source: 'all',
|
source: 'all',
|
||||||
|
|
@ -66,22 +65,5 @@ describe('SearchResultFilter', () => {
|
||||||
year: 'all',
|
year: 'all',
|
||||||
yearOrder: 'desc',
|
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';
|
import WeekdaySelector from '../WeekdaySelector';
|
||||||
|
|
||||||
describe('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();
|
const onWeekdayChange = jest.fn();
|
||||||
|
|
||||||
render(<WeekdaySelector onWeekdayChange={onWeekdayChange} />);
|
render(<WeekdaySelector onWeekdayChange={onWeekdayChange} />);
|
||||||
|
|
||||||
const tablist = screen.getByRole('tablist', { name: '星期筛选' });
|
const listbox = screen.getByRole('listbox', { name: '星期选项' });
|
||||||
expect(tablist).toBeInTheDocument();
|
expect(listbox).toBeInTheDocument();
|
||||||
|
|
||||||
const today = new Date().getDay();
|
const today = new Date().getDay();
|
||||||
const weekdayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
const weekdayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
expect(screen.getByRole('tab', { name: weekdayMap[today] })).toHaveAttribute(
|
expect(screen.getByRole('option', { name: weekdayMap[today] })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Label,
|
||||||
|
ListBox,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollShadow,
|
ScrollShadow,
|
||||||
|
Select,
|
||||||
Spinner,
|
Spinner,
|
||||||
Tabs,
|
Tabs,
|
||||||
useOverlayState,
|
useOverlayState,
|
||||||
|
|
@ -42,6 +45,72 @@ export function AppScrollShadow(props: ScrollShadowProps) {
|
||||||
return <ScrollShadow hideScrollBar {...props} />;
|
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({
|
export function AppLoading({
|
||||||
label = '加载中...',
|
label = '加载中...',
|
||||||
...props
|
...props
|
||||||
|
|
@ -214,18 +283,15 @@ export function AppTabs({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppFilterTabs({
|
export function AppFilterTabs({
|
||||||
className,
|
|
||||||
...props
|
...props
|
||||||
}: AppTabsProps) {
|
}: AppTabsProps) {
|
||||||
return (
|
return (
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
orientation='horizontal'
|
orientation='horizontal'
|
||||||
className='app-filter-scroll'
|
hideScrollBar
|
||||||
|
className='w-full min-w-0'
|
||||||
>
|
>
|
||||||
<AppTabs
|
<AppTabs {...props} />
|
||||||
{...props}
|
|
||||||
className={`app-filter-tabs ${className ?? ''}`.trim()}
|
|
||||||
/>
|
|
||||||
</ScrollShadow>
|
</ScrollShadow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue