Refactor UI toward HeroUI

This commit is contained in:
leowang 2026-05-24 10:38:02 +08:00
parent eb71c83aa5
commit 9389be8b97
41 changed files with 3902 additions and 6442 deletions

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,10 @@ const MenuActionContext = createContext<{
onAction?: (key: React.Key) => void;
selectedKeys?: Iterable<React.Key>;
}>({});
const SelectContext = createContext<{
value?: React.Key | React.Key[] | null;
onChange?: (value: React.Key | React.Key[] | null) => void;
}>({});
type OverlayStateValue = {
isOpen: boolean;
@ -370,11 +374,132 @@ export const Dropdown = Object.assign(DropdownRoot, {
ItemIndicator: () => <span aria-hidden='true' />,
});
export const ScrollShadow = ({
const SelectRoot = ({
children,
value,
onChange,
fullWidth: _fullWidth,
isDisabled: _isDisabled,
variant: _variant,
placeholder: _placeholder,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
value?: React.Key | React.Key[] | null;
onChange?: (value: React.Key | React.Key[] | null) => void;
fullWidth?: boolean;
isDisabled?: boolean;
variant?: string;
placeholder?: string;
}) => (
<SelectContext.Provider value={{ value, onChange }}>
<div {...props}>{children}</div>
</SelectContext.Provider>
);
const SelectTrigger = ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type='button' role='combobox' aria-expanded='true' {...props}>
{children}
</button>
);
const SelectValue = ({
children,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => <span {...props}>{children}</span>;
const SelectIndicator = ({
children,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span aria-hidden='true' {...props}>
{children}
</span>
);
const SelectPopover = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
const ListBoxRoot = ({
children,
selectionMode: _selectionMode,
selectedKeys: _selectedKeys,
onSelectionChange: _onSelectionChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
selectionMode?: string;
selectedKeys?: Iterable<React.Key>;
onSelectionChange?: (keys: Iterable<React.Key>) => void;
}) => (
<div role='listbox' {...props}>
{children}
</div>
);
const ListBoxItem = ({
children,
id,
textValue,
isDisabled,
...props
}: React.HTMLAttributes<HTMLButtonElement> & {
id: React.Key;
textValue?: string;
isDisabled?: boolean;
}) => {
const { value, onChange } = useContext(SelectContext);
const selected = Array.isArray(value)
? value.includes(id)
: value === id;
return (
<button
type='button'
role='option'
aria-label={textValue}
aria-selected={selected}
disabled={isDisabled}
{...props}
onClick={() => onChange?.(id)}
>
{children}
</button>
);
};
export const Select = Object.assign(SelectRoot, {
Root: SelectRoot,
Trigger: SelectTrigger,
Value: SelectValue,
Indicator: SelectIndicator,
Popover: SelectPopover,
});
export const ListBox = Object.assign(ListBoxRoot, {
Root: ListBoxRoot,
Item: Object.assign(ListBoxItem, {
Indicator: () => <span aria-hidden='true' />,
}),
ItemIndicator: () => <span aria-hidden='true' />,
Section: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
});
export const ScrollShadow = ({
children,
hideScrollBar: _hideScrollBar,
orientation: _orientation,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
hideScrollBar?: boolean;
orientation?: string;
}) => <div {...props}>{children}</div>;
export const Spinner = (props: React.HTMLAttributes<HTMLSpanElement>) => (
<span role='status' {...props} />
);

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { Card, EmptyState, Spinner } from '@heroui/react';
import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -728,7 +729,7 @@ function DoubanPageClient() {
{/* 选择器组件 */}
{type !== 'custom' ? (
<div className='app-filter-panel'>
<Card>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show' | 'anime'}
primarySelection={primarySelection}
@ -738,9 +739,9 @@ function DoubanPageClient() {
onMultiLevelChange={handleMultiLevelChange}
onWeekdayChange={handleWeekdayChange}
/>
</div>
</Card>
) : (
<div className='app-filter-panel'>
<Card>
<DoubanCustomSelector
customCategories={customCategories}
primarySelection={primarySelection}
@ -748,7 +749,7 @@ function DoubanPageClient() {
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
</Card>
)}
</div>
@ -792,8 +793,8 @@ function DoubanPageClient() {
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
<span className='text-gray-600'>...</span>
<Spinner size='sm' />
<span className='text-muted'>...</span>
</div>
)}
</div>
@ -801,12 +802,12 @@ function DoubanPageClient() {
{/* 没有更多数据提示 */}
{!hasMore && doubanData.length > 0 && (
<div className='text-center text-gray-500 py-8'></div>
<EmptyState className='py-8'></EmptyState>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && (
<div className='text-center text-gray-500 py-8'></div>
<EmptyState className='py-8'></EmptyState>
)}
</div>
</div>

View File

@ -8,57 +8,57 @@
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace;
--background: oklch(0.985 0.006 255);
--foreground: oklch(0.21 0.025 258);
--surface: oklch(0.998 0.002 255);
--background: oklch(0.985 0.002 286);
--foreground: oklch(0.19 0.006 286);
--surface: oklch(0.998 0.001 286);
--surface-foreground: var(--foreground);
--surface-secondary: oklch(0.965 0.009 255);
--surface-secondary: oklch(0.955 0.004 286);
--surface-secondary-foreground: var(--foreground);
--surface-tertiary: oklch(0.935 0.015 255);
--surface-tertiary: oklch(0.92 0.006 286);
--surface-tertiary-foreground: var(--foreground);
--overlay: oklch(0.998 0.002 255 / 92%);
--overlay: oklch(0.998 0.001 286 / 92%);
--overlay-foreground: var(--foreground);
--muted: oklch(0.53 0.032 258);
--border: oklch(0.85 0.018 255 / 70%);
--separator: oklch(0.9 0.012 255 / 72%);
--accent: oklch(0.59 0.19 255);
--accent-foreground: oklch(0.99 0.004 255);
--muted: oklch(0.48 0.01 286);
--border: oklch(0.84 0.006 286 / 70%);
--separator: oklch(0.89 0.004 286 / 72%);
--accent: oklch(0.58 0.22 16);
--accent-foreground: oklch(0.99 0.004 20);
--success: oklch(0.66 0.16 150);
--success-foreground: oklch(0.99 0.004 150);
--warning: oklch(0.73 0.15 82);
--warning-foreground: oklch(0.22 0.035 70);
--danger: oklch(0.62 0.2 28);
--danger-foreground: oklch(0.99 0.004 28);
--field-background: oklch(0.995 0.003 255);
--field-background: oklch(0.995 0.002 286);
--field-foreground: var(--foreground);
--field-placeholder: var(--muted);
--field-border: var(--border);
--color-background: 248 250 252;
--color-foreground: 15 23 42;
--color-background: 250 250 250;
--color-foreground: 24 24 27;
--color-surface: 255 255 255;
--color-surface-secondary: 241 245 249;
--color-surface-tertiary: 226 232 240;
--color-surface-secondary: 244 244 245;
--color-surface-tertiary: 228 228 231;
--color-overlay: 255 255 255;
--color-muted: 100 116 139;
--color-border: 203 213 225;
--color-accent: 37 99 235;
--color-accent-strong: 29 78 216;
--color-muted: 113 113 122;
--color-border: 212 212 216;
--color-accent: 225 29 72;
--color-accent-strong: 190 18 60;
--color-success: 22 163 74;
--color-warning: 217 119 6;
--color-danger: 220 38 38;
--color-primary-50: 239 246 255;
--color-primary-100: 219 234 254;
--color-primary-200: 191 219 254;
--color-primary-300: 147 197 253;
--color-primary-400: 96 165 250;
--color-primary-500: 59 130 246;
--color-primary-600: 37 99 235;
--color-primary-700: 29 78 216;
--color-primary-800: 30 64 175;
--color-primary-900: 30 58 138;
--color-dark: 15 23 42;
--color-primary-50: 255 241 242;
--color-primary-100: 255 228 230;
--color-primary-200: 254 205 211;
--color-primary-300: 253 164 175;
--color-primary-400: 251 113 133;
--color-primary-500: 244 63 94;
--color-primary-600: 225 29 72;
--color-primary-700: 190 18 60;
--color-primary-800: 159 18 57;
--color-primary-900: 136 19 55;
--color-dark: 24 24 27;
--color-theme-bg: var(--color-background);
--color-theme-surface: var(--color-surface);
@ -71,60 +71,47 @@
--color-theme-error: var(--color-danger);
--color-theme-info: var(--color-accent);
/* Temporary compatibility aliases. They render as HeroUI Clean colors. */
--a2-canvas: var(--color-background);
--a2-stage: var(--color-surface);
--a2-rail: var(--color-surface-secondary);
--a2-text: var(--color-foreground);
--a2-text-soft: 51 65 85;
--a2-text-muted: var(--color-muted);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
--a2-line: var(--color-border);
--a2-signal-stable: var(--color-success);
--a2-signal-warn: var(--color-warning);
--a2-signal-error: var(--color-danger);
}
.dark {
color-scheme: dark;
--background: oklch(0.18 0.022 258);
--foreground: oklch(0.94 0.012 255);
--surface: oklch(0.225 0.025 258);
--background: oklch(0.15 0.006 286);
--foreground: oklch(0.94 0.006 286);
--surface: oklch(0.205 0.008 286);
--surface-foreground: var(--foreground);
--surface-secondary: oklch(0.27 0.028 258);
--surface-secondary: oklch(0.255 0.01 286);
--surface-secondary-foreground: var(--foreground);
--surface-tertiary: oklch(0.32 0.031 258);
--surface-tertiary: oklch(0.31 0.012 286);
--surface-tertiary-foreground: var(--foreground);
--overlay: oklch(0.22 0.025 258 / 94%);
--overlay: oklch(0.19 0.008 286 / 94%);
--overlay-foreground: var(--foreground);
--muted: oklch(0.7 0.028 255);
--border: oklch(0.42 0.03 258 / 68%);
--separator: oklch(0.36 0.025 258 / 70%);
--accent: oklch(0.69 0.17 255);
--accent-foreground: oklch(0.16 0.03 258);
--muted: oklch(0.7 0.012 286);
--border: oklch(0.38 0.012 286 / 68%);
--separator: oklch(0.33 0.01 286 / 70%);
--accent: oklch(0.69 0.18 14);
--accent-foreground: oklch(0.16 0.02 14);
--success: oklch(0.72 0.16 150);
--success-foreground: oklch(0.14 0.025 150);
--warning: oklch(0.78 0.15 82);
--warning-foreground: oklch(0.18 0.035 70);
--danger: oklch(0.7 0.18 28);
--danger-foreground: oklch(0.99 0.004 28);
--field-background: oklch(0.25 0.025 258);
--field-background: oklch(0.235 0.009 286);
--field-foreground: var(--foreground);
--field-placeholder: var(--muted);
--field-border: var(--border);
--color-background: 15 23 42;
--color-foreground: 241 245 249;
--color-surface: 30 41 59;
--color-surface-secondary: 51 65 85;
--color-surface-tertiary: 71 85 105;
--color-overlay: 15 23 42;
--color-muted: 148 163 184;
--color-border: 71 85 105;
--color-accent: 96 165 250;
--color-accent-strong: 147 197 253;
--color-background: 18 18 20;
--color-foreground: 244 244 245;
--color-surface: 31 31 35;
--color-surface-secondary: 39 39 42;
--color-surface-tertiary: 63 63 70;
--color-overlay: 24 24 27;
--color-muted: 161 161 170;
--color-border: 82 82 91;
--color-accent: 251 113 133;
--color-accent-strong: 253 164 175;
--color-success: 74 222 128;
--color-warning: 251 191 36;
--color-danger: 248 113 113;
@ -140,18 +127,6 @@
--color-theme-error: var(--color-danger);
--color-theme-info: var(--color-accent);
--a2-canvas: var(--color-background);
--a2-stage: var(--color-surface);
--a2-rail: var(--color-surface-secondary);
--a2-text: var(--color-foreground);
--a2-text-soft: 203 213 225;
--a2-text-muted: var(--color-muted);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
--a2-line: var(--color-border);
--a2-signal-stable: var(--color-success);
--a2-signal-warn: var(--color-warning);
--a2-signal-error: var(--color-danger);
}
[data-theme="minimal"] {
@ -161,8 +136,6 @@
--color-accent-strong: 64 64 64;
--color-theme-accent: var(--color-accent);
--color-theme-info: var(--color-accent);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
}
[data-theme="minimal"].dark {
@ -172,8 +145,6 @@
--color-accent-strong: 245 245 245;
--color-theme-accent: var(--color-accent);
--color-theme-info: var(--color-accent);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
}
[data-theme="warm"] {
@ -183,8 +154,6 @@
--color-accent-strong: 180 83 9;
--color-theme-accent: var(--color-accent);
--color-theme-info: var(--color-accent);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
}
[data-theme="warm"].dark {
@ -194,8 +163,6 @@
--color-accent-strong: 253 224 71;
--color-theme-accent: var(--color-accent);
--color-theme-info: var(--color-accent);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
}
[data-theme="fresh"] {
@ -205,8 +172,6 @@
--color-accent-strong: 21 128 61;
--color-theme-accent: var(--color-accent);
--color-theme-info: var(--color-accent);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
}
[data-theme="fresh"].dark {
@ -216,8 +181,6 @@
--color-accent-strong: 134 239 172;
--color-theme-accent: var(--color-accent);
--color-theme-info: var(--color-accent);
--a2-copper: var(--color-accent);
--a2-copper-strong: var(--color-accent-strong);
}
html,
@ -296,18 +259,6 @@ body {
transform 180ms ease;
}
.theme-card {
@apply rounded-2xl border border-border/70 bg-surface shadow-sm;
}
.theme-button {
@apply rounded-xl border border-accent bg-accent px-4 py-2 font-medium text-accent-foreground hover:bg-accent-strong;
}
.theme-button-secondary {
@apply rounded-xl border border-border bg-surface px-4 py-2 font-medium text-foreground hover:bg-surface-secondary;
}
.theme-input {
@apply rounded-xl border border-border bg-field px-3 py-2 text-foreground placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20;
}
@ -324,152 +275,6 @@ body {
background: linear-gradient(to bottom, transparent 0%, rgb(15 23 42 / 0.74) 100%);
}
.bg-white,
.bg-gray-50,
.bg-gray-100 {
background-color: rgb(var(--color-surface)) !important;
}
.bg-gray-200,
.bg-gray-300,
.bg-gray-700,
.bg-gray-800,
.bg-gray-900 {
background-color: rgb(var(--color-surface-secondary)) !important;
}
.bg-blue-50,
.bg-blue-100,
.bg-blue-900\/20,
.bg-blue-900\/30,
.bg-blue-900\/40,
.bg-purple-100,
.bg-purple-900\/40,
.from-blue-50,
.to-emerald-50 {
background-color: rgb(var(--color-accent) / 0.1) !important;
}
.bg-blue-500,
.bg-blue-600,
.bg-blue-700,
.from-blue-500,
.to-blue-600,
.from-cyan-500,
.to-cyan-500,
.via-cyan-500,
.via-purple-500 {
background-color: rgb(var(--color-accent)) !important;
}
.text-gray-300,
.text-gray-400,
.text-gray-500,
.text-gray-600 {
color: rgb(var(--color-muted)) !important;
}
.text-gray-700,
.text-gray-800,
.text-gray-900,
.text-blue-200,
.text-blue-300,
.text-blue-400,
.text-blue-500,
.text-blue-600,
.text-blue-700,
.text-blue-800,
.text-purple-200,
.text-purple-400,
.text-purple-700,
.text-purple-800 {
color: rgb(var(--color-foreground)) !important;
}
.border-gray-200,
.border-gray-300,
.border-gray-600,
.border-gray-700,
.border-blue-200,
.border-blue-700,
.border-blue-800 {
border-color: rgb(var(--color-border) / 0.7) !important;
}
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-gray-200:hover,
.hover\:bg-gray-300:hover,
.hover\:bg-gray-700:hover {
background-color: rgb(var(--color-surface-tertiary) / 0.8) !important;
}
.hover\:bg-blue-200:hover,
.hover\:bg-blue-600:hover,
.hover\:bg-blue-700:hover,
.hover\:bg-purple-200:hover {
background-color: rgb(var(--color-accent) / 0.16) !important;
}
.hover\:text-blue-500:hover,
.hover\:text-blue-600:hover,
.hover\:text-blue-700:hover,
.hover\:text-gray-600:hover,
.hover\:text-gray-800:hover {
color: rgb(var(--color-accent)) !important;
}
.focus\:ring-blue-400:focus,
.focus\:ring-blue-500:focus,
.focus\:ring-blue-400\/50:focus {
--tw-ring-color: rgb(var(--color-accent) / 0.22) !important;
}
.focus\:border-blue-500:focus {
border-color: rgb(var(--color-accent)) !important;
}
.border-t-blue-500 {
border-top-color: rgb(var(--color-accent)) !important;
}
.dark .dark\:bg-gray-600,
.dark .dark\:bg-gray-700,
.dark .dark\:bg-gray-800,
.dark .dark\:bg-gray-900 {
background-color: rgb(var(--color-surface)) !important;
}
.dark .dark\:bg-blue-800\/40,
.dark .dark\:bg-blue-900\/20,
.dark .dark\:bg-blue-900\/30,
.dark .dark\:bg-blue-900\/40,
.dark .dark\:bg-purple-900\/40 {
background-color: rgb(var(--color-accent) / 0.12) !important;
}
.dark .dark\:text-gray-100,
.dark .dark\:text-gray-200,
.dark .dark\:text-gray-300,
.dark .dark\:text-blue-200,
.dark .dark\:text-blue-300,
.dark .dark\:text-blue-400,
.dark .dark\:text-purple-200,
.dark .dark\:text-purple-400 {
color: rgb(var(--color-foreground)) !important;
}
.dark .dark\:text-gray-400,
.dark .dark\:text-gray-500 {
color: rgb(var(--color-muted)) !important;
}
.dark .dark\:border-gray-600,
.dark .dark\:border-gray-700,
.dark .dark\:border-blue-700,
.dark .dark\:border-blue-800 {
border-color: rgb(var(--color-border) / 0.7) !important;
}
}
@keyframes slide-from-top {
@ -528,326 +333,3 @@ div[data-media-provider] video {
display: none !important;
}
}
@layer components {
.a2-icon-button {
@apply inline-flex h-9 w-9 items-center justify-center rounded-xl border border-border bg-surface text-muted shadow-sm hover:border-accent/40 hover:bg-surface-secondary hover:text-foreground active:translate-y-px;
}
.a2-tablist,
.a2-filter-tabs {
@apply inline-flex min-w-max items-center gap-2 rounded-2xl border border-border bg-surface p-1 shadow-sm;
}
.a2-tab,
.a2-filter-tab,
.a2-selector-trigger {
@apply rounded-xl border border-transparent px-3 py-2 text-sm font-medium tracking-normal text-muted hover:bg-surface-secondary hover:text-foreground;
}
.a2-tab-active,
.a2-filter-tab-active,
.a2-selector-trigger-active {
@apply border-accent/20 bg-accent/10 text-accent shadow-sm;
}
.a2-filter-row {
@apply flex flex-col gap-2 sm:flex-row sm:items-center;
}
.a2-filter-label {
@apply min-w-[56px] text-sm font-medium tracking-normal text-muted;
}
.a2-selector-menu {
@apply fixed z-[9999] flex max-h-[50vh] flex-col overflow-hidden rounded-2xl border border-border bg-overlay p-1 shadow-2xl backdrop-blur-xl;
}
.a2-selector-option {
@apply rounded-xl border border-transparent px-3 py-2 text-left text-sm text-muted hover:bg-surface-secondary hover:text-foreground;
}
.a2-selector-option-active {
@apply border-accent/20 bg-accent/10 text-accent;
}
.a2-field {
@apply w-full rounded-xl border border-border bg-field px-3 py-2.5 text-sm text-foreground placeholder:text-muted hover:border-accent/40 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20;
}
.a2-button {
@apply inline-flex items-center justify-center rounded-xl border border-border bg-surface px-4 py-2.5 text-sm font-medium text-foreground shadow-sm hover:border-accent/40 hover:bg-surface-secondary active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50;
}
.a2-button-accent {
@apply border-accent bg-accent text-accent-foreground hover:bg-accent-strong;
}
.a2-button-danger {
@apply border-danger/30 bg-danger/10 text-danger hover:bg-danger/15;
}
.a2-panel {
@apply rounded-2xl border border-border bg-surface text-foreground shadow-xl;
}
.a2-kicker {
@apply text-xs font-medium tracking-normal text-muted;
}
.a2-title {
@apply text-balance text-2xl font-semibold tracking-normal text-foreground md:text-[2rem];
}
.a2-muted-copy {
@apply text-sm leading-7 text-muted;
}
.a2-link-action {
@apply inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-accent hover:bg-accent/10;
}
.app-filter-row {
@apply grid min-w-0 gap-2 sm:grid-cols-[3.75rem_minmax(0,1fr)] sm:items-center;
}
.app-filter-panel {
@apply rounded-2xl border border-border/40 bg-surface/75 p-4 shadow-sm backdrop-blur sm:p-6;
}
.app-filter-label {
@apply text-xs font-semibold uppercase tracking-normal text-muted sm:text-sm;
}
.app-filter-scroll {
@apply w-full min-w-0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.app-filter-scroll::-webkit-scrollbar {
display: none;
}
.app-filter-dropdowns {
@apply flex min-w-0 flex-wrap items-center gap-2;
}
.app-filter-trigger {
@apply h-9 whitespace-nowrap rounded-xl border border-border bg-surface-secondary px-3 text-sm font-semibold text-muted shadow-none hover:border-accent/30 hover:bg-accent/10 hover:text-foreground;
}
.app-filter-trigger-active {
@apply border-accent/30 bg-accent/10 text-accent hover:text-accent;
}
.app-search-filter-bar {
@apply inline-flex max-w-full flex-wrap items-center gap-2 rounded-2xl border border-border/70 bg-surface/85 p-1.5 shadow-sm backdrop-blur;
}
.app-search-filter-trigger {
@apply h-10 max-w-[11rem] cursor-pointer whitespace-nowrap rounded-xl border border-transparent bg-transparent px-3 text-sm font-semibold tracking-normal text-muted shadow-none hover:bg-surface-secondary hover:text-foreground;
}
.app-search-filter-trigger-active {
@apply border-accent/30 bg-accent/10 text-accent hover:text-accent;
}
.app-search-filter-popover {
@apply w-[min(88vw,22rem)] rounded-2xl p-1;
}
.app-search-filter-scroll {
@apply max-h-[min(60vh,24rem)] overflow-y-auto;
}
.app-search-filter-menu {
@apply flex flex-col gap-1 p-1;
}
.app-search-filter-option,
.app-search-filter-option-active {
@apply min-h-10 rounded-xl border border-transparent px-3 py-2 text-sm font-medium;
}
.app-search-filter-option {
@apply text-muted hover:bg-surface-secondary hover:text-foreground;
}
.app-search-filter-option-active {
@apply border-accent/20 bg-accent/10 text-accent;
}
.app-search-filter-option-label {
@apply block min-w-0 flex-1 truncate whitespace-nowrap text-left leading-5;
}
.a2-rule {
border-color: rgb(var(--color-border) / 0.7);
}
.a2-data {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.button {
border-radius: 0.75rem;
font-weight: 600;
letter-spacing: 0;
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
transform 180ms ease,
opacity 180ms ease;
}
.button[data-pressed="true"],
.button:active {
transform: translateY(1px);
}
.button--primary {
background: var(--accent);
color: var(--accent-foreground);
}
.button--secondary,
.button--tertiary,
.button--outline {
border-color: var(--border);
background: color-mix(in oklab, var(--surface) 94%, transparent);
color: var(--foreground);
}
.button--ghost {
color: var(--muted);
}
.button--danger,
.button--danger-soft {
color: var(--danger);
}
.card,
.modal__container,
.drawer__content,
.dropdown__popover,
.popover__content,
.toast {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 94%, transparent);
color: var(--foreground);
box-shadow: 0 18px 60px -42px color-mix(in oklab, var(--foreground) 48%, transparent);
backdrop-filter: blur(18px);
}
.card {
border-radius: 1rem;
}
.tabs__list {
gap: 0.25rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: color-mix(in oklab, var(--surface) 94%, transparent);
padding: 0.25rem;
}
.tabs__tab {
min-height: 2.25rem;
border-radius: 0.75rem;
color: var(--muted);
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0;
}
.tabs__tab[aria-selected="true"] {
color: var(--accent);
}
.tabs__indicator {
border-radius: 0.75rem;
background: color-mix(in oklab, var(--accent) 12%, transparent);
}
.app-filter-tabs {
width: max-content;
max-width: 100%;
}
.app-filter-tabs .tabs__list {
width: max-content;
min-width: max-content;
height: 2.25rem;
min-height: 2.25rem;
align-items: stretch;
gap: 0;
border: 0 !important;
border-width: 0 !important;
border-bottom-width: 0 !important;
border-radius: 999px !important;
background-color: rgb(var(--color-surface-secondary) / 0.86) !important;
padding: 0 !important;
overflow: hidden;
backdrop-filter: blur(12px);
}
.app-filter-tabs .tabs__tab {
flex: 0 0 auto;
width: auto;
height: 100%;
min-width: max-content;
min-height: 0;
align-self: stretch;
white-space: nowrap;
border: 0;
border-radius: 999px !important;
padding: 0 1rem;
background: transparent;
color: var(--muted);
line-height: 1;
box-shadow: none;
}
.app-filter-tabs .tabs__tab:hover,
.app-filter-tabs .tabs__tab[data-hovered="true"] {
color: var(--foreground);
}
.app-filter-tabs .tabs__tab[aria-selected="true"] {
background-color: rgb(var(--color-surface)) !important;
color: var(--foreground) !important;
box-shadow: 0 8px 22px -16px color-mix(in oklab, var(--foreground) 44%, transparent);
}
.app-filter-tabs .tabs__separator {
display: none;
}
.app-filter-tabs .tabs__indicator {
display: none;
}
.input,
.textfield,
.select__trigger,
.textarea {
border-color: var(--field-border);
background: var(--field-background);
color: var(--field-foreground);
}
.table-root {
border-color: var(--border);
background: var(--surface);
}
.table__header,
.table__row {
border-color: var(--separator);
}
}

View File

@ -5,6 +5,7 @@
import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Heart, Radio, Tv } from 'lucide-react';
import { Alert, Button, Card, Chip, EmptyState, ProgressBar, ScrollShadow, Spinner, Tabs } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
@ -1058,68 +1059,26 @@ function LivePageClient() {
}, []);
if (loading) {
const progressValue =
loadingStage === 'loading' ? 33 : loadingStage === 'fetching' ? 66 : 100;
return (
<PageLayout activePath='/live'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画直播图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>📺</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'loading' ? 'bg-blue-500 scale-125' : 'bg-blue-500'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'fetching' ? 'bg-blue-500 scale-125' : 'bg-blue-500'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready' ? 'bg-blue-500 scale-125' : 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-blue-300 to-blue-700 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'loading' ? '33%' : loadingStage === 'fetching' ? '66%' : '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
<Card className='w-full max-w-md text-center'>
<Card.Header className='items-center'>
<Spinner />
<Card.Title>{loadingMessage}</Card.Title>
<Card.Description></Card.Description>
</Card.Header>
<Card.Content>
<ProgressBar aria-label='加载进度' value={progressValue} color='accent'>
<ProgressBar.Track>
<ProgressBar.Fill />
</ProgressBar.Track>
</ProgressBar>
</Card.Content>
</Card>
</div>
</PageLayout>
);
@ -1129,41 +1088,24 @@ function LivePageClient() {
return (
<PageLayout activePath='/live'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gradient-to-r from-blue-300 to-blue-700 text-white rounded-xl font-medium hover:from-blue-400 hover:to-blue-800 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
<Card className='w-full max-w-md'>
<Card.Header>
<Card.Title></Card.Title>
<Card.Description></Card.Description>
</Card.Header>
<Card.Content>
<Alert status='danger'>
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
</Card.Content>
<Card.Footer>
<Button fullWidth onPress={() => window.location.reload()}>
🔄
</button>
</div>
</div>
</Button>
</Card.Footer>
</Card>
</div>
</PageLayout>
);
@ -1175,7 +1117,7 @@ function LivePageClient() {
{/* 第一行:页面标题 */}
<div className='py-1'>
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2 max-w-[80%]'>
<Radio className='w-5 h-5 text-blue-500 flex-shrink-0' />
<Radio className='w-5 h-5 text-accent flex-shrink-0' />
<div className='min-w-0 flex-1'>
<div className='truncate'>
{currentSource?.name}
@ -1198,17 +1140,16 @@ function LivePageClient() {
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
<Button
variant='secondary'
size='sm'
aria-label={isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'}
onPress={() =>
setIsChannelListCollapsed(!isChannelListCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isChannelListCollapsed ? '显示频道列表' : '隐藏频道列表'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isChannelListCollapsed ? 'rotate-180' : 'rotate-0'
className={`w-3.5 h-3.5 transition-transform duration-200 ${isChannelListCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
@ -1221,18 +1162,8 @@ function LivePageClient() {
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isChannelListCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isChannelListCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-blue-400'
}`}
></div>
</button>
{isChannelListCollapsed ? '显示' : '隐藏'}
</Button>
</div>
<div className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isChannelListCollapsed
@ -1249,50 +1180,27 @@ function LivePageClient() {
{/* 不支持的直播类型提示 */}
{unsupportedType && (
<div className='absolute inset-0 bg-black/90 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[600] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'></div>
<div className='absolute -inset-2 bg-gradient-to-r from-orange-500 to-red-600 rounded-2xl opacity-20 animate-pulse'></div>
</div>
</div>
<div className='space-y-4'>
<h3 className='text-xl font-semibold text-white'>
</h3>
<div className='bg-orange-500/20 border border-orange-500/30 rounded-lg p-4'>
<p className='text-orange-300 font-medium'>
<span className='text-white font-bold'>{unsupportedType.toUpperCase()}</span>
</p>
<p className='text-sm text-orange-200 mt-2'>
M3U8
</p>
</div>
<p className='text-sm text-gray-300'>
</p>
</div>
</div>
<div className='absolute inset-0 z-[600] flex items-center justify-center bg-black/90'>
<Card variant='default' className='max-w-md p-6 text-center'>
<Alert status='warning'>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
{unsupportedType.toUpperCase()} M3U8
</Alert.Description>
</Alert.Content>
</Alert>
</Card>
</div>
)}
{/* 视频加载蒙层 */}
{isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl overflow-hidden shadow-lg border border-white/0 dark:border-white/30 flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>📺</div>
<div className='absolute -inset-2 bg-gradient-to-r from-blue-300 to-blue-700 rounded-2xl opacity-20 animate-spin'></div>
</div>
</div>
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
🔄 IPTV ...
</p>
</div>
</div>
<div className='absolute inset-0 z-[500] flex items-center justify-center bg-black/85'>
<Card variant='default' className='max-w-md p-6 text-center'>
<Spinner />
<p className='mt-4 text-lg font-semibold'>IPTV ...</p>
</Card>
</div>
)}
</div>
@ -1303,42 +1211,28 @@ function LivePageClient() {
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}>
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
<Card variant='default' className='md:ml-2 h-full p-4 flex flex-col overflow-hidden'>
{/* 主要的 Tab 切换 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'>
<div
onClick={() => setActiveTab('channels')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${activeTab === 'channels'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
<div
onClick={() => setActiveTab('sources')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${activeTab === 'sources'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
</div>
<Tabs
selectedKey={activeTab}
variant='secondary'
onSelectionChange={(key) => setActiveTab(String(key) as 'channels' | 'sources')}
>
<Tabs.List aria-label='直播列表类型'>
<Tabs.Tab id='channels'></Tabs.Tab>
<Tabs.Tab id='sources'></Tabs.Tab>
</Tabs.List>
</Tabs>
{/* 频道 Tab 内容 */}
{activeTab === 'channels' && (
<>
{/* 分组标签 */}
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
<div className='flex items-center gap-4 my-4 flex-shrink-0'>
{/* 切换状态提示 */}
{isSwitchingSource && (
<div className='flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400'>
<div className='w-2 h-2 bg-amber-500 rounded-full animate-pulse'></div>
<Spinner size='sm' />
...
</div>
)}
@ -1370,57 +1264,46 @@ function LivePageClient() {
}
}}
>
<div className='flex gap-4 min-w-max'>
<div className='flex gap-2 min-w-max'>
{Object.keys(groupedChannels).map((group, index) => (
<button
<Button
key={group}
data-group={group}
ref={(el) => {
groupButtonRefs.current[index] = el;
}}
onClick={() => handleGroupChange(group)}
disabled={isSwitchingSource}
className={`w-20 relative py-2 text-sm font-medium transition-colors flex-shrink-0 text-center overflow-hidden
${isSwitchingSource
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50'
: selectedGroup === group
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400'
}
`.trim()}
size='sm'
variant={selectedGroup === group ? 'secondary' : 'tertiary'}
isDisabled={isSwitchingSource}
className='w-20 flex-shrink-0 overflow-hidden'
onPress={() => handleGroupChange(group)}
>
<div className='px-1 overflow-hidden whitespace-nowrap' title={group}>
{group}
</div>
{selectedGroup === group && !isSwitchingSource && (
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400' />
)}
</button>
</Button>
))}
</div>
</div>
</div>
{/* 频道列表 */}
<div ref={channelListRef} className='flex-1 overflow-y-auto space-y-2 pb-4'>
<ScrollShadow hideScrollBar ref={channelListRef} className='flex-1 space-y-2 pb-4'>
{filteredChannels.length > 0 ? (
filteredChannels.map(channel => {
const isActive = channel.id === currentChannel?.id;
return (
<button
<Button
key={channel.id}
data-channel-id={channel.id}
onClick={() => handleChannelChange(channel)}
disabled={isSwitchingSource}
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
fullWidth
variant={isActive ? 'secondary' : 'tertiary'}
isDisabled={isSwitchingSource}
className='h-auto justify-start p-3'
onPress={() => handleChannelChange(channel)}
>
<div className='flex items-center gap-3'>
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
<div className='w-10 h-10 flex items-center justify-center flex-shrink-0 overflow-hidden'>
{channel.logo ? (
<img
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
@ -1433,90 +1316,77 @@ function LivePageClient() {
)}
</div>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
<div className='text-sm font-medium truncate' title={channel.name}>
{channel.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
<div className='text-xs text-muted mt-1' title={channel.group}>
{channel.group}
</div>
</div>
</div>
</button>
</Button>
);
})
) : (
<div className='flex flex-col items-center justify-center py-12 text-center'>
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
<Tv className='w-8 h-8 text-gray-400 dark:text-gray-600' />
</div>
<p className='text-gray-500 dark:text-gray-400 font-medium'>
</p>
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
</p>
</div>
<EmptyState>
<Tv className='w-8 h-8' />
<p className='font-medium'></p>
<p className='text-sm text-muted'></p>
</EmptyState>
)}
</div>
</ScrollShadow>
</>
)}
{/* 直播源 Tab 内容 */}
{activeTab === 'sources' && (
<div className='flex flex-col h-full mt-4'>
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
<ScrollShadow hideScrollBar className='flex-1 space-y-2 pb-20'>
{liveSources.length > 0 ? (
liveSources.map((source) => {
const isCurrentSource = source.key === currentSource?.key;
return (
<div
<Button
key={source.key}
onClick={() => !isCurrentSource && handleSourceChange(source)}
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${isCurrentSource
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()}
fullWidth
variant={isCurrentSource ? 'secondary' : 'tertiary'}
className='h-auto justify-start p-3'
isDisabled={isCurrentSource}
onPress={() => handleSourceChange(source)}
>
{/* 图标 */}
<div className='w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-lg flex items-center justify-center flex-shrink-0'>
<div className='w-12 h-12 flex items-center justify-center flex-shrink-0'>
<Radio className='w-6 h-6 text-gray-500' />
</div>
{/* 信息 */}
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
<div className='text-sm font-medium truncate'>
{source.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<div className='text-xs text-muted mt-1'>
{!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`}
</div>
</div>
{/* 当前标识 */}
{isCurrentSource && (
<div className='absolute top-2 right-2 w-2 h-2 bg-blue-500 rounded-full'></div>
<Chip color='accent' size='sm'></Chip>
)}
</div>
</Button>
);
})
) : (
<div className='flex flex-col items-center justify-center py-12 text-center'>
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
<Radio className='w-8 h-8 text-gray-400 dark:text-gray-600' />
</div>
<p className='text-gray-500 dark:text-gray-400 font-medium'>
</p>
<p className='text-sm text-gray-400 dark:text-gray-500 mt-1'>
</p>
</div>
<EmptyState>
<Radio className='w-8 h-8' />
<p className='font-medium'></p>
<p className='text-sm text-muted'></p>
</EmptyState>
)}
</div>
</ScrollShadow>
</div>
)}
</div>
</Card>
</div>
</div>
</div>
@ -1545,16 +1415,17 @@ function LivePageClient() {
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
{currentChannel.name}
</h3>
<button
onClick={(e) => {
e.stopPropagation();
<Button
isIconOnly
variant='tertiary'
aria-label={favorited ? '取消收藏' : '收藏'}
onPress={() => {
handleToggleFavorite();
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
title={favorited ? '取消收藏' : '收藏'}
>
<FavoriteIcon filled={favorited} />
</button>
</Button>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
{currentSource?.name} {' > '} {currentChannel.group}

View File

@ -3,7 +3,7 @@
'use client';
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
import { Form, Input, Label, TextField } from '@heroui/react';
import { Alert, Checkbox, Form, Input, Label, Link, TextField } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@ -37,11 +37,10 @@ function VersionDisplay() {
}, []);
return (
<button
onClick={() =>
window.open('https://github.com/djteang/OrangeTV', '_blank')
}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
<Link
href='https://github.com/djteang/OrangeTV'
target='_blank'
className='absolute bottom-4 left-1/2 -translate-x-1/2 text-xs'
>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
@ -49,7 +48,7 @@ function VersionDisplay() {
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
? 'text-yellow-600 dark:text-yellow-400'
: updateStatus === UpdateStatus.NO_UPDATE
? 'text-blue-600 dark:text-blue-400'
? 'text-success'
: ''
}`}
>
@ -67,7 +66,7 @@ function VersionDisplay() {
)}
</div>
)}
</button>
</Link>
);
}
@ -203,7 +202,7 @@ function LoginPageClient() {
<ThemeToggle />
</div>
<AppSurface className='relative z-10 w-full max-w-md p-8 sm:p-10'>
<h1 className='text-blue-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
<h1 className='mb-8 text-center text-3xl font-semibold'>
{siteName}
</h1>
<Form onSubmit={handleSubmit} className='space-y-6'>
@ -236,36 +235,35 @@ function LoginPageClient() {
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
<div className='space-y-4'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<Shield className='w-4 h-4 text-blue-600 dark:text-blue-400' />
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'></span>
</div>
<div className='space-y-2'>
<div className='text-xs font-mono text-gray-700 dark:text-gray-300 break-all'>
<Alert status='accent'>
<Alert.Indicator>
<Shield className='w-4 h-4' />
</Alert.Indicator>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
{MachineCode.formatMachineCode(machineCode)}
</div>
<div className='text-xs text-gray-600 dark:text-gray-400'>
<br />
: {deviceInfo}
</div>
</div>
</div>
</Alert.Description>
</Alert.Content>
</Alert>
{/* 绑定选项 */}
{!requireMachineCode && (
<div className='space-y-2'>
<div className='flex items-center space-x-3'>
<input
id='bindMachineCode'
type='checkbox'
checked={bindMachineCode}
onChange={(e) => setBindMachineCode(e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
<label htmlFor='bindMachineCode' className='text-sm text-gray-700 dark:text-gray-300'>
<Checkbox
id='bindMachineCode'
isSelected={bindMachineCode}
onChange={setBindMachineCode}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>
</label>
</div>
</Checkbox.Content>
</Checkbox>
{/* <p className='text-xs text-gray-500 dark:text-gray-400 ml-7'>
// 管理员可选择不绑定机器码直接登录
</p> */}

View File

@ -2,8 +2,7 @@
'use client';
import { X } from 'lucide-react';
import Link from 'next/link';
import { Button, Card, EmptyState, Link as HeroLink, Skeleton } from '@heroui/react';
import { Suspense, useEffect, useState } from 'react';
import {
@ -26,6 +25,7 @@ import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
import { AppDialog } from '@/components/ui/HeroPrimitives';
function HomeClient() {
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
@ -185,26 +185,24 @@ function HomeClient() {
<div className='mx-auto max-w-[1380px] space-y-10'>
{activeTab === 'favorites' ? (
// 收藏夹视图
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-5 flex items-end justify-between gap-4'>
<div className='space-y-1'>
<p className='a2-kicker'>Saved</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2>
<Card>
<Card.Header className='flex-row items-end justify-between gap-4'>
<div>
<Card.Description>Saved</Card.Description>
<Card.Title></Card.Title>
</div>
{favoriteItems.length > 0 && (
<button
className='a2-link-action'
onClick={async () => {
<Button
variant='danger'
onPress={async () => {
await clearAllFavorites();
setFavoriteItems([]);
}}
>
</button>
</Button>
)}
</div>
</Card.Header>
<div className='grid justify-start grid-cols-3 gap-x-3 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20'>
{favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'>
@ -217,12 +215,12 @@ function HomeClient() {
</div>
))}
{favoriteItems.length === 0 && (
<div className='col-span-full rounded-2xl border border-dashed border-border bg-surface-secondary/60 py-10 text-center text-sm font-medium tracking-normal text-muted'>
<EmptyState className='col-span-full'>
</div>
</EmptyState>
)}
</div>
</section>
</Card>
) : (
// 首页视图
<>
@ -230,21 +228,18 @@ function HomeClient() {
<ContinueWatching />
{/* 热门电影 */}
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-5 flex items-end justify-between gap-4'>
<div className='space-y-1'>
<p className='a2-kicker'></p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2>
<Card>
<Card.Header className='flex-row items-end justify-between gap-4'>
<div>
<Card.Description></Card.Description>
<Card.Title></Card.Title>
</div>
<Link
<HeroLink
href='/douban?type=movie'
className='a2-link-action'
>
</Link>
</div>
</HeroLink>
</Card.Header>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
@ -253,10 +248,8 @@ function HomeClient() {
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
<div className='absolute inset-0 bg-surface-tertiary'></div>
</div>
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
<Skeleton className='aspect-[2/3] w-full' />
<Skeleton className='mt-3 h-4' />
</div>
))
: // 显示真实数据
@ -277,21 +270,19 @@ function HomeClient() {
</div>
))}
</ScrollableRow>
</section>
</Card>
{/* 热门剧集 */}
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-5 flex items-end justify-between gap-4'>
<div className='space-y-1'>
<p className='a2-kicker'>Series</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2>
<Card>
<Card.Header className='flex-row items-end justify-between gap-4'>
<div>
<Card.Description>Series</Card.Description>
<Card.Title></Card.Title>
</div>
<Link href='/douban?type=tv' className='a2-link-action'>
<HeroLink href='/douban?type=tv'>
</Link>
</div>
</HeroLink>
</Card.Header>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
@ -300,10 +291,8 @@ function HomeClient() {
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
<div className='absolute inset-0 bg-surface-tertiary'></div>
</div>
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
<Skeleton className='aspect-[2/3] w-full' />
<Skeleton className='mt-3 h-4' />
</div>
))
: // 显示真实数据
@ -323,24 +312,21 @@ function HomeClient() {
</div>
))}
</ScrollableRow>
</section>
</Card>
{/* 每日新番放送 */}
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-5 flex items-end justify-between gap-4'>
<div className='space-y-1'>
<p className='a2-kicker'>Bangumi</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2>
<Card>
<Card.Header className='flex-row items-end justify-between gap-4'>
<div>
<Card.Description>Bangumi</Card.Description>
<Card.Title></Card.Title>
</div>
<Link
<HeroLink
href='/douban?type=anime'
className='a2-link-action'
>
</Link>
</div>
</HeroLink>
</Card.Header>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
@ -349,10 +335,8 @@ function HomeClient() {
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
<div className='absolute inset-0 bg-surface-tertiary'></div>
</div>
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
<Skeleton className='aspect-[2/3] w-full' />
<Skeleton className='mt-3 h-4' />
</div>
))
: // 展示当前日期的番剧
@ -401,24 +385,21 @@ function HomeClient() {
));
})()}
</ScrollableRow>
</section>
</Card>
{/* 热门综艺 */}
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-5 flex items-end justify-between gap-4'>
<div className='space-y-1'>
<p className='a2-kicker'>Shows</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2>
<Card>
<Card.Header className='flex-row items-end justify-between gap-4'>
<div>
<Card.Description>Shows</Card.Description>
<Card.Title></Card.Title>
</div>
<Link
<HeroLink
href='/douban?type=show'
className='a2-link-action'
>
</Link>
</div>
</HeroLink>
</Card.Header>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
@ -427,10 +408,8 @@ function HomeClient() {
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
<div className='absolute inset-0 bg-surface-tertiary'></div>
</div>
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
<Skeleton className='aspect-[2/3] w-full' />
<Skeleton className='mt-3 h-4' />
</div>
))
: // 显示真实数据
@ -450,75 +429,29 @@ function HomeClient() {
</div>
))}
</ScrollableRow>
</section>
</Card>
</>
)}
</div>
</div>
{announcement && showAnnouncement && (
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${showAnnouncement ? '' : 'opacity-0 pointer-events-none'
}`}
onTouchStart={(e) => {
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
}
{announcement && (
<AppDialog
isOpen={showAnnouncement}
onOpenChange={(isOpen) => {
if (!isOpen) handleCloseAnnouncement(announcement);
}}
onTouchMove={(e) => {
// 如果触摸的是背景区域,阻止触摸移动,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
e.stopPropagation();
}
}}
onTouchEnd={(e) => {
// 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}}
>
<div
className='a2-panel w-full max-w-md p-6 transform transition-all duration-300'
onTouchMove={(e) => {
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许内容区域的正常触摸操作
}}
>
<div className='flex justify-between items-start mb-4'>
<h3 className='a2-title border-b border-border/70 pb-3 text-[1.75rem]'>
</h3>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='a2-icon-button h-8 w-8 p-1.5'
aria-label='关闭'
>
<X className='h-4 w-4' />
</button>
</div>
<div className='mb-6'>
<div className='border-l-4 border-accent pl-4'>
<p className='a2-muted-copy'>
{announcement}
</p>
</div>
</div>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='a2-link-action w-full justify-center border-b-0 border-t border-border/70 px-4 pt-3'
title='提示'
footer={
<Button
fullWidth
onPress={() => handleCloseAnnouncement(announcement)}
>
</button>
</div>
</div>
</Button>
}
>
<p className='text-sm leading-6 text-muted'>{announcement}</p>
</AppDialog>
)}
</PageLayout>
);

View File

@ -4,6 +4,7 @@
// Artplayer 和 Hls 以及弹幕插件将动态加载
import { Heart } from 'lucide-react';
import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
@ -707,38 +708,6 @@ function PlayPageClient() {
return vodTagString.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
};
// 为标签生成颜色的函数
const getTagColor = (tag: string, isClass: boolean = false) => {
if (isClass) {
// vod_class 使用更显眼的颜色
const classColors = [
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
];
const hash = tag.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
return classColors[hash % classColors.length];
} else {
// vod_tag 使用较为柔和的颜色
const tagColors = [
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
'bg-amber-100 text-amber-700 dark:bg-amber-800 dark:text-amber-300',
'bg-orange-100 text-orange-700 dark:bg-orange-800 dark:text-orange-300',
'bg-red-100 text-red-700 dark:bg-red-800 dark:text-red-300',
'bg-pink-100 text-pink-700 dark:bg-pink-800 dark:text-pink-300',
'bg-rose-100 text-rose-700 dark:bg-rose-800 dark:text-rose-300'
];
const hash = tag.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
return tagColors[hash % tagColors.length];
}
};
// 短剧播放地址处理函数 - 参考utils.ts中的代理逻辑
const processShortDramaUrl = (originalUrl: string): string => {
if (!originalUrl) {
@ -2482,89 +2451,30 @@ function PlayPageClient() {
}, []);
if (loading) {
const progressValue =
loadingStage === 'searching' || loadingStage === 'fetching'
? 33
: loadingStage === 'preferring'
? 66
: 100;
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>
{loadingStage === 'searching' && '🔍'}
{loadingStage === 'preferring' && '⚡'}
{loadingStage === 'fetching' && '🎬'}
{loadingStage === 'ready' && '✨'}
</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-blue-300 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'searching' || loadingStage === 'fetching'
? 'bg-blue-500 scale-125'
: loadingStage === 'preferring' ||
loadingStage === 'ready'
? 'bg-blue-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'preferring'
? 'bg-blue-500 scale-125'
: loadingStage === 'ready'
? 'bg-blue-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready'
? 'bg-blue-500 scale-125'
: 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'searching' ||
loadingStage === 'fetching'
? '33%'
: loadingStage === 'preferring'
? '66%'
: '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
<Card className='w-full max-w-md text-center'>
<Card.Header className='items-center'>
<Spinner />
<Card.Title>{loadingMessage}</Card.Title>
<Card.Description></Card.Description>
</Card.Header>
<Card.Content>
<ProgressBar aria-label='加载进度' value={progressValue} color='accent'>
<ProgressBar.Track>
<ProgressBar.Fill />
</ProgressBar.Track>
</ProgressBar>
</Card.Content>
</Card>
</div>
</PageLayout>
);
@ -2574,65 +2484,39 @@ function PlayPageClient() {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
{/* 浮动错误粒子 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
<Card className='w-full max-w-md'>
<Card.Header>
<Card.Title></Card.Title>
<Card.Description></Card.Description>
</Card.Header>
<Card.Content>
<Alert status='danger'>
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
</Card.Content>
<Card.Footer className='gap-3'>
<Button
fullWidth
onPress={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
</Button>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
<Button
fullWidth
variant='tertiary'
onPress={() => window.location.reload()}
>
🔄
</button>
</div>
</div>
</Button>
</Card.Footer>
</Card>
</div>
</PageLayout>
);
@ -2656,17 +2540,16 @@ function PlayPageClient() {
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
<Button
variant='secondary'
size='sm'
aria-label={isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'}
onPress={() =>
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
className={`w-3.5 h-3.5 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
@ -2679,18 +2562,8 @@ function PlayPageClient() {
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isEpisodeSelectorCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-blue-400'
}`}
></div>
</button>
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
</Button>
</div>
<div
@ -2712,39 +2585,15 @@ function PlayPageClient() {
{/* 换源加载蒙层 */}
{isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>🎬</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-blue-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-blue-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-blue-300 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 换源消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
{videoLoadingStage === 'sourceChanging'
? '🔄 切换播放源...'
: '🔄 视频加载中...'}
</p>
</div>
</div>
<div className='absolute inset-0 z-[500] flex items-center justify-center bg-black/85'>
<Card variant='default' className='max-w-md p-6 text-center'>
<Spinner />
<p className='mt-4 text-lg font-semibold'>
{videoLoadingStage === 'sourceChanging'
? '切换播放源...'
: '视频加载中...'}
</p>
</Card>
</div>
)}
</div>
@ -2783,33 +2632,35 @@ function PlayPageClient() {
{/* 标题 */}
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
{videoTitle || '影片标题'}
<button
onClick={(e) => {
e.stopPropagation();
<Button
isIconOnly
variant='tertiary'
aria-label={favorited ? '取消收藏' : '收藏'}
onPress={() => {
handleToggleFavorite();
}}
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
className='ml-3 flex-shrink-0'
>
<FavoriteIcon filled={favorited} />
</button>
</Button>
</h1>
{/* 关键信息行 */}
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
{detail?.class && (
<span className='text-blue-600 font-semibold'>
<Chip color='accent' variant='secondary'>
{detail.class}
</span>
</Chip>
)}
{(detail?.year || videoYear) && (
<span>{detail?.year || videoYear}</span>
<Chip variant='secondary'>{detail?.year || videoYear}</Chip>
)}
{detail?.source_name && (
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
<Chip variant='secondary'>
{detail.source_name}
</span>
</Chip>
)}
{detail?.type_name && <span>{detail.type_name}</span>}
{detail?.type_name && <Chip variant='secondary'>{detail.type_name}</Chip>}
</div>
{/* 短剧专用标签展示 */}
@ -2822,11 +2673,9 @@ function PlayPageClient() {
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
:
</span>
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${getTagColor(vodClass, true)}`}
>
<Chip color='accent' variant='secondary' size='sm'>
📂 {vodClass}
</span>
</Chip>
</div>
)}
@ -2837,12 +2686,13 @@ function PlayPageClient() {
:
</span>
{parseVodTags(vodTag).map((tag, index) => (
<span
<Chip
key={index}
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getTagColor(tag, false)}`}
variant='secondary'
size='sm'
>
🏷 {tag}
</span>
</Chip>
))}
</div>
)}
@ -2881,7 +2731,7 @@ function PlayPageClient() {
rel='noopener noreferrer'
className='absolute top-3 left-3'
>
<div className='bg-blue-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-blue-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<Button isIconOnly size='sm' variant='primary' aria-label='打开豆瓣页面'>
<svg
width='16'
height='16'
@ -2895,7 +2745,7 @@ function PlayPageClient() {
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
</svg>
</div>
</Button>
</a>
)}
</>

View File

@ -2,6 +2,16 @@
'use client';
import { ChevronUp, Search, X } from 'lucide-react';
import {
Button,
Card,
Chip,
EmptyState,
Input,
Spinner,
Switch,
Tooltip,
} from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
@ -1021,7 +1031,7 @@ function SearchPageClient() {
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
<div className='relative'>
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted' />
<input
<Input
id='searchInput'
type='text'
value={searchQuery}
@ -1029,14 +1039,18 @@ function SearchPageClient() {
onFocus={handleInputFocus}
placeholder='搜索电影、电视剧、短剧...'
autoComplete="off"
className='w-full h-12 rounded-2xl border border-border bg-surface/90 py-3 pl-10 pr-12 text-sm text-foreground placeholder:text-muted shadow-sm backdrop-blur focus:border-accent focus:bg-surface focus:outline-none focus:ring-2 focus:ring-accent/20'
fullWidth
className='pl-10 pr-12'
/>
{/* 清除按钮 */}
{searchQuery && (
<button
<Button
type='button'
onClick={() => {
isIconOnly
size='sm'
variant='ghost'
onPress={() => {
setSearchQuery('');
setShowSuggestions(false);
document.getElementById('searchInput')?.focus();
@ -1045,7 +1059,7 @@ function SearchPageClient() {
aria-label='清除搜索内容'
>
<X className='h-5 w-5' />
</button>
</Button>
)}
{/* 搜索建议 */}
@ -1071,7 +1085,7 @@ function SearchPageClient() {
{/* 搜索结果或搜索历史 */}
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
{showResults ? (
<section className='mb-12 rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<Card className='mb-12'>
{/* 标题 */}
<div className='mb-4'>
<h2 className='text-xl font-semibold tracking-normal text-foreground'>
@ -1082,9 +1096,7 @@ function SearchPageClient() {
</span>
)}
{isLoading && useFluidSearch && (
<span className='ml-2 inline-block align-middle'>
<span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-border border-t-accent'></span>
</span>
<Spinner size='sm' className='ml-2 inline-flex align-middle' />
)}
</h2>
</div>
@ -1106,29 +1118,26 @@ function SearchPageClient() {
)}
</div>
{/* 聚合开关 */}
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
<span className='text-xs sm:text-sm font-medium text-muted'></span>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={viewMode === 'agg'}
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
/>
<div className='w-9 h-5 rounded-full bg-surface-secondary transition-colors peer-checked:bg-accent'></div>
<div className='absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-surface shadow-sm transition-transform peer-checked:translate-x-4'></div>
</div>
</label>
<Switch
size='sm'
isSelected={viewMode === 'agg'}
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Content></Switch.Content>
</Switch>
</div>
{searchResults.length === 0 ? (
isLoading ? (
<div className='flex justify-center items-center h-40'>
<div className='h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent'></div>
<div className='flex h-40 items-center justify-center'>
<Spinner />
</div>
) : (
<div className='rounded-2xl border border-dashed border-border bg-surface-secondary/60 py-8 text-center text-muted'>
<EmptyState>
</div>
</EmptyState>
)
) : (
<div
@ -1196,51 +1205,56 @@ function SearchPageClient() {
))}
</div>
)}
</section>
</Card>
) : searchHistory.length > 0 ? (
// 搜索历史
<section className='mb-12 rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<h2 className='mb-4 text-left text-xl font-semibold tracking-normal text-foreground'>
<Card className='mb-12'>
<Card.Header className='flex-row items-center justify-between gap-3'>
<Card.Title></Card.Title>
{searchHistory.length > 0 && (
<button
onClick={() => {
<Button
variant='danger'
size='sm'
onPress={() => {
clearSearchHistory(); // 事件监听会自动更新界面
}}
className='ml-3 text-sm text-muted transition-colors hover:text-danger'
>
</button>
</Button>
)}
</h2>
</Card.Header>
<div className='flex flex-wrap gap-2'>
{searchHistory.map((item) => (
<div key={item} className='relative group'>
<button
<Chip
role='button'
tabIndex={0}
onClick={() => {
// 直接调用搜索函数
performSearch(item.trim());
}}
className='rounded-full border border-border bg-surface-secondary px-4 py-2 text-sm text-foreground transition-colors duration-200 hover:border-accent/40 hover:bg-accent/10 hover:text-accent'
color='accent'
variant='soft'
>
{item}
</button>
<Chip.Label>{item}</Chip.Label>
</Chip>
{/* 删除按钮 */}
<button
<Button
aria-label='删除搜索历史'
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
isIconOnly
size='sm'
variant='danger'
className='absolute -right-2 -top-2 opacity-0 group-hover:opacity-100'
onPress={() => {
deleteSearchHistory(item); // 事件监听会自动更新界面
}}
className='absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] text-white opacity-0 transition-colors hover:bg-danger group-hover:opacity-100'
>
<X className='w-3 h-3' />
</button>
</Button>
</div>
))}
</div>
</section>
</Card>
) : null}
</div>
</div>
@ -1252,62 +1266,18 @@ function SearchPageClient() {
: 'opacity-0 translate-y-4 pointer-events-none'
}`}
>
<button
onClick={scrollToTop}
className='group relative h-14 w-14 rounded-2xl border border-border bg-overlay shadow-xl backdrop-blur-xl transition-all duration-300 ease-out hover:scale-105 hover:border-accent/40 focus:outline-none focus:ring-2 focus:ring-accent/20'
aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
style={{
background: `conic-gradient(from 0deg, rgb(var(--color-accent)) ${scrollProgress * 3.6}deg, rgb(var(--color-accent) / 0.12) ${scrollProgress * 3.6}deg)`
}}
>
{/* 内部发光圆圈 */}
<div className='absolute inset-1 flex items-center justify-center rounded-xl bg-surface/90 backdrop-blur-sm transition-all duration-300 group-hover:bg-accent/15'>
<ChevronUp className='w-6 h-6 text-accent transition-all duration-300 group-hover:scale-110' />
</div>
{/* 进度环 */}
<svg className='absolute inset-0 w-full h-full -rotate-90' viewBox='0 0 56 56'>
<circle
cx='28'
cy='28'
r='25'
fill='none'
stroke='rgba(255, 255, 255, 0.1)'
strokeWidth='2'
/>
<circle
cx='28'
cy='28'
r='25'
fill='none'
stroke='url(#progressGradient)'
strokeWidth='2'
strokeLinecap='round'
strokeDasharray={`${(scrollProgress / 100) * 157} 157`}
className='transition-all duration-300 ease-out'
/>
<defs>
<linearGradient id='progressGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
<stop offset='0%' stopColor='rgb(var(--color-accent))' />
<stop offset='50%' stopColor='rgb(var(--color-accent))' />
<stop offset='100%' stopColor='rgb(var(--color-accent-strong))' />
</linearGradient>
</defs>
</svg>
{/* 悬停时的进度提示 */}
<div className='absolute -top-12 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none'>
<div className='rounded-xl border border-border bg-overlay px-3 py-1.5 text-xs text-foreground shadow-xl backdrop-blur'>
<div className='text-center font-medium'>
{Math.round(scrollProgress)}%
</div>
<div className='absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 border-b border-r border-border bg-overlay'></div>
</div>
</div>
{/* 脉冲动画 */}
<div className='absolute inset-0 animate-pulse rounded-2xl bg-accent/10 opacity-0 transition-opacity duration-300 group-hover:opacity-100'></div>
</button>
<Tooltip>
<Tooltip.Trigger>
<Button
onPress={scrollToTop}
isIconOnly
aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
>
<ChevronUp className='w-6 h-6' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{Math.round(scrollProgress)}%</Tooltip.Content>
</Tooltip>
</div>
</PageLayout>
);

View File

@ -3,6 +3,7 @@
'use client';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import { Card, EmptyState, Spinner } from '@heroui/react';
import {
getShortDramaList,
@ -250,12 +251,12 @@ function ShortDramaPageClient() {
</div>
{/* 选择器组件 */}
<div className='app-filter-panel'>
<Card>
<ShortDramaSelector
selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
</div>
</Card>
</div>
{/* 内容展示区域 */}
@ -305,8 +306,8 @@ function ShortDramaPageClient() {
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500'></div>
<span className='text-gray-600 dark:text-gray-400'>...</span>
<Spinner size='sm' />
<span className='text-muted'>...</span>
</div>
)}
</div>
@ -314,9 +315,9 @@ function ShortDramaPageClient() {
{/* 没有更多数据提示 */}
{!hasMore && shortDramaData.length > 0 && (
<div className='text-center text-gray-500 dark:text-gray-400 py-8'>
<EmptyState className='py-8'>
</div>
</EmptyState>
)}
{/* 空状态 */}

View File

@ -1,4 +1,5 @@
import { Metadata } from 'next';
import WarningClient from './warning-client';
export const metadata: Metadata = {
title: '安全警告 - OrangeTV',
@ -6,92 +7,5 @@ export const metadata: Metadata = {
};
export default function WarningPage() {
return (
<div className='min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4'>
<div className='max-w-2xl w-full bg-white rounded-2xl shadow-2xl p-4 sm:p-8 border border-red-200'>
{/* 警告图标 */}
<div className='flex justify-center mb-4 sm:mb-6'>
<div className='w-16 h-16 sm:w-20 sm:h-20 bg-red-100 rounded-full flex items-center justify-center'>
<svg
className='w-10 h-10 sm:w-12 sm:h-12 text-red-600'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
/>
</svg>
</div>
</div>
{/* 标题 */}
<div className='text-center mb-6 sm:mb-8'>
<h1 className='text-2xl sm:text-3xl font-bold text-gray-900 mb-2'>
</h1>
<div className='w-12 sm:w-16 h-1 bg-red-500 mx-auto rounded-full'></div>
</div>
{/* 警告内容 */}
<div className='space-y-4 sm:space-y-6 text-gray-700'>
<div className='bg-red-50 border-l-4 border-red-500 p-3 sm:p-4 rounded-r-lg'>
<p className='text-base sm:text-lg font-semibold text-red-800 mb-2'>
</p>
<p className='text-sm sm:text-base text-red-700'>
访
</p>
</div>
<div className='space-y-3 sm:space-y-4'>
<h2 className='text-lg sm:text-xl font-semibold text-gray-900'>
</h2>
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-gray-600'>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span>访</span>
</li>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span></span>
</li>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span></span>
</li>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span></span>
</li>
</ul>
</div>
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4'>
<h3 className='text-base sm:text-lg font-semibold text-yellow-800 mb-2'>
🔒
</h3>
<p className='text-sm sm:text-base text-yellow-700'>
{' '}
<code className='bg-yellow-100 px-1.5 py-0.5 rounded text-xs sm:text-sm font-mono'>
PASSWORD
</code>{' '}
访
</p>
</div>
</div>
{/* 底部装饰 */}
<div className='mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-gray-200'>
<div className='text-center text-xs sm:text-sm text-gray-500'>
<p></p>
</div>
</div>
</div>
</div>
);
return <WarningClient />;
}

View File

@ -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>
);
}

View File

@ -6,10 +6,9 @@ export function BackButton() {
return (
<AppIconButton
onPress={() => window.history.back()}
className='a2-icon-button'
aria-label='Back'
>
<ArrowLeft className='w-full h-full' />
<ArrowLeft className='h-5 w-5' />
</AppIconButton>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Card, Skeleton } from '@heroui/react';
import type { PlayRecord } from '@/lib/db.client';
import {
@ -86,26 +87,24 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
};
return (
<section className={`rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6 ${className || ''}`}>
<div className='mb-5 flex items-end justify-between gap-4'>
<div className='space-y-1'>
<p className='a2-kicker'></p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2>
<Card className={className}>
<Card.Header className='flex-row items-end justify-between gap-4'>
<div>
<Card.Description></Card.Description>
<Card.Title></Card.Title>
</div>
{!loading && playRecords.length > 0 && (
<button
className='a2-link-action'
onClick={async () => {
<Button
variant='danger'
onPress={async () => {
await clearAllPlayRecords();
setPlayRecords([]);
}}
>
</button>
</Button>
)}
</div>
</Card.Header>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
@ -114,11 +113,9 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
<div className='absolute inset-0 bg-surface-tertiary'></div>
</div>
<div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
<div className='mt-1 h-3 rounded-lg bg-surface-secondary animate-pulse'></div>
<Skeleton className='aspect-[2/3] w-full' />
<Skeleton className='mt-3 h-4' />
<Skeleton className='mt-1 h-3' />
</div>
))
: // 显示真实数据
@ -152,6 +149,6 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
);
})}
</ScrollableRow>
</section>
</Card>
);
}

View File

@ -3,7 +3,8 @@
import { AlertCircle, AlertTriangle, CheckCircle, Download, FileCheck, Lock, Upload } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Alert, Button, Card, Chip, Input, Label, Spinner, TextField } from '@heroui/react';
import { AppDialog } from './ui/HeroPrimitives';
interface DataMigrationProps {
onRefreshConfig?: () => Promise<void>;
@ -34,107 +35,80 @@ const AlertModal = ({
showConfirm = false,
timer
}: AlertModalProps) => {
const [isVisible, setIsVisible] = useState(false);
// 控制动画状态
useEffect(() => {
if (isOpen) {
setIsVisible(true);
if (timer) {
setTimeout(() => {
onClose();
}, timer);
}
} else {
setIsVisible(false);
if (isOpen && timer) {
const timeout = setTimeout(onClose, timer);
return () => clearTimeout(timeout);
}
}, [isOpen, timer, onClose]);
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-12 h-12 text-green-500" />;
return <CheckCircle className="h-5 w-5" />;
case 'error':
return <AlertCircle className="w-12 h-12 text-red-500" />;
return <AlertCircle className="h-5 w-5" />;
case 'warning':
return <AlertTriangle className="w-12 h-12 text-yellow-500" />;
return <AlertTriangle className="h-5 w-5" />;
default:
return null;
}
};
const getBgColor = () => {
const getStatus = () => {
switch (type) {
case 'success':
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
return 'success';
case 'error':
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
return 'danger';
case 'warning':
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
return 'warning';
default:
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
return 'accent';
}
};
return createPortal(
<div className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`} onClick={onClose}>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border ${getBgColor()} transition-all duration-200 ${isVisible ? 'scale-100' : 'scale-95'}`} onClick={(e) => e.stopPropagation()}>
<div className="p-6 text-center">
<div className="flex justify-center mb-4">
{getIcon()}
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{title}
</h3>
{message && (
<p className="text-gray-600 dark:text-gray-400 mb-4">
{message}
</p>
)}
{html && (
<div
className="text-left text-gray-600 dark:text-gray-400 mb-4"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
<div className="flex justify-center space-x-3">
{showConfirm && onConfirm ? (
<>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
</button>
<button
onClick={() => {
onConfirm();
onClose();
}}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
{confirmText}
</button>
</>
) : (
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
</button>
)}
</div>
</div>
</div>
</div>,
document.body
return (
<AppDialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
title={title}
icon={getIcon()}
footer={
showConfirm && onConfirm ? (
<>
<Button variant='secondary' onPress={onClose}>
</Button>
<Button
variant='primary'
onPress={() => {
onConfirm();
onClose();
}}
>
{confirmText}
</Button>
</>
) : (
<Button variant='primary' onPress={onClose}>
</Button>
)
}
>
<Alert status={getStatus()}>
{message ? <p>{message}</p> : null}
{html ? (
<div
className='text-sm leading-6'
dangerouslySetInnerHTML={{ __html: html }}
/>
) : null}
</Alert>
</AppDialog>
);
};
@ -334,51 +308,47 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
<>
<div className="max-w-6xl mx-auto space-y-6">
{/* 简洁警告提示 */}
<div className="flex items-center gap-3 p-4 border border-amber-200 dark:border-amber-700 rounded-lg bg-amber-50/30 dark:bg-amber-900/5">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
</p>
</div>
<Alert status='warning'>
</Alert>
{/* 主要操作区域 - 响应式布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 数据导出 */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-800 hover:shadow-sm transition-shadow flex flex-col">
<Card variant='default' className='p-6'>
<div className="flex items-center gap-3 mb-6">
<div className="w-8 h-8 rounded-lg bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center">
<Download className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<Chip variant='secondary' size='lg'>
<Download className="w-4 h-4" />
</Chip>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100"></h3>
<p className="text-sm text-gray-600 dark:text-gray-400"></p>
<h3 className="font-semibold"></h3>
<p className="text-sm text-muted"></p>
</div>
</div>
<div className="flex-1 flex flex-col">
<div className="space-y-4">
{/* 密码输入 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<TextField>
<Label className="flex items-center gap-2">
<Lock className="w-4 h-4" />
</label>
<input
</Label>
<Input
type="password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="设置强密码保护备份文件"
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
disabled={isExporting}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p className="text-xs text-muted">
使
</p>
</div>
</TextField>
{/* 备份内容列表 */}
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<p className="font-medium text-gray-700 dark:text-gray-300 mb-2"></p>
<div className="text-xs text-muted space-y-1">
<p className="font-medium text-foreground mb-2"></p>
<div className="grid grid-cols-2 gap-1">
<div> </div>
<div> </div>
@ -389,17 +359,16 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
</div>
{/* 导出按钮 */}
<button
onClick={handleExport}
disabled={isExporting || !exportPassword.trim()}
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isExporting || !exportPassword.trim()
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
<Button
fullWidth
variant='primary'
className='mt-10'
onPress={handleExport}
isDisabled={isExporting || !exportPassword.trim()}
>
{isExporting ? (
<div className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<Spinner size='sm' />
...
</div>
) : (
@ -408,74 +377,80 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
</div>
)}
</button>
</Button>
</div>
</div>
</Card>
{/* 数据导入 */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-800 hover:shadow-sm transition-shadow flex flex-col">
<Card variant='default' className='p-6'>
<div className="flex items-center gap-3 mb-6">
<div className="w-8 h-8 rounded-lg bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<Upload className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
<Chip color='danger' variant='secondary' size='lg'>
<Upload className="w-4 h-4" />
</Chip>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100"></h3>
<p className="text-sm text-red-600 dark:text-red-400"> </p>
<h3 className="font-semibold"></h3>
<p className="text-sm text-danger"></p>
</div>
</div>
<div className="flex-1 flex flex-col">
<div className="space-y-4">
{/* 文件选择 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className='space-y-2'>
<p className="flex items-center gap-2 text-sm font-medium">
<FileCheck className="w-4 h-4" />
{selectedFile && (
<span className="ml-auto text-xs text-green-600 dark:text-green-400 font-normal">
<span className="ml-auto text-xs text-success font-normal">
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
</span>
)}
</label>
</p>
<input
ref={fileInputRef}
type="file"
accept=".dat"
onChange={handleFileSelect}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-50 dark:file:bg-gray-600 file:text-gray-700 dark:file:text-gray-300 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 transition-colors"
className="sr-only"
disabled={isImporting}
/>
<Button
variant='secondary'
onPress={() => fileInputRef.current?.click()}
isDisabled={isImporting}
>
<FileCheck className='h-4 w-4' />
</Button>
</div>
{/* 密码输入 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<TextField>
<Label className="flex items-center gap-2">
<Lock className="w-4 h-4" />
</label>
<input
</Label>
<Input
type="password"
value={importPassword}
onChange={(e) => setImportPassword(e.target.value)}
placeholder="输入导出时的加密密码"
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
disabled={isImporting}
/>
</div>
</TextField>
</div>
{/* 导入按钮 */}
<button
onClick={handleImport}
disabled={isImporting || !selectedFile || !importPassword.trim()}
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isImporting || !selectedFile || !importPassword.trim()
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
: 'bg-red-600 hover:bg-red-700 text-white'
}`}
<Button
fullWidth
variant='primary'
className='mt-10'
onPress={handleImport}
isDisabled={isImporting || !selectedFile || !importPassword.trim()}
>
{isImporting ? (
<div className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<Spinner size='sm' />
...
</div>
) : (
@ -484,9 +459,9 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
</div>
)}
</button>
</Button>
</div>
</div>
</Card>
</div>
</div>

View File

@ -4,7 +4,7 @@
import React, { useMemo } from 'react';
import { AppFilterTabs } from './ui/HeroPrimitives';
import { AppFilterSelect } from './ui/HeroPrimitives';
interface CustomCategory {
name: string;
@ -22,18 +22,17 @@ interface DoubanCustomSelectorProps {
const renderSelector = (
label: string,
ariaLabel: string,
options: { label: string; value: string }[],
activeValue: string | undefined,
onChange: (value: string) => void
) => (
<AppFilterTabs
ariaLabel={label}
selectedKey={activeValue}
onSelectionChange={onChange}
items={options.map((option) => ({
key: option.value,
label: option.label,
}))}
<AppFilterSelect
ariaLabel={ariaLabel}
label={label}
options={options}
value={activeValue}
onChange={onChange}
/>
);
@ -76,31 +75,23 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
return (
<div className='space-y-4 sm:space-y-6'>
<div className='space-y-3 sm:space-y-4'>
<div className='app-filter-row'>
<span className='app-filter-label'></span>
<div className='min-w-0'>
{renderSelector(
'自定义类型',
primaryOptions,
primarySelection || primaryOptions[0]?.value,
onPrimaryChange
)}
</div>
</div>
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
{renderSelector(
'类型',
'自定义类型选项',
primaryOptions,
primarySelection || primaryOptions[0]?.value,
onPrimaryChange
)}
{secondaryOptions.length > 0 && (
<div className='app-filter-row'>
<span className='app-filter-label'></span>
<div className='min-w-0'>
{renderSelector(
'自定义片单',
secondaryOptions,
secondarySelection || secondaryOptions[0]?.value,
onSecondaryChange
)}
</div>
</div>
renderSelector(
'片单',
'自定义片单选项',
secondaryOptions,
secondarySelection || secondaryOptions[0]?.value,
onSecondaryChange
)
)}
</div>
</div>

View File

@ -5,7 +5,7 @@
import React from 'react';
import MultiLevelSelector from './MultiLevelSelector';
import { AppFilterTabs } from './ui/HeroPrimitives';
import { AppFilterSelect } from './ui/HeroPrimitives';
import WeekdaySelector from './WeekdaySelector';
interface SelectorOption {
@ -73,31 +73,27 @@ const animePrimaryOptions: SelectorOption[] = [
const renderSelector = (
label: string,
ariaLabel: string,
options: SelectorOption[],
activeValue: string | undefined,
onChange: (value: string) => void
) => (
<AppFilterTabs
ariaLabel={label}
selectedKey={activeValue}
onSelectionChange={onChange}
items={options.map((option) => ({
key: option.value,
label: option.label,
}))}
<AppFilterSelect
ariaLabel={ariaLabel}
label={label}
options={options}
value={activeValue}
onChange={onChange}
/>
);
const FilterRow = ({
label,
const FilterGrid = ({
children,
}: {
label: string;
children: React.ReactNode;
}) => (
<div className='app-filter-row'>
<span className='app-filter-label'>{label}</span>
<div className='min-w-0'>{children}</div>
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
{children}
</div>
);
@ -117,86 +113,81 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
return (
<div className='space-y-4 sm:space-y-6'>
{type === 'movie' && (
<div className='space-y-3 sm:space-y-4'>
<FilterRow label='分类'>
{renderSelector(
'电影分类',
moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange
)}
</FilterRow>
<FilterGrid>
{renderSelector(
'分类',
'电影分类选项',
moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange
)}
{primarySelection !== '全部' ? (
<FilterRow label='地区'>
{renderSelector(
'电影地区',
movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange
)}
</FilterRow>
renderSelector(
'地区',
'电影地区选项',
movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange
)
) : (
<FilterRow label='筛选'>
<div className='col-span-full'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</FilterRow>
</div>
)}
</div>
</FilterGrid>
)}
{type === 'tv' && (
<div className='space-y-3 sm:space-y-4'>
<FilterRow label='分类'>
{renderSelector(
'剧集分类',
tvPrimaryOptions,
primarySelection || tvPrimaryOptions[1].value,
onPrimaryChange
)}
</FilterRow>
<FilterGrid>
{renderSelector(
'分类',
'剧集分类选项',
tvPrimaryOptions,
primarySelection || tvPrimaryOptions[1].value,
onPrimaryChange
)}
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
<FilterRow label='类型'>
{renderSelector(
'剧集类型',
tvSecondaryOptions,
secondarySelection || tvSecondaryOptions[0].value,
onSecondaryChange
)}
</FilterRow>
renderSelector(
'类型',
'剧集类型选项',
tvSecondaryOptions,
secondarySelection || tvSecondaryOptions[0].value,
onSecondaryChange
)
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
<FilterRow label='筛选'>
<div className='col-span-full'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</FilterRow>
</div>
) : null}
</div>
</FilterGrid>
)}
{type === 'anime' && (
<div className='space-y-3 sm:space-y-4'>
<FilterRow label='分类'>
{renderSelector(
'动漫分类',
animePrimaryOptions,
primarySelection || animePrimaryOptions[0].value,
onPrimaryChange
)}
</FilterRow>
<FilterGrid>
{renderSelector(
'分类',
'动漫分类选项',
animePrimaryOptions,
primarySelection || animePrimaryOptions[0].value,
onPrimaryChange
)}
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
<FilterRow label='星期'>
<div className='min-w-0'>
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
</FilterRow>
</div>
) : (
<FilterRow label='筛选'>
<div className='col-span-full'>
{(primarySelection || animePrimaryOptions[0].value) === '番剧' ? (
<MultiLevelSelector
key={`anime-tv-${primarySelection}`}
@ -210,41 +201,39 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
contentType='anime-movie'
/>
)}
</FilterRow>
</div>
)}
</div>
</FilterGrid>
)}
{type === 'show' && (
<div className='space-y-3 sm:space-y-4'>
<FilterRow label='分类'>
{renderSelector(
'综艺分类',
showPrimaryOptions,
primarySelection || showPrimaryOptions[1].value,
onPrimaryChange
)}
</FilterRow>
<FilterGrid>
{renderSelector(
'分类',
'综艺分类选项',
showPrimaryOptions,
primarySelection || showPrimaryOptions[1].value,
onPrimaryChange
)}
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
<FilterRow label='类型'>
{renderSelector(
'综艺类型',
showSecondaryOptions,
secondarySelection || showSecondaryOptions[0].value,
onSecondaryChange
)}
</FilterRow>
renderSelector(
'类型',
'综艺类型选项',
showSecondaryOptions,
secondarySelection || showSecondaryOptions[0].value,
onSecondaryChange
)
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
<FilterRow label='筛选'>
<div className='col-span-full'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</FilterRow>
</div>
) : null}
</div>
</FilterGrid>
)}
</div>
);

View File

@ -1,6 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Clock, Target, Tv } from 'lucide-react';
import { Button, Card, Chip, EmptyState, Spinner } from '@heroui/react';
import { useEffect, useRef, useState } from 'react';
import { formatTimeToHHMM, parseCustomTimeFormat } from '@/lib/time';
@ -147,19 +148,16 @@ export default function EpgScrollableRow({
// 加载中状态
if (isLoading) {
return (
<div className="pt-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
<div className='pt-4'>
<div className='mb-3 flex items-center justify-between'>
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
</h4>
<div className="w-16 sm:w-20"></div>
</div>
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
<div className="flex items-center gap-3 sm:gap-4 text-gray-500 dark:text-gray-400">
<div className="w-5 h-5 sm:w-6 sm:h-6 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
<span className="text-sm sm:text-base">...</span>
</div>
<div className='flex min-h-[100px] items-center justify-center sm:min-h-[120px]'>
<Spinner />
<span className='ml-3 text-sm text-muted sm:text-base'>...</span>
</div>
</div>
);
@ -168,41 +166,40 @@ export default function EpgScrollableRow({
// 无节目单状态
if (!programs || programs.length === 0) {
return (
<div className="pt-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
<div className='pt-4'>
<div className='mb-3 flex items-center justify-between'>
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
</h4>
<div className="w-16 sm:w-20"></div>
</div>
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
<div className="flex items-center gap-2 sm:gap-3 text-gray-400 dark:text-gray-500">
<Tv className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="text-sm sm:text-base"></span>
</div>
</div>
<EmptyState>
<Tv className='mx-auto mb-2 h-5 w-5' />
<p className='mt-1 text-sm text-muted'> EPG </p>
</EmptyState>
</div>
);
}
return (
<div className="pt-4 mt-2">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
<div className='mt-2 pt-4'>
<div className='mb-3 flex items-center justify-between'>
<h4 className='flex items-center gap-2 text-xs font-medium text-muted sm:text-sm'>
<Clock className='h-3 w-3 sm:h-4 sm:w-4' />
</h4>
{currentPlayingIndex !== -1 && (
<button
onClick={scrollToCurrentProgram}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
title="滚动到当前播放位置"
<Button
size='sm'
variant='secondary'
onPress={scrollToCurrentProgram}
aria-label='滚动到当前播放位置'
>
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</button>
<Target className='h-3 w-3' />
<span className='hidden sm:inline'></span>
<span className='sm:hidden'></span>
</Button>
)}
</div>
@ -213,53 +210,29 @@ export default function EpgScrollableRow({
>
<div
ref={containerRef}
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
className='scrollbar-hide flex min-h-[100px] gap-3 overflow-x-auto px-2 py-2 pb-4 sm:min-h-[120px] sm:px-4'
>
{programs.map((program, index) => {
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
const isPlaying = index === currentPlayingIndex;
const isFinishedProgram = index < currentPlayingIndex;
const isUpcomingProgram = index > currentPlayingIndex;
return (
<div
<Card
key={index}
className={`flex-shrink-0 w-36 sm:w-48 p-2 sm:p-3 rounded-lg border transition-all duration-200 flex flex-col min-h-[100px] sm:min-h-[120px] ${isPlaying
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
: isFinishedProgram
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
: isUpcomingProgram
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
className='flex min-h-[100px] w-36 flex-shrink-0 flex-col p-2 sm:min-h-[120px] sm:w-48 sm:p-3'
>
{/* 时间显示在顶部 */}
<div className="flex items-center justify-between mb-2 sm:mb-3 flex-shrink-0">
<span className={`text-xs font-medium ${isPlaying
? 'text-green-600 dark:text-green-400'
: isFinishedProgram
? 'text-gray-500 dark:text-gray-400'
: isUpcomingProgram
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-300'
}`}>
<div className='mb-2 flex flex-shrink-0 items-center justify-between sm:mb-3'>
<Chip size='sm' variant={isPlaying ? 'primary' : 'secondary'}>
{formatTime(program.start)}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
</Chip>
<span className='text-xs text-muted'>
{formatTime(program.end)}
</span>
</div>
{/* 标题在中间,占据剩余空间 */}
<div
className={`text-xs sm:text-sm font-medium flex-1 ${isPlaying
? 'text-green-900 dark:text-green-100'
: isFinishedProgram
? 'text-gray-600 dark:text-gray-400'
: isUpcomingProgram
? 'text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
className='flex-1 text-xs font-medium text-foreground sm:text-sm'
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
@ -276,14 +249,11 @@ export default function EpgScrollableRow({
{/* 正在播放状态在底部 */}
{isPlaying && (
<div className="mt-auto pt-1 sm:pt-2 flex items-center gap-1 sm:gap-1.5 flex-shrink-0">
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
</span>
</div>
<Chip size='sm' variant='primary' className='mt-auto'>
</Chip>
)}
</div>
</Card>
);
})}
</div>

View File

@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import { useRouter } from 'next/navigation';
import { Button, Chip, Spinner } from '@heroui/react';
import React, {
useCallback,
useEffect,
@ -403,32 +404,29 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{categories.map((label, idx) => {
const isActive = idx === displayPage;
return (
<button
<Button
key={label}
ref={(el) => {
buttonRefs.current[idx] = el;
}}
onClick={() => handleCategoryClick(idx)}
className={`relative w-20 flex-shrink-0 py-2 text-center text-[11px] font-medium uppercase tracking-[0.14em] transition-colors whitespace-nowrap
${isActive
? 'text-foreground'
: 'text-muted hover:text-muted'
}
`.trim()}
onPress={() => handleCategoryClick(idx)}
variant={isActive ? 'primary' : 'ghost'}
size='sm'
className='w-20 flex-shrink-0'
>
{label}
{isActive && (
<div className='absolute bottom-0 left-0 right-0 h-px bg-accent' />
)}
</button>
</Button>
);
})}
</div>
</div>
{/* 向上/向下按钮 */}
<button
className='a2-icon-button h-8 w-8 flex-shrink-0 translate-y-[-4px]'
onClick={() => {
<Button
isIconOnly
size='sm'
variant='tertiary'
className='flex-shrink-0 translate-y-[-4px]'
onPress={() => {
// 切换集数排序(正序/倒序)
setDescending((prev) => !prev);
}}
@ -446,7 +444,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
/>
</svg>
</button>
</Button>
</div>
{/* 集数网格 */}
@ -460,14 +458,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
})().map((episodeNumber) => {
const isActive = episodeNumber === value;
return (
<button
<Button
key={episodeNumber}
onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`a2-data flex h-10 min-w-10 items-center justify-center border px-3 py-2 text-sm font-medium transition-all duration-200 whitespace-nowrap
${isActive
? 'border-accent bg-accent text-accent-foreground'
: 'border-border/70 bg-surface/60 text-muted hover:border-accent/35 hover:text-foreground'
}`.trim()}
onPress={() => handleEpisodeClick(episodeNumber - 1)}
variant={isActive ? 'primary' : 'tertiary'}
size='sm'
className='min-w-10'
>
{(() => {
const title = episodes_titles?.[episodeNumber - 1];
@ -481,7 +477,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
}
return title;
})()}
</button>
</Button>
);
})}
</div>
@ -493,9 +489,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div className='flex flex-col h-full mt-4'>
{sourceSearchLoading && (
<div className='flex items-center justify-center py-8'>
<div className='h-px w-24 bg-border/70'>
<div className='h-full w-1/2 animate-pulse bg-accent'></div>
</div>
<Spinner size='sm' />
<span className='ml-3 text-xs uppercase tracking-[0.16em] text-muted'>
</span>
@ -630,9 +624,9 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{/* 源名称和集数信息 - 垂直居中 */}
<div className='flex items-center justify-between'>
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
{source.source_name}
</span>
<Chip size='sm' variant='secondary'>
<Chip.Label>{source.source_name}</Chip.Label>
</Chip>
{source.episodes.length > 1 && (
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
{source.episodes.length}
@ -649,12 +643,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
if (!videoInfo.hasError) {
return (
<div className='flex items-end gap-3 text-xs'>
<div className='a2-data text-accent font-medium text-xs'>
{videoInfo.loadSpeed}
</div>
<div className='a2-data text-warning font-medium text-xs'>
{videoInfo.pingTime}ms
</div>
<Chip size='sm' color='accent' variant='soft'>
<Chip.Label>{videoInfo.loadSpeed}</Chip.Label>
</Chip>
<Chip size='sm' color='warning' variant='soft'>
<Chip.Label>{videoInfo.pingTime}ms</Chip.Label>
</Chip>
</div>
);
} else {
@ -672,18 +666,19 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
);
})}
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
<button
onClick={() => {
<Button
variant='tertiary'
fullWidth
onPress={() => {
if (videoTitle) {
router.push(
`/search?q=${encodeURIComponent(videoTitle)}`
);
}
}}
className='a2-link-action w-full justify-center border-b-0 pt-2 text-center'
>
</button>
</Button>
</div>
</div>
)}

View File

@ -1,5 +1,7 @@
'use client';
import { Alert, Button } from '@heroui/react';
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
interface ErrorInfo {
@ -60,35 +62,25 @@ export function GlobalErrorIndicator() {
return (
<div className='fixed top-4 right-4 z-[2000]'>
{/* 错误卡片 */}
<div
className={`bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between min-w-[300px] max-w-[400px] transition-all duration-300 ${
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
} animate-fade-in`}
<Alert
status='danger'
className={`min-w-[300px] max-w-[400px] transition-transform duration-300 ${
isReplacing ? 'scale-105' : 'scale-100'
}`}
>
<span className='text-sm font-medium flex-1 mr-3'>
{currentError.message}
</span>
<button
onClick={handleClose}
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
<Alert.Content>
<Alert.Description>{currentError.message}</Alert.Description>
</Alert.Content>
<Button
isIconOnly
size='sm'
variant='ghost'
onPress={handleClose}
aria-label='关闭错误提示'
>
<svg
className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
<X className='h-4 w-4' />
</Button>
</Alert>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { Radio } from 'lucide-react';
import Image from 'next/image';
import React from 'react';
import { Card, Chip } from '@heroui/react';
import { AppButton, AppDrawer, AppScrollShadow } from './ui/HeroPrimitives';
@ -59,7 +60,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
>
<div className='space-y-4'>
{(poster || sourceName) && (
<div className='flex items-center gap-3 rounded-lg border border-border/70 bg-surface-secondary/60 p-3'>
<Card variant='secondary' className='flex-row items-center gap-3'>
{poster && (
<div className='relative h-16 w-12 flex-shrink-0 overflow-hidden rounded-md border border-border/70 bg-surface-secondary/60'>
<Image
@ -74,25 +75,25 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
<div className='min-w-0 flex-1'>
<p className='truncate text-base font-semibold text-foreground'>{title}</p>
{sourceName ? (
<span className='a2-data mt-1 inline-flex max-w-full items-center border border-border/70 px-2 py-1 text-[10px] text-muted'>
<Chip size='sm' variant='soft' color='accent' className='mt-1 max-w-full'>
{origin === 'live' ? (
<Radio size={12} className='mr-1.5 text-accent' />
) : null}
<span className='truncate'>{sourceName}</span>
</span>
<Chip.Label>{sourceName}</Chip.Label>
</Chip>
) : null}
</div>
</div>
</Card>
)}
<div className='divide-y divide-border/10 overflow-hidden rounded-lg border border-border/70'>
<Card variant='default' className='gap-1 p-1'>
{actions.map((action) => (
<AppButton
key={action.id}
variant='tertiary'
fullWidth
isDisabled={action.disabled}
className='h-auto justify-start rounded-none px-3 py-4'
className='h-auto justify-start'
onPress={() => {
action.onClick();
onClose();
@ -115,36 +116,30 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
{action.label}
</span>
{action.id === 'play' && currentEpisode && totalEpisodes ? (
<span className='a2-data text-xs text-muted'>
{currentEpisode}/{totalEpisodes}
</span>
<Chip size='sm' variant='secondary'>
<Chip.Label>{currentEpisode}/{totalEpisodes}</Chip.Label>
</Chip>
) : null}
</AppButton>
))}
</div>
</Card>
{isAggregate && sources && sources.length > 0 ? (
<div className='rounded-lg border border-border/70 p-3'>
<Card variant='secondary'>
<div className='mb-3'>
<h4 className='mb-1 text-sm font-medium text-foreground'></h4>
<p className='a2-kicker'> {sources.length} </p>
<p className='text-sm text-muted'> {sources.length} </p>
</div>
<AppScrollShadow className='max-h-32'>
<div className='grid grid-cols-2 gap-2'>
{sources.map((source) => (
<div
key={source}
className='flex min-w-0 items-center gap-2 border-l border-border/70 px-3 py-2'
>
<div className='h-1.5 w-1.5 flex-shrink-0 bg-accent/80' />
<span className='truncate text-xs text-muted'>
{source}
</span>
</div>
<Chip key={source} size='sm' variant='secondary'>
<Chip.Label>{source}</Chip.Label>
</Chip>
))}
</div>
</AppScrollShadow>
</div>
</Card>
) : null}
</div>
</AppDrawer>

View File

@ -3,8 +3,8 @@
'use client';
import { Cat, Clover, Film, Home, Play, Radio, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Button, Card, ScrollShadow } from '@heroui/react';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
interface MobileBottomNavProps {
@ -16,6 +16,7 @@ interface MobileBottomNavProps {
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
const pathname = usePathname();
const router = useRouter();
// 当前激活路径:优先使用传入的 activePath否则回退到浏览器地址
const currentActive = activePath ?? pathname;
@ -95,8 +96,8 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
};
return (
<nav
className='fixed left-0 right-0 z-[600] overflow-hidden border-t border-border/70 bg-surface/90 shadow-[0_-12px_40px_-30px_rgb(15_23_42)] backdrop-blur-xl md:hidden'
<Card
className='fixed left-0 right-0 z-[600] rounded-none p-0 md:hidden'
style={{
/* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0,
@ -104,41 +105,31 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
}}
>
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
{navItems.map((item) => {
const active = isActive(item.href);
return (
<li
key={item.href}
className='flex-shrink-0'
style={{ width: '20vw', minWidth: '20vw' }}
>
<Link
href={item.href}
className='theme-transition relative flex h-14 w-full flex-col items-center justify-center gap-1 text-xs font-medium tracking-normal'
<ScrollShadow orientation='horizontal' hideScrollBar>
<ul className='flex items-center gap-1 px-2'>
{navItems.map((item) => {
const active = isActive(item.href);
return (
<li
key={item.href}
className='flex-shrink-0'
style={{ width: '20vw', minWidth: '20vw' }}
>
{active && <span className='absolute left-3 right-3 top-1 h-1 rounded-full bg-accent' />}
<item.icon
className={`h-5 w-5 ${active
? 'text-accent'
: 'text-muted'
}`}
/>
<span
className={
active
? 'text-foreground'
: 'text-muted'
}
<Button
variant={active ? 'primary' : 'ghost'}
fullWidth
className='h-14 flex-col gap-1'
onPress={() => router.push(item.href)}
>
{item.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
<item.icon className='h-5 w-5' />
<span className='text-xs'>{item.label}</span>
</Button>
</li>
);
})}
</ul>
</ScrollShadow>
</Card>
);
};

View File

@ -1,6 +1,6 @@
'use client';
import Link from 'next/link';
import { Card, Link as HeroLink } from '@heroui/react';
import { BackButton } from './BackButton';
import { useSite } from './SiteProvider';
@ -14,16 +14,16 @@ interface MobileHeaderProps {
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite();
return (
<header className='fixed left-0 right-0 top-0 z-[999] w-full border-b border-border/70 bg-surface/90 shadow-sm backdrop-blur-xl md:hidden'>
<Card className='fixed left-0 right-0 top-0 z-[999] w-full rounded-none p-0 md:hidden'>
<div className='flex h-12 items-center justify-between px-4'>
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
<div className='flex items-center gap-2'>
<Link
<HeroLink
href='/search'
className='a2-icon-button h-8 w-8 p-1.5'
aria-label='搜索'
>
<svg
className='w-full h-full'
className='h-5 w-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
@ -36,7 +36,7 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
/>
</svg>
</Link>
</HeroLink>
{showBackButton && <BackButton />}
</div>
@ -49,14 +49,14 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
{/* 中间Logo绝对居中 */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link
<HeroLink
href='/'
className='theme-transition text-lg font-semibold tracking-normal text-foreground hover:text-accent'
className='text-lg font-semibold'
>
{siteName}
</Link>
</HeroLink>
</div>
</header>
</Card>
);
};

View File

@ -1,9 +1,8 @@
'use client';
import { Dropdown, Label } from '@heroui/react';
import React, { useState } from 'react';
import { AppButton } from './ui/HeroPrimitives';
import { AppFilterSelect } from './ui/HeroPrimitives';
interface MultiLevelOption {
label: string;
@ -367,103 +366,17 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
};
// 获取显示文本
const getDisplayText = (categoryKey: string) => {
const category = categories.find((cat) => cat.key === categoryKey);
if (!category) return '';
const value = values[categoryKey];
if (
!value ||
value === 'all' ||
(categoryKey === 'sort' && value === 'T')
) {
return category.label;
}
const option = category.options.find((opt) => opt.value === value);
return option?.label || category.label;
};
// 检查是否为默认值
const isDefaultValue = (categoryKey: string) => {
const value = values[categoryKey];
return (
!value || value === 'all' || (categoryKey === 'sort' && value === 'T')
);
};
// 检查选项是否被选中
const isOptionSelected = (categoryKey: string, optionValue: string) => {
let value = values[categoryKey];
if (value === undefined) {
value = 'all';
if (categoryKey === 'sort') {
value = 'T';
}
}
return value === optionValue;
};
return (
<div className='app-filter-dropdowns'>
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
{categories.map((category) => (
<Dropdown key={category.key}>
<AppButton
aria-label={`${category.label}筛选`}
variant='tertiary'
className={`app-filter-trigger ${
isDefaultValue(category.key)
? ''
: 'app-filter-trigger-active'
}`}
>
<span>{getDisplayText(category.key)}</span>
<svg
className='ml-0.5 inline-block h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</AppButton>
<Dropdown.Popover className='w-[min(92vw,600px)]'>
<Dropdown.Menu
aria-label={`${category.label}选项`}
selectionMode='single'
selectedKeys={
new Set([
values[category.key] ||
(category.key === 'sort' ? 'T' : 'all'),
])
}
onAction={(key) => handleOptionSelect(category.key, String(key))}
className='grid grid-cols-3 gap-1 p-2 sm:grid-cols-4 sm:gap-2 md:grid-cols-5'
>
{category.options.map((option) => (
<Dropdown.Item
key={option.value}
id={option.value}
textValue={option.label}
className={
isOptionSelected(category.key, option.value)
? 'a2-selector-option-active'
: ''
}
>
<Label>{option.label}</Label>
<Dropdown.ItemIndicator />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
<AppFilterSelect
key={category.key}
ariaLabel={`${category.label}选项`}
label={category.label}
options={category.options}
value={values[category.key] || (category.key === 'sort' ? 'T' : 'all')}
onChange={(value) => handleOptionSelect(category.key, value)}
/>
))}
</div>
);

View File

@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@heroui/react';
import { useEffect, useRef, useState } from 'react';
interface ScrollableRowProps {
@ -126,12 +127,14 @@ export default function ScrollableRow({
pointerEvents: 'auto',
}}
>
<button
onClick={handleScrollLeftClick}
className='theme-transition flex h-11 w-11 items-center justify-center rounded-2xl border border-border bg-overlay/95 text-muted shadow-xl backdrop-blur hover:border-accent/40 hover:text-accent'
<Button
isIconOnly
variant='secondary'
onPress={handleScrollLeftClick}
aria-label='向左滚动'
>
<ChevronLeft className='h-5 w-5' />
</button>
</Button>
</div>
</div>
)}
@ -155,12 +158,14 @@ export default function ScrollableRow({
pointerEvents: 'auto',
}}
>
<button
onClick={handleScrollRightClick}
className='theme-transition flex h-11 w-11 items-center justify-center rounded-2xl border border-border bg-overlay/95 text-muted shadow-xl backdrop-blur hover:border-accent/40 hover:text-accent'
<Button
isIconOnly
variant='secondary'
onPress={handleScrollRightClick}
aria-label='向右滚动'
>
<ChevronRight className='h-5 w-5' />
</button>
</Button>
</div>
</div>
)}

View File

@ -1,10 +1,8 @@
'use client';
import { Dropdown, Label, ScrollShadow } from '@heroui/react';
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
import React, { useMemo } from 'react';
import { AppButton } from './ui/HeroPrimitives';
import { AppFilterSelect } from './ui/HeroPrimitives';
export type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder';
@ -32,6 +30,12 @@ const DEFAULTS: Record<SearchFilterKey, string> = {
yearOrder: 'none',
};
const YEAR_ORDER_OPTIONS: SearchFilterOption[] = [
{ label: '默认排序', value: 'none' },
{ label: '年份降序', value: 'desc' },
{ label: '年份升序', value: 'asc' },
];
const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, values, onChange }) => {
const mergedValues = useMemo(() => {
return {
@ -48,109 +52,25 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
onChange(newValues);
};
const getDisplayText = (categoryKey: SearchFilterKey) => {
const category = categories.find((cat) => cat.key === categoryKey);
if (!category) return '';
const value = mergedValues[categoryKey];
if (!value || value === DEFAULTS[categoryKey]) return category.label;
const option = category.options.find((opt) => opt.value === value);
return option?.label || category.label;
};
const isDefaultValue = (categoryKey: SearchFilterKey) => {
const value = mergedValues[categoryKey];
return !value || value === DEFAULTS[categoryKey];
};
const isOptionSelected = (categoryKey: SearchFilterKey, optionValue: string) => {
const value = mergedValues[categoryKey] ?? DEFAULTS[categoryKey];
return value === optionValue;
};
return (
<div className='app-search-filter-bar'>
{categories.map((category) => (
<Dropdown key={category.key}>
<AppButton
variant='tertiary'
className={`app-search-filter-trigger ${
isDefaultValue(category.key) ? '' : 'app-search-filter-trigger-active'
}`}
>
<span>{getDisplayText(category.key)}</span>
<svg className='inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 9l-7 7-7-7' />
</svg>
</AppButton>
<Dropdown.Popover className='app-search-filter-popover'>
<ScrollShadow className='app-search-filter-scroll'>
<Dropdown.Menu
aria-label={`${category.label}筛选`}
selectionMode='single'
selectedKeys={new Set([mergedValues[category.key]])}
onAction={(key) => handleOptionSelect(category.key, String(key))}
className='app-search-filter-menu'
>
{category.options.map((option) => (
<Dropdown.Item
key={option.value}
id={option.value}
textValue={option.label}
className={
isOptionSelected(category.key, option.value)
? 'app-search-filter-option-active'
: 'app-search-filter-option'
}
>
<Label className='app-search-filter-option-label'>
{option.label}
</Label>
<Dropdown.ItemIndicator />
</Dropdown.Item>
))}
</Dropdown.Menu>
</ScrollShadow>
</Dropdown.Popover>
</Dropdown>
))}
{/* 通用年份排序切换按钮 */}
<div className='relative'>
<AppButton
variant='ghost'
onPress={() => {
let next;
switch (mergedValues.yearOrder) {
case 'none':
next = 'desc';
break;
case 'desc':
next = 'asc';
break;
case 'asc':
next = 'none';
break;
default:
next = 'desc';
}
onChange({ ...mergedValues, yearOrder: next });
}}
className={`app-search-filter-trigger ${
mergedValues.yearOrder === 'none'
? ''
: 'app-search-filter-trigger-active'
}`}
aria-label={`按年份${mergedValues.yearOrder === 'none' ? '排序' : mergedValues.yearOrder === 'desc' ? '降序' : '升序'}排序`}
>
<span></span>
{mergedValues.yearOrder === 'none' ? (
<ArrowUpDown className='inline-block ml-1 w-4 h-4 sm:w-4 sm:h-4' />
) : mergedValues.yearOrder === 'desc' ? (
<ArrowDownWideNarrow className='inline-block ml-1 w-4 h-4 sm:w-4 sm:h-4' />
) : (
<ArrowUpNarrowWide className='inline-block ml-1 w-4 h-4 sm:w-4 sm:h-4' />
)}
</AppButton>
</div>
<div className='grid max-w-full grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4'>
{categories.map((category) => (
<AppFilterSelect
key={category.key}
ariaLabel={`${category.label}选项`}
label={category.label}
options={category.options}
value={mergedValues[category.key]}
onChange={(value) => handleOptionSelect(category.key, value)}
/>
))}
<AppFilterSelect
ariaLabel='排序选项'
label='排序'
options={YEAR_ORDER_OPTIONS}
value={mergedValues.yearOrder}
onChange={(value) => onChange({ ...mergedValues, yearOrder: value })}
/>
</div>
);
};

View File

@ -1,5 +1,6 @@
'use client';
import { Card, Label, ListBox, ScrollShadow } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface SearchSuggestionsProps {
@ -146,21 +147,27 @@ export default function SearchSuggestions({
}
return (
<div
<Card
ref={containerRef}
className='absolute top-full left-0 right-0 z-[600] mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
className='absolute left-0 right-0 top-full z-[600] mt-1 p-0'
>
{suggestions.map((suggestion) => (
<button
key={`related-${suggestion.text}`}
onClick={() => onSelect(suggestion.text)}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3"
<ScrollShadow className='max-h-80' hideScrollBar>
<ListBox
aria-label='搜索建议'
selectionMode='none'
onAction={(key) => onSelect(String(key))}
>
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
{suggestion.text}
</span>
</button>
))}
</div>
{suggestions.map((suggestion) => (
<ListBox.Item
key={`related-${suggestion.text}`}
id={suggestion.text}
textValue={suggestion.text}
>
<Label className='truncate'>{suggestion.text}</Label>
</ListBox.Item>
))}
</ListBox>
</ScrollShadow>
</Card>
);
}

View File

@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useState } from 'react';
import { Skeleton } from '@heroui/react';
import { getShortDramaCategories, ShortDramaCategory } from '@/lib/shortdrama.client';
import { AppFilterTabs } from './ui/HeroPrimitives';
import { AppFilterSelect } from './ui/HeroPrimitives';
interface ShortDramaSelectorProps {
selectedCategory: string;
@ -49,44 +50,31 @@ const ShortDramaSelector = ({
fetchCategories();
}, []);
// 渲染胶囊式选择器
const renderCapsuleSelector = () => {
const renderCategorySelector = () => {
if (loading) {
return (
<div className='inline-flex rounded-full bg-surface-secondary p-1'>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='mx-0.5 h-8 w-16 rounded-full bg-surface-tertiary animate-pulse'
/>
))}
</div>
<Skeleton className='h-16 w-full' />
);
}
return (
<AppFilterTabs
ariaLabel='短剧分类'
selectedKey={selectedCategory}
onSelectionChange={onCategoryChange}
items={categories.map((category) => ({
key: category.type_id.toString(),
<AppFilterSelect
ariaLabel='短剧分类选项'
label='分类'
options={categories.map((category) => ({
value: category.type_id.toString(),
label: category.type_name,
}))}
value={selectedCategory}
onChange={onCategoryChange}
/>
);
};
return (
<div className='space-y-4 sm:space-y-6'>
{/* 分类选择 */}
<div className='app-filter-row'>
<span className='app-filter-label'>
</span>
<div className='min-w-0'>
{renderCapsuleSelector()}
</div>
<div className='grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5'>
{renderCategorySelector()}
</div>
</div>
);

View File

@ -16,8 +16,8 @@ import {
Tv,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react';
import {
createContext,
useCallback,
@ -26,6 +26,7 @@ import {
useLayoutEffect,
useState,
} from 'react';
import type { ComponentType } from 'react';
import { useSite } from './SiteProvider';
@ -50,10 +51,11 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
if (isCollapsed) {
return (
<button
onClick={onClick}
className='theme-transition flex h-12 w-12 cursor-pointer items-center justify-center hover:opacity-80'
title='点击展开侧边栏'
<Button
onPress={onClick}
isIconOnly
variant='ghost'
aria-label='点击展开侧边栏'
>
<Image
src='/logo.png'
@ -62,28 +64,26 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
height={32}
className='rounded-lg'
/>
</button>
</Button>
);
}
return (
<Link
<HeroLink
href='/'
className='theme-transition flex h-16 items-center justify-center select-none hover:opacity-80'
className='flex h-16 items-center justify-center gap-2'
>
<div className='flex items-center gap'>
<Image
src='/logo.png'
alt={siteName}
width={40}
height={40}
className='rounded-lg'
/>
<span className='text-xl font-semibold tracking-normal text-foreground'>
{siteName}
</span>
</div>
</Link>
<Image
src='/logo.png'
alt={siteName}
width={40}
height={40}
className='rounded-lg'
/>
<span className='text-xl font-semibold'>
{siteName}
</span>
</HeroLink>
);
};
@ -217,25 +217,60 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
}
}, []);
const getNavClasses = (isActive: boolean) =>
`group flex min-h-[42px] items-center gap-3 rounded-xl border px-3 py-2 text-sm font-medium tracking-normal transition-all duration-200 ${
isActive
? 'border-accent/25 bg-accent/10 text-accent shadow-sm'
: 'border-transparent text-muted hover:border-border hover:bg-surface-secondary hover:text-foreground'
}`;
const renderNavButton = ({
href,
label,
icon: Icon,
isActive,
onPress,
}: {
href: string;
label: string;
icon: ComponentType<{ className?: string }>;
isActive: boolean;
onPress?: () => void;
}) => {
const button = (
<Button
aria-label={label}
variant={isActive ? 'primary' : 'ghost'}
fullWidth={!isCollapsed}
isIconOnly={isCollapsed}
className={isCollapsed ? '' : 'justify-start'}
onPress={() => {
setActive(href);
if (onPress) {
onPress();
} else {
router.push(href);
}
}}
>
<Icon className='h-4 w-4' />
{!isCollapsed ? <span>{label}</span> : null}
</Button>
);
return isCollapsed ? (
<Tooltip>
<Tooltip.Trigger>{button}</Tooltip.Trigger>
<Tooltip.Content placement='right'>{label}</Tooltip.Content>
</Tooltip>
) : button;
};
return (
<SidebarContext.Provider value={contextValue}>
{/* 在移动端隐藏侧边栏 */}
<div className='hidden md:flex'>
<aside
<Card
data-sidebar
className={`fixed left-0 top-0 z-10 h-screen border-r border-border/70 bg-surface/90 shadow-sm backdrop-blur-xl transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'
className={`fixed left-0 top-0 z-10 h-screen rounded-none p-0 transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'
}`}
>
<div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */}
<div className='relative h-16 border-b border-border/70'>
<div className='relative h-16'>
<div className='absolute inset-0 flex items-center justify-center transition-all duration-200'>
{isCollapsed ? (
<Logo isCollapsed={true} onClick={handleToggle} />
@ -246,57 +281,41 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
)}
</div>
{!isCollapsed && (
<button
onClick={handleToggle}
className='a2-icon-button absolute right-3 top-1/2 z-10 -translate-y-1/2'
title='收起侧边栏'
<Button
onPress={handleToggle}
isIconOnly
size='sm'
variant='ghost'
className='absolute right-3 top-1/2 z-10 -translate-y-1/2'
aria-label='收起侧边栏'
>
<Menu className='h-4 w-4' />
</button>
</Button>
)}
</div>
<Separator />
{/* 首页和搜索导航 */}
<nav className='mt-6 space-y-1 px-3'>
<Link
href='/'
onClick={() => setActive('/')}
data-active={active === '/'}
className={getNavClasses(active === '/')}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Home className='h-4 w-4' />
</div>
{!isCollapsed && (
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
</span>
)}
</Link>
<Link
href='/search'
onClick={(e) => {
e.preventDefault();
handleSearchClick();
setActive('/search');
}}
data-active={active === '/search'}
className={getNavClasses(active === '/search')}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4' />
</div>
{!isCollapsed && (
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
</span>
)}
</Link>
{renderNavButton({
href: '/',
label: '首页',
icon: Home,
isActive: active === '/',
})}
{renderNavButton({
href: '/search',
label: '搜索',
icon: Search,
isActive: active === '/search',
onPress: handleSearchClick,
})}
</nav>
{/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-3 pt-6'>
<div className='space-y-1 border-t border-border/70 pt-4'>
<Separator className='mb-4' />
<div className='space-y-1'>
{menuItems.map((item) => {
// 检查当前路径是否匹配这个菜单项
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
@ -312,22 +331,14 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
(item.href === '/shortdrama' && decodedActive.startsWith('/shortdrama'));
const Icon = item.icon;
return (
<Link
key={item.label}
href={item.href}
onClick={() => setActive(item.href)}
data-active={isActive}
className={getNavClasses(isActive)}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Icon className='h-4 w-4' />
</div>
{!isCollapsed && (
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
{item.label}
</span>
)}
</Link>
<div key={item.label}>
{renderNavButton({
href: item.href,
label: item.label,
icon: Icon,
isActive,
})}
</div>
);
})}
</div>
@ -335,40 +346,49 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
{/* 致谢信息 */}
<div className='px-3 pb-5'>
<div className='border-t border-border/70 pt-4'>
<Separator className='mb-4' />
<div>
{!isCollapsed ? (
<div className='px-2 text-center text-xs leading-relaxed text-muted'>
<span> </span>
<button
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
className='theme-transition font-medium text-accent hover:text-accent-strong'
<HeroLink
href='https://github.com/MoonTechLab/LunaTV'
target='_blank'
>
MoonTV
</button>
<button
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
className='theme-transition ml-1 text-accent hover:text-accent-strong'
title='访问 MoonTV 项目'
</HeroLink>
<HeroLink
href='https://github.com/MoonTechLab/LunaTV'
target='_blank'
aria-label='访问 MoonTV 项目'
className='ml-1'
>
<ExternalLink className='h-3 w-3 inline' />
</button>
</HeroLink>
<span> </span>
</div>
) : (
<div className='flex justify-center'>
<button
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
className='theme-transition p-1 text-accent hover:text-accent-strong'
title='基于 MoonTV 的二次开发'
>
<ExternalLink className='h-4 w-4' />
</button>
<Tooltip>
<Tooltip.Trigger>
<HeroLink
href='https://github.com/MoonTechLab/LunaTV'
target='_blank'
aria-label='基于 MoonTV 的二次开发'
>
<ExternalLink className='h-4 w-4' />
</HeroLink>
</Tooltip.Trigger>
<Tooltip.Content placement='right'>
MoonTV
</Tooltip.Content>
</Tooltip>
</div>
)}
</div>
</div>
</div>
</aside>
</Card>
<div
className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
}`}

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Palette, Eye, Check } from 'lucide-react';
import { Alert, Button, Card, Chip, TextArea } from '@heroui/react';
// CSS模板配置
const cssTemplates = [
@ -9,10 +10,10 @@ const cssTemplates = [
id: 'gradient-bg',
name: '渐变背景',
description: '为页面添加漂亮的渐变背景',
preview: 'body {\n background: linear-gradient(135deg, \n #667eea 0%, #764ba2 100%);\n}',
preview: 'body {\n background: linear-gradient(135deg, \n #18181b 0%, #be123c 100%);\n}',
css: `/* 渐变背景主题 */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #18181b 0%, #be123c 100%);
background-attachment: fixed;
}
@ -67,26 +68,26 @@ body::before {
id: 'sidebar-glow',
name: '发光侧边栏',
description: '为侧边栏添加发光效果',
preview: '.sidebar {\n box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);\n border-radius: 15px;\n}',
preview: '.sidebar {\n box-shadow: 0 0 20px rgba(225, 29, 72, 0.3);\n border-radius: 15px;\n}',
css: `/* 发光侧边栏效果 */
.sidebar, [data-sidebar] {
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
box-shadow: 0 0 20px rgba(225, 29, 72, 0.3);
border-radius: 15px;
border: 1px solid rgba(14, 165, 233, 0.2);
border: 1px solid rgba(225, 29, 72, 0.2);
backdrop-filter: blur(10px);
}
/* 侧边栏项目悬停效果 */
.sidebar a:hover, [data-sidebar] a:hover {
background: rgba(14, 165, 233, 0.1);
background: rgba(225, 29, 72, 0.1);
transform: translateX(5px);
transition: all 0.3s ease;
}
/* 活动项目发光 */
.sidebar [data-active="true"], [data-sidebar] [data-active="true"] {
background: rgba(14, 165, 233, 0.15);
box-shadow: inset 0 0 10px rgba(14, 165, 233, 0.2);
background: rgba(225, 29, 72, 0.15);
box-shadow: inset 0 0 10px rgba(225, 29, 72, 0.2);
border-radius: 8px;
}`
},
@ -134,9 +135,9 @@ body::before {
css: `/* 毛玻璃主题 */
body {
background: linear-gradient(45deg,
rgba(59, 130, 246, 0.1) 0%,
rgba(147, 51, 234, 0.1) 50%,
rgba(236, 72, 153, 0.1) 100%);
rgba(24, 24, 27, 0.1) 0%,
rgba(225, 29, 72, 0.1) 50%,
rgba(244, 63, 94, 0.1) 100%);
}
/* 所有面板使用毛玻璃效果 */
@ -212,13 +213,13 @@ const themes = [
{
id: 'default',
name: '默认主题',
description: '现代蓝色主题,清新优雅',
description: '石墨玫瑰,冷静高级',
preview: {
bg: '#ffffff',
surface: '#f9fafb',
accent: '#0ea5e9',
text: '#111827',
border: '#e5e7eb'
bg: '#fafafa',
surface: '#ffffff',
accent: '#e11d48',
text: '#18181b',
border: '#d4d4d8'
}
},
{
@ -560,51 +561,49 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
<div className="space-y-6">
{/* 管理员控制面板 */}
{isAdmin && globalThemeConfig && (
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<Palette className="h-5 w-5" />
</h3>
<Card variant='default' className='p-4'>
<Card.Header>
<Card.Title className="flex items-center gap-2">
<Palette className="h-5 w-5" />
</Card.Title>
</Card.Header>
<div className="space-y-4">
<div className="p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
<div className="text-sm text-theme-text">
<Card variant='secondary' className='p-3'>
<div className="text-sm">
<strong></strong>
</div>
<div className="text-xs text-theme-text-secondary mt-1">
<div className="text-xs text-muted mt-1">
: {themes.find(t => t.id === globalThemeConfig.defaultTheme)?.name || globalThemeConfig.defaultTheme}
{globalThemeConfig.customCSS && ' | 包含自定义CSS'}
{!globalThemeConfig.allowUserCustomization && ' | 禁止用户自定义'}
</div>
</div>
</Card>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-700">
<div className="flex items-center gap-2 text-blue-800 dark:text-blue-200">
<span className="text-sm font-medium"> </span>
</div>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
<Alert status='accent'>
<Alert.Title></Alert.Title>
<Alert.Description>
</p>
</div>
</Alert.Description>
</Alert>
</div>
</div>
</Card>
)}
{/* 主题选择器 */}
<div>
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Palette className="h-5 w-5" />
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{themes.map((theme) => (
<div
<Card
key={theme.id}
className={`relative p-4 border-2 rounded-xl transition-all ${currentTheme === theme.id
? 'border-theme-accent bg-theme-accent/5'
: 'border-theme-border bg-theme-surface'
} ${isAdmin ? 'cursor-pointer hover:border-theme-accent/50' : 'cursor-not-allowed opacity-60'}`}
variant={currentTheme === theme.id ? 'secondary' : 'default'}
className={`relative p-4 ${isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
onClick={() => isAdmin && handleThemeChange(theme.id)}
>
{/* 主题预览 */}
@ -615,83 +614,86 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.accent }} />
</div>
<div className="flex gap-1">
<button
<Button
isIconOnly
size='sm'
variant='tertiary'
onClick={(e) => {
e.stopPropagation();
if (isAdmin) handleThemePreview(theme.id);
}}
className={`p-1 transition-colors ${isAdmin ? 'text-theme-text-secondary hover:text-theme-accent' : 'text-theme-text-secondary opacity-50 cursor-not-allowed'}`}
title={isAdmin ? "预览主题" : "仅管理员可预览"}
disabled={previewMode || !isAdmin}
aria-label={isAdmin ? "预览主题" : "仅管理员可预览"}
isDisabled={previewMode || !isAdmin}
>
<Eye className="h-4 w-4" />
</button>
</Button>
{currentTheme === theme.id && (
<Check className="h-4 w-4 text-theme-accent" />
<Chip variant='primary' size='sm'>
<Check className="h-3.5 w-3.5" />
</Chip>
)}
</div>
</div>
<h4 className="font-medium text-theme-text">{theme.name}</h4>
<p className="text-sm text-theme-text-secondary mt-1">{theme.description}</p>
</div>
<h4 className="font-medium">{theme.name}</h4>
<p className="text-sm text-muted mt-1">{theme.description}</p>
</Card>
))}
</div>
{previewMode && (
<div className="mt-4 p-3 bg-theme-info/10 border border-theme-info/20 rounded-lg">
<p className="text-sm text-theme-info">3...</p>
</div>
<Alert status='accent' className='mt-4'>
3...
</Alert>
)}
</div>
{/* 自定义CSS编辑器 */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-theme-text flex items-center gap-2">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Palette className="h-5 w-5" />
</h3>
{isAdmin ? (
<button
onClick={() => setShowCustomEditor(!showCustomEditor)}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-surface border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors"
<Button
variant='secondary'
size='sm'
onPress={() => setShowCustomEditor(!showCustomEditor)}
>
{showCustomEditor ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCustomEditor ? '收起编辑器' : '展开编辑器'}
</button>
</Button>
) : (
<div className="text-sm text-theme-text-secondary">
<div className="text-sm text-muted">
</div>
)}
</div>
{!isAdmin && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-700 mb-4">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
<span className="text-sm font-medium"> </span>
</div>
<p className="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
<Alert status='warning' className='mb-4'>
<Alert.Title></Alert.Title>
<Alert.Description>
</p>
</div>
</Alert.Description>
</Alert>
)}
{isAdmin && showCustomEditor && (
<div className="space-y-4">
<div className="text-sm text-theme-text-secondary bg-theme-surface p-3 rounded-lg border border-theme-border">
<Card variant='secondary' className='p-3 text-sm text-muted'>
<p className="mb-2">💡 <strong>使</strong></p>
<ul className="space-y-1 text-xs">
<li> 使CSS变量覆盖主题颜色<code className="bg-theme-bg px-1 rounded">--color-theme-accent: 255, 0, 0;</code></li>
<li> 使Tailwind类名<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
<li> <code className="bg-theme-bg px-1 rounded">{`.admin-panel { border-radius: 20px; }`}</code></li>
<li> 使CSS变量覆盖主题颜色<code>--color-theme-accent: 255, 0, 0;</code></li>
<li> 使Tailwind类名<code>{`.my-class { @apply bg-red-500; }`}</code></li>
<li> <code>{`.admin-panel { border-radius: 20px; }`}</code></li>
<li> 使</li>
</ul>
</div>
</Card>
<div className="relative">
<textarea
<TextArea
value={customCSS}
onChange={(e) => setCustomCSS(e.target.value)}
placeholder="/* 在此输入您的自定义CSS */
@ -704,27 +706,22 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
/* 使用Tailwind类名 */
.custom-button {
@apply bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-xl;
}"
className="w-full h-64 p-4 bg-theme-surface border border-theme-border rounded-lg text-sm font-mono text-theme-text placeholder-theme-text-secondary resize-none focus:outline-none focus:ring-2 focus:ring-theme-accent/50"
/* 使用Tailwind类名 */
.custom-button {
@apply bg-accent text-accent-foreground px-6 py-3;
}"
className="h-64 w-full font-mono text-sm"
fullWidth
/>
</div>
<div className="flex gap-3">
<button
onClick={handleCustomCSSApply}
className="px-4 py-2 bg-theme-accent text-white rounded-lg hover:opacity-90 transition-opacity"
>
<Button variant='primary' onPress={handleCustomCSSApply}>
</button>
<button
onClick={handleCustomCSSReset}
className="px-4 py-2 bg-theme-surface border border-theme-border text-theme-text rounded-lg hover:bg-theme-accent/5 transition-colors"
>
</Button>
<Button variant='secondary' onPress={handleCustomCSSReset}>
</button>
</Button>
</div>
</div>
)}
@ -732,68 +729,71 @@ const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
{/* CSS 模板库 */}
{isAdmin && (
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
<h4 className="font-medium text-theme-text mb-3 flex items-center gap-2">
<Palette className="h-4 w-4" />
🎨
</h4>
<p className="text-sm text-theme-text-secondary mb-4"></p>
<Card variant='default' className='p-4'>
<Card.Header>
<Card.Title className="flex items-center gap-2">
<Palette className="h-4 w-4" />
</Card.Title>
<Card.Description></Card.Description>
</Card.Header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{cssTemplates.map((template) => (
<div key={template.id} className="p-3 border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors group">
<Card key={template.id} variant='secondary' className='p-3'>
<div className="flex items-center justify-between mb-2">
<h5 className="text-sm font-medium text-theme-text">{template.name}</h5>
<button
onClick={() => handleApplyTemplate(template.css, template.name)}
className="text-xs px-2 py-1 bg-theme-accent text-white rounded hover:opacity-90 transition-opacity opacity-0 group-hover:opacity-100"
<h5 className="text-sm font-medium">{template.name}</h5>
<Button
size='sm'
variant='primary'
onPress={() => handleApplyTemplate(template.css, template.name)}
>
</button>
</Button>
</div>
<p className="text-xs text-theme-text-secondary mb-2">{template.description}</p>
<div className="text-xs bg-theme-bg rounded p-2 max-h-16 overflow-y-auto">
<code className="whitespace-pre-wrap text-theme-text-secondary">{template.preview}</code>
<p className="text-xs text-muted mb-2">{template.description}</p>
<div className="text-xs max-h-16 overflow-y-auto">
<code className="whitespace-pre-wrap text-muted">{template.preview}</code>
</div>
</div>
</Card>
))}
</div>
<div className="mt-4 p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
<p className="text-xs text-theme-text-secondary">
<Alert status='accent' className='mt-4'>
<Alert.Description>
<strong>💡 使</strong> "应用"CSS编辑器"应用样式"
</p>
</div>
</div>
</Alert.Description>
</Alert>
</Card>
)}
{/* 使用说明 */}
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
<h4 className="font-medium text-theme-text mb-2">📖 </h4>
<div className="text-sm text-theme-text-secondary space-y-2">
<Card variant='default' className='p-4'>
<Card.Title></Card.Title>
<div className="text-sm text-muted space-y-2 mt-2">
<p><strong></strong>{isAdmin ? '选择预设主题即可一键切换全站整体风格' : '由管理员设置的全站预设主题'}</p>
{isAdmin && <p><strong>CSS</strong>CSS变量或直接样式实现全站个性化定制</p>}
{isAdmin && <p><strong></strong>使</p>}
<p><strong></strong></p>
<ul className="text-xs space-y-1 ml-4 mt-1">
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-bg</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-surface</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-accent</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-text</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-border</code> - </li>
<li> <code>--color-theme-bg</code> - </li>
<li> <code>--color-theme-surface</code> - </li>
<li> <code>--color-theme-accent</code> - </li>
<li> <code>--color-theme-text</code> - </li>
<li> <code>--color-theme-border</code> - </li>
</ul>
{isAdmin && (
<>
<p><strong></strong></p>
<ul className="text-xs space-y-1 ml-4 mt-1">
<li> <code className="bg-theme-bg px-1 rounded">{`body { background: linear-gradient(...); }`}</code></li>
<li> 使Tailwind<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
<li> <code>{`body { background: linear-gradient(...); }`}</code></li>
<li> 使Tailwind<code>{`.my-class { @apply bg-red-500; }`}</code></li>
<li> </li>
</ul>
</>
)}
</div>
</div>
</Card>
</div>
);
};

View File

@ -6,6 +6,7 @@ import { MessageCircle, Moon, Sun } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes';
import { useEffect, useState, useCallback } from 'react';
import { Badge } from '@heroui/react';
import { ChatModal } from './ChatModal';
import { AppIconButton } from './ui/HeroPrimitives';
import { useWebSocket } from '../hooks/useWebSocket';
@ -105,28 +106,28 @@ export function ThemeToggle() {
{!isLoginPage && (
<AppIconButton
onPress={() => setIsChatModalOpen(true)}
className={`a2-icon-button relative ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
size={isMobile ? 'sm' : 'md'}
aria-label='Open chat'
>
<MessageCircle className='w-full h-full' />
{messageCount > 0 && (
<span className={`absolute ${isMobile ? '-right-0.5 -top-0.5 h-4 w-4 text-[10px]' : '-right-1 -top-1 h-5 w-5 text-[10px]'} flex items-center justify-center border border-border/70 bg-accent text-accent-foreground`}>
{messageCount > 99 ? '99+' : messageCount}
</span>
<Badge size='sm' color='accent' variant='primary' className='absolute -right-1 -top-1'>
<Badge.Label>{messageCount > 99 ? '99+' : messageCount}</Badge.Label>
</Badge>
)}
<MessageCircle className='h-5 w-5' />
</AppIconButton>
)}
{/* 主题切换按钮 */}
<AppIconButton
onPress={toggleTheme}
className={`a2-icon-button ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
size={isMobile ? 'sm' : 'md'}
aria-label='Toggle theme'
>
{resolvedTheme === 'dark' ? (
<Sun className='w-full h-full' />
<Sun className='h-5 w-5' />
) : (
<Moon className='w-full h-full' />
<Moon className='h-5 w-5' />
)}
</AppIconButton>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,16 @@ import {
Download,
Plus,
RefreshCw,
X,
} from 'lucide-react';
import { Alert, Button, Card, Chip, Link as HeroLink } from '@heroui/react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { changelog, ChangelogEntry } from '@/lib/changelog';
import { CURRENT_VERSION } from '@/lib/version';
import { compareVersions, UpdateStatus } from '@/lib/version_check';
import { AppDialog } from './ui/HeroPrimitives';
interface VersionPanelProps {
isOpen: boolean;
onClose: () => void;
@ -193,34 +194,27 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
return (
<div
<Card
key={entry.version}
className={`p-4 rounded-lg border ${isCurrentVersion
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: isUpdate
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
}`}
className='p-4'
>
{/* 版本标题 */}
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
<h4 className='text-lg font-semibold text-foreground'>
v{entry.version}
</h4>
{isCurrentVersion && (
<span className='px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
</span>
<Chip size='sm' variant='primary'></Chip>
)}
{isUpdate && (
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
<Chip size='sm' variant='secondary'>
<Download className='w-3 h-3' />
</span>
</Chip>
)}
</div>
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
<div className='flex items-center gap-2 text-sm text-muted'>
{entry.date}
</div>
</div>
@ -229,7 +223,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
<div className='space-y-3'>
{entry.added.length > 0 && (
<div>
<h5 className='text-sm font-medium text-green-700 dark:text-green-400 mb-2 flex items-center gap-1'>
<h5 className='mb-2 flex items-center gap-1 text-sm font-medium text-success'>
<Plus className='w-4 h-4' />
</h5>
@ -237,9 +231,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
{entry.added.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
className='text-sm text-muted'
>
<span className='w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
@ -249,7 +242,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
{entry.changed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-blue-700 dark:text-blue-400 mb-2 flex items-center gap-1'>
<h5 className='mb-2 flex items-center gap-1 text-sm font-medium text-accent'>
<RefreshCw className='w-4 h-4' />
</h5>
@ -257,9 +250,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
{entry.changed.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
className='text-sm text-muted'
>
<span className='w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
@ -269,7 +261,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
{entry.fixed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
<h5 className='mb-2 flex items-center gap-1 text-sm font-medium text-danger'>
<Bug className='w-4 h-4' />
</h5>
@ -277,9 +269,8 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
{entry.fixed.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
className='text-sm text-muted'
>
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
@ -287,289 +278,110 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
</div>
)}
</div>
</div>
</Card>
);
};
// 版本面板内容
const versionPanelContent = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={onClose}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
if (!mounted) return null;
{/* 版本面板 */}
<div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
onTouchMove={(e) => {
// 允许版本面板内部滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许面板内的正常触摸操作
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between p-3 sm:p-6 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-2 sm:gap-3'>
<h3 className='text-lg sm:text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<div className='flex flex-wrap items-center gap-1 sm:gap-2'>
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
v{CURRENT_VERSION}
</span>
{hasUpdate && (
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
<span className='hidden sm:inline'></span>
<span className='sm:hidden'></span>
</span>
)}
</div>
</div>
<button
onClick={onClose}
className='w-6 h-6 sm:w-8 sm:h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='关闭'
>
<X className='w-full h-full' />
</button>
</div>
const remoteUpdates = remoteChangelog
.filter((entry) => {
const localVersions = changelog.map((local) => local.version);
return !localVersions.includes(entry.version);
})
.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime();
});
{/* 内容区域 */}
<div className='p-3 sm:p-6 overflow-y-auto max-h-[calc(95vh-140px)] sm:max-h-[calc(90vh-120px)]'>
<div className='space-y-3 sm:space-y-6'>
{/* 远程更新信息 */}
{hasUpdate && (
<div className='bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-yellow-100 dark:bg-yellow-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
<Download className='w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-sm sm:text-base font-semibold text-yellow-800 dark:text-yellow-200'>
</h4>
<p className='text-xs sm:text-sm text-yellow-700 dark:text-yellow-300 break-all'>
v{CURRENT_VERSION} v{latestVersion}
</p>
</div>
</div>
<a
href='https://github.com/djteang/OrangeTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
>
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
</a>
</div>
</div>
)}
return (
<AppDialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
title='版本信息'
description={`当前版本 v${CURRENT_VERSION}`}
size='lg'
>
<div className='space-y-6'>
{hasUpdate ? (
<Alert status='warning'>
<Alert.Indicator>
<Download className='h-4 w-4' />
</Alert.Indicator>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>
v{CURRENT_VERSION} {'->'} v{latestVersion}
</Alert.Description>
</Alert.Content>
</Alert>
) : (
<Alert status='success'>
<Alert.Indicator>
<CheckCircle className='h-4 w-4' />
</Alert.Indicator>
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description> v{CURRENT_VERSION}</Alert.Description>
</Alert.Content>
</Alert>
)}
{/* 当前为最新版本信息 */}
{!hasUpdate && (
<div className='bg-gradient-to-r from-blue-50 to-emerald-50 dark:from-blue-900/20 dark:to-emerald-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 sm:p-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 dark:bg-blue-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
<CheckCircle className='w-4 h-4 sm:w-5 sm:h-5 text-blue-600 dark:text-blue-400' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-sm sm:text-base font-semibold text-blue-800 dark:text-blue-200'>
</h4>
<p className='text-xs sm:text-sm text-blue-700 dark:text-blue-300 break-all'>
v{CURRENT_VERSION}
</p>
</div>
</div>
<a
href='https://github.com/djteang/OrangeTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
>
<CheckCircle className='w-3 h-3 sm:w-4 sm:h-4' />
</a>
</div>
</div>
)}
<HeroLink href='https://github.com/djteang/OrangeTV' target='_blank'>
</HeroLink>
{/* 远程可更新内容 */}
{hasUpdate && (
<div className='space-y-4'>
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
<Download className='w-5 h-5 text-yellow-500' />
</h4>
<button
onClick={() => setShowRemoteContent(!showRemoteContent)}
className='inline-flex items-center justify-center gap-2 px-3 py-1.5 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 dark:bg-yellow-800/30 dark:hover:bg-yellow-800/50 dark:text-yellow-200 rounded-lg transition-colors text-sm w-full sm:w-auto'
>
{showRemoteContent ? (
<>
<ChevronUp className='w-4 h-4' />
</>
) : (
<>
<ChevronDown className='w-4 h-4' />
</>
)}
</button>
</div>
{showRemoteContent && remoteChangelog.length > 0 && (
<div className='space-y-4'>
{remoteChangelog
.filter((entry) => {
// 找到第一个本地版本,过滤掉本地已有的版本
const localVersions = changelog.map(
(local) => local.version
);
return !localVersions.includes(entry.version);
})
.sort((a, b) => {
// 按日期排序,确保最新的版本在前面显示
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime(); // 降序排列,最新的在前
})
.map((entry, index) => (
<div
key={index}
className={`p-4 rounded-lg border ${entry.version === latestVersion
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
}`}
>
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
v{entry.version}
</h4>
{entry.version === latestVersion && (
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
</span>
)}
</div>
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
{entry.date}
</div>
</div>
{entry.added && entry.added.length > 0 && (
<div className='mb-3'>
<h5 className='text-sm font-medium text-green-600 dark:text-green-400 mb-2 flex items-center gap-1'>
<Plus className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.added.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-green-400 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.changed && entry.changed.length > 0 && (
<div className='mb-3'>
<h5 className='text-sm font-medium text-blue-600 dark:text-blue-400 mb-2 flex items-center gap-1'>
<RefreshCw className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.changed.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-blue-400 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.fixed && entry.fixed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
<Bug className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.fixed.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* 变更日志标题 */}
<div className='border-b border-gray-200 dark:border-gray-700 pb-4'>
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 pb-3 sm:pb-4'>
{hasUpdate ? (
<div className='space-y-4'>
<div className='flex flex-col justify-between gap-3 sm:flex-row sm:items-center'>
<h4 className='flex items-center gap-2 text-lg font-semibold text-foreground'>
<Download className='h-5 w-5 text-warning' />
</h4>
<Button
variant='secondary'
size='sm'
onPress={() => setShowRemoteContent(!showRemoteContent)}
>
{showRemoteContent ? (
<>
<ChevronUp className='h-4 w-4' />
</>
) : (
<>
<ChevronDown className='h-4 w-4' />
</>
)}
</Button>
</div>
{showRemoteContent && remoteUpdates.length > 0 ? (
<div className='space-y-4'>
{/* 本地变更日志 */}
{changelog.map((entry) =>
renderChangelogEntry(
entry,
entry.version === CURRENT_VERSION,
false
)
{remoteUpdates.map((entry) =>
renderChangelogEntry(entry, false, true)
)}
</div>
</div>
) : null}
</div>
) : null}
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h4 className='text-lg font-semibold text-foreground'></h4>
<Chip size='sm' variant='secondary'>
{changelog.length}
</Chip>
</div>
{changelog.map((entry) =>
renderChangelogEntry(entry, entry.version === CURRENT_VERSION, false)
)}
</div>
</div>
</>
</AppDialog>
);
// 使用 Portal 渲染到 document.body
if (!mounted || !isOpen) return null;
return createPortal(versionPanelContent, document.body);
};

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Trash2 } from 'lucide-react';
import { ExternalLink, Heart, Link as LinkIcon, PlayCircleIcon, Radio, Trash2 } from 'lucide-react';
import { Badge, Button, Card, Chip, Link as HeroLink, ProgressBar, Tooltip } from '@heroui/react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import React, {
@ -500,7 +501,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
actions.push({
id: 'douban',
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
icon: <Link size={20} />,
icon: <LinkIcon size={20} />,
onClick: () => {
const url = isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
@ -530,8 +531,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return (
<>
<div
className='group relative z-0 w-full cursor-pointer rounded-2xl bg-transparent p-1 transition-all duration-300 ease-in-out hover:-translate-y-1 hover:z-[500]'
<Card
variant='transparent'
className='group relative z-0 w-full cursor-pointer overflow-visible rounded-none p-0'
onClick={handleClick}
{...longPressProps}
style={{
@ -567,8 +569,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}}
>
{/* 海报容器 */}
<div
className={`relative aspect-[2/3] overflow-hidden rounded-2xl border border-border/70 bg-surface shadow-sm transition-all duration-300 group-hover:border-accent/30 group-hover:shadow-xl ${origin === 'live' ? 'ring-1 ring-accent/20' : ''}`}
<Card
variant='default'
className='relative aspect-[2/3] overflow-hidden rounded-lg p-0'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -615,7 +618,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 悬浮遮罩 */}
<div
className='absolute inset-0 bg-gradient-to-t from-slate-950/82 via-slate-950/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100'
className='absolute inset-0 bg-black/35 opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -645,7 +648,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<PlayCircleIcon
size={50}
strokeWidth={0.8}
className='fill-surface/80 text-accent drop-shadow-lg transition-all duration-300 ease-out hover:fill-accent hover:text-accent-foreground hover:scale-[1.06]'
className='fill-background text-accent'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -675,47 +678,44 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}}
>
{config.showCheckCircle && (
<Trash2
onClick={handleDeleteRecord}
size={20}
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
<Button
isIconOnly
size='sm'
variant='danger'
onPress={() =>
handleDeleteRecord({
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as React.MouseEvent)
}
>
<Trash2 size={16} />
</Button>
)}
{config.showHeart && from !== 'search' && from !== 'shortdrama' && (
<Heart
onClick={handleToggleFavorite}
size={20}
className={`transition-all duration-300 ease-out ${favorited
? 'fill-danger stroke-danger'
: 'fill-transparent stroke-white hover:stroke-accent'
} hover:scale-[1.1]`}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
<Button
isIconOnly
size='sm'
variant={favorited ? 'danger' : 'secondary'}
onPress={() =>
handleToggleFavorite({
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as React.MouseEvent)
}
>
<Heart size={16} className={favorited ? 'fill-current' : ''} />
</Button>
)}
</div>
)}
{/* 年份徽章 */}
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
<div
className="absolute left-2 top-2 rounded-lg border border-border/70 bg-overlay/85 px-2 py-1 text-[10px] font-medium tracking-normal text-foreground shadow-sm backdrop-blur transition-all duration-300 ease-out group-hover:opacity-90"
<Badge
size='sm'
variant='secondary'
className='absolute left-2 top-2'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -726,14 +726,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
{actualYear}
</div>
<Badge.Label>{actualYear}</Badge.Label>
</Badge>
)}
{/* 徽章 */}
{config.showRating && rate && (
<div
className='absolute right-2 top-2 flex min-w-[2rem] items-center justify-center rounded-lg border border-accent/30 bg-accent px-2 py-1 text-[10px] font-semibold text-accent-foreground shadow-sm transition-all duration-300 ease-out group-hover:scale-105'
<Chip
size='md'
color='accent'
variant='primary'
className='absolute right-2 top-2'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -744,13 +747,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
{rate}
</div>
<Chip.Label>{rate}</Chip.Label>
</Chip>
)}
{actualEpisodes && actualEpisodes > 1 && (
<div
className='absolute right-2 top-2 rounded-lg border border-border/70 bg-overlay/85 px-2 py-1 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur transition-all duration-300 ease-out group-hover:scale-105'
<Badge
size='sm'
variant='secondary'
className='absolute right-2 top-2'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -761,15 +766,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
{currentEpisode
<Badge.Label>{currentEpisode
? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}
</div>
: actualEpisodes}</Badge.Label>
</Badge>
)}
{/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
<a
<HeroLink
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
@ -789,20 +794,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
<div
className='theme-transition flex h-7 w-7 items-center justify-center rounded-lg border border-border/70 bg-overlay/90 text-accent shadow-sm backdrop-blur hover:border-accent/40 hover:text-foreground'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<Link
size={16}
<LinkIcon
size={18}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -810,8 +803,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
pointerEvents: 'none',
} as React.CSSProperties}
/>
</div>
</a>
</HeroLink>
)}
{/* 聚合播放源指示器 */}
@ -840,8 +832,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
WebkitTouchCallout: 'none',
} as React.CSSProperties}
>
<div
className='theme-transition flex h-6 w-6 cursor-pointer items-center justify-center rounded-lg border border-border/70 bg-overlay/90 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur hover:border-accent/40 hover:text-accent sm:h-7 sm:w-7'
<Badge
size='sm'
color='accent'
variant='secondary'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -852,8 +846,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
{sourceCount}
</div>
<Badge.Label>{sourceCount}</Badge.Label>
</Badge>
{/* 播放源详情悬浮框 */}
{(() => {
@ -931,12 +925,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</div>
);
})()}
</div>
</Card>
{/* 进度条 */}
{config.showProgress && progress !== undefined && (
<div
className='mt-2 h-1 w-full overflow-hidden rounded-full bg-surface-secondary'
<ProgressBar
aria-label='观看进度'
value={progress}
className='mt-2'
size='sm'
color='accent'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -947,20 +945,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
<div
className='h-full rounded-full bg-accent transition-all duration-500 ease-out'
style={{
width: `${progress}%`,
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
/>
</div>
<ProgressBar.Track>
<ProgressBar.Fill />
</ProgressBar.Track>
</ProgressBar>
)}
{/* 标题与来源 */}
@ -976,16 +964,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
<div
className='relative'
<Tooltip>
<Tooltip.Trigger>
<div
className='relative'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
>
>
<span
className='peer block truncate text-sm font-semibold text-foreground transition-colors duration-300 ease-in-out group-hover:text-accent'
className='block truncate text-sm font-semibold'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -998,33 +988,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
>
{actualTitle}
</span>
{/* 自定义 tooltip */}
<div
className='invisible pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-xl border border-border/70 bg-overlay/95 px-3 py-1 text-xs text-foreground opacity-0 shadow-xl backdrop-blur transition-all duration-200 ease-out delay-100 peer-hover:visible peer-hover:opacity-100'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{actualTitle}
<div
className='absolute left-1/2 top-full h-2 w-px -translate-x-1/2 bg-border/70'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
></div>
</div>
</div>
</Tooltip.Trigger>
<Tooltip.Content placement='top'>
{actualTitle}
</Tooltip.Content>
</Tooltip>
{config.showSourceName && source_name && (
<span
className='mt-1 block text-xs font-medium tracking-normal text-muted'
<Chip
size='sm'
color='accent'
variant='soft'
className='mt-1'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
@ -1035,27 +1010,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false;
}}
>
<span
className='inline-flex items-center gap-1 border-l-2 border-accent/70 pl-2 transition-all duration-300 ease-in-out group-hover:text-foreground'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
{origin === 'live' && (
<Radio size={12} className="inline-block mr-1 text-muted" />
)}
{source_name}
</span>
</span>
<Chip.Label>{source_name}</Chip.Label>
</Chip>
)}
</div>
</div>
</Card>
{/* 操作菜单 - 支持右键和长按触发 */}
<MobileActionSheet

View File

@ -4,7 +4,7 @@
import React, { useEffect, useState } from 'react';
import { AppFilterTabs } from './ui/HeroPrimitives';
import { AppFilterSelect } from './ui/HeroPrimitives';
interface WeekdaySelectorProps {
onWeekdayChange: (weekday: string) => void;
@ -43,15 +43,16 @@ const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
}, []); // 只在组件挂载时执行一次
return (
<AppFilterTabs
ariaLabel='星期选'
<AppFilterSelect
ariaLabel='星期'
className={className}
items={weekdays.map((weekday) => ({
key: weekday.value,
label='星期'
options={weekdays.map((weekday) => ({
value: weekday.value,
label: weekday.shortLabel,
}))}
selectedKey={selectedWeekday}
onSelectionChange={(value) => {
value={selectedWeekday}
onChange={(value) => {
setSelectedWeekday(value);
onWeekdayChange(value);
}}

View File

@ -3,12 +3,13 @@ import { fireEvent, render, screen } from '@testing-library/react';
import MultiLevelSelector from '../MultiLevelSelector';
describe('MultiLevelSelector', () => {
it('applies selected filter values through accessible menu items', () => {
it('applies selected filter values through accessible listbox options', () => {
const onChange = jest.fn();
render(<MultiLevelSelector contentType='movie' onChange={onChange} />);
fireEvent.click(screen.getByRole('menuitem', { name: '喜剧' }));
expect(screen.getByRole('listbox', { name: '类型选项' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: '喜剧' }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({

View File

@ -24,7 +24,7 @@ const categories: SearchFilterCategory[] = [
];
describe('SearchResultFilter', () => {
it('opens an options menu and applies the selected option', () => {
it('applies selected options through accessible listbox options', () => {
const onChange = jest.fn();
render(
@ -35,8 +35,8 @@ describe('SearchResultFilter', () => {
/>
);
expect(screen.getByRole('menu', { name: '来源选' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('menuitem', { name: '稳定源' }));
expect(screen.getByRole('listbox', { name: '来源' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: '稳定源' }));
expect(onChange).toHaveBeenCalledWith({
source: 'stable',
@ -46,10 +46,10 @@ describe('SearchResultFilter', () => {
});
});
it('cycles year ordering when the year button is clicked repeatedly', () => {
it('applies year ordering from the same select pattern', () => {
const onChange = jest.fn();
const { rerender } = render(
render(
<SearchResultFilter
categories={categories}
values={{ source: 'all', title: 'all', yearOrder: 'none' }}
@ -57,8 +57,7 @@ describe('SearchResultFilter', () => {
/>
);
const yearButton = screen.getByRole('button', { name: '按年份排序排序' });
fireEvent.click(yearButton);
fireEvent.click(screen.getByRole('option', { name: '年份降序' }));
expect(onChange).toHaveBeenLastCalledWith({
source: 'all',
@ -66,22 +65,5 @@ describe('SearchResultFilter', () => {
year: 'all',
yearOrder: 'desc',
});
rerender(
<SearchResultFilter
categories={categories}
values={{ source: 'all', title: 'all', yearOrder: 'desc' }}
onChange={onChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: '按年份降序排序' }));
expect(onChange).toHaveBeenLastCalledWith({
source: 'all',
title: 'all',
year: 'all',
yearOrder: 'asc',
});
});
});

View File

@ -3,17 +3,17 @@ import { render, screen } from '@testing-library/react';
import WeekdaySelector from '../WeekdaySelector';
describe('WeekdaySelector', () => {
it('renders as a tablist and marks the current weekday as selected', () => {
it('renders as a select listbox and marks the current weekday as selected', () => {
const onWeekdayChange = jest.fn();
render(<WeekdaySelector onWeekdayChange={onWeekdayChange} />);
const tablist = screen.getByRole('tablist', { name: '星期选' });
expect(tablist).toBeInTheDocument();
const listbox = screen.getByRole('listbox', { name: '星期' });
expect(listbox).toBeInTheDocument();
const today = new Date().getDay();
const weekdayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
expect(screen.getByRole('tab', { name: weekdayMap[today] })).toHaveAttribute(
expect(screen.getByRole('option', { name: weekdayMap[today] })).toHaveAttribute(
'aria-selected',
'true'
);

View File

@ -4,8 +4,11 @@ import {
Button,
Card,
Drawer,
Label,
ListBox,
Modal,
ScrollShadow,
Select,
Spinner,
Tabs,
useOverlayState,
@ -42,6 +45,72 @@ export function AppScrollShadow(props: ScrollShadowProps) {
return <ScrollShadow hideScrollBar {...props} />;
}
export interface AppFilterSelectOption {
label: string;
value: string;
isDisabled?: boolean;
}
interface AppFilterSelectProps {
label: string;
value?: string;
options: AppFilterSelectOption[];
onChange: (value: string) => void;
ariaLabel?: string;
placeholder?: string;
className?: string;
isDisabled?: boolean;
}
export function AppFilterSelect({
label,
value,
options,
onChange,
ariaLabel,
placeholder,
className,
isDisabled,
}: AppFilterSelectProps) {
return (
<Select
className={className}
fullWidth
isDisabled={isDisabled}
placeholder={placeholder ?? `选择${label}`}
value={value ?? null}
variant='secondary'
onChange={(nextValue) => {
if (Array.isArray(nextValue) || nextValue == null) return;
onChange(String(nextValue));
}}
>
<Label>{label}</Label>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ScrollShadow className='max-h-[min(52vh,22rem)]' hideScrollBar>
<ListBox aria-label={ariaLabel ?? `${label}选项`} selectionMode='single'>
{options.map((option) => (
<ListBox.Item
key={option.value}
id={option.value}
isDisabled={option.isDisabled}
textValue={option.label}
>
<Label>{option.label}</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</ScrollShadow>
</Select.Popover>
</Select>
);
}
export function AppLoading({
label = '加载中...',
...props
@ -214,18 +283,15 @@ export function AppTabs({
}
export function AppFilterTabs({
className,
...props
}: AppTabsProps) {
return (
<ScrollShadow
orientation='horizontal'
className='app-filter-scroll'
hideScrollBar
className='w-full min-w-0'
>
<AppTabs
{...props}
className={`app-filter-tabs ${className ?? ''}`.trim()}
/>
<AppTabs {...props} />
</ScrollShadow>
);
}