refactor ui with HeroUI refresh

This commit is contained in:
leowang 2026-05-22 17:42:41 +08:00
parent 7f87806f6c
commit eb71c83aa5
49 changed files with 4524 additions and 4374 deletions

View File

@ -20,10 +20,12 @@ const customJestConfig = {
* Absolute imports and Module Path Aliases * Absolute imports and Module Path Aliases
*/ */
moduleNameMapper: { moduleNameMapper: {
'^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/public/$1', '^~/(.*)$': '<rootDir>/public/$1',
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx', '^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
}, },
modulePathIgnorePatterns: ['<rootDir>/.next/'],
}; };
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async

View File

@ -9,12 +9,6 @@ const nextConfig = {
}, },
reactStrictMode: false, reactStrictMode: false,
swcMinify: false,
experimental: {
instrumentationHook: process.env.NODE_ENV === 'production',
},
// Uncoment to add domain whitelist // Uncoment to add domain whitelist
images: { images: {
unoptimized: true, unoptimized: true,

View File

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm gen:manifest && node simple-dev.js", "dev": "pnpm gen:manifest && node simple-dev.js",
"dev:redis": "node scripts/dev-with-redis.js",
"dev:complex": "pnpm gen:manifest && node dev-server.js", "dev:complex": "pnpm gen:manifest && node dev-server.js",
"dev:ws": "node standalone-websocket.js", "dev:ws": "node standalone-websocket.js",
"test:ws": "node test-websocket-connection.js", "test:ws": "node test-websocket-connection.js",
@ -29,8 +30,8 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.4", "@heroui/react": "3.0.5",
"@heroicons/react": "^2.2.0", "@heroui/styles": "3.0.5",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@upstash/redis": "^1.25.0", "@upstash/redis": "^1.25.0",
@ -45,16 +46,17 @@
"hls.js": "^1.6.10", "hls.js": "^1.6.10",
"lucide-react": "^0.438.0", "lucide-react": "^0.438.0",
"media-icons": "^1.1.5", "media-icons": "^1.1.5",
"next": "^14.2.30", "next": "^15.5.18",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.2.0", "react": "^19.2.3",
"react-dom": "^18.2.0", "react-dom": "^19.2.3",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"redis": "^4.6.7", "redis": "^4.6.7",
"swiper": "^11.2.8", "swiper": "^11.2.8",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^3.4.0",
"tailwind-variants": "3.2.2",
"vidstack": "^0.6.15", "vidstack": "^0.6.15",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^3.24.1" "zod": "^3.24.1"
@ -64,19 +66,21 @@
"@commitlint/config-conventional": "^16.2.4", "@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7", "@testing-library/react": "^16.3.2",
"@types/bs58": "^5.0.0", "@types/bs58": "^5.0.0",
"@types/he": "^1.2.3", "@types/he": "^1.2.3",
"@types/node": "24.0.3", "@types/node": "24.0.3",
"@types/react": "^18.3.18", "@types/react": "^19.2.15",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@types/testing-library__jest-dom": "^5.14.9", "@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-next": "^14.2.23", "eslint-config-next": "^15.5.18",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^8.10.0",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
@ -84,11 +88,12 @@
"jest": "^27.5.1", "jest": "^27.5.1",
"lint-staged": "^12.5.0", "lint-staged": "^12.5.0",
"next-router-mock": "^0.9.0", "next-router-mock": "^0.9.0",
"playwright": "^1.60.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.5.0", "prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^4.3.0",
"typescript": "^4.9.5", "typescript": "^5.9.3",
"webpack-obfuscator": "^3.5.1" "webpack-obfuscator": "^3.5.1"
}, },
"lint-staged": { "lint-staged": {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
}; };

1
public/sw.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,62 @@
const {
buildRedisUrl,
getRedisConfig,
isPortAvailable,
resolveRedisAction,
} = require('../dev-with-redis');
const net = require('net');
describe('dev-with-redis helpers', () => {
test('uses stable defaults for the local Redis container', () => {
const config = getRedisConfig({});
expect(config).toEqual({
containerName: 'orangetv-redis-dev',
image: 'redis:alpine',
port: '6379',
});
});
test('allows env overrides for container name, image, and port', () => {
const config = getRedisConfig({
REDIS_CONTAINER_NAME: 'custom-redis',
REDIS_IMAGE: 'redis:7-alpine',
REDIS_PORT: '6380',
});
expect(config).toEqual({
containerName: 'custom-redis',
image: 'redis:7-alpine',
port: '6380',
});
});
test('builds the Redis URL passed to the Next.js dev process', () => {
expect(buildRedisUrl('6380')).toBe('redis://localhost:6380');
});
test('starts an existing stopped container instead of creating it', () => {
expect(resolveRedisAction({ exists: true, running: false })).toBe('start');
});
test('does nothing when the container is already running', () => {
expect(resolveRedisAction({ exists: true, running: true })).toBe('none');
});
test('creates the container when it does not exist', () => {
expect(resolveRedisAction({ exists: false, running: false })).toBe('create');
});
test('detects when a dev port is already occupied', async () => {
const server = net.createServer();
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
try {
await expect(isPortAvailable(port)).resolves.toBe(false);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
});

193
scripts/dev-with-redis.js Normal file
View File

@ -0,0 +1,193 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const { spawn, spawnSync } = require('child_process');
const net = require('net');
function getRedisConfig(env = process.env) {
return {
containerName: env.REDIS_CONTAINER_NAME || 'orangetv-redis-dev',
image: env.REDIS_IMAGE || 'redis:alpine',
port: env.REDIS_PORT || '6379',
};
}
function buildRedisUrl(port) {
return `redis://localhost:${port}`;
}
function resolveRedisAction(state) {
if (!state.exists) return 'create';
if (!state.running) return 'start';
return 'none';
}
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port);
});
}
async function assertDevPortsAvailable(ports = [3000, 3001]) {
const checks = await Promise.all(
ports.map(async (port) => ({
port,
available: await isPortAvailable(port),
}))
);
const blocked = checks.filter((check) => !check.available);
if (blocked.length > 0) {
throw new Error(
`Dev port${blocked.length > 1 ? 's are' : ' is'} already in use: ${blocked
.map((check) => check.port)
.join(', ')}.\nStop the existing OrangeTV dev server or free those ports, then retry.`
);
}
}
function runDocker(args, options = {}) {
return spawnSync('docker', args, {
encoding: 'utf8',
stdio: options.stdio || 'pipe',
});
}
function assertDockerAvailable() {
const result = runDocker(['info']);
if (result.status !== 0) {
const detail = result.stderr?.trim() || result.stdout?.trim();
throw new Error(
`Docker is not available. Start Docker Desktop and retry.\n${detail || ''}`
);
}
}
function getContainerState(containerName) {
const result = runDocker([
'inspect',
'-f',
'{{.State.Running}}',
containerName,
]);
if (result.status !== 0) {
return { exists: false, running: false };
}
return {
exists: true,
running: result.stdout.trim() === 'true',
};
}
function ensureRedis(config = getRedisConfig()) {
assertDockerAvailable();
const state = getContainerState(config.containerName);
const action = resolveRedisAction(state);
if (action === 'none') {
console.log(`Redis container "${config.containerName}" is already running.`);
return;
}
if (action === 'start') {
console.log(`Starting Redis container "${config.containerName}"...`);
const result = runDocker(['start', config.containerName], {
stdio: 'inherit',
});
if (result.status !== 0) {
throw new Error(`Failed to start Redis container "${config.containerName}".`);
}
return;
}
console.log(
`Creating Redis container "${config.containerName}" on localhost:${config.port}...`
);
const result = runDocker(
[
'run',
'--name',
config.containerName,
'-p',
`${config.port}:6379`,
'-d',
config.image,
],
{ stdio: 'inherit' }
);
if (result.status !== 0) {
throw new Error(
`Failed to create Redis container. If port ${config.port} is already in use, retry with REDIS_PORT=6380 pnpm dev:redis.`
);
}
}
function startNextDev(config = getRedisConfig()) {
const redisUrl = buildRedisUrl(config.port);
const env = {
...process.env,
NEXT_PUBLIC_STORAGE_TYPE: 'redis',
REDIS_URL: redisUrl,
};
console.log(`Using Redis storage: ${redisUrl}`);
console.log('Starting OrangeTV dev server...');
const child = spawn('pnpm', ['dev'], {
env,
stdio: 'inherit',
});
process.on('SIGINT', () => {
if (!child.killed) child.kill('SIGINT');
});
process.on('SIGTERM', () => {
if (!child.killed) child.kill('SIGTERM');
});
child.on('exit', (code, signal) => {
if (signal) {
process.exit(1);
}
process.exit(code || 0);
});
}
async function main() {
try {
const config = getRedisConfig();
await assertDevPortsAvailable();
ensureRedis(config);
startNextDev(config);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
assertDevPortsAvailable,
buildRedisUrl,
ensureRedis,
getRedisConfig,
isPortAvailable,
resolveRedisAction,
};

View File

@ -0,0 +1,397 @@
import React, { createContext, useContext } from 'react';
type SelectionContextValue = {
selectedKey?: React.Key;
onSelectionChange?: (key: React.Key) => void;
};
const SelectionContext = createContext<SelectionContextValue>({});
const MenuActionContext = createContext<{
onAction?: (key: React.Key) => void;
selectedKeys?: Iterable<React.Key>;
}>({});
type OverlayStateValue = {
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
open: () => void;
close: () => void;
toggle: () => void;
};
const OverlayContext = createContext<OverlayStateValue | null>(null);
export const useOverlayState = ({
isOpen = false,
defaultOpen = false,
onOpenChange,
}: {
isOpen?: boolean;
defaultOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
} = {}): OverlayStateValue => {
const openState = isOpen || defaultOpen;
const setOpen = (nextIsOpen: boolean) => onOpenChange?.(nextIsOpen);
return {
isOpen: openState,
setOpen,
open: () => setOpen(true),
close: () => setOpen(false),
toggle: () => setOpen(!openState),
};
};
export const Button = ({
children,
onPress,
onClick,
isIconOnly: _isIconOnly,
isDisabled,
isPending: _isPending,
variant: _variant,
fullWidth: _fullWidth,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
onPress?: () => void;
isIconOnly?: boolean;
isDisabled?: boolean;
isPending?: boolean;
variant?: string;
fullWidth?: boolean;
}) => (
<button
{...props}
disabled={isDisabled}
onClick={(event) => {
onClick?.(event);
onPress?.();
}}
>
{typeof children === 'function'
? (children as (values: { isPending: boolean }) => React.ReactNode)({
isPending: Boolean(_isPending),
})
: children}
</button>
);
export const Label = ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label {...props}>{children}</label>
);
export const Form = ({
children,
...props
}: React.FormHTMLAttributes<HTMLFormElement>) => (
<form {...props}>{children}</form>
);
export const TextField = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { name?: string }) => (
<div {...props}>{children}</div>
);
export const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) => (
<input {...props} />
);
const TabsRoot = ({
children,
selectedKey,
onSelectionChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & SelectionContextValue) => (
<SelectionContext.Provider value={{ selectedKey, onSelectionChange }}>
<div {...props}>{children}</div>
</SelectionContext.Provider>
);
const TabsListContainer = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
const TabsList = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div role='tablist' {...props}>
{children}
</div>
);
const TabsTab = ({
children,
id,
isDisabled,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
id: string;
isDisabled?: boolean;
}) => {
const { selectedKey, onSelectionChange } = useContext(SelectionContext);
const selected = selectedKey === id;
return (
<button
{...props}
disabled={isDisabled}
role='tab'
aria-selected={selected}
onClick={() => onSelectionChange?.(id)}
>
{children}
</button>
);
};
const TabsSeparator = () => <span aria-hidden='true' />;
const TabsIndicator = () => <span aria-hidden='true' />;
const TabsPanel = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div role='tabpanel' {...props}>
{children}
</div>
);
export const Tabs = Object.assign(TabsRoot, {
ListContainer: TabsListContainer,
List: TabsList,
Tab: TabsTab,
Separator: TabsSeparator,
Indicator: TabsIndicator,
Panel: TabsPanel,
});
export const Card = Object.assign(
({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
{
Header: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Title: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 {...props}>{children}</h3>
),
Description: ({
children,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) => <p {...props}>{children}</p>,
Content: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Footer: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
}
);
const ModalRoot = ({
children,
state,
...props
}: React.HTMLAttributes<HTMLDivElement> & { state?: OverlayStateValue }) => {
const overlayState =
state ||
({
isOpen: true,
setOpen: () => undefined,
open: () => undefined,
close: () => undefined,
toggle: () => undefined,
} satisfies OverlayStateValue);
if (!overlayState.isOpen) return null;
return (
<OverlayContext.Provider value={overlayState}>
<div {...props}>{children}</div>
</OverlayContext.Provider>
);
};
const ModalBackdrop = ({
children,
isDismissable: _isDismissable,
variant: _variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
isDismissable?: boolean;
variant?: string;
}) => <div {...props}>{children}</div>;
const ModalContainer = ({
children,
placement: _placement,
scroll: _scroll,
size: _size,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
placement?: string;
scroll?: string;
size?: string;
}) => <div {...props}>{children}</div>;
const ModalDialog = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div role='dialog' {...props}>
{children}
</div>
);
const OverlayCloseTrigger = ({
children = 'Close',
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const state = useContext(OverlayContext);
return (
<button {...props} onClick={() => state?.close()}>
{children}
</button>
);
};
export const Modal = Object.assign(ModalRoot, {
Backdrop: ModalBackdrop,
Container: ModalContainer,
Dialog: ModalDialog,
CloseTrigger: OverlayCloseTrigger,
Header: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Icon: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Heading: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 {...props}>{children}</h2>
),
Body: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Footer: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
});
export const Drawer = Object.assign(ModalRoot, {
Backdrop: ModalBackdrop,
Content: ModalContainer,
Dialog: ModalDialog,
CloseTrigger: OverlayCloseTrigger,
Handle: (props: React.HTMLAttributes<HTMLDivElement>) => (
<div aria-hidden='true' {...props} />
),
Header: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Heading: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 {...props}>{children}</h2>
),
Body: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Footer: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
});
const DropdownRoot = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
const DropdownPopover = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
const DropdownMenu = ({
children,
onAction,
selectedKeys,
selectionMode: _selectionMode,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
onAction?: (key: React.Key) => void;
selectedKeys?: Iterable<React.Key>;
selectionMode?: string;
}) => (
<MenuActionContext.Provider value={{ onAction, selectedKeys }}>
<div role='menu' {...props}>
{children}
</div>
</MenuActionContext.Provider>
);
const DropdownItem = ({
children,
id,
textValue,
...props
}: React.HTMLAttributes<HTMLButtonElement> & {
id: React.Key;
textValue?: string;
}) => {
const { onAction, selectedKeys } = useContext(MenuActionContext);
const selected = selectedKeys ? Array.from(selectedKeys).includes(id) : false;
return (
<button
type='button'
role='menuitem'
aria-label={textValue}
aria-selected={selected}
{...props}
onClick={() => onAction?.(id)}
>
{children}
</button>
);
};
export const Dropdown = Object.assign(DropdownRoot, {
Popover: DropdownPopover,
Menu: DropdownMenu,
Item: Object.assign(DropdownItem, {
Indicator: () => <span aria-hidden='true' />,
}),
ItemIndicator: () => <span aria-hidden='true' />,
});
export const ScrollShadow = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>;
export const Spinner = (props: React.HTMLAttributes<HTMLSpanElement>) => (
<span role='status' {...props} />
);
const toastMock = () => 'toast-id';
export const Toast = {
Provider: () => <div data-testid='heroui-toast-provider' />,
toast: Object.assign(toastMock, {
success: () => 'toast-id',
danger: () => 'toast-id',
warning: () => 'toast-id',
info: () => 'toast-id',
close: () => undefined,
clear: () => undefined,
pauseAll: () => undefined,
resumeAll: () => undefined,
getQueue: () => ({}),
}),
};

View File

@ -53,43 +53,43 @@ import PageLayout from '@/components/PageLayout';
// 统一按钮样式系统 // 统一按钮样式系统
const buttonStyles = { const buttonStyles = {
// 主要操作按钮(蓝色)- 用于配置、设置、确认等 // 主要操作按钮(蓝色)- 用于配置、设置、确认等
primary: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors', primary: 'px-3 py-1.5 text-sm font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-xl transition-colors',
// 成功操作按钮(绿色)- 用于添加、启用、保存等 // 成功操作按钮(绿色)- 用于添加、启用、保存等
success: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors', success: 'px-3 py-1.5 text-sm font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-xl transition-colors',
// 危险操作按钮(红色)- 用于删除、禁用、重置等 // 危险操作按钮(红色)- 用于删除、禁用、重置等
danger: 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors', danger: 'px-3 py-1.5 text-sm font-medium bg-danger hover:bg-danger/90 text-white rounded-xl transition-colors',
// 次要操作按钮(灰色)- 用于取消、关闭等 // 次要操作按钮(灰色)- 用于取消、关闭等
secondary: 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors', secondary: 'px-3 py-1.5 text-sm font-medium bg-surface-secondary hover:bg-surface-tertiary text-foreground border border-border rounded-xl transition-colors',
// 警告操作按钮(黄色)- 用于批量禁用等 // 警告操作按钮(黄色)- 用于批量禁用等
warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors', warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors',
// 小尺寸主要按钮 // 小尺寸主要按钮
primarySmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors', primarySmall: 'px-2 py-1 text-xs font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-lg transition-colors',
// 小尺寸成功按钮 // 小尺寸成功按钮
successSmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors', successSmall: 'px-2 py-1 text-xs font-medium bg-accent hover:bg-accent-strong text-accent-foreground rounded-lg transition-colors',
// 小尺寸危险按钮 // 小尺寸危险按钮
dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors', dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors',
// 小尺寸次要按钮 // 小尺寸次要按钮
secondarySmall: 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors', secondarySmall: 'px-2 py-1 text-xs font-medium bg-surface-secondary hover:bg-surface-tertiary text-foreground border border-border rounded-lg transition-colors',
// 小尺寸警告按钮 // 小尺寸警告按钮
warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors', warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors',
// 圆角小按钮(用于表格操作) // 圆角小按钮(用于表格操作)
roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors', roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-accent/10 text-accent hover:bg-accent/15 transition-colors',
roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors', roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-accent/10 text-accent hover:bg-accent/15 transition-colors',
roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors', roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors',
roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors', roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-surface-secondary text-foreground hover:bg-surface-tertiary transition-colors',
roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors', roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors',
roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors', roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-accent/10 text-accent hover:bg-accent/15 transition-colors',
// 禁用状态 // 禁用状态
disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors', disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors',
disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors', disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors',
// 开关按钮样式 // 开关按钮样式
toggleOn: 'bg-blue-600 dark:bg-blue-600', toggleOn: 'bg-accent',
toggleOff: 'bg-gray-200 dark:bg-gray-700', toggleOff: 'bg-surface-tertiary',
toggleThumb: 'bg-white', toggleThumb: 'bg-surface',
toggleThumbOn: 'translate-x-6', toggleThumbOn: 'translate-x-6',
toggleThumbOff: 'translate-x-1', toggleThumbOff: 'translate-x-1',
// 快速操作按钮样式 // 快速操作按钮样式
quickAction: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors', quickAction: 'px-3 py-1.5 text-xs font-medium text-muted bg-surface border border-border hover:bg-surface-secondary rounded-lg transition-colors',
}; };
// 获取用户头像的函数 // 获取用户头像的函数
@ -548,18 +548,18 @@ const CollapsibleTab = ({
children, children,
}: CollapsibleTabProps) => { }: CollapsibleTabProps) => {
return ( return (
<div className='rounded-xl shadow-sm mb-4 overflow-hidden bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'> <div className='mb-4 overflow-hidden rounded-3xl border border-border/70 bg-surface/80 shadow-sm backdrop-blur-md'>
<button <button
onClick={onToggle} onClick={onToggle}
className='w-full px-6 py-4 flex items-center justify-between bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60 transition-colors' className='flex w-full items-center justify-between bg-surface-secondary/70 px-6 py-4 transition-colors hover:bg-surface-tertiary/70'
> >
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
{icon} {icon}
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'> <h3 className='text-lg font-semibold text-foreground'>
{title} {title}
</h3> </h3>
</div> </div>
<div className='text-gray-500 dark:text-gray-400'> <div className='text-muted'>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />} {isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div> </div>
</button> </button>
@ -1255,7 +1255,7 @@ const UserConfig = ({ config, role, refreshConfig, machineCodeUsers, fetchMachin
data-table="user-list" data-table="user-list"
style={{ style={{
scrollbarWidth: 'thin', scrollbarWidth: 'thin',
['scrollbar-color' as any]: '#cbd5e0 transparent' scrollbarColor: '#cbd5e0 transparent',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
const target = e.currentTarget; const target = e.currentTarget;

View File

@ -8,7 +8,7 @@ import { getConfig, setCachedConfig, clearCachedConfig } from '@/lib/config';
export async function GET() { export async function GET() {
try { try {
// 创建一个模拟的NextRequest对象来使用getAuthInfoFromCookie // 创建一个模拟的NextRequest对象来使用getAuthInfoFromCookie
const cookieStore = cookies(); const cookieStore = await cookies();
const authCookie = cookieStore.get('auth'); const authCookie = cookieStore.get('auth');
if (!authCookie) { if (!authCookie) {
@ -42,7 +42,7 @@ export async function GET() {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
// 获取认证信息 // 获取认证信息
const cookieStore = cookies(); const cookieStore = await cookies();
const authCookie = cookieStore.get('auth'); const authCookie = cookieStore.get('auth');
if (!authCookie) { if (!authCookie) {

View File

@ -126,3 +126,5 @@
// }, // },
// }); // });
// } // }
export {};

View File

@ -728,7 +728,7 @@ function DoubanPageClient() {
{/* 选择器组件 */} {/* 选择器组件 */}
{type !== 'custom' ? ( {type !== 'custom' ? (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'> <div className='app-filter-panel'>
<DoubanSelector <DoubanSelector
type={type as 'movie' | 'tv' | 'show' | 'anime'} type={type as 'movie' | 'tv' | 'show' | 'anime'}
primarySelection={primarySelection} primarySelection={primarySelection}
@ -740,7 +740,7 @@ function DoubanPageClient() {
/> />
</div> </div>
) : ( ) : (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'> <div className='app-filter-panel'>
<DoubanCustomSelector <DoubanCustomSelector
customCategories={customCategories} customCategories={customCategories}
primarySelection={primarySelection} primarySelection={primarySelection}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css'; import './globals.css';
@ -13,7 +12,6 @@ import { ThemeProvider } from '../components/ThemeProvider';
import { ToastProvider } from '../components/Toast'; import { ToastProvider } from '../components/Toast';
import GlobalThemeLoader from '../components/GlobalThemeLoader'; import GlobalThemeLoader from '../components/GlobalThemeLoader';
const inter = Inter({ subsets: ['latin'] });
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
// 动态生成 metadata支持配置更新后的标题变化 // 动态生成 metadata支持配置更新后的标题变化
@ -166,12 +164,10 @@ export default async function RootLayout({
/> />
</head> </head>
<body <body className='min-h-[100dvh] bg-background text-foreground antialiased'>
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
>
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
defaultTheme='system' defaultTheme='light'
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >

View File

@ -3,6 +3,7 @@
'use client'; 'use client';
import { AlertCircle, CheckCircle, Shield } from 'lucide-react'; import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
import { Form, Input, Label, TextField } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
@ -13,6 +14,7 @@ import MachineCode from '@/lib/machine-code';
import { useSite } from '@/components/SiteProvider'; import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle'; import { ThemeToggle } from '@/components/ThemeToggle';
import GlobalThemeLoader from '@/components/GlobalThemeLoader'; import GlobalThemeLoader from '@/components/GlobalThemeLoader';
import { AppButton, AppSurface } from '@/components/ui/HeroPrimitives';
// 版本显示组件 // 版本显示组件
function VersionDisplay() { function VersionDisplay() {
@ -200,54 +202,36 @@ function LoginPageClient() {
<div className='absolute top-4 right-4'> <div className='absolute top-4 right-4'>
<ThemeToggle /> <ThemeToggle />
</div> </div>
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'> <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='text-blue-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
{siteName} {siteName}
</h1> </h1>
<form onSubmit={handleSubmit} className='space-y-8'> <Form onSubmit={handleSubmit} className='space-y-6'>
{shouldAskUsername && ( {shouldAskUsername && (
<div className='relative'> <TextField name='username' className='w-full'>
<input <Label></Label>
<Input
id='username' id='username'
type='text' type='text'
autoComplete='username' autoComplete='username'
className='peer block w-full rounded-lg border-0 py-4 px-4 pt-6 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 focus:ring-2 focus:ring-blue-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur placeholder-transparent'
placeholder='用户名' placeholder='用户名'
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<label </TextField>
htmlFor='username'
className={`absolute left-4 transition-all duration-200 pointer-events-none ${username
? 'top-1 text-xs text-blue-600 dark:text-blue-400'
: 'top-4 text-base text-gray-500 dark:text-gray-400 peer-focus:top-1 peer-focus:text-xs peer-focus:text-blue-600 peer-focus:dark:text-blue-400'
}`}
>
</label>
</div>
)} )}
<div className='relative'> <TextField name='password' className='w-full'>
<input <Label></Label>
<Input
id='password' id='password'
type='password' type='password'
autoComplete='current-password' autoComplete='current-password'
className='peer block w-full rounded-lg border-0 py-4 px-4 pt-6 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 focus:ring-2 focus:ring-blue-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur placeholder-transparent'
placeholder='密码' placeholder='密码'
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<label </TextField>
htmlFor='password'
className={`absolute left-4 transition-all duration-200 pointer-events-none ${password
? 'top-1 text-xs text-blue-600 dark:text-blue-400'
: 'top-4 text-base text-gray-500 dark:text-gray-400 peer-focus:top-1 peer-focus:text-xs peer-focus:text-blue-600 peer-focus:dark:text-blue-400'
}`}
>
</label>
</div>
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */} {/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && ( {deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
@ -295,20 +279,21 @@ function LoginPageClient() {
)} )}
{/* 登录按钮 */} {/* 登录按钮 */}
<button <AppButton
type='submit' type='submit'
disabled={ fullWidth
isDisabled={
!password || !password ||
loading || loading ||
(shouldAskUsername && !username) || (shouldAskUsername && !username) ||
(deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode) (deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
} }
className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50' isPending={loading}
> >
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </AppButton>
</form> </Form>
</div> </AppSurface>
{/* 版本信息显示 */} {/* 版本信息显示 */}
<VersionDisplay /> <VersionDisplay />

View File

@ -2,7 +2,7 @@
'use client'; 'use client';
import { ChevronRight } from 'lucide-react'; import { X } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
@ -169,9 +169,9 @@ function HomeClient() {
return ( return (
<PageLayout> <PageLayout>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'> <div className='overflow-visible px-4 py-6 sm:px-10 sm:py-10'>
{/* 顶部 Tab 切换 */} {/* 顶部 Tab 切换 */}
<div className='mb-8 flex justify-center'> <div className='mb-10 flex justify-center'>
<CapsuleSwitch <CapsuleSwitch
options={[ options={[
{ label: '首页', value: 'home' }, { label: '首页', value: 'home' },
@ -182,17 +182,20 @@ function HomeClient() {
/> />
</div> </div>
<div className='max-w-[95%] mx-auto'> <div className='mx-auto max-w-[1380px] space-y-10'>
{activeTab === 'favorites' ? ( {activeTab === 'favorites' ? (
// 收藏夹视图 // 收藏夹视图
<section className='mb-8'> <section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-4 flex items-center justify-between'> <div className='mb-5 flex items-end justify-between gap-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <div className='space-y-1'>
<p className='a2-kicker'>Saved</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2> </h2>
</div>
{favoriteItems.length > 0 && ( {favoriteItems.length > 0 && (
<button <button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='a2-link-action'
onClick={async () => { onClick={async () => {
await clearAllFavorites(); await clearAllFavorites();
setFavoriteItems([]); setFavoriteItems([]);
@ -202,7 +205,7 @@ function HomeClient() {
</button> </button>
)} )}
</div> </div>
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'> <div className='grid justify-start grid-cols-3 gap-x-3 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20'>
{favoriteItems.map((item) => ( {favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'> <div key={item.id + item.source} className='w-full'>
<VideoCard <VideoCard
@ -214,7 +217,7 @@ function HomeClient() {
</div> </div>
))} ))}
{favoriteItems.length === 0 && ( {favoriteItems.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'> <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'>
</div> </div>
)} )}
@ -227,17 +230,19 @@ function HomeClient() {
<ContinueWatching /> <ContinueWatching />
{/* 热门电影 */} {/* 热门电影 */}
<section className='mb-8'> <section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-4 flex items-center justify-between'> <div className='mb-5 flex items-end justify-between gap-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <div className='space-y-1'>
<p className='a2-kicker'></p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2> </h2>
</div>
<Link <Link
href='/douban?type=movie' href='/douban?type=movie'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='a2-link-action'
> >
<ChevronRight className='w-4 h-4 ml-1' />
</Link> </Link>
</div> </div>
<ScrollableRow> <ScrollableRow>
@ -248,10 +253,10 @@ function HomeClient() {
key={index} key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
> >
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'> <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-gray-300 dark:bg-gray-700'></div> <div className='absolute inset-0 bg-surface-tertiary'></div>
</div> </div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div> <div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
</div> </div>
)) ))
: // 显示真实数据 : // 显示真实数据
@ -275,17 +280,16 @@ function HomeClient() {
</section> </section>
{/* 热门剧集 */} {/* 热门剧集 */}
<section className='mb-8'> <section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-4 flex items-center justify-between'> <div className='mb-5 flex items-end justify-between gap-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <div className='space-y-1'>
<p className='a2-kicker'>Series</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2> </h2>
<Link </div>
href='/douban?type=tv' <Link href='/douban?type=tv' className='a2-link-action'>
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link> </Link>
</div> </div>
<ScrollableRow> <ScrollableRow>
@ -296,10 +300,10 @@ function HomeClient() {
key={index} key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
> >
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'> <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-gray-300 dark:bg-gray-700'></div> <div className='absolute inset-0 bg-surface-tertiary'></div>
</div> </div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div> <div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
</div> </div>
)) ))
: // 显示真实数据 : // 显示真实数据
@ -322,17 +326,19 @@ function HomeClient() {
</section> </section>
{/* 每日新番放送 */} {/* 每日新番放送 */}
<section className='mb-8'> <section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-4 flex items-center justify-between'> <div className='mb-5 flex items-end justify-between gap-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <div className='space-y-1'>
<p className='a2-kicker'>Bangumi</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2> </h2>
</div>
<Link <Link
href='/douban?type=anime' href='/douban?type=anime'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='a2-link-action'
> >
<ChevronRight className='w-4 h-4 ml-1' />
</Link> </Link>
</div> </div>
<ScrollableRow> <ScrollableRow>
@ -343,10 +349,10 @@ function HomeClient() {
key={index} key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
> >
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'> <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-gray-300 dark:bg-gray-700'></div> <div className='absolute inset-0 bg-surface-tertiary'></div>
</div> </div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div> <div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
</div> </div>
)) ))
: // 展示当前日期的番剧 : // 展示当前日期的番剧
@ -398,17 +404,19 @@ function HomeClient() {
</section> </section>
{/* 热门综艺 */} {/* 热门综艺 */}
<section className='mb-8'> <section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
<div className='mb-4 flex items-center justify-between'> <div className='mb-5 flex items-end justify-between gap-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <div className='space-y-1'>
<p className='a2-kicker'>Shows</p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2> </h2>
</div>
<Link <Link
href='/douban?type=show' href='/douban?type=show'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='a2-link-action'
> >
<ChevronRight className='w-4 h-4 ml-1' />
</Link> </Link>
</div> </div>
<ScrollableRow> <ScrollableRow>
@ -419,10 +427,10 @@ function HomeClient() {
key={index} key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
> >
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'> <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-gray-300 dark:bg-gray-700'></div> <div className='absolute inset-0 bg-surface-tertiary'></div>
</div> </div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div> <div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
</div> </div>
)) ))
: // 显示真实数据 : // 显示真实数据
@ -475,7 +483,7 @@ function HomeClient() {
}} }}
> >
<div <div
className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl' className='a2-panel w-full max-w-md p-6 transform transition-all duration-300'
onTouchMove={(e) => { onTouchMove={(e) => {
// 允许公告内容区域正常滚动,阻止事件冒泡到外层 // 允许公告内容区域正常滚动,阻止事件冒泡到外层
e.stopPropagation(); e.stopPropagation();
@ -485,26 +493,27 @@ function HomeClient() {
}} }}
> >
<div className='flex justify-between items-start mb-4'> <div className='flex justify-between items-start mb-4'>
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-blue-500 pb-1'> <h3 className='a2-title border-b border-border/70 pb-3 text-[1.75rem]'>
</h3> </h3>
<button <button
onClick={() => handleCloseAnnouncement(announcement)} onClick={() => handleCloseAnnouncement(announcement)}
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors' className='a2-icon-button h-8 w-8 p-1.5'
aria-label='关闭' aria-label='关闭'
></button> >
<X className='h-4 w-4' />
</button>
</div> </div>
<div className='mb-6'> <div className='mb-6'>
<div className='relative overflow-hidden rounded-lg mb-4 bg-blue-50 dark:bg-blue-900/20'> <div className='border-l-4 border-accent pl-4'>
<div className='absolute inset-y-0 left-0 w-1.5 bg-blue-500 dark:bg-blue-400'></div> <p className='a2-muted-copy'>
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
{announcement} {announcement}
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={() => handleCloseAnnouncement(announcement)} onClick={() => handleCloseAnnouncement(announcement)}
className='w-full rounded-lg bg-gradient-to-r from-blue-600 to-blue-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-blue-700 hover:to-blue-800 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-700 dark:hover:to-blue-800 transition-all duration-300 transform hover:-translate-y-0.5' className='a2-link-action w-full justify-center border-b-0 border-t border-border/70 px-4 pt-3'
> >
</button> </button>

View File

@ -42,7 +42,7 @@ function SearchPageClient() {
const flushTimerRef = useRef<number | null>(null); const flushTimerRef = useRef<number | null>(null);
const [useFluidSearch, setUseFluidSearch] = useState(true); const [useFluidSearch, setUseFluidSearch] = useState(true);
// 聚合卡片 refs 与聚合统计缓存 // 聚合卡片 refs 与聚合统计缓存
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map()); const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle | null>>>(new Map());
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map()); const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
// 执行搜索的通用函数 // 执行搜索的通用函数
@ -1020,7 +1020,7 @@ function SearchPageClient() {
<div className='mb-8'> <div className='mb-8'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'> <form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
<div className='relative'> <div className='relative'>
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' /> <Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted' />
<input <input
id='searchInput' id='searchInput'
type='text' type='text'
@ -1029,7 +1029,7 @@ function SearchPageClient() {
onFocus={handleInputFocus} onFocus={handleInputFocus}
placeholder='搜索电影、电视剧、短剧...' placeholder='搜索电影、电视剧、短剧...'
autoComplete="off" autoComplete="off"
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700' 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'
/> />
{/* 清除按钮 */} {/* 清除按钮 */}
@ -1041,7 +1041,7 @@ function SearchPageClient() {
setShowSuggestions(false); setShowSuggestions(false);
document.getElementById('searchInput')?.focus(); document.getElementById('searchInput')?.focus();
}} }}
className='absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors dark:text-gray-500 dark:hover:text-gray-300' className='absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted transition-colors hover:text-foreground'
aria-label='清除搜索内容' aria-label='清除搜索内容'
> >
<X className='h-5 w-5' /> <X className='h-5 w-5' />
@ -1071,19 +1071,19 @@ function SearchPageClient() {
{/* 搜索结果或搜索历史 */} {/* 搜索结果或搜索历史 */}
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'> <div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
{showResults ? ( {showResults ? (
<section className='mb-12'> <section className='mb-12 rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
{/* 标题 */} {/* 标题 */}
<div className='mb-4'> <div className='mb-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <h2 className='text-xl font-semibold tracking-normal text-foreground'>
{totalSources > 0 && useFluidSearch && ( {totalSources > 0 && useFluidSearch && (
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'> <span className='ml-2 text-sm font-normal text-muted'>
{completedSources}/{totalSources} {completedSources}/{totalSources}
</span> </span>
)} )}
{isLoading && useFluidSearch && ( {isLoading && useFluidSearch && (
<span className='ml-2 inline-block align-middle'> <span className='ml-2 inline-block align-middle'>
<span className='inline-block h-3 w-3 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin'></span> <span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-border border-t-accent'></span>
</span> </span>
)} )}
</h2> </h2>
@ -1107,7 +1107,7 @@ function SearchPageClient() {
</div> </div>
{/* 聚合开关 */} {/* 聚合开关 */}
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'> <label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
<span className='text-xs sm:text-sm text-gray-700 dark:text-gray-300'></span> <span className='text-xs sm:text-sm font-medium text-muted'></span>
<div className='relative'> <div className='relative'>
<input <input
type='checkbox' type='checkbox'
@ -1115,18 +1115,18 @@ function SearchPageClient() {
checked={viewMode === 'agg'} checked={viewMode === 'agg'}
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')} onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
/> />
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-blue-500 transition-colors dark:bg-gray-600'></div> <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 bg-white rounded-full transition-transform peer-checked:translate-x-4'></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> </div>
</label> </label>
</div> </div>
{searchResults.length === 0 ? ( {searchResults.length === 0 ? (
isLoading ? ( isLoading ? (
<div className='flex justify-center items-center h-40'> <div className='flex justify-center items-center h-40'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div> <div className='h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent'></div>
</div> </div>
) : ( ) : (
<div className='text-center text-gray-500 py-8 dark:text-gray-400'> <div className='rounded-2xl border border-dashed border-border bg-surface-secondary/60 py-8 text-center text-muted'>
</div> </div>
) )
@ -1199,15 +1199,15 @@ function SearchPageClient() {
</section> </section>
) : searchHistory.length > 0 ? ( ) : searchHistory.length > 0 ? (
// 搜索历史 // 搜索历史
<section className='mb-12'> <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-xl font-bold text-gray-800 text-left dark:text-gray-200'> <h2 className='mb-4 text-left text-xl font-semibold tracking-normal text-foreground'>
{searchHistory.length > 0 && ( {searchHistory.length > 0 && (
<button <button
onClick={() => { onClick={() => {
clearSearchHistory(); // 事件监听会自动更新界面 clearSearchHistory(); // 事件监听会自动更新界面
}} }}
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500' className='ml-3 text-sm text-muted transition-colors hover:text-danger'
> >
</button> </button>
@ -1221,7 +1221,7 @@ function SearchPageClient() {
// 直接调用搜索函数 // 直接调用搜索函数
performSearch(item.trim()); performSearch(item.trim());
}} }}
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300' 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'
> >
{item} {item}
</button> </button>
@ -1233,7 +1233,7 @@ function SearchPageClient() {
e.preventDefault(); e.preventDefault();
deleteSearchHistory(item); // 事件监听会自动更新界面 deleteSearchHistory(item); // 事件监听会自动更新界面
}} }}
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors' className='absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] text-white opacity-0 transition-colors hover:bg-danger group-hover:opacity-100'
> >
<X className='w-3 h-3' /> <X className='w-3 h-3' />
</button> </button>
@ -1245,7 +1245,7 @@ function SearchPageClient() {
</div> </div>
</div> </div>
{/* 返回顶部悬浮按钮 - 科技风格 */} {/* 返回顶部悬浮按钮 */}
<div <div
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] transition-all duration-300 ease-in-out ${showBackToTop className={`fixed bottom-20 md:bottom-6 right-6 z-[500] transition-all duration-300 ease-in-out ${showBackToTop
? 'opacity-100 translate-y-0 pointer-events-auto' ? 'opacity-100 translate-y-0 pointer-events-auto'
@ -1254,15 +1254,15 @@ function SearchPageClient() {
> >
<button <button
onClick={scrollToTop} onClick={scrollToTop}
className='relative w-14 h-14 bg-gradient-to-br from-blue-500/20 via-cyan-500/20 to-purple-500/20 backdrop-blur-xl rounded-full shadow-2xl transition-all duration-300 ease-out group hover:scale-110 hover:shadow-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-400/50 border border-white/20' 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)}%)`} aria-label={`返回顶部 (${Math.round(scrollProgress)}%)`}
style={{ style={{
background: `conic-gradient(from 0deg, #3b82f6 ${scrollProgress * 3.6}deg, rgba(59, 130, 246, 0.1) ${scrollProgress * 3.6}deg)` 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 bg-gradient-to-br from-blue-500/30 to-cyan-500/30 rounded-full backdrop-blur-sm flex items-center justify-center transition-all duration-300 group-hover:from-blue-400/40 group-hover:to-cyan-400/40'> <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-white/90 transition-all duration-300 group-hover:scale-110 group-hover:text-white drop-shadow-lg' /> <ChevronUp className='w-6 h-6 text-accent transition-all duration-300 group-hover:scale-110' />
</div> </div>
{/* 进度环 */} {/* 进度环 */}
@ -1288,25 +1288,25 @@ function SearchPageClient() {
/> />
<defs> <defs>
<linearGradient id='progressGradient' x1='0%' y1='0%' x2='100%' y2='100%'> <linearGradient id='progressGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
<stop offset='0%' stopColor='#3b82f6' /> <stop offset='0%' stopColor='rgb(var(--color-accent))' />
<stop offset='50%' stopColor='#06b6d4' /> <stop offset='50%' stopColor='rgb(var(--color-accent))' />
<stop offset='100%' stopColor='#8b5cf6' /> <stop offset='100%' stopColor='rgb(var(--color-accent-strong))' />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </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='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='bg-gray-900/90 text-white text-xs px-3 py-1.5 rounded-lg backdrop-blur-sm border border-white/10 shadow-xl'> <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'> <div className='text-center font-medium'>
{Math.round(scrollProgress)}% {Math.round(scrollProgress)}%
</div> </div>
<div className='w-2 h-2 bg-gray-900/90 rotate-45 absolute -bottom-1 left-1/2 transform -translate-x-1/2 border-r border-b border-white/10'></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> </div>
{/* 脉冲动画 */} {/* 脉冲动画 */}
<div className='absolute inset-0 rounded-full bg-gradient-to-br from-blue-400/20 to-cyan-400/20 animate-pulse opacity-0 group-hover:opacity-100 transition-opacity duration-300'></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> </button>
</div> </div>
</PageLayout> </PageLayout>

View File

@ -250,7 +250,7 @@ function ShortDramaPageClient() {
</div> </div>
{/* 选择器组件 */} {/* 选择器组件 */}
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'> <div className='app-filter-panel'>
<ShortDramaSelector <ShortDramaSelector
selectedCategory={selectedCategory} selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange} onCategoryChange={handleCategoryChange}

View File

@ -1,13 +1,15 @@
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { AppIconButton } from './ui/HeroPrimitives';
export function BackButton() { export function BackButton() {
return ( return (
<button <AppIconButton
onClick={() => window.history.back()} onPress={() => window.history.back()}
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors' className='a2-icon-button'
aria-label='Back' aria-label='Back'
> >
<ArrowLeft className='w-full h-full' /> <ArrowLeft className='w-full h-full' />
</button> </AppIconButton>
); );
} }

View File

@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ import React from 'react';
import React, { useEffect, useRef, useState } from 'react'; import { AppFilterTabs } from './ui/HeroPrimitives';
interface CapsuleSwitchProps { interface CapsuleSwitchProps {
options: { label: string; value: string }[]; options: { label: string; value: string }[];
@ -15,88 +15,14 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
onChange, onChange,
className, className,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const activeIndex = options.findIndex((opt) => opt.value === active);
// 更新指示器位置
const updateIndicatorPosition = () => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, []);
// 监听选中项变化
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, [activeIndex]);
return ( return (
<div <AppFilterTabs
ref={containerRef} ariaLabel='内容切换'
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${ className={className}
className || '' items={options.map((opt) => ({ key: opt.value, label: opt.label }))}
}`} selectedKey={active}
> onSelectionChange={onChange}
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/> />
)}
{options.map((opt, index) => {
const isActive = active === opt.value;
return (
<button
key={opt.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(opt.value)}
className={`relative z-10 w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{opt.label}
</button>
);
})}
</div>
); );
}; };

View File

@ -86,14 +86,17 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
}; };
return ( return (
<section className={`mb-8 ${className || ''}`}> <section className={`rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6 ${className || ''}`}>
<div className='mb-4 flex items-center justify-between'> <div className='mb-5 flex items-end justify-between gap-4'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <div className='space-y-1'>
<p className='a2-kicker'></p>
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
</h2> </h2>
</div>
{!loading && playRecords.length > 0 && ( {!loading && playRecords.length > 0 && (
<button <button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='a2-link-action'
onClick={async () => { onClick={async () => {
await clearAllPlayRecords(); await clearAllPlayRecords();
setPlayRecords([]); setPlayRecords([]);
@ -111,11 +114,11 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
key={index} key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
> >
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'> <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-gray-300 dark:bg-gray-700'></div> <div className='absolute inset-0 bg-surface-tertiary'></div>
</div> </div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div> <div className='mt-3 h-4 rounded-lg bg-surface-secondary animate-pulse'></div>
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div> <div className='mt-1 h-3 rounded-lg bg-surface-secondary animate-pulse'></div>
</div> </div>
)) ))
: // 显示真实数据 : // 显示真实数据

View File

@ -2,7 +2,9 @@
'use client'; 'use client';
import React, { useEffect, useRef, useState } from 'react'; import React, { useMemo } from 'react';
import { AppFilterTabs } from './ui/HeroPrimitives';
interface CustomCategory { interface CustomCategory {
name: string; name: string;
@ -18,6 +20,23 @@ interface DoubanCustomSelectorProps {
onSecondaryChange: (value: string) => void; onSecondaryChange: (value: string) => void;
} }
const renderSelector = (
label: 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,
}))}
/>
);
const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
customCategories, customCategories,
primarySelection, primarySelection,
@ -25,42 +44,24 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
onPrimaryChange, onPrimaryChange,
onSecondaryChange, onSecondaryChange,
}) => { }) => {
// 为不同的选择器创建独立的refs和状态 const primaryOptions = useMemo(() => {
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 二级选择器滚动容器的ref
const secondaryScrollContainerRef = useRef<HTMLDivElement>(null);
// 根据 customCategories 生成一级选择器选项(按 type 分组,电影优先)
const primaryOptions = React.useMemo(() => {
const types = Array.from(new Set(customCategories.map((cat) => cat.type))); const types = Array.from(new Set(customCategories.map((cat) => cat.type)));
// 确保电影类型排在前面
const sortedTypes = types.sort((a, b) => { return types
.sort((a, b) => {
if (a === 'movie' && b !== 'movie') return -1; if (a === 'movie' && b !== 'movie') return -1;
if (a !== 'movie' && b === 'movie') return 1; if (a !== 'movie' && b === 'movie') return 1;
return 0; return 0;
}); })
return sortedTypes.map((type) => ({ .map((type) => ({
label: type === 'movie' ? '电影' : '剧集', label: type === 'movie' ? '电影' : '剧集',
value: type, value: type,
})); }));
}, [customCategories]); }, [customCategories]);
// 根据选中的一级选项生成二级选择器选项 const secondaryOptions = useMemo(() => {
const secondaryOptions = React.useMemo(() => {
if (!primarySelection) return []; if (!primarySelection) return [];
return customCategories return customCategories
.filter((cat) => cat.type === primarySelection) .filter((cat) => cat.type === primarySelection)
.map((cat) => ({ .map((cat) => ({
@ -69,242 +70,34 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
})); }));
}, [customCategories, primarySelection]); }, [customCategories, primarySelection]);
// 处理二级选择器的鼠标滚轮事件(原生 DOM 事件)
const handleSecondaryWheel = React.useCallback((e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const container = secondaryScrollContainerRef.current;
if (container) {
const scrollAmount = e.deltaY * 2;
container.scrollLeft += scrollAmount;
}
}, []);
// 添加二级选择器的鼠标滚轮事件监听器
useEffect(() => {
const scrollContainer = secondaryScrollContainerRef.current;
const capsuleContainer = secondaryContainerRef.current;
if (scrollContainer && capsuleContainer) {
// 同时监听滚动容器和胶囊容器的滚轮事件
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
return () => {
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
};
}
}, [handleSecondaryWheel]);
// 当二级选项变化时重新添加事件监听器
useEffect(() => {
const scrollContainer = secondaryScrollContainerRef.current;
const capsuleContainer = secondaryContainerRef.current;
if (scrollContainer && capsuleContainer && secondaryOptions.length > 0) {
// 重新添加事件监听器
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
return () => {
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
};
}
}, [handleSecondaryWheel, secondaryOptions]);
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (primaryOptions.length > 0) {
const activeIndex = primaryOptions.findIndex(
(opt) => opt.value === (primarySelection || primaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
if (secondaryOptions.length > 0) {
const activeIndex = secondaryOptions.findIndex(
(opt) => opt.value === (secondarySelection || secondaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [primaryOptions, secondaryOptions]); // 当选项变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (primaryOptions.length > 0) {
const activeIndex = primaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection, primaryOptions]);
// 监听副选择器变化
useEffect(() => {
if (secondaryOptions.length > 0) {
const activeIndex = secondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection, secondaryOptions]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: { label: string; value: string }[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${isActive
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
// 如果没有自定义分类,则不渲染任何内容
if (!customCategories || customCategories.length === 0) { if (!customCategories || customCategories.length === 0) {
return null; return null;
} }
return ( return (
<div className='space-y-4 sm:space-y-6'> <div className='space-y-4 sm:space-y-6'>
{/* 两级选择器包装 */}
<div className='space-y-3 sm:space-y-4'> <div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */} <div className='app-filter-row'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <span className='app-filter-label'></span>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> <div className='min-w-0'>
{renderSelector(
</span> '自定义类型',
<div className='overflow-x-auto'>
{renderCapsuleSelector(
primaryOptions, primaryOptions,
primarySelection || primaryOptions[0]?.value, primarySelection || primaryOptions[0]?.value,
onPrimaryChange, onPrimaryChange
true
)} )}
</div> </div>
</div> </div>
{/* 二级选择器 */}
{secondaryOptions.length > 0 && ( {secondaryOptions.length > 0 && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <div className='app-filter-row'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> <span className='app-filter-label'></span>
<div className='min-w-0'>
</span> {renderSelector(
<div ref={secondaryScrollContainerRef} className='overflow-x-auto'> '自定义片单',
{renderCapsuleSelector(
secondaryOptions, secondaryOptions,
secondarySelection || secondaryOptions[0]?.value, secondarySelection || secondaryOptions[0]?.value,
onSecondaryChange, onSecondaryChange
false
)} )}
</div> </div>
</div> </div>

View File

@ -2,9 +2,10 @@
'use client'; 'use client';
import React, { useEffect, useRef, useState } from 'react'; import React from 'react';
import MultiLevelSelector from './MultiLevelSelector'; import MultiLevelSelector from './MultiLevelSelector';
import { AppFilterTabs } from './ui/HeroPrimitives';
import WeekdaySelector from './WeekdaySelector'; import WeekdaySelector from './WeekdaySelector';
interface SelectorOption { interface SelectorOption {
@ -22,31 +23,6 @@ interface DoubanSelectorProps {
onWeekdayChange: (weekday: string) => void; onWeekdayChange: (weekday: string) => void;
} }
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
onMultiLevelChange,
onWeekdayChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 电影的一级选择器选项
const moviePrimaryOptions: SelectorOption[] = [ const moviePrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' }, { label: '全部', value: '全部' },
{ label: '热门电影', value: '热门' }, { label: '热门电影', value: '热门' },
@ -55,7 +31,6 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
{ label: '冷门佳片', value: '冷门佳片' }, { label: '冷门佳片', value: '冷门佳片' },
]; ];
// 电影的二级选择器选项
const movieSecondaryOptions: SelectorOption[] = [ const movieSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' }, { label: '全部', value: '全部' },
{ label: '华语', value: '华语' }, { label: '华语', value: '华语' },
@ -64,13 +39,11 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
{ label: '日本', value: '日本' }, { label: '日本', value: '日本' },
]; ];
// 电视剧一级选择器选项
const tvPrimaryOptions: SelectorOption[] = [ const tvPrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' }, { label: '全部', value: '全部' },
{ label: '最近热门', value: '最近热门' }, { label: '最近热门', value: '最近热门' },
]; ];
// 电视剧二级选择器选项
const tvSecondaryOptions: SelectorOption[] = [ const tvSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: 'tv' }, { label: '全部', value: 'tv' },
{ label: '国产', value: 'tv_domestic' }, { label: '国产', value: 'tv_domestic' },
@ -81,416 +54,150 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
{ label: '纪录片', value: 'tv_documentary' }, { label: '纪录片', value: 'tv_documentary' },
]; ];
// 综艺一级选择器选项
const showPrimaryOptions: SelectorOption[] = [ const showPrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' }, { label: '全部', value: '全部' },
{ label: '最近热门', value: '最近热门' }, { label: '最近热门', value: '最近热门' },
]; ];
// 综艺二级选择器选项
const showSecondaryOptions: SelectorOption[] = [ const showSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: 'show' }, { label: '全部', value: 'show' },
{ label: '国内', value: 'show_domestic' }, { label: '国内', value: 'show_domestic' },
{ label: '国外', value: 'show_foreign' }, { label: '国外', value: 'show_foreign' },
]; ];
// 动漫一级选择器选项
const animePrimaryOptions: SelectorOption[] = [ const animePrimaryOptions: SelectorOption[] = [
{ label: '每日放送', value: '每日放送' }, { label: '每日放送', value: '每日放送' },
{ label: '番剧', value: '番剧' }, { label: '番剧', value: '番剧' },
{ label: '剧场版', value: '剧场版' }, { label: '剧场版', value: '剧场版' },
]; ];
// 处理多级选择器变化 const renderSelector = (
label: 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,
}))}
/>
);
const FilterRow = ({
label,
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>
);
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
onMultiLevelChange,
onWeekdayChange,
}) => {
const handleMultiLevelChange = (values: Record<string, string>) => { const handleMultiLevelChange = (values: Record<string, string>) => {
onMultiLevelChange?.(values); onMultiLevelChange?.(values);
}; };
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || moviePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'tv') {
const activeIndex = tvPrimaryOptions.findIndex(
(opt) => opt.value === (primarySelection || tvPrimaryOptions[1].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'anime') {
const activeIndex = animePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || animePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'show') {
const activeIndex = showPrimaryOptions.findIndex(
(opt) => opt.value === (primarySelection || showPrimaryOptions[1].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
let secondaryActiveIndex = -1;
if (type === 'movie') {
secondaryActiveIndex = movieSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
);
} else if (type === 'tv') {
secondaryActiveIndex = tvSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || tvSecondaryOptions[0].value)
);
} else if (type === 'show') {
secondaryActiveIndex = showSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || showSecondaryOptions[0].value)
);
}
if (secondaryActiveIndex >= 0) {
updateIndicatorPosition(
secondaryActiveIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [type]); // 只在type变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'tv') {
const activeIndex = tvPrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'anime') {
const activeIndex = animePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'show') {
const activeIndex = showPrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection]);
// 监听副选择器变化
useEffect(() => {
let activeIndex = -1;
let options: SelectorOption[] = [];
if (type === 'movie') {
activeIndex = movieSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = movieSecondaryOptions;
} else if (type === 'tv') {
activeIndex = tvSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = tvSecondaryOptions;
} else if (type === 'show') {
activeIndex = showSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = showSecondaryOptions;
}
if (options.length > 0) {
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: SelectorOption[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${isActive
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
return ( return (
<div className='space-y-4 sm:space-y-6'> <div className='space-y-4 sm:space-y-6'>
{/* 电影类型 - 显示两级选择器 */}
{type === 'movie' && ( {type === 'movie' && (
<div className='space-y-3 sm:space-y-4'> <div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */} <FilterRow label='分类'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> {renderSelector(
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> '电影分类',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
moviePrimaryOptions, moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value, primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange, onPrimaryChange
true
)} )}
</div> </FilterRow>
</div>
{/* 二级选择器 - 只在非"全部"时显示 */}
{primarySelection !== '全部' ? ( {primarySelection !== '全部' ? (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <FilterRow label='地区'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> {renderSelector(
'电影地区',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
movieSecondaryOptions, movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value, secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange, onSecondaryChange
false
)} )}
</div> </FilterRow>
</div>
) : ( ) : (
/* 多级选择器 - 只在选中"全部"时显示 */ <FilterRow label='筛选'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<MultiLevelSelector <MultiLevelSelector
key={`${type}-${primarySelection}`} key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange} onChange={handleMultiLevelChange}
contentType={type} contentType={type}
/> />
</div> </FilterRow>
</div>
)} )}
</div> </div>
)} )}
{/* 电视剧类型 - 显示两级选择器 */}
{type === 'tv' && ( {type === 'tv' && (
<div className='space-y-3 sm:space-y-4'> <div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */} <FilterRow label='分类'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> {renderSelector(
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> '剧集分类',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvPrimaryOptions, tvPrimaryOptions,
primarySelection || tvPrimaryOptions[1].value, primarySelection || tvPrimaryOptions[1].value,
onPrimaryChange, onPrimaryChange
true
)} )}
</div> </FilterRow>
</div>
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? ( {(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <FilterRow label='类型'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> {renderSelector(
'剧集类型',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvSecondaryOptions, tvSecondaryOptions,
secondarySelection || tvSecondaryOptions[0].value, secondarySelection || tvSecondaryOptions[0].value,
onSecondaryChange, onSecondaryChange
false
)} )}
</div> </FilterRow>
</div>
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? ( ) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
/* 多级选择器 - 只在选中"全部"时显示 */ <FilterRow label='筛选'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<MultiLevelSelector <MultiLevelSelector
key={`${type}-${primarySelection}`} key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange} onChange={handleMultiLevelChange}
contentType={type} contentType={type}
/> />
</div> </FilterRow>
</div>
) : null} ) : null}
</div> </div>
)} )}
{/* 动漫类型 - 显示一级选择器和多级选择器 */}
{type === 'anime' && ( {type === 'anime' && (
<div className='space-y-3 sm:space-y-4'> <div className='space-y-3 sm:space-y-4'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <FilterRow label='分类'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> {renderSelector(
'动漫分类',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
animePrimaryOptions, animePrimaryOptions,
primarySelection || animePrimaryOptions[0].value, primarySelection || animePrimaryOptions[0].value,
onPrimaryChange, onPrimaryChange
true
)} )}
</div> </FilterRow>
</div>
{/* 筛选部分 - 根据一级选择器显示不同内容 */}
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? ( {(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
// 每日放送分类下显示星期选择器 <FilterRow label='星期'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<WeekdaySelector onWeekdayChange={onWeekdayChange} /> <WeekdaySelector onWeekdayChange={onWeekdayChange} />
</div> </FilterRow>
</div>
) : ( ) : (
// 其他分类下显示原有的筛选功能 <FilterRow label='筛选'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> {(primarySelection || animePrimaryOptions[0].value) === '番剧' ? (
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{(primarySelection || animePrimaryOptions[0].value) ===
'番剧' ? (
<MultiLevelSelector <MultiLevelSelector
key={`anime-tv-${primarySelection}`} key={`anime-tv-${primarySelection}`}
onChange={handleMultiLevelChange} onChange={handleMultiLevelChange}
@ -503,59 +210,39 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
contentType='anime-movie' contentType='anime-movie'
/> />
)} )}
</div> </FilterRow>
</div>
)} )}
</div> </div>
)} )}
{/* 综艺类型 - 显示两级选择器 */}
{type === 'show' && ( {type === 'show' && (
<div className='space-y-3 sm:space-y-4'> <div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */} <FilterRow label='分类'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> {renderSelector(
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> '综艺分类',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showPrimaryOptions, showPrimaryOptions,
primarySelection || showPrimaryOptions[1].value, primarySelection || showPrimaryOptions[1].value,
onPrimaryChange, onPrimaryChange
true
)} )}
</div> </FilterRow>
</div>
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? ( {(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <FilterRow label='类型'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> {renderSelector(
'综艺类型',
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showSecondaryOptions, showSecondaryOptions,
secondarySelection || showSecondaryOptions[0].value, secondarySelection || showSecondaryOptions[0].value,
onSecondaryChange, onSecondaryChange
false
)} )}
</div> </FilterRow>
</div>
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? ( ) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
/* 多级选择器 - 只在选中"全部"时显示 */ <FilterRow label='筛选'>
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
<MultiLevelSelector <MultiLevelSelector
key={`${type}-${primarySelection}`} key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange} onChange={handleMultiLevelChange}
contentType={type} contentType={type}
/> />
</div> </FilterRow>
</div>
) : null} ) : null}
</div> </div>
)} )}

View File

@ -359,16 +359,16 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
); );
return ( return (
<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'> <div className='flex h-full flex-col overflow-hidden border-t border-border/70 px-4 py-0 md:ml-2'>
{/* 主要的 Tab 切换 - 无缝融入设计 */} {/* 主要的 Tab 切换 - 无缝融入设计 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'> <div className='-mx-4 mb-2 flex flex-shrink-0 border-b border-border/70 px-4'>
{totalEpisodes > 1 && ( {totalEpisodes > 1 && (
<div <div
onClick={() => setActiveTab('episodes')} onClick={() => setActiveTab('episodes')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium className={`flex-1 cursor-pointer border-b py-3 text-center text-[11px] font-medium uppercase tracking-[0.16em] transition-all duration-200
${activeTab === 'episodes' ${activeTab === 'episodes'
? 'text-blue-600 dark:text-blue-400' ? 'border-accent text-foreground'
: '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' : 'border-transparent text-muted hover:text-muted'
} }
`.trim()} `.trim()}
> >
@ -377,10 +377,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
)} )}
<div <div
onClick={handleSourceTabClick} onClick={handleSourceTabClick}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium className={`flex-1 cursor-pointer border-b py-3 text-center text-[11px] font-medium uppercase tracking-[0.16em] transition-all duration-200
${activeTab === 'sources' ${activeTab === 'sources'
? 'text-blue-600 dark:text-blue-400' ? 'border-accent text-foreground'
: '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' : 'border-transparent text-muted hover:text-muted'
} }
`.trim()} `.trim()}
> >
@ -392,7 +392,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{activeTab === 'episodes' && ( {activeTab === 'episodes' && (
<> <>
{/* 分类标签 */} {/* 分类标签 */}
<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='-mx-4 mb-4 flex flex-shrink-0 items-center gap-4 border-b border-border/70 px-4'>
<div <div
className='flex-1 overflow-x-auto' className='flex-1 overflow-x-auto'
ref={categoryContainerRef} ref={categoryContainerRef}
@ -409,16 +409,16 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
buttonRefs.current[idx] = el; buttonRefs.current[idx] = el;
}} }}
onClick={() => handleCategoryClick(idx)} onClick={() => handleCategoryClick(idx)}
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center className={`relative w-20 flex-shrink-0 py-2 text-center text-[11px] font-medium uppercase tracking-[0.14em] transition-colors whitespace-nowrap
${isActive ${isActive
? 'text-blue-500 dark:text-blue-400' ? 'text-foreground'
: 'text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400' : 'text-muted hover:text-muted'
} }
`.trim()} `.trim()}
> >
{label} {label}
{isActive && ( {isActive && (
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400' /> <div className='absolute bottom-0 left-0 right-0 h-px bg-accent' />
)} )}
</button> </button>
); );
@ -427,7 +427,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
</div> </div>
{/* 向上/向下按钮 */} {/* 向上/向下按钮 */}
<button <button
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-blue-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-blue-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]' className='a2-icon-button h-8 w-8 flex-shrink-0 translate-y-[-4px]'
onClick={() => { onClick={() => {
// 切换集数排序(正序/倒序) // 切换集数排序(正序/倒序)
setDescending((prev) => !prev); setDescending((prev) => !prev);
@ -450,7 +450,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
</div> </div>
{/* 集数网格 */} {/* 集数网格 */}
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'> <div className='content-start flex flex-1 flex-wrap gap-3 overflow-y-auto pb-4'>
{(() => { {(() => {
const len = currentEnd - currentStart + 1; const len = currentEnd - currentStart + 1;
const episodes = Array.from({ length: len }, (_, i) => const episodes = Array.from({ length: len }, (_, i) =>
@ -463,10 +463,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<button <button
key={episodeNumber} key={episodeNumber}
onClick={() => handleEpisodeClick(episodeNumber - 1)} onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono 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 ${isActive
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/25 dark:bg-blue-600' ? 'border-accent bg-accent text-accent-foreground'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-white/10 dark:text-gray-300 dark:hover:bg-white/20' : 'border-border/70 bg-surface/60 text-muted hover:border-accent/35 hover:text-foreground'
}`.trim()} }`.trim()}
> >
{(() => { {(() => {
@ -493,9 +493,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div className='flex flex-col h-full mt-4'> <div className='flex flex-col h-full mt-4'>
{sourceSearchLoading && ( {sourceSearchLoading && (
<div className='flex items-center justify-center py-8'> <div className='flex items-center justify-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div> <div className='h-px w-24 bg-border/70'>
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'> <div className='h-full w-1/2 animate-pulse bg-accent'></div>
... </div>
<span className='ml-3 text-xs uppercase tracking-[0.16em] text-muted'>
</span> </span>
</div> </div>
)} )}
@ -503,8 +505,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{sourceSearchError && ( {sourceSearchError && (
<div className='flex items-center justify-center py-8'> <div className='flex items-center justify-center py-8'>
<div className='text-center'> <div className='text-center'>
<div className='text-red-500 text-2xl mb-2'></div> <div className='mb-3 text-xs uppercase tracking-[0.16em] text-danger'>Source error</div>
<p className='text-sm text-red-600 dark:text-red-400'> <p className='text-sm text-danger'>
{sourceSearchError} {sourceSearchError}
</p> </p>
</div> </div>
@ -516,8 +518,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
availableSources.length === 0 && ( availableSources.length === 0 && (
<div className='flex items-center justify-center py-8'> <div className='flex items-center justify-center py-8'>
<div className='text-center'> <div className='text-center'>
<div className='text-gray-400 text-2xl mb-2'>📺</div> <div className='mb-3 text-xs uppercase tracking-[0.16em] text-muted'>No sources</div>
<p className='text-sm text-gray-600 dark:text-gray-300'> <p className='text-sm text-muted'>
</p> </p>
</div> </div>
@ -550,14 +552,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
onClick={() => onClick={() =>
!isCurrentSource && handleSourceClick(source) !isCurrentSource && handleSourceClick(source)
} }
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative className={`relative flex select-none items-start gap-3 border-t px-2 py-3 transition-all duration-200
${isCurrentSource ${isCurrentSource
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30 border' ? 'border-accent bg-accent/10'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer' : 'cursor-pointer border-border/70 hover:border-accent/30 hover:bg-surface/50'
}`.trim()} }`.trim()}
> >
{/* 封面 */} {/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'> <div className='h-20 w-12 flex-shrink-0 overflow-hidden border border-border/70 bg-surface/60'>
{source.episodes && source.episodes.length > 0 && ( {source.episodes && source.episodes.length > 0 && (
<img <img
src={processImageUrl(source.poster)} src={processImageUrl(source.poster)}
@ -576,14 +578,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{/* 标题和分辨率 - 顶部 */} {/* 标题和分辨率 - 顶部 */}
<div className='flex items-start justify-between gap-3 h-6'> <div className='flex items-start justify-between gap-3 h-6'>
<div className='flex-1 min-w-0 relative group/title'> <div className='flex-1 min-w-0 relative group/title'>
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'> <h3 className='truncate text-base font-medium leading-none text-foreground'>
{source.title} {source.title}
</h3> </h3>
{/* 标题级别的 tooltip - 第一个元素不显示 */} {/* 标题级别的 tooltip - 第一个元素不显示 */}
{index !== 0 && ( {index !== 0 && (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'> <div className='invisible pointer-events-none absolute bottom-full left-1/2 z-[500] mb-2 -translate-x-1/2 border border-border/70 bg-surface/95 px-3 py-1 text-xs text-foreground opacity-0 transition-all duration-200 ease-out delay-100 whitespace-nowrap group-hover/title:visible group-hover/title:opacity-100'>
{source.title} {source.title}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div> <div className='absolute left-1/2 top-full h-2 w-px -translate-x-1/2 bg-border/70'></div>
</div> </div>
)} )}
</div> </div>
@ -594,7 +596,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
if (videoInfo && videoInfo.quality !== '未知') { if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) { if (videoInfo.hasError) {
return ( return (
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'> <div className='min-w-[50px] flex-shrink-0 border border-border/70 px-1.5 py-0 text-center text-xs text-danger'>
</div> </div>
); );
@ -607,14 +609,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
videoInfo.quality videoInfo.quality
); );
const textColorClasses = isUltraHigh const textColorClasses = isUltraHigh
? 'text-purple-600 dark:text-purple-400' ? 'text-accent'
: isHigh : isHigh
? 'text-blue-600 dark:text-blue-400' ? 'text-success'
: 'text-yellow-600 dark:text-yellow-400'; : 'text-warning';
return ( return (
<div <div
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`} className={`min-w-[50px] flex-shrink-0 border border-border/70 px-1.5 py-0 text-center text-xs ${textColorClasses}`}
> >
{videoInfo.quality} {videoInfo.quality}
</div> </div>
@ -647,10 +649,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
if (!videoInfo.hasError) { if (!videoInfo.hasError) {
return ( return (
<div className='flex items-end gap-3 text-xs'> <div className='flex items-end gap-3 text-xs'>
<div className='text-blue-600 dark:text-blue-400 font-medium text-xs'> <div className='a2-data text-accent font-medium text-xs'>
{videoInfo.loadSpeed} {videoInfo.loadSpeed}
</div> </div>
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'> <div className='a2-data text-warning font-medium text-xs'>
{videoInfo.pingTime}ms {videoInfo.pingTime}ms
</div> </div>
</div> </div>
@ -678,7 +680,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
); );
} }
}} }}
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors py-2' className='a2-link-action w-full justify-center border-b-0 pt-2 text-center'
> >
</button> </button>

View File

@ -1,6 +1,8 @@
import { Radio, X } from 'lucide-react'; import { Radio } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import React, { useEffect, useState } from 'react'; import React from 'react';
import { AppButton, AppDrawer, AppScrollShadow } from './ui/HeroPrimitives';
interface ActionItem { interface ActionItem {
id: string; id: string;
@ -17,14 +19,20 @@ interface MobileActionSheetProps {
title: string; title: string;
actions: ActionItem[]; actions: ActionItem[];
poster?: string; poster?: string;
sources?: string[]; // 播放源信息 sources?: string[];
isAggregate?: boolean; // 是否为聚合内容 isAggregate?: boolean;
sourceName?: string; // 播放源名称 sourceName?: string;
currentEpisode?: number; // 当前集数 currentEpisode?: number;
totalEpisodes?: number; // 总集数 totalEpisodes?: number;
origin?: 'vod' | 'live'; origin?: 'vod' | 'live';
} }
const actionToneClass: Record<NonNullable<ActionItem['color']>, string> = {
default: 'text-foreground',
danger: 'text-danger',
primary: 'text-accent',
};
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
isOpen, isOpen,
onClose, onClose,
@ -38,311 +46,108 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
totalEpisodes, totalEpisodes,
origin = 'vod', origin = 'vod',
}) => { }) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
// 控制动画状态
useEffect(() => {
let animationId: number;
let timer: NodeJS.Timeout;
if (isOpen) {
setIsVisible(true);
// 使用双重 requestAnimationFrame 确保DOM完全渲染
animationId = requestAnimationFrame(() => {
animationId = requestAnimationFrame(() => {
setIsAnimating(true);
});
});
} else {
setIsAnimating(false);
// 等待动画完成后隐藏组件
timer = setTimeout(() => {
setIsVisible(false);
}, 200);
}
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (timer) {
clearTimeout(timer);
}
};
}, [isOpen]);
// 阻止背景滚动
useEffect(() => {
if (isVisible) {
// 保存当前滚动位置
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const body = document.body;
const html = document.documentElement;
// 获取滚动条宽度
const scrollBarWidth = window.innerWidth - html.clientWidth;
// 保存原始样式
const originalBodyStyle = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
width: body.style.width,
paddingRight: body.style.paddingRight,
overflow: body.style.overflow,
};
// 设置body样式来阻止滚动但保持原位置
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.left = `-${scrollX}px`;
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
body.style.paddingRight = `${scrollBarWidth}px`;
return () => {
// 恢复所有原始样式
body.style.position = originalBodyStyle.position;
body.style.top = originalBodyStyle.top;
body.style.left = originalBodyStyle.left;
body.style.right = originalBodyStyle.right;
body.style.width = originalBodyStyle.width;
body.style.paddingRight = originalBodyStyle.paddingRight;
body.style.overflow = originalBodyStyle.overflow;
// 使用 requestAnimationFrame 确保样式恢复后再滚动
requestAnimationFrame(() => {
window.scrollTo(scrollX, scrollY);
});
};
}
}, [isVisible]);
// ESC键关闭
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isVisible) {
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}
}, [isVisible, onClose]);
if (!isVisible) return null;
const getActionColor = (color: ActionItem['color']) => {
switch (color) {
case 'danger':
return 'text-red-600 dark:text-red-400';
case 'primary':
return 'text-blue-600 dark:text-blue-400';
default:
return 'text-gray-700 dark:text-gray-300';
}
};
const getActionHoverColor = (color: ActionItem['color']) => {
switch (color) {
case 'danger':
return 'hover:bg-red-50/50 dark:hover:bg-red-900/10';
case 'primary':
return 'hover:bg-blue-50/50 dark:hover:bg-blue-900/10';
default:
return 'hover:bg-gray-50/50 dark:hover:bg-gray-800/20';
}
};
return ( return (
<div <AppDrawer
className="fixed inset-0 z-[9999] flex items-end justify-center" isOpen={isOpen}
onTouchMove={(e) => { onOpenChange={(nextIsOpen) => {
// 阻止最外层容器的触摸移动,防止背景滚动 if (!nextIsOpen) onClose();
e.preventDefault();
e.stopPropagation();
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}} }}
title={title}
description='选择操作'
className='max-h-[86dvh]'
placement='bottom'
> >
{/* 背景遮罩 */} <div className='space-y-4'>
<div {(poster || sourceName) && (
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${isAnimating ? 'opacity-100' : 'opacity-0' <div className='flex items-center gap-3 rounded-lg border border-border/70 bg-surface-secondary/60 p-3'>
}`}
onClick={onClose}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件(包括点击)
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
backdropFilter: 'blur(4px)',
willChange: 'opacity',
touchAction: 'none', // 禁用所有触摸操作
}}
/>
{/* 操作表单 */}
<div
className="relative w-full max-w-lg mx-4 mb-4 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl transition-all duration-200 ease-out"
onTouchMove={(e) => {
// 允许操作表单内部滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
marginBottom: 'calc(1rem + env(safe-area-inset-bottom))',
willChange: 'transform, opacity',
backfaceVisibility: 'hidden', // 避免闪烁
transform: isAnimating
? 'translateY(0) translateZ(0)'
: 'translateY(100%) translateZ(0)', // 组合变换保持滑入效果和硬件加速
opacity: isAnimating ? 1 : 0,
touchAction: 'auto', // 允许操作表单内的正常触摸操作
}}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3 flex-1 min-w-0">
{poster && ( {poster && (
<div className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0"> <div className='relative h-16 w-12 flex-shrink-0 overflow-hidden rounded-md border border-border/70 bg-surface-secondary/60'>
<Image <Image
src={poster} src={poster}
alt={title} alt={title}
fill fill
className={origin === 'live' ? 'object-contain' : 'object-cover'} className={origin === 'live' ? 'object-contain' : 'object-cover'}
loading="lazy" loading='lazy'
/> />
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className='min-w-0 flex-1'>
<div className="flex items-center gap-2 mb-1"> <p className='truncate text-base font-semibold text-foreground'>{title}</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate"> {sourceName ? (
{title} <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'>
</h3> {origin === 'live' ? (
{sourceName && ( <Radio size={12} className='mr-1.5 text-accent' />
<span className="flex-shrink-0 text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800"> ) : null}
{origin === 'live' && ( <span className='truncate'>{sourceName}</span>
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
)}
{sourceName}
</span> </span>
) : null}
</div>
</div>
)} )}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
</div>
<button <div className='divide-y divide-border/10 overflow-hidden rounded-lg border border-border/70'>
onClick={onClose} {actions.map((action) => (
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150" <AppButton
> key={action.id}
<X size={20} className="text-gray-500 dark:text-gray-400" /> variant='tertiary'
</button> fullWidth
</div> isDisabled={action.disabled}
className='h-auto justify-start rounded-none px-3 py-4'
{/* 操作列表 */} onPress={() => {
<div className="px-4 py-2">
{actions.map((action, index) => (
<div key={action.id}>
<button
onClick={() => {
action.onClick(); action.onClick();
onClose(); onClose();
}} }}
disabled={action.disabled}
className={`
w-full flex items-center gap-4 py-4 px-2 transition-all duration-150 ease-out
${action.disabled
? 'opacity-50 cursor-not-allowed'
: `${getActionHoverColor(action.color)} active:scale-[0.98]`
}
`}
style={{ willChange: 'transform, background-color' }}
> >
{/* 图标 - 使用线条风格 */} <span
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0"> className={`flex h-6 w-6 flex-shrink-0 items-center justify-center ${
<span className={`transition-colors duration-150 ${action.disabled action.disabled
? 'text-gray-400 dark:text-gray-600' ? 'text-muted/60'
: getActionColor(action.color) : actionToneClass[action.color || 'default']
}`}> }`}
>
{action.icon} {action.icon}
</span> </span>
</div> <span
className={`min-w-0 flex-1 text-left text-base font-medium ${
{/* 文字 */} action.disabled ? 'text-muted/60' : 'text-foreground'
<span className={` }`}
text-left font-medium text-base flex-1 >
${action.disabled
? 'text-gray-400 dark:text-gray-600'
: 'text-gray-900 dark:text-gray-100'
}
`}>
{action.label} {action.label}
</span> </span>
{action.id === 'play' && currentEpisode && totalEpisodes ? (
{/* 播放进度 - 只在播放按钮且有播放记录时显示 */} <span className='a2-data text-xs text-muted'>
{action.id === 'play' && currentEpisode && totalEpisodes && (
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
{currentEpisode}/{totalEpisodes} {currentEpisode}/{totalEpisodes}
</span> </span>
)} ) : null}
</AppButton>
</button>
{/* 分割线 - 最后一项不显示 */}
{index < actions.length - 1 && (
<div className="border-b border-gray-100 dark:border-gray-800 ml-10"></div>
)}
</div>
))} ))}
</div> </div>
{/* 播放源信息展示区域 */} {isAggregate && sources && sources.length > 0 ? (
{isAggregate && sources && sources.length > 0 && ( <div className='rounded-lg border border-border/70 p-3'>
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800"> <div className='mb-3'>
{/* 标题区域 */} <h4 className='mb-1 text-sm font-medium text-foreground'></h4>
<div className="mb-3"> <p className='a2-kicker'> {sources.length} </p>
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{sources.length}
</p>
</div> </div>
<AppScrollShadow className='max-h-32'>
{/* 播放源列表 */} <div className='grid grid-cols-2 gap-2'>
<div className="max-h-32 overflow-y-auto"> {sources.map((source) => (
<div className="grid grid-cols-2 gap-2">
{sources.map((source, index) => (
<div <div
key={index} key={source}
className="flex items-center gap-2 py-2 px-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/30" className='flex min-w-0 items-center gap-2 border-l border-border/70 px-3 py-2'
> >
<div className="w-1 h-1 bg-gray-400 dark:bg-gray-500 rounded-full flex-shrink-0" /> <div className='h-1.5 w-1.5 flex-shrink-0 bg-accent/80' />
<span className="text-xs text-gray-600 dark:text-gray-400 truncate"> <span className='truncate text-xs text-muted'>
{source} {source}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</AppScrollShadow>
</div> </div>
) : null}
</div> </div>
)} </AppDrawer>
</div>
</div>
); );
}; };

View File

@ -96,7 +96,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
return ( return (
<nav <nav
className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-gray-700/50' 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'
style={{ style={{
/* 紧贴视口底部,同时在内部留出安全区高度 */ /* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0, bottom: 0,
@ -115,19 +115,20 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
> >
<Link <Link
href={item.href} href={item.href}
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs' className='theme-transition relative flex h-14 w-full flex-col items-center justify-center gap-1 text-xs font-medium tracking-normal'
> >
{active && <span className='absolute left-3 right-3 top-1 h-1 rounded-full bg-accent' />}
<item.icon <item.icon
className={`h-6 w-6 ${active className={`h-5 w-5 ${active
? 'text-blue-600 dark:text-blue-400' ? 'text-accent'
: 'text-gray-500 dark:text-gray-400' : 'text-muted'
}`} }`}
/> />
<span <span
className={ className={
active active
? 'text-blue-600 dark:text-blue-400' ? 'text-foreground'
: 'text-gray-600 dark:text-gray-300' : 'text-muted'
} }
> >
{item.label} {item.label}

View File

@ -14,13 +14,13 @@ interface MobileHeaderProps {
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => { const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite(); const { siteName } = useSite();
return ( return (
<header className='md:hidden fixed top-0 left-0 right-0 z-[999] w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'> <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'>
<div className='h-12 flex items-center justify-between px-4'> <div className='flex h-12 items-center justify-between px-4'>
{/* 左侧:搜索按钮、返回按钮和设置按钮 */} {/* 左侧:搜索按钮、返回按钮和设置按钮 */}
<div className='flex items-center gap-1'> <div className='flex items-center gap-2'>
<Link <Link
href='/search' href='/search'
className='w-8 h-8 p-1.5 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors' className='a2-icon-button h-8 w-8 p-1.5'
> >
<svg <svg
className='w-full h-full' className='w-full h-full'
@ -51,7 +51,7 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'> <div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link <Link
href='/' href='/'
className='text-2xl font-bold text-blue-600 tracking-tight hover:opacity-80 transition-opacity' className='theme-transition text-lg font-semibold tracking-normal text-foreground hover:text-accent'
> >
{siteName} {siteName}
</Link> </Link>

View File

@ -1,7 +1,9 @@
'use client'; 'use client';
import React, { useEffect, useRef, useState } from 'react'; import { Dropdown, Label } from '@heroui/react';
import { createPortal } from 'react-dom'; import React, { useState } from 'react';
import { AppButton } from './ui/HeroPrimitives';
interface MultiLevelOption { interface MultiLevelOption {
label: string; label: string;
@ -24,15 +26,7 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
onChange, onChange,
contentType = 'movie', contentType = 'movie',
}) => { }) => {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{
x: number;
y: number;
width: number;
}>({ x: 0, y: 0, width: 0 });
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
const dropdownRef = useRef<HTMLDivElement>(null);
// 根据内容类型获取对应的类型选项 // 根据内容类型获取对应的类型选项
const getTypeOptions = ( const getTypeOptions = (
@ -333,54 +327,6 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
}, },
]; ];
// 计算下拉框位置
const calculateDropdownPosition = (categoryKey: string) => {
const element = categoryRefs.current[categoryKey];
if (element) {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth < 768; // md breakpoint
let x = rect.left;
let dropdownWidth = Math.max(rect.width, 300);
let useFixedWidth = false; // 标记是否使用固定宽度
// 移动端优化:防止下拉框被右侧视口截断
if (isMobile) {
const padding = 16; // 左右各留16px的边距
const maxWidth = viewportWidth - padding * 2;
dropdownWidth = Math.min(dropdownWidth, maxWidth);
useFixedWidth = true; // 移动端使用固定宽度
// 如果右侧超出视口则调整x位置
if (x + dropdownWidth > viewportWidth - padding) {
x = viewportWidth - dropdownWidth - padding;
}
// 如果左侧超出视口,则贴左边
if (x < padding) {
x = padding;
}
}
setDropdownPosition({
x,
y: rect.bottom,
width: useFixedWidth ? dropdownWidth : rect.width, // PC端保持原有逻辑
});
}
};
// 处理分类点击
const handleCategoryClick = (categoryKey: string) => {
if (activeCategory === categoryKey) {
setActiveCategory(null);
} else {
setActiveCategory(categoryKey);
calculateDropdownPosition(categoryKey);
}
};
// 处理选项选择 // 处理选项选择
const handleOptionSelect = (categoryKey: string, optionValue: string) => { const handleOptionSelect = (categoryKey: string, optionValue: string) => {
// 更新本地状态 // 更新本地状态
@ -419,7 +365,6 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
// 调用父组件的回调,传递处理后的选择值 // 调用父组件的回调,传递处理后的选择值
onChange(selectionsForParent); onChange(selectionsForParent);
setActiveCategory(null);
}; };
// 获取显示文本 // 获取显示文本
@ -460,75 +405,22 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
return value === optionValue; return value === optionValue;
}; };
// 监听滚动和窗口大小变化事件
useEffect(() => {
const handleScroll = () => {
// 滚动时直接关闭面板,而不是重新计算位置
if (activeCategory) {
setActiveCategory(null);
}
};
const handleResize = () => {
if (activeCategory) {
calculateDropdownPosition(activeCategory);
}
};
// 监听 body 滚动事件,因为该项目的滚动容器是 document.body
document.body.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
return () => {
document.body.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, [activeCategory]);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!Object.values(categoryRefs.current).some(
(ref) => ref && ref.contains(event.target as Node)
)
) {
setActiveCategory(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return ( return (
<> <div className='app-filter-dropdowns'>
{/* 胶囊样式筛选栏 */}
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
{categories.map((category) => ( {categories.map((category) => (
<div <Dropdown key={category.key}>
key={category.key} <AppButton
ref={(el) => { aria-label={`${category.label}筛选`}
categoryRefs.current[category.key] = el; variant='tertiary'
}} className={`app-filter-trigger ${
className='relative' isDefaultValue(category.key)
> ? ''
<button : 'app-filter-trigger-active'
onClick={() => handleCategoryClick(category.key)}
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${activeCategory === category.key
? isDefaultValue(category.key)
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-green-600 dark:text-green-400 cursor-default'
: isDefaultValue(category.key)
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 cursor-pointer'
}`} }`}
> >
<span>{getDisplayText(category.key)}</span> <span>{getDisplayText(category.key)}</span>
<svg <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 ${activeCategory === category.key ? 'rotate-180' : '' className='ml-0.5 inline-block h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3'
}`}
fill='none' fill='none'
stroke='currentColor' stroke='currentColor'
viewBox='0 0 24 24' viewBox='0 0 24 24'
@ -540,51 +432,40 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
d='M19 9l-7 7-7-7' d='M19 9l-7 7-7-7'
/> />
</svg> </svg>
</button> </AppButton>
</div> <Dropdown.Popover className='w-[min(92vw,600px)]'>
))} <Dropdown.Menu
</div> aria-label={`${category.label}选项`}
selectionMode='single'
{/* 展开的筛选选项 - 悬浮显示 */} selectedKeys={
{activeCategory && new Set([
createPortal( values[category.key] ||
<div (category.key === 'sort' ? 'T' : 'all'),
ref={dropdownRef} ])
className='fixed z-[9999] bg-white/95 dark:bg-gray-800/95 rounded-xl border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm'
style={{
left: `${dropdownPosition.x}px`,
top: `${dropdownPosition.y}px`,
...(window.innerWidth < 768
? { width: `${dropdownPosition.width}px` } // 移动端使用固定宽度
: { minWidth: `${Math.max(dropdownPosition.width, 300)}px` }), // PC端使用最小宽度
maxWidth: '600px',
position: 'fixed',
}}
>
<div className='p-2 sm:p-4'>
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1 sm:gap-2'>
{categories
.find((cat) => cat.key === activeCategory)
?.options.map((option) => (
<button
key={option.value}
onClick={() =>
handleOptionSelect(activeCategory, option.value)
} }
className={`px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm rounded-lg transition-all duration-200 text-left ${isOptionSelected(activeCategory, option.value) onAction={(key) => handleOptionSelect(category.key, String(key))}
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-700' className='grid grid-cols-3 gap-1 p-2 sm:grid-cols-4 sm:gap-2 md:grid-cols-5'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-700/80'
}`}
> >
{option.label} {category.options.map((option) => (
</button> <Dropdown.Item
key={option.value}
id={option.value}
textValue={option.label}
className={
isOptionSelected(category.key, option.value)
? 'a2-selector-option-active'
: ''
}
>
<Label>{option.label}</Label>
<Dropdown.ItemIndicator />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
))} ))}
</div> </div>
</div>
</div>,
document.body
)}
</>
); );
}; };

View File

@ -12,12 +12,12 @@ interface PageLayoutProps {
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
return ( return (
<div className='w-full min-h-screen'> <div className='w-full min-h-[100dvh] bg-transparent text-foreground'>
{/* 移动端头部 */} {/* 移动端头部 */}
<MobileHeader showBackButton={['/play', '/live'].includes(activePath)} /> <MobileHeader showBackButton={['/play', '/live'].includes(activePath)} />
{/* 主要布局容器 */} {/* 主要布局容器 */}
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'> <div className='flex w-full md:grid md:min-h-auto md:grid-cols-[auto_1fr]'>
{/* 侧边栏 - 桌面端显示,移动端隐藏 */} {/* 侧边栏 - 桌面端显示,移动端隐藏 */}
<div className='hidden md:block'> <div className='hidden md:block'>
<Sidebar activePath={activePath} /> <Sidebar activePath={activePath} />
@ -27,20 +27,20 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
<div className='relative min-w-0 flex-1 transition-all duration-300'> <div className='relative min-w-0 flex-1 transition-all duration-300'>
{/* 桌面端左上角返回按钮 */} {/* 桌面端左上角返回按钮 */}
{['/play', '/live'].includes(activePath) && ( {['/play', '/live'].includes(activePath) && (
<div className='absolute top-3 left-1 z-20 hidden md:flex'> <div className='absolute left-2 top-4 z-20 hidden md:flex'>
<BackButton /> <BackButton />
</div> </div>
)} )}
{/* 桌面端顶部按钮 */} {/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'> <div className='absolute right-6 top-4 z-20 hidden items-center gap-3 md:flex'>
<ThemeToggle /> <ThemeToggle />
<UserMenu /> <UserMenu />
</div> </div>
{/* 主内容 */} {/* 主内容 */}
<main <main
className='flex-1 md:min-h-0 mb-14 md:mb-0 md:mt-0 mt-12' className='mb-14 mt-12 flex-1 md:mb-0 md:mt-0 md:min-h-0'
style={{ style={{
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))', paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
}} }}

View File

@ -102,7 +102,7 @@ export default function ScrollableRow({
> >
<div <div
ref={containerRef} ref={containerRef}
className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14 px-4 sm:px-6' className='scrollbar-hide flex space-x-4 overflow-x-auto px-1 py-2 pb-12 sm:space-x-5 sm:pb-14'
onScroll={checkScroll} onScroll={checkScroll}
> >
{children} {children}
@ -128,9 +128,9 @@ export default function ScrollableRow({
> >
<button <button
onClick={handleScrollLeftClick} onClick={handleScrollLeftClick}
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600' 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'
> >
<ChevronLeft className='w-6 h-6 text-gray-600 dark:text-gray-300' /> <ChevronLeft className='h-5 w-5' />
</button> </button>
</div> </div>
</div> </div>
@ -157,9 +157,9 @@ export default function ScrollableRow({
> >
<button <button
onClick={handleScrollRightClick} onClick={handleScrollRightClick}
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600' 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'
> >
<ChevronRight className='w-6 h-6 text-gray-600 dark:text-gray-300' /> <ChevronRight className='h-5 w-5' />
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,8 +1,10 @@
'use client'; 'use client';
import { Dropdown, Label, ScrollShadow } from '@heroui/react';
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react'; import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useMemo } from 'react';
import { createPortal } from 'react-dom';
import { AppButton } from './ui/HeroPrimitives';
export type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder'; export type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder';
@ -31,11 +33,6 @@ const DEFAULTS: Record<SearchFilterKey, string> = {
}; };
const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, values, onChange }) => { const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, values, onChange }) => {
const [activeCategory, setActiveCategory] = useState<SearchFilterKey | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ x: number; y: number; width: number }>({ x: 0, y: 0, width: 0 });
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
const dropdownRef = useRef<HTMLDivElement>(null);
const mergedValues = useMemo(() => { const mergedValues = useMemo(() => {
return { return {
...DEFAULTS, ...DEFAULTS,
@ -43,53 +40,12 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
} as Record<SearchFilterKey, string>; } as Record<SearchFilterKey, string>;
}, [values]); }, [values]);
const calculateDropdownPosition = (categoryKey: SearchFilterKey) => {
const element = categoryRefs.current[categoryKey];
if (element) {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth < 768;
let x = rect.left;
// 为标题筛选设置更大的最小宽度,其他保持原来的最小宽度
const minWidth = categoryKey === 'title' ? 400 : 240;
let dropdownWidth = Math.max(rect.width, minWidth);
let useFixedWidth = false;
if (isMobile) {
const padding = 16;
const maxWidth = viewportWidth - padding * 2;
dropdownWidth = Math.min(dropdownWidth, maxWidth);
useFixedWidth = true;
if (x + dropdownWidth > viewportWidth - padding) {
x = viewportWidth - dropdownWidth - padding;
}
if (x < padding) {
x = padding;
}
}
setDropdownPosition({ x, y: rect.bottom, width: useFixedWidth ? dropdownWidth : rect.width });
}
};
const handleCategoryClick = (categoryKey: SearchFilterKey) => {
if (activeCategory === categoryKey) {
setActiveCategory(null);
} else {
setActiveCategory(categoryKey);
calculateDropdownPosition(categoryKey);
}
};
const handleOptionSelect = (categoryKey: SearchFilterKey, optionValue: string) => { const handleOptionSelect = (categoryKey: SearchFilterKey, optionValue: string) => {
const newValues = { const newValues = {
...mergedValues, ...mergedValues,
[categoryKey]: optionValue, [categoryKey]: optionValue,
} as Record<SearchFilterKey, string>; } as Record<SearchFilterKey, string>;
onChange(newValues); onChange(newValues);
setActiveCategory(null);
}; };
const getDisplayText = (categoryKey: SearchFilterKey) => { const getDisplayText = (categoryKey: SearchFilterKey) => {
@ -111,66 +67,57 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
return value === optionValue; return value === optionValue;
}; };
useEffect(() => {
const handleScroll = () => {
// 滚动时直接关闭面板,而不是重新计算位置
if (activeCategory) {
setActiveCategory(null);
}
};
const handleResize = () => {
if (activeCategory) calculateDropdownPosition(activeCategory);
};
// 监听 body 滚动事件,因为该项目的滚动容器是 document.body
document.body.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
return () => {
document.body.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, [activeCategory]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!Object.values(categoryRefs.current).some((ref) => ref && ref.contains(event.target as Node))
) {
setActiveCategory(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return ( return (
<> <div className='app-search-filter-bar'>
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
{categories.map((category) => ( {categories.map((category) => (
<div key={category.key} ref={(el) => { categoryRefs.current[category.key] = el; }} className='relative'> <Dropdown key={category.key}>
<button <AppButton
onClick={() => handleCategoryClick(category.key)} variant='tertiary'
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${activeCategory === category.key className={`app-search-filter-trigger ${
? isDefaultValue(category.key) isDefaultValue(category.key) ? '' : 'app-search-filter-trigger-active'
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-blue-600 dark:text-blue-400 cursor-default'
: isDefaultValue(category.key)
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
: 'text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 cursor-pointer'
}`} }`}
> >
<span>{getDisplayText(category.key)}</span> <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 ${activeCategory === category.key ? 'rotate-180' : ''}`} fill='none' stroke='currentColor' viewBox='0 0 24 24'> <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' /> <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M19 9l-7 7-7-7' />
</svg> </svg>
</button> </AppButton>
</div> <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'> <div className='relative'>
<button <AppButton
onClick={() => { variant='ghost'
onPress={() => {
let next; let next;
switch (mergedValues.yearOrder) { switch (mergedValues.yearOrder) {
case 'none': case 'none':
@ -187,9 +134,10 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
} }
onChange({ ...mergedValues, yearOrder: next }); onChange({ ...mergedValues, yearOrder: next });
}} }}
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${mergedValues.yearOrder === 'none' className={`app-search-filter-trigger ${
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer' mergedValues.yearOrder === 'none'
: 'text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 cursor-pointer' ? ''
: 'app-search-filter-trigger-active'
}`} }`}
aria-label={`按年份${mergedValues.yearOrder === 'none' ? '排序' : mergedValues.yearOrder === 'desc' ? '降序' : '升序'}排序`} aria-label={`按年份${mergedValues.yearOrder === 'none' ? '排序' : mergedValues.yearOrder === 'desc' ? '降序' : '升序'}排序`}
> >
@ -201,45 +149,10 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
) : ( ) : (
<ArrowUpNarrowWide 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' />
)} )}
</button> </AppButton>
</div> </div>
</div> </div>
{activeCategory && createPortal(
<div
ref={dropdownRef}
className='fixed z-[9999] bg-white/95 dark:bg-gray-800/95 rounded-xl border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm max-h-[50vh] flex flex-col'
style={{
left: `${dropdownPosition.x}px`,
top: `${dropdownPosition.y}px`,
...(typeof window !== 'undefined' && window.innerWidth < 768 ? { width: `${dropdownPosition.width}px` } : { minWidth: `${Math.max(dropdownPosition.width, activeCategory === 'title' ? 400 : 240)}px` }),
maxWidth: '600px',
position: 'fixed',
}}
>
<div className='p-2 sm:p-4 overflow-y-auto flex-1 min-h-0'>
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1 sm:gap-2'>
{categories.find((cat) => cat.key === activeCategory)?.options.map((option) => (
<button
key={option.value}
onClick={() => handleOptionSelect(activeCategory, option.value)}
className={`px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm rounded-lg transition-all duration-200 text-left ${isOptionSelected(activeCategory, option.value)
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-700/80'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>,
document.body
)}
</>
); );
}; };
export default SearchResultFilter; export default SearchResultFilter;

View File

@ -1,8 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { getShortDramaCategories, ShortDramaCategory } from '@/lib/shortdrama.client'; import { getShortDramaCategories, ShortDramaCategory } from '@/lib/shortdrama.client';
import { AppFilterTabs } from './ui/HeroPrimitives';
interface ShortDramaSelectorProps { interface ShortDramaSelectorProps {
selectedCategory: string; selectedCategory: string;
onCategoryChange: (category: string) => void; onCategoryChange: (category: string) => void;
@ -15,14 +18,6 @@ const ShortDramaSelector = ({
const [categories, setCategories] = useState<ShortDramaCategory[]>([]); const [categories, setCategories] = useState<ShortDramaCategory[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 胶囊选择器相关状态
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 获取分类数据 // 获取分类数据
useEffect(() => { useEffect(() => {
const fetchCategories = async () => { const fetchCategories = async () => {
@ -54,52 +49,15 @@ const ShortDramaSelector = ({
fetchCategories(); fetchCategories();
}, []); }, []);
// 更新指示器位置
const updateIndicatorPosition = () => {
const activeIndex = categories.findIndex(
(cat) => cat.type_id.toString() === selectedCategory
);
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 当分类数据加载完成或选中项变化时更新指示器位置
useEffect(() => {
if (!loading && categories.length > 0) {
updateIndicatorPosition();
}
}, [loading, categories, selectedCategory]);
// 渲染胶囊式选择器 // 渲染胶囊式选择器
const renderCapsuleSelector = () => { const renderCapsuleSelector = () => {
if (loading) { if (loading) {
return ( return (
<div className='flex flex-wrap gap-2'> <div className='inline-flex rounded-full bg-surface-secondary p-1'>
{Array.from({ length: 8 }).map((_, index) => ( {Array.from({ length: 8 }).map((_, index) => (
<div <div
key={index} key={index}
className='h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse' className='mx-0.5 h-8 w-16 rounded-full bg-surface-tertiary animate-pulse'
/> />
))} ))}
</div> </div>
@ -107,51 +65,26 @@ const ShortDramaSelector = ({
} }
return ( return (
<div <AppFilterTabs
ref={containerRef} ariaLabel='短剧分类'
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm' selectedKey={selectedCategory}
> onSelectionChange={onCategoryChange}
{/* 滑动的白色背景指示器 */} items={categories.map((category) => ({
{indicatorStyle.width > 0 && ( key: category.type_id.toString(),
<div label: category.type_name,
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out' }))}
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/> />
)}
{categories.map((category, index) => {
const isActive = selectedCategory === category.type_id.toString();
return (
<button
key={category.type_id}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onCategoryChange(category.type_id.toString())}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${isActive
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{category.type_name}
</button>
);
})}
</div>
); );
}; };
return ( return (
<div className='space-y-4 sm:space-y-6'> <div className='space-y-4 sm:space-y-6'>
{/* 分类选择 */} {/* 分类选择 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'> <div className='app-filter-row'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'> <span className='app-filter-label'>
</span> </span>
<div className='overflow-x-auto'> <div className='min-w-0'>
{renderCapsuleSelector()} {renderCapsuleSelector()}
</div> </div>
</div> </div>

View File

@ -2,7 +2,19 @@
'use client'; 'use client';
import { Cat, Clover, Film, Home, Menu, PlayCircle, Radio, Search, Star, Tv, ExternalLink } from 'lucide-react'; import {
Cat,
Clover,
ExternalLink,
Film,
Home,
Menu,
PlayCircle,
Radio,
Search,
Star,
Tv,
} from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
@ -40,7 +52,7 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className='flex items-center justify-center w-12 h-12 hover:opacity-80 transition-opacity duration-200 cursor-pointer' className='theme-transition flex h-12 w-12 cursor-pointer items-center justify-center hover:opacity-80'
title='点击展开侧边栏' title='点击展开侧边栏'
> >
<Image <Image
@ -57,7 +69,7 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
return ( return (
<Link <Link
href='/' href='/'
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200' className='theme-transition flex h-16 items-center justify-center select-none hover:opacity-80'
> >
<div className='flex items-center gap'> <div className='flex items-center gap'>
<Image <Image
@ -67,7 +79,7 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
height={40} height={40}
className='rounded-lg' className='rounded-lg'
/> />
<span className='text-2xl font-bold text-blue-600 tracking-tight'> <span className='text-xl font-semibold tracking-normal text-foreground'>
{siteName} {siteName}
</span> </span>
</div> </div>
@ -205,27 +217,30 @@ 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'
}`;
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
{/* 在移动端隐藏侧边栏 */} {/* 在移动端隐藏侧边栏 */}
<div className='hidden md:flex'> <div className='hidden md:flex'>
<aside <aside
data-sidebar data-sidebar
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${isCollapsed ? 'w-16' : 'w-64' 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'
}`} }`}
style={{
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
> >
<div className='flex h-full flex-col'> <div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */} {/* 顶部 Logo 区域 */}
<div className='relative h-16'> <div className='relative h-16 border-b border-border/70'>
<div className='absolute inset-0 flex items-center justify-center transition-all duration-200'> <div className='absolute inset-0 flex items-center justify-center transition-all duration-200'>
{isCollapsed ? ( {isCollapsed ? (
<Logo isCollapsed={true} onClick={handleToggle} /> <Logo isCollapsed={true} onClick={handleToggle} />
) : ( ) : (
<div className='w-[calc(100%-4rem)] flex justify-center'> <div className='flex w-[calc(100%-4rem)] justify-center'>
<Logo isCollapsed={false} /> <Logo isCollapsed={false} />
</div> </div>
)} )}
@ -233,7 +248,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
{!isCollapsed && ( {!isCollapsed && (
<button <button
onClick={handleToggle} onClick={handleToggle}
className='absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50' className='a2-icon-button absolute right-3 top-1/2 z-10 -translate-y-1/2'
title='收起侧边栏' title='收起侧边栏'
> >
<Menu className='h-4 w-4' /> <Menu className='h-4 w-4' />
@ -242,19 +257,18 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
</div> </div>
{/* 首页和搜索导航 */} {/* 首页和搜索导航 */}
<nav className='px-2 mt-4 space-y-1'> <nav className='mt-6 space-y-1 px-3'>
<Link <Link
href='/' href='/'
onClick={() => setActive('/')} onClick={() => setActive('/')}
data-active={active === '/'} data-active={active === '/'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-blue-600 data-[active=true]:bg-blue-500/20 data-[active=true]:text-blue-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-blue-400 dark:data-[active=true]:bg-blue-500/10 dark:data-[active=true]:text-blue-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0' className={getNavClasses(active === '/')}
} gap-3 justify-start`}
> >
<div className='w-4 h-4 flex items-center justify-center'> <div className='w-4 h-4 flex items-center justify-center'>
<Home className='h-4 w-4 text-gray-500 group-hover:text-blue-600 group-data-[active=true]:text-blue-700 dark:text-gray-400 dark:group-hover:text-blue-400 dark:group-data-[active=true]:text-blue-400' /> <Home className='h-4 w-4' />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'> <span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
</span> </span>
)} )}
@ -267,14 +281,13 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
setActive('/search'); setActive('/search');
}} }}
data-active={active === '/search'} data-active={active === '/search'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-blue-600 data-[active=true]:bg-blue-500/20 data-[active=true]:text-blue-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-blue-400 dark:data-[active=true]:bg-blue-500/10 dark:data-[active=true]:text-blue-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0' className={getNavClasses(active === '/search')}
} gap-3 justify-start`}
> >
<div className='w-4 h-4 flex items-center justify-center'> <div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4 text-gray-500 group-hover:text-blue-600 group-data-[active=true]:text-blue-700 dark:text-gray-400 dark:group-hover:text-blue-400 dark:group-data-[active=true]:text-blue-400' /> <Search className='h-4 w-4' />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'> <span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
</span> </span>
)} )}
@ -282,8 +295,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
</nav> </nav>
{/* 菜单项 */} {/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-2 pt-4'> <div className='flex-1 overflow-y-auto px-3 pt-6'>
<div className='space-y-1'> <div className='space-y-1 border-t border-border/70 pt-4'>
{menuItems.map((item) => { {menuItems.map((item) => {
// 检查当前路径是否匹配这个菜单项 // 检查当前路径是否匹配这个菜单项
const typeMatch = item.href.match(/type=([^&]+)/)?.[1]; const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
@ -304,14 +317,13 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
href={item.href} href={item.href}
onClick={() => setActive(item.href)} onClick={() => setActive(item.href)}
data-active={isActive} data-active={isActive}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-blue-600 data-[active=true]:bg-blue-500/20 data-[active=true]:text-blue-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-blue-400 dark:data-[active=true]:bg-blue-500/10 dark:data-[active=true]:text-blue-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0' className={getNavClasses(isActive)}
} gap-3 justify-start`}
> >
<div className='w-4 h-4 flex items-center justify-center'> <div className='w-4 h-4 flex items-center justify-center'>
<Icon className='h-4 w-4 text-gray-500 group-hover:text-blue-600 group-data-[active=true]:text-blue-700 dark:text-gray-400 dark:group-hover:text-blue-400 dark:group-data-[active=true]:text-blue-400' /> <Icon className='h-4 w-4' />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'> <span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
{item.label} {item.label}
</span> </span>
)} )}
@ -322,20 +334,20 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
</div> </div>
{/* 致谢信息 */} {/* 致谢信息 */}
<div className='px-2 pb-4'> <div className='px-3 pb-5'>
<div className='border-t border-gray-200/50 dark:border-gray-700/50 pt-3'> <div className='border-t border-border/70 pt-4'>
{!isCollapsed ? ( {!isCollapsed ? (
<div className='text-xs text-gray-500 dark:text-gray-400 text-center px-2 leading-relaxed'> <div className='px-2 text-center text-xs leading-relaxed text-muted'>
<span> </span> <span> </span>
<button <button
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')} onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
className='text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors' className='theme-transition font-medium text-accent hover:text-accent-strong'
> >
MoonTV MoonTV
</button> </button>
<button <button
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')} onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
className='text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ml-1' className='theme-transition ml-1 text-accent hover:text-accent-strong'
title='访问 MoonTV 项目' title='访问 MoonTV 项目'
> >
<ExternalLink className='h-3 w-3 inline' /> <ExternalLink className='h-3 w-3 inline' />
@ -346,7 +358,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
<div className='flex justify-center'> <div className='flex justify-center'>
<button <button
onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')} onClick={() => window.open('https://github.com/MoonTechLab/LunaTV', '_blank')}
className='text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors p-1' className='theme-transition p-1 text-accent hover:text-accent-strong'
title='基于 MoonTV 的二次开发' title='基于 MoonTV 的二次开发'
> >
<ExternalLink className='h-4 w-4' /> <ExternalLink className='h-4 w-4' />

View File

@ -2,11 +2,12 @@
'use client'; 'use client';
import { Moon, Sun, MessageCircle } from 'lucide-react'; import { MessageCircle, Moon, Sun } from 'lucide-react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { ChatModal } from './ChatModal'; import { ChatModal } from './ChatModal';
import { AppIconButton } from './ui/HeroPrimitives';
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
import { WebSocketMessage } from '../lib/types'; import { WebSocketMessage } from '../lib/types';
@ -46,10 +47,10 @@ export function ThemeToggle() {
if (!meta) { if (!meta) {
const meta = document.createElement('meta'); const meta = document.createElement('meta');
meta.name = 'theme-color'; meta.name = 'theme-color';
meta.content = theme === 'dark' ? '#0c111c' : '#f9fbfe'; meta.content = theme === 'dark' ? '#080707' : '#151212';
document.head.appendChild(meta); document.head.appendChild(meta);
} else { } else {
meta.setAttribute('content', theme === 'dark' ? '#0c111c' : '#f9fbfe'); meta.setAttribute('content', theme === 'dark' ? '#080707' : '#151212');
} }
}; };
@ -102,24 +103,24 @@ export function ThemeToggle() {
<div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}> <div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
{/* 聊天按钮 - 在登录页面不显示 */} {/* 聊天按钮 - 在登录页面不显示 */}
{!isLoginPage && ( {!isLoginPage && (
<button <AppIconButton
onClick={() => setIsChatModalOpen(true)} onPress={() => setIsChatModalOpen(true)}
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative`} className={`a2-icon-button relative ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
aria-label='Open chat' aria-label='Open chat'
> >
<MessageCircle className='w-full h-full' /> <MessageCircle className='w-full h-full' />
{messageCount > 0 && ( {messageCount > 0 && (
<span className={`absolute ${isMobile ? '-top-0.5 -right-0.5 w-4 h-4 text-xs' : '-top-1 -right-1 w-5 h-5 text-xs'} bg-red-500 text-white rounded-full flex items-center justify-center`}> <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} {messageCount > 99 ? '99+' : messageCount}
</span> </span>
)} )}
</button> </AppIconButton>
)} )}
{/* 主题切换按钮 */} {/* 主题切换按钮 */}
<button <AppIconButton
onClick={toggleTheme} onPress={toggleTheme}
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors`} className={`a2-icon-button ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
aria-label='Toggle theme' aria-label='Toggle theme'
> >
{resolvedTheme === 'dark' ? ( {resolvedTheme === 'dark' ? (
@ -127,7 +128,7 @@ export function ThemeToggle() {
) : ( ) : (
<Moon className='w-full h-full' /> <Moon className='w-full h-full' />
)} )}
</button> </AppIconButton>
</div> </div>
{/* 聊天模态框 - 在登录页面不渲染 */} {/* 聊天模态框 - 在登录页面不渲染 */}

View File

@ -1,13 +1,11 @@
'use client'; 'use client';
import React, { createContext, useContext, useCallback, useState } from 'react'; import { Toast as HeroToast } from '@heroui/react';
import { createPortal } from 'react-dom'; import React, { createContext, useCallback, useContext, useMemo } from 'react';
import { AlertCircle, CheckCircle, Info, X, XCircle } from 'lucide-react';
type ToastType = 'success' | 'error' | 'warning' | 'info'; type ToastType = 'success' | 'error' | 'warning' | 'info';
interface Toast { interface Toast {
id: string;
type: ToastType; type: ToastType;
title: string; title: string;
message?: string; message?: string;
@ -15,7 +13,7 @@ interface Toast {
} }
interface ToastContextType { interface ToastContextType {
showToast: (toast: Omit<Toast, 'id'>) => void; showToast: (toast: Toast) => void;
showSuccess: (title: string, message?: string) => void; showSuccess: (title: string, message?: string) => void;
showError: (title: string, message?: string) => void; showError: (title: string, message?: string) => void;
showWarning: (title: string, message?: string) => void; showWarning: (title: string, message?: string) => void;
@ -36,135 +34,73 @@ interface ToastProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
const showHeroToast = ({ type, title, message, duration }: Toast) => {
const options = {
description: message,
timeout: duration,
};
switch (type) {
case 'success':
HeroToast.toast.success(title, options);
break;
case 'error':
HeroToast.toast.danger(title, options);
break;
case 'warning':
HeroToast.toast.warning(title, options);
break;
case 'info':
default:
HeroToast.toast.info(title, options);
break;
}
};
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => { export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]); const showToast = useCallback((toast: Toast) => {
const [mounted, setMounted] = useState(false); showHeroToast(toast);
const [isMobile, setIsMobile] = useState(false);
React.useEffect(() => {
setMounted(true);
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}, []); }, []);
const removeToast = useCallback((id: string) => { const showSuccess = useCallback(
setToasts(prev => prev.filter(toast => toast.id !== id)); (title: string, message?: string) =>
}, []); showToast({ type: 'success', title, message }),
[showToast]
);
const showToast = useCallback((toast: Omit<Toast, 'id'>) => { const showError = useCallback(
const id = Math.random().toString(36).substring(2, 9); (title: string, message?: string) =>
const newToast = { ...toast, id }; showToast({ type: 'error', title, message }),
[showToast]
);
setToasts(prev => [...prev, newToast]); const showWarning = useCallback(
(title: string, message?: string) =>
showToast({ type: 'warning', title, message }),
[showToast]
);
// 自动移除toast const showInfo = useCallback(
const duration = toast.duration || 5000; (title: string, message?: string) =>
setTimeout(() => { showToast({ type: 'info', title, message }),
removeToast(id); [showToast]
}, duration); );
}, [removeToast]);
const showSuccess = useCallback((title: string, message?: string) => { const contextValue = useMemo<ToastContextType>(
showToast({ type: 'success', title, message }); () => ({
}, [showToast]);
const showError = useCallback((title: string, message?: string) => {
showToast({ type: 'error', title, message });
}, [showToast]);
const showWarning = useCallback((title: string, message?: string) => {
showToast({ type: 'warning', title, message });
}, [showToast]);
const showInfo = useCallback((title: string, message?: string) => {
showToast({ type: 'info', title, message });
}, [showToast]);
const contextValue: ToastContextType = {
showToast, showToast,
showSuccess, showSuccess,
showError, showError,
showWarning, showWarning,
showInfo, showInfo,
}; }),
[showError, showInfo, showSuccess, showToast, showWarning]
const getToastIcon = (type: ToastType) => {
const iconSize = isMobile ? 'w-4 h-4' : 'w-5 h-5';
switch (type) {
case 'success':
return <CheckCircle className={`${iconSize} flex-shrink-0 text-green-500`} />;
case 'error':
return <XCircle className={`${iconSize} flex-shrink-0 text-red-500`} />;
case 'warning':
return <AlertCircle className={`${iconSize} flex-shrink-0 text-yellow-500`} />;
case 'info':
return <Info className={`${iconSize} flex-shrink-0 text-blue-500`} />;
}
};
const getToastStyles = (type: ToastType) => {
switch (type) {
case 'success':
return 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200';
case 'error':
return 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200';
case 'warning':
return 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200';
case 'info':
return 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-200';
}
};
const toastContainer = mounted && toasts.length > 0 && (
<div
className={`fixed ${isMobile ? 'space-y-1' : 'space-y-2'} ${isMobile
? 'top-14 left-3 right-3 max-w-none z-[2147483648]'
: 'top-4 right-4 max-w-sm w-full z-[9999]'
}`}
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`
flex items-start gap-3 rounded-lg border shadow-lg
transform transition-all duration-300 ease-out
${isMobile ? 'p-3 text-sm' : 'p-4'}
${isMobile ? 'animate-in slide-in-from-top-2' : 'animate-in slide-in-from-right-2'}
${getToastStyles(toast.type)}
`}
>
{getToastIcon(toast.type)}
<div className="flex-1 min-w-0">
<h4 className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.title}</h4>
{toast.message && (
<p className={`opacity-90 mt-1 ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.message}</p>
)}
</div>
<button
onClick={() => removeToast(toast.id)}
className={`flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity ${isMobile ? 'p-1' : ''
}`}
>
<X className={isMobile ? 'w-3 h-3' : 'w-4 h-4'} />
</button>
</div>
))}
</div>
); );
return ( return (
<ToastContext.Provider value={contextValue}> <ToastContext.Provider value={contextValue}>
{children} {children}
{mounted && createPortal(toastContainer, document.body)} <HeroToast.Provider placement='top end' />
</ToastContext.Provider> </ToastContext.Provider>
); );
}; };

View File

@ -2,6 +2,10 @@
'use client'; 'use client';
import {
Dropdown,
Label,
} from '@heroui/react';
import { import {
Camera, Camera,
Check, Check,
@ -28,6 +32,7 @@ import { checkForUpdates, UpdateStatus } from '@/lib/version_check';
import { VersionPanel } from './VersionPanel'; import { VersionPanel } from './VersionPanel';
import { useToast } from './Toast'; import { useToast } from './Toast';
import { AppIconButton } from './ui/HeroPrimitives';
interface AuthInfo { interface AuthInfo {
username?: string; username?: string;
@ -695,12 +700,12 @@ export const UserMenu: React.FC = () => {
/> />
{/* 菜单面板 */} {/* 菜单面板 */}
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'> <div className='fixed right-4 top-14 z-[1001] w-56 overflow-hidden border border-border/70 bg-surface/95 shadow-[0_20px_45px_-30px_rgba(0,0,0,0.7)] select-none'>
{/* 用户信息区域 */} {/* 用户信息区域 */}
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'> <div className='border-b border-border/70 px-3 py-2.5'>
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
{/* 用户头像 */} {/* 用户头像 */}
<div className='w-10 h-10 rounded-full overflow-hidden relative flex-shrink-0'> <div className='relative h-10 w-10 flex-shrink-0 overflow-hidden border border-border/70'>
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
src={avatarUrl} src={avatarUrl}
@ -710,33 +715,33 @@ export const UserMenu: React.FC = () => {
className='object-cover' className='object-cover'
/> />
) : ( ) : (
<div className='w-full h-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center'> <div className='flex h-full w-full items-center justify-center bg-surface-secondary/60'>
<User className='w-6 h-6 text-blue-500 dark:text-blue-400' /> <User className='h-6 w-6 text-accent' />
</div> </div>
)} )}
</div> </div>
{/* 用户信息 */} {/* 用户信息 */}
<div className='flex-1 min-w-0'> <div className='flex-1 min-w-0'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'> <span className='text-xs font-medium uppercase tracking-[0.16em] text-muted'>
</span> </span>
<span <span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${(authInfo?.role || 'user') === 'owner' className={`inline-flex items-center border border-border/70 px-1.5 py-0.5 text-xs font-medium ${(authInfo?.role || 'user') === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' ? 'text-accent'
: (authInfo?.role || 'user') === 'admin' : (authInfo?.role || 'user') === 'admin'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' ? 'text-foreground'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'text-success'
}`} }`}
> >
{getRoleText(authInfo?.role || 'user')} {getRoleText(authInfo?.role || 'user')}
</span> </span>
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'> <div className='truncate text-sm font-semibold text-foreground'>
{authInfo?.username || 'default'} {authInfo?.username || 'default'}
</div> </div>
<div className='text-[10px] text-gray-400 dark:text-gray-500'> <div className='text-[10px] uppercase tracking-[0.14em] text-muted'>
{storageType === 'localstorage' ? '本地' : storageType} {storageType === 'localstorage' ? '本地' : storageType}
</div> </div>
</div> </div>
@ -749,9 +754,9 @@ export const UserMenu: React.FC = () => {
{/* 设置按钮 */} {/* 设置按钮 */}
<button <button
onClick={handleSettings} onClick={handleSettings}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm' className='theme-transition flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-muted hover:bg-background/20 hover:text-foreground'
> >
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' /> <Settings className='h-4 w-4 text-muted' />
<span className='font-medium'></span> <span className='font-medium'></span>
</button> </button>
@ -759,9 +764,9 @@ export const UserMenu: React.FC = () => {
{showAdminPanel && ( {showAdminPanel && (
<button <button
onClick={handleAdminPanel} onClick={handleAdminPanel}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm' className='theme-transition flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-muted hover:bg-background/20 hover:text-foreground'
> >
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' /> <Shield className='h-4 w-4 text-muted' />
<span className='font-medium'></span> <span className='font-medium'></span>
</button> </button>
)} )}
@ -769,9 +774,9 @@ export const UserMenu: React.FC = () => {
{/* 修改头像按钮 */} {/* 修改头像按钮 */}
<button <button
onClick={handleChangeAvatar} onClick={handleChangeAvatar}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm' className='theme-transition flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-muted hover:bg-background/20 hover:text-foreground'
> >
<Camera className='w-4 h-4 text-gray-500 dark:text-gray-400' /> <Camera className='h-4 w-4 text-muted' />
<span className='font-medium'></span> <span className='font-medium'></span>
</button> </button>
@ -779,27 +784,27 @@ export const UserMenu: React.FC = () => {
{showChangePassword && ( {showChangePassword && (
<button <button
onClick={handleChangePassword} onClick={handleChangePassword}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm' className='theme-transition flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-muted hover:bg-background/20 hover:text-foreground'
> >
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' /> <KeyRound className='h-4 w-4 text-muted' />
<span className='font-medium'></span> <span className='font-medium'></span>
</button> </button>
)} )}
{/* 分割线 */} {/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div> <div className='my-1 border-t border-border/70'></div>
{/* 登出按钮 */} {/* 登出按钮 */}
<button <button
onClick={handleLogout} onClick={handleLogout}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm' className='theme-transition flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-danger hover:bg-danger/10'
> >
<LogOut className='w-4 h-4' /> <LogOut className='w-4 h-4' />
<span className='font-medium'></span> <span className='font-medium'></span>
</button> </button>
{/* 分割线 */} {/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div> <div className='my-1 border-t border-border/70'></div>
{/* 版本信息 */} {/* 版本信息 */}
<button <button
@ -807,7 +812,7 @@ export const UserMenu: React.FC = () => {
setIsVersionPanelOpen(true); setIsVersionPanelOpen(true);
handleCloseMenu(); handleCloseMenu();
}} }}
className='w-full px-3 py-2 text-center flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors text-xs' className='theme-transition flex w-full items-center justify-center px-3 py-2 text-center text-xs text-muted hover:bg-background/20'
> >
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
<span className='font-mono'>v{CURRENT_VERSION}</span> <span className='font-mono'>v{CURRENT_VERSION}</span>
@ -816,9 +821,9 @@ export const UserMenu: React.FC = () => {
updateStatus !== UpdateStatus.FETCH_FAILED && ( updateStatus !== UpdateStatus.FETCH_FAILED && (
<div <div
className={`w-2 h-2 rounded-full -translate-y-2 ${updateStatus === UpdateStatus.HAS_UPDATE className={`w-2 h-2 rounded-full -translate-y-2 ${updateStatus === UpdateStatus.HAS_UPDATE
? 'bg-yellow-500' ? 'bg-warning'
: updateStatus === UpdateStatus.NO_UPDATE : updateStatus === UpdateStatus.NO_UPDATE
? 'bg-green-400' ? 'bg-success'
: '' : ''
}`} }`}
></div> ></div>
@ -852,7 +857,7 @@ export const UserMenu: React.FC = () => {
{/* 设置面板 */} {/* 设置面板 */}
<div <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] flex flex-col' className='a2-panel fixed left-1/2 top-1/2 z-[1001] flex max-h-[90vh] w-full max-w-xl -translate-x-1/2 -translate-y-1/2 flex-col bg-surface/95'
> >
{/* 内容容器 - 独立的滚动区域 */} {/* 内容容器 - 独立的滚动区域 */}
<div <div
@ -866,12 +871,12 @@ export const UserMenu: React.FC = () => {
{/* 标题栏 */} {/* 标题栏 */}
<div className='flex items-center justify-between mb-6'> <div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <h3 className='text-xl font-semibold tracking-[-0.045em] text-foreground'>
</h3> </h3>
<button <button
onClick={handleResetSettings} onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors' className='a2-button a2-button-danger px-2 py-1 text-xs'
title='重置为默认设置' title='重置为默认设置'
> >
@ -879,7 +884,7 @@ export const UserMenu: React.FC = () => {
</div> </div>
<button <button
onClick={handleCloseSettings} onClick={handleCloseSettings}
className='w-8 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' className='a2-icon-button h-8 w-8 p-1.5'
aria-label='Close' aria-label='Close'
> >
<X className='w-full h-full' /> <X className='w-full h-full' />
@ -891,10 +896,10 @@ export const UserMenu: React.FC = () => {
{/* 豆瓣数据源选择 */} {/* 豆瓣数据源选择 */}
<div className='space-y-3'> <div className='space-y-3'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
</p> </p>
</div> </div>
@ -903,7 +908,7 @@ export const UserMenu: React.FC = () => {
<button <button
type='button' type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)} onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left' className='a2-field pr-10 text-left'
> >
{ {
doubanDataSourceOptions.find( doubanDataSourceOptions.find(
@ -915,14 +920,14 @@ export const UserMenu: React.FC = () => {
{/* 下拉箭头 */} {/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'> <div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown <ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : '' className={`h-4 w-4 text-muted transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`} }`}
/> />
</div> </div>
{/* 下拉选项列表 */} {/* 下拉选项列表 */}
{isDoubanDropdownOpen && ( {isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'> <div className='a2-panel absolute z-50 mt-1 max-h-60 w-full overflow-auto bg-surface/95'>
{doubanDataSourceOptions.map((option) => ( {doubanDataSourceOptions.map((option) => (
<button <button
key={option.value} key={option.value}
@ -931,14 +936,14 @@ export const UserMenu: React.FC = () => {
handleDoubanDataSourceChange(option.value); handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false); setIsDoubanDropdownOpen(false);
}} }}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSource === option.value className={`flex w-full items-center justify-between px-3 py-2.5 text-left text-sm theme-transition hover:bg-background/25 ${doubanDataSource === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' ? 'bg-accent/10 text-accent'
: 'text-gray-900 dark:text-gray-100' : 'text-foreground'
}`} }`}
> >
<span className='truncate'>{option.label}</span> <span className='truncate'>{option.label}</span>
{doubanDataSource === option.value && ( {doubanDataSource === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' /> <Check className='ml-2 h-4 w-4 flex-shrink-0 text-accent' />
)} )}
</button> </button>
))} ))}
@ -954,7 +959,7 @@ export const UserMenu: React.FC = () => {
onClick={() => onClick={() =>
window.open(getThanksInfo(doubanDataSource)!.url, '_blank') window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
} }
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer' className='flex w-full cursor-pointer items-center justify-center gap-1.5 px-3 text-xs text-muted'
> >
<span className='font-medium'> <span className='font-medium'>
{getThanksInfo(doubanDataSource)!.text} {getThanksInfo(doubanDataSource)!.text}
@ -969,16 +974,16 @@ export const UserMenu: React.FC = () => {
{doubanDataSource === 'custom' && ( {doubanDataSource === 'custom' && (
<div className='space-y-3'> <div className='space-y-3'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
</p> </p>
</div> </div>
<input <input
type='text' type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500' className='a2-field'
placeholder='例如: https://proxy.example.com/fetch?url=' placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl} value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)} onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
@ -987,15 +992,15 @@ export const UserMenu: React.FC = () => {
)} )}
{/* 分割线 */} {/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div> <div className='border-t border-border/70'></div>
{/* 豆瓣图片代理设置 */} {/* 豆瓣图片代理设置 */}
<div className='space-y-3'> <div className='space-y-3'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
</p> </p>
</div> </div>
@ -1008,7 +1013,7 @@ export const UserMenu: React.FC = () => {
!isDoubanImageProxyDropdownOpen !isDoubanImageProxyDropdownOpen
) )
} }
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left' className='a2-field pr-10 text-left'
> >
{ {
doubanImageProxyTypeOptions.find( doubanImageProxyTypeOptions.find(
@ -1020,14 +1025,14 @@ export const UserMenu: React.FC = () => {
{/* 下拉箭头 */} {/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'> <div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown <ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : '' className={`h-4 w-4 text-muted transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`} }`}
/> />
</div> </div>
{/* 下拉选项列表 */} {/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && ( {isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'> <div className='a2-panel absolute z-50 mt-1 max-h-60 w-full overflow-auto bg-surface/95'>
{doubanImageProxyTypeOptions.map((option) => ( {doubanImageProxyTypeOptions.map((option) => (
<button <button
key={option.value} key={option.value}
@ -1036,14 +1041,14 @@ export const UserMenu: React.FC = () => {
handleDoubanImageProxyTypeChange(option.value); handleDoubanImageProxyTypeChange(option.value);
setIsDoubanImageProxyDropdownOpen(false); setIsDoubanImageProxyDropdownOpen(false);
}} }}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyType === option.value className={`flex w-full items-center justify-between px-3 py-2.5 text-left text-sm theme-transition hover:bg-background/25 ${doubanImageProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' ? 'bg-accent/10 text-accent'
: 'text-gray-900 dark:text-gray-100' : 'text-foreground'
}`} }`}
> >
<span className='truncate'>{option.label}</span> <span className='truncate'>{option.label}</span>
{doubanImageProxyType === option.value && ( {doubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' /> <Check className='ml-2 h-4 w-4 flex-shrink-0 text-accent' />
)} )}
</button> </button>
))} ))}
@ -1062,7 +1067,7 @@ export const UserMenu: React.FC = () => {
'_blank' '_blank'
) )
} }
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer' className='flex w-full cursor-pointer items-center justify-center gap-1.5 px-3 text-xs text-muted'
> >
<span className='font-medium'> <span className='font-medium'>
{getThanksInfo(doubanImageProxyType)!.text} {getThanksInfo(doubanImageProxyType)!.text}
@ -1077,16 +1082,16 @@ export const UserMenu: React.FC = () => {
{doubanImageProxyType === 'custom' && ( {doubanImageProxyType === 'custom' && (
<div className='space-y-3'> <div className='space-y-3'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
</p> </p>
</div> </div>
<input <input
type='text' type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500' className='a2-field'
placeholder='例如: https://proxy.example.com/fetch?url=' placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrl} value={doubanImageProxyUrl}
onChange={(e) => onChange={(e) =>
@ -1097,15 +1102,15 @@ export const UserMenu: React.FC = () => {
)} )}
{/* 分割线 */} {/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div> <div className='border-t border-border/70'></div>
{/* 默认聚合搜索结果 */} {/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
</p> </p>
</div> </div>
@ -1117,8 +1122,8 @@ export const UserMenu: React.FC = () => {
checked={defaultAggregateSearch} checked={defaultAggregateSearch}
onChange={(e) => handleAggregateToggle(e.target.checked)} onChange={(e) => handleAggregateToggle(e.target.checked)}
/> />
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div> <div className='h-6 w-11 border border-border/20 bg-surface-secondary/60 transition-colors peer-checked:border-accent/50 peer-checked:bg-accent/10'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div> <div className='absolute left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
</div> </div>
</label> </label>
</div> </div>
@ -1126,10 +1131,10 @@ export const UserMenu: React.FC = () => {
{/* 优选和测速 */} {/* 优选和测速 */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
</p> </p>
</div> </div>
@ -1141,8 +1146,8 @@ export const UserMenu: React.FC = () => {
checked={enableOptimization} checked={enableOptimization}
onChange={(e) => handleOptimizationToggle(e.target.checked)} onChange={(e) => handleOptimizationToggle(e.target.checked)}
/> />
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div> <div className='h-6 w-11 border border-border/20 bg-surface-secondary/60 transition-colors peer-checked:border-accent/50 peer-checked:bg-accent/10'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div> <div className='absolute left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
</div> </div>
</label> </label>
</div> </div>
@ -1150,10 +1155,10 @@ export const UserMenu: React.FC = () => {
{/* 流式搜索 */} {/* 流式搜索 */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
使 使
</p> </p>
</div> </div>
@ -1165,8 +1170,8 @@ export const UserMenu: React.FC = () => {
checked={fluidSearch} checked={fluidSearch}
onChange={(e) => handleFluidSearchToggle(e.target.checked)} onChange={(e) => handleFluidSearchToggle(e.target.checked)}
/> />
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div> <div className='h-6 w-11 border border-border/20 bg-surface-secondary/60 transition-colors peer-checked:border-accent/50 peer-checked:bg-accent/10'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div> <div className='absolute left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
</div> </div>
</label> </label>
</div> </div>
@ -1174,10 +1179,10 @@ export const UserMenu: React.FC = () => {
{/* 直播视频浏览器直连 */} {/* 直播视频浏览器直连 */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'> <h4 className='text-sm font-medium text-foreground'>
IPTV IPTV
</h4> </h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> <p className='mt-1 text-xs text-muted'>
IPTV Allow CORS IPTV Allow CORS
</p> </p>
</div> </div>
@ -1189,16 +1194,16 @@ export const UserMenu: React.FC = () => {
checked={liveDirectConnect} checked={liveDirectConnect}
onChange={(e) => handleLiveDirectConnectToggle(e.target.checked)} onChange={(e) => handleLiveDirectConnectToggle(e.target.checked)}
/> />
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div> <div className='h-6 w-11 border border-border/20 bg-surface-secondary/60 transition-colors peer-checked:border-accent/50 peer-checked:bg-accent/10'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div> <div className='absolute left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
</div> </div>
</label> </label>
</div> </div>
</div> </div>
{/* 底部说明 */} {/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'> <div className='mt-6 border-t border-border/70 pt-4'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'> <p className='text-center text-xs text-muted'>
</p> </p>
</div> </div>
@ -1229,7 +1234,7 @@ export const UserMenu: React.FC = () => {
{/* 修改密码面板 */} {/* 修改密码面板 */}
<div <div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden' className='a2-panel fixed left-1/2 top-1/2 z-[1001] w-full max-w-md -translate-x-1/2 -translate-y-1/2 overflow-hidden bg-surface/95'
> >
{/* 内容容器 - 独立的滚动区域 */} {/* 内容容器 - 独立的滚动区域 */}
<div <div
@ -1245,12 +1250,12 @@ export const UserMenu: React.FC = () => {
> >
{/* 标题栏 */} {/* 标题栏 */}
<div className='flex items-center justify-between mb-6'> <div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <h3 className='text-xl font-semibold tracking-[-0.045em] text-foreground'>
</h3> </h3>
<button <button
onClick={handleCloseChangePassword} onClick={handleCloseChangePassword}
className='w-8 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' className='a2-icon-button h-8 w-8 p-1.5'
aria-label='Close' aria-label='Close'
> >
<X className='w-full h-full' /> <X className='w-full h-full' />
@ -1261,12 +1266,12 @@ export const UserMenu: React.FC = () => {
<div className='space-y-4'> <div className='space-y-4'>
{/* 新密码输入 */} {/* 新密码输入 */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='mb-2 block text-sm font-medium text-foreground'>
</label> </label>
<input <input
type='password' type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400' className='a2-field'
placeholder='请输入新密码' placeholder='请输入新密码'
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
@ -1276,12 +1281,12 @@ export const UserMenu: React.FC = () => {
{/* 确认密码输入 */} {/* 确认密码输入 */}
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='mb-2 block text-sm font-medium text-foreground'>
</label> </label>
<input <input
type='password' type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400' className='a2-field'
placeholder='请再次输入新密码' placeholder='请再次输入新密码'
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
@ -1291,24 +1296,24 @@ export const UserMenu: React.FC = () => {
{/* 错误信息 */} {/* 错误信息 */}
{passwordError && ( {passwordError && (
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'> <div className='border border-danger/30 bg-danger/10 p-3 text-sm text-danger'>
{passwordError} {passwordError}
</div> </div>
)} )}
</div> </div>
{/* 操作按钮 */} {/* 操作按钮 */}
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'> <div className='mt-6 flex gap-3 border-t border-border/70 pt-4'>
<button <button
onClick={handleCloseChangePassword} onClick={handleCloseChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors' className='a2-button flex-1'
disabled={passwordLoading} disabled={passwordLoading}
> >
</button> </button>
<button <button
onClick={handleSubmitChangePassword} onClick={handleSubmitChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed' className='a2-button a2-button-accent flex-1'
disabled={passwordLoading || !newPassword || !confirmPassword} disabled={passwordLoading || !newPassword || !confirmPassword}
> >
{passwordLoading ? '修改中...' : '确认修改'} {passwordLoading ? '修改中...' : '确认修改'}
@ -1316,8 +1321,8 @@ export const UserMenu: React.FC = () => {
</div> </div>
{/* 底部说明 */} {/* 底部说明 */}
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'> <div className='mt-4 border-t border-border/70 pt-4'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'> <p className='text-center text-xs text-muted'>
</p> </p>
</div> </div>
@ -1328,33 +1333,116 @@ export const UserMenu: React.FC = () => {
return ( return (
<> <>
<div className='relative'> <Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
<button <AppIconButton
onClick={handleMenuClick} className={`a2-icon-button overflow-hidden ${isMobile ? 'h-8 w-8 p-0.5' : 'h-10 w-10 p-0.5'}`}
className={`${isMobile ? 'w-8 h-8 p-0.5' : 'w-10 h-10 p-0.5'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors overflow-hidden`}
aria-label='User Menu' aria-label='User Menu'
> >
<span className='relative flex h-full w-full items-center justify-center overflow-hidden'>
{avatarUrl ? ( {avatarUrl ? (
<div className='w-full h-full rounded-full overflow-hidden relative'>
<Image <Image
src={avatarUrl} src={avatarUrl}
alt="用户头像" alt='用户头像'
fill fill
sizes="40px" sizes='40px'
className='object-cover' className='object-cover'
/> />
</div>
) : ( ) : (
<User className='w-6 h-6' /> <User className='h-6 w-6' />
)} )}
</button> </span>
{updateStatus === UpdateStatus.HAS_UPDATE && ( </AppIconButton>
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div> <Dropdown.Popover className='w-64'>
<Dropdown.Menu
aria-label='用户菜单'
onAction={(key) => {
switch (String(key)) {
case 'settings':
handleSettings();
break;
case 'admin':
handleAdminPanel();
break;
case 'avatar':
handleChangeAvatar();
break;
case 'password':
handleChangePassword();
break;
case 'logout':
handleLogout();
break;
case 'version':
setIsVersionPanelOpen(true);
handleCloseMenu();
break;
default:
break;
}
}}
>
<Dropdown.Item id='profile' textValue='当前用户'>
<div className='flex items-center gap-3'>
<div className='relative h-10 w-10 flex-shrink-0 overflow-hidden border border-border/70'>
{avatarUrl ? (
<Image
src={avatarUrl}
alt='用户头像'
fill
sizes='40px'
className='object-cover'
/>
) : (
<div className='flex h-full w-full items-center justify-center bg-surface-secondary/60'>
<User className='h-6 w-6 text-accent' />
</div>
)} )}
</div> </div>
<div className='min-w-0 flex-1'>
{/* 使用 Portal 将菜单面板渲染到 document.body */} <p className='truncate text-sm font-semibold text-foreground'>
{isOpen && mounted && createPortal(menuPanel, document.body)} {authInfo?.username || 'default'}
</p>
<p className='text-xs text-muted'>
{getRoleText(authInfo?.role || 'user')} ·{' '}
{storageType === 'localstorage' ? '本地' : storageType}
</p>
</div>
</div>
</Dropdown.Item>
<Dropdown.Item id='settings' textValue='设置'>
<Settings className='h-4 w-4 text-muted' />
<Label></Label>
</Dropdown.Item>
{showAdminPanel ? (
<Dropdown.Item id='admin' textValue='管理面板'>
<Shield className='h-4 w-4 text-muted' />
<Label></Label>
</Dropdown.Item>
) : null}
<Dropdown.Item id='avatar' textValue='修改头像'>
<Camera className='h-4 w-4 text-muted' />
<Label></Label>
</Dropdown.Item>
{showChangePassword ? (
<Dropdown.Item id='password' textValue='修改密码'>
<KeyRound className='h-4 w-4 text-muted' />
<Label></Label>
</Dropdown.Item>
) : null}
<Dropdown.Item id='logout' textValue='登出' variant='danger'>
<LogOut className='h-4 w-4 text-danger' />
<Label></Label>
</Dropdown.Item>
<Dropdown.Item id='version' textValue={`v${CURRENT_VERSION}`}>
<ExternalLink className='h-4 w-4 text-muted' />
<Label>v{CURRENT_VERSION}</Label>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<div className='absolute right-[2px] top-[2px] h-2 w-2 bg-warning'></div>
)}
</Dropdown>
{/* 使用 Portal 将设置面板渲染到 document.body */} {/* 使用 Portal 将设置面板渲染到 document.body */}
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)} {isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
@ -1379,16 +1467,16 @@ export const UserMenu: React.FC = () => {
/> />
{/* 修改头像面板 */} {/* 修改头像面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'> <div className='a2-panel fixed left-1/2 top-1/2 z-[1001] w-full max-w-md -translate-x-1/2 -translate-y-1/2 overflow-hidden bg-surface/95'>
<div className='p-6'> <div className='p-6'>
{/* 标题栏 */} {/* 标题栏 */}
<div className='flex items-center justify-between mb-6'> <div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'> <h3 className='text-xl font-semibold tracking-[-0.045em] text-foreground'>
</h3> </h3>
<button <button
onClick={handleCloseChangeAvatar} onClick={handleCloseChangeAvatar}
className='w-8 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' className='a2-icon-button h-8 w-8 p-1.5'
aria-label='Close' aria-label='Close'
> >
<X className='w-full h-full' /> <X className='w-full h-full' />
@ -1399,7 +1487,7 @@ export const UserMenu: React.FC = () => {
<> <>
{/* 头像预览 */} {/* 头像预览 */}
<div className='flex flex-col items-center justify-center gap-6 my-6'> <div className='flex flex-col items-center justify-center gap-6 my-6'>
<div className='w-24 h-24 rounded-full overflow-hidden relative'> <div className='relative h-24 w-24 overflow-hidden border border-border/70'>
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
src={avatarUrl} src={avatarUrl}
@ -1409,8 +1497,8 @@ export const UserMenu: React.FC = () => {
className='object-cover' className='object-cover'
/> />
) : ( ) : (
<div className='w-full h-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center'> <div className='flex h-full w-full items-center justify-center bg-surface-secondary/60'>
<User className='w-12 h-12 text-blue-500 dark:text-blue-400' /> <User className='h-12 w-12 text-accent' />
</div> </div>
)} )}
</div> </div>
@ -1428,7 +1516,7 @@ export const UserMenu: React.FC = () => {
<button <button
onClick={handleOpenFileSelector} onClick={handleOpenFileSelector}
disabled={isUploadingAvatar} disabled={isUploadingAvatar}
className='flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors' className='a2-button a2-button-accent flex items-center gap-2'
> >
<Upload className='w-4 h-4' /> <Upload className='w-4 h-4' />
@ -1476,14 +1564,14 @@ export const UserMenu: React.FC = () => {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}} }}
className='px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg transition-colors' className='a2-button'
> >
</button> </button>
<button <button
onClick={handleConfirmCrop} onClick={handleConfirmCrop}
disabled={isUploadingAvatar || !completedCrop} disabled={isUploadingAvatar || !completedCrop}
className='flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors' className='a2-button a2-button-accent flex items-center gap-2'
> >
<Check className='w-4 h-4' /> <Check className='w-4 h-4' />
{isUploadingAvatar ? '上传中...' : '确认上传'} {isUploadingAvatar ? '上传中...' : '确认上传'}
@ -1494,7 +1582,7 @@ export const UserMenu: React.FC = () => {
)} )}
{/* 底部提示 */} {/* 底部提示 */}
<p className='text-xs text-gray-500 dark:text-gray-400 text-center mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'> <p className='mt-4 border-t border-border/70 pt-4 text-center text-xs text-muted'>
JPGPNGGIF 2MB JPGPNGGIF 2MB
</p> </p>
</div> </div>

View File

@ -531,7 +531,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return ( return (
<> <>
<div <div
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]' 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]'
onClick={handleClick} onClick={handleClick}
{...longPressProps} {...longPressProps}
style={{ style={{
@ -568,7 +568,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
> >
{/* 海报容器 */} {/* 海报容器 */}
<div <div
className={`relative aspect-[2/3] overflow-hidden rounded-lg ${origin === 'live' ? 'ring-1 ring-gray-300/80 dark:ring-gray-600/80' : ''}`} 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' : ''}`}
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -615,7 +615,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 悬浮遮罩 */} {/* 悬浮遮罩 */}
<div <div
className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ease-in-out opacity-0 group-hover:opacity-100' 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'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -645,7 +645,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<PlayCircleIcon <PlayCircleIcon
size={50} size={50}
strokeWidth={0.8} strokeWidth={0.8}
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-blue-500 hover:scale-[1.1]' 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]'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -695,8 +695,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
size={20} size={20}
className={`transition-all duration-300 ease-out ${favorited className={`transition-all duration-300 ease-out ${favorited
? 'fill-red-600 stroke-red-600' ? 'fill-danger stroke-danger'
: 'fill-transparent stroke-white hover:stroke-red-400' : 'fill-transparent stroke-white hover:stroke-accent'
} hover:scale-[1.1]`} } hover:scale-[1.1]`}
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -715,7 +715,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 年份徽章 */} {/* 年份徽章 */}
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && ( {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
<div <div
className="absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 left-2" 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"
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -733,7 +733,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 徽章 */} {/* 徽章 */}
{config.showRating && rate && ( {config.showRating && rate && (
<div <div
className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110' 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'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -750,7 +750,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{actualEpisodes && actualEpisodes > 1 && ( {actualEpisodes && actualEpisodes > 1 && (
<div <div
className='absolute top-2 right-2 bg-blue-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110' 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'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -790,7 +790,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
> >
<div <div
className='bg-blue-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-blue-600 hover:scale-[1.1] transition-all duration-300 ease-out' 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={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -841,7 +841,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
} as React.CSSProperties} } as React.CSSProperties}
> >
<div <div
className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer' 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'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -889,7 +889,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
> >
<div <div
className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden' className='min-w-[100px] max-w-[140px] overflow-hidden rounded-xl border border-border/70 bg-overlay/95 p-1.5 text-xs text-foreground shadow-xl backdrop-blur sm:min-w-[120px] sm:max-w-[200px] sm:p-2'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -904,7 +904,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<div className='space-y-0.5 sm:space-y-1'> <div className='space-y-0.5 sm:space-y-1'>
{displaySources.map((sourceName, index) => ( {displaySources.map((sourceName, index) => (
<div key={index} className='flex items-center gap-1 sm:gap-1.5'> <div key={index} className='flex items-center gap-1 sm:gap-1.5'>
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div> <div className='h-3 w-1 rounded-full flex-shrink-0 bg-accent/70'></div>
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}> <span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}>
{sourceName} {sourceName}
</span> </span>
@ -914,15 +914,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 显示更多提示 */} {/* 显示更多提示 */}
{hasMore && ( {hasMore && (
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'> <div className='mt-1 border-t border-border/70 pt-1 sm:mt-2 sm:pt-1.5'>
<div className='flex items-center justify-center text-gray-400'> <div className='flex items-center justify-center text-muted'>
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} </span> <span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} </span>
</div> </div>
</div> </div>
)} )}
{/* 小箭头 */} {/* 小箭头 */}
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div> <div className='absolute right-2 top-full h-2 w-px bg-border/70 sm:right-3'></div>
</div> </div>
</div> </div>
); );
@ -936,7 +936,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 进度条 */} {/* 进度条 */}
{config.showProgress && progress !== undefined && ( {config.showProgress && progress !== undefined && (
<div <div
className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden' className='mt-2 h-1 w-full overflow-hidden rounded-full bg-surface-secondary'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -948,7 +948,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
> >
<div <div
className='h-full bg-blue-500 transition-all duration-500 ease-out' className='h-full rounded-full bg-accent transition-all duration-500 ease-out'
style={{ style={{
width: `${progress}%`, width: `${progress}%`,
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -965,7 +965,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 标题与来源 */} {/* 标题与来源 */}
<div <div
className='mt-2 text-center' className='mt-3 text-left'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -985,7 +985,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
} as React.CSSProperties} } as React.CSSProperties}
> >
<span <span
className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-blue-600 dark:group-hover:text-blue-400 peer' className='peer block truncate text-sm font-semibold text-foreground transition-colors duration-300 ease-in-out group-hover:text-accent'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -1000,7 +1000,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</span> </span>
{/* 自定义 tooltip */} {/* 自定义 tooltip */}
<div <div
className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none' 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={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -1013,7 +1013,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
> >
{actualTitle} {actualTitle}
<div <div
className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800' className='absolute left-1/2 top-full h-2 w-px -translate-x-1/2 bg-border/70'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -1024,7 +1024,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</div> </div>
{config.showSourceName && source_name && ( {config.showSourceName && source_name && (
<span <span
className='block text-xs text-gray-500 dark:text-gray-400 mt-1' className='mt-1 block text-xs font-medium tracking-normal text-muted'
style={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -1036,7 +1036,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
> >
<span <span
className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-blue-500/60 group-hover:text-blue-600 dark:group-hover:text-blue-400' 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={{ style={{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -1048,7 +1048,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
> >
{origin === 'live' && ( {origin === 'live' && (
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" /> <Radio size={12} className="inline-block mr-1 text-muted" />
)} )}
{source_name} {source_name}
</span> </span>

View File

@ -4,6 +4,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { AppFilterTabs } from './ui/HeroPrimitives';
interface WeekdaySelectorProps { interface WeekdaySelectorProps {
onWeekdayChange: (weekday: string) => void; onWeekdayChange: (weekday: string) => void;
className?: string; className?: string;
@ -41,32 +43,19 @@ const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
}, []); // 只在组件挂载时执行一次 }, []); // 只在组件挂载时执行一次
return ( return (
<div <AppFilterTabs
className={`relative inline-flex rounded-full p-0.5 sm:p-1 ${className}`} ariaLabel='星期筛选'
> className={className}
{weekdays.map((weekday) => { items={weekdays.map((weekday) => ({
const isActive = selectedWeekday === weekday.value; key: weekday.value,
return ( label: weekday.shortLabel,
<button }))}
key={weekday.value} selectedKey={selectedWeekday}
onClick={() => { onSelectionChange={(value) => {
setSelectedWeekday(weekday.value); setSelectedWeekday(value);
onWeekdayChange(weekday.value); onWeekdayChange(value);
}} }}
className={` />
relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap
${isActive
? 'text-blue-600 dark:text-blue-400 font-semibold'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer'
}
`}
title={weekday.label}
>
{weekday.shortLabel}
</button>
);
})}
</div>
); );
}; };

View File

@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react';
import CapsuleSwitch from '../CapsuleSwitch';
describe('CapsuleSwitch', () => {
it('renders as a tablist with the active option selected', () => {
render(
<CapsuleSwitch
options={[
{ label: '首页', value: 'home' },
{ label: '收藏夹', value: 'favorites' },
]}
active='favorites'
onChange={() => {}}
/>
);
const tablist = screen.getByRole('tablist', { name: '内容切换' });
expect(tablist).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '首页' })).toHaveAttribute(
'aria-selected',
'false'
);
expect(screen.getByRole('tab', { name: '收藏夹' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('calls onChange when a different tab is activated', () => {
const onChange = jest.fn();
render(
<CapsuleSwitch
options={[
{ label: '首页', value: 'home' },
{ label: '收藏夹', value: 'favorites' },
]}
active='home'
onChange={onChange}
/>
);
fireEvent.click(screen.getByRole('tab', { name: '收藏夹' }));
expect(onChange).toHaveBeenCalledWith('favorites');
});
});

View File

@ -0,0 +1,22 @@
import { fireEvent, render, screen } from '@testing-library/react';
import MultiLevelSelector from '../MultiLevelSelector';
describe('MultiLevelSelector', () => {
it('applies selected filter values through accessible menu items', () => {
const onChange = jest.fn();
render(<MultiLevelSelector contentType='movie' onChange={onChange} />);
fireEvent.click(screen.getByRole('menuitem', { name: '喜剧' }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
type: '喜剧',
region: 'all',
year: 'all',
sort: 'T',
})
);
});
});

View File

@ -0,0 +1,87 @@
import { fireEvent, render, screen } from '@testing-library/react';
import SearchResultFilter, {
SearchFilterCategory,
} from '../SearchResultFilter';
const categories: SearchFilterCategory[] = [
{
key: 'source',
label: '来源',
options: [
{ label: '全部来源', value: 'all' },
{ label: '稳定源', value: 'stable' },
],
},
{
key: 'title',
label: '标题',
options: [
{ label: '全部标题', value: 'all' },
{ label: '精确匹配', value: 'exact' },
],
},
];
describe('SearchResultFilter', () => {
it('opens an options menu and applies the selected option', () => {
const onChange = jest.fn();
render(
<SearchResultFilter
categories={categories}
values={{ source: 'all', title: 'all', yearOrder: 'none' }}
onChange={onChange}
/>
);
expect(screen.getByRole('menu', { name: '来源筛选' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('menuitem', { name: '稳定源' }));
expect(onChange).toHaveBeenCalledWith({
source: 'stable',
title: 'all',
year: 'all',
yearOrder: 'none',
});
});
it('cycles year ordering when the year button is clicked repeatedly', () => {
const onChange = jest.fn();
const { rerender } = render(
<SearchResultFilter
categories={categories}
values={{ source: 'all', title: 'all', yearOrder: 'none' }}
onChange={onChange}
/>
);
const yearButton = screen.getByRole('button', { name: '按年份排序排序' });
fireEvent.click(yearButton);
expect(onChange).toHaveBeenLastCalledWith({
source: 'all',
title: 'all',
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

@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import { useEffect } from 'react';
import { ToastProvider, useToast } from '../Toast';
function ToastProbe() {
const toast = useToast();
useEffect(() => {
toast.showSuccess('保存成功', '配置已更新');
}, [toast]);
return null;
}
describe('ToastProvider', () => {
it('renders the HeroUI toast provider and preserves the toast hook API', () => {
render(
<ToastProvider>
<ToastProbe />
</ToastProvider>
);
expect(screen.getByTestId('heroui-toast-provider')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,21 @@
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', () => {
const onWeekdayChange = jest.fn();
render(<WeekdaySelector onWeekdayChange={onWeekdayChange} />);
const tablist = screen.getByRole('tablist', { name: '星期筛选' });
expect(tablist).toBeInTheDocument();
const today = new Date().getDay();
const weekdayMap = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
expect(screen.getByRole('tab', { name: weekdayMap[today] })).toHaveAttribute(
'aria-selected',
'true'
);
});
});

View File

@ -0,0 +1,231 @@
'use client';
import {
Button,
Card,
Drawer,
Modal,
ScrollShadow,
Spinner,
Tabs,
useOverlayState,
} from '@heroui/react';
import type {
ButtonProps,
CardProps,
ScrollShadowProps,
SpinnerProps,
TabsProps,
} from '@heroui/react';
import { forwardRef } from 'react';
import type { Key, ReactNode } from 'react';
type AppButtonProps = ButtonProps;
export const AppButton = forwardRef<HTMLButtonElement, AppButtonProps>(
function AppButton(props, ref) {
return <Button ref={ref} {...props} />;
}
);
export const AppIconButton = forwardRef<HTMLButtonElement, AppButtonProps>(
function AppIconButton(props, ref) {
return <Button ref={ref} isIconOnly variant='tertiary' {...props} />;
}
);
export function AppSurface(props: CardProps) {
return <Card variant='default' {...props} />;
}
export function AppScrollShadow(props: ScrollShadowProps) {
return <ScrollShadow hideScrollBar {...props} />;
}
export function AppLoading({
label = '加载中...',
...props
}: SpinnerProps & { label?: string }) {
return (
<div className='flex items-center justify-center gap-2 text-muted'>
<Spinner size='sm' {...props} />
{label ? <span className='text-sm'>{label}</span> : null}
</div>
);
}
interface AppDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
icon?: ReactNode;
className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'cover' | 'full';
placement?: 'auto' | 'top' | 'center' | 'bottom';
isDismissable?: boolean;
}
export function AppDialog({
isOpen,
onOpenChange,
title,
description,
children,
footer,
icon,
className,
size = 'md',
placement = 'center',
isDismissable = true,
}: AppDialogProps) {
const state = useOverlayState({ isOpen, onOpenChange });
return (
<Modal state={state}>
<Modal.Backdrop variant='blur' isDismissable={isDismissable}>
<Modal.Container placement={placement} scroll='inside' size={size}>
<Modal.Dialog aria-label={String(title)} className={className}>
<Modal.CloseTrigger />
<Modal.Header>
{icon ? <Modal.Icon>{icon}</Modal.Icon> : null}
<div>
<Modal.Heading>{title}</Modal.Heading>
{description ? (
<p className='mt-1 text-sm leading-5 text-muted'>
{description}
</p>
) : null}
</div>
</Modal.Header>
{children ? <Modal.Body>{children}</Modal.Body> : null}
{footer ? <Modal.Footer>{footer}</Modal.Footer> : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}
interface AppDrawerProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
className?: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
isDismissable?: boolean;
}
export function AppDrawer({
isOpen,
onOpenChange,
title,
description,
children,
footer,
className,
placement = 'bottom',
isDismissable = true,
}: AppDrawerProps) {
const state = useOverlayState({ isOpen, onOpenChange });
return (
<Drawer state={state}>
<Drawer.Backdrop variant='blur' isDismissable={isDismissable}>
<Drawer.Content placement={placement}>
<Drawer.Dialog aria-label={String(title)} className={className}>
<Drawer.Handle />
<Drawer.CloseTrigger />
<Drawer.Header>
<div>
<Drawer.Heading>{title}</Drawer.Heading>
{description ? (
<p className='mt-1 text-sm leading-5 text-muted'>
{description}
</p>
) : null}
</div>
</Drawer.Header>
{children ? <Drawer.Body>{children}</Drawer.Body> : null}
{footer ? <Drawer.Footer>{footer}</Drawer.Footer> : null}
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
);
}
interface AppTabsItem {
key: string;
label: ReactNode;
isDisabled?: boolean;
}
interface AppTabsProps
extends Omit<TabsProps, 'children' | 'onSelectionChange'> {
ariaLabel: string;
items: AppTabsItem[];
onSelectionChange?: (key: string) => void;
}
export function AppTabs({
ariaLabel,
items,
selectedKey,
onSelectionChange,
variant = 'secondary',
className,
...props
}: AppTabsProps) {
const handleSelectionChange = (key: Key) => {
onSelectionChange?.(String(key));
};
return (
<Tabs
className={className}
selectedKey={selectedKey}
variant={variant}
onSelectionChange={handleSelectionChange}
{...props}
>
<Tabs.ListContainer>
<Tabs.List aria-label={ariaLabel}>
{items.map((item, index) => (
<Tabs.Tab
key={item.key}
id={item.key}
isDisabled={item.isDisabled}
>
{index > 0 ? <Tabs.Separator /> : null}
{item.label}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
);
}
export function AppFilterTabs({
className,
...props
}: AppTabsProps) {
return (
<ScrollShadow
orientation='horizontal'
className='app-filter-scroll'
>
<AppTabs
{...props}
className={`app-filter-tabs ${className ?? ''}`.trim()}
/>
</ScrollShadow>
);
}

View File

@ -0,0 +1,75 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { AppButton, AppDialog, AppDrawer, AppTabs } from '../HeroPrimitives';
describe('HeroPrimitives', () => {
it('invokes AppButton actions through an accessible button', () => {
const onPress = jest.fn();
render(<AppButton onPress={onPress}></AppButton>);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('renders AppTabs as an accessible tablist and changes selection', () => {
const onChange = jest.fn();
render(
<AppTabs
ariaLabel='内容切换'
selectedKey='home'
onSelectionChange={onChange}
items={[
{ key: 'home', label: '首页' },
{ key: 'favorites', label: '收藏夹' },
]}
/>
);
expect(screen.getByRole('tablist', { name: '内容切换' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: '首页' })).toHaveAttribute(
'aria-selected',
'true'
);
fireEvent.click(screen.getByRole('tab', { name: '收藏夹' }));
expect(onChange).toHaveBeenCalledWith('favorites');
});
it('renders controlled dialogs with accessible roles and close actions', () => {
const onOpenChange = jest.fn();
render(
<AppDialog
isOpen
title='删除记录'
description='确认删除这条播放记录'
onOpenChange={onOpenChange}
footer={<AppButton onPress={() => onOpenChange(false)}></AppButton>}
>
<p></p>
</AppDialog>
);
expect(screen.getByRole('dialog', { name: '删除记录' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('renders controlled drawers with accessible roles and close actions', () => {
const onOpenChange = jest.fn();
render(
<AppDrawer isOpen title='更多操作' onOpenChange={onOpenChange}>
<AppButton onPress={() => onOpenChange(false)}></AppButton>
</AppDrawer>
);
expect(screen.getByRole('dialog', { name: '更多操作' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '播放' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});

View File

@ -10,15 +10,39 @@ const config: Config = {
], ],
theme: { theme: {
extend: { extend: {
screens: {
'mobile-landscape': {
raw: '(orientation: landscape) and (max-height: 700px)',
},
},
fontFamily: { fontFamily: {
primary: ['Inter', ...defaultTheme.fontFamily.sans], primary: ['var(--font-body)', ...defaultTheme.fontFamily.sans],
mono: ['var(--font-mono)', ...defaultTheme.fontFamily.mono],
}, },
colors: { colors: {
background: 'rgb(var(--color-background) / <alpha-value>)',
foreground: 'rgb(var(--color-foreground) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
'surface-secondary': 'rgb(var(--color-surface-secondary) / <alpha-value>)',
'surface-tertiary': 'rgb(var(--color-surface-tertiary) / <alpha-value>)',
overlay: 'rgb(var(--color-overlay) / <alpha-value>)',
border: 'rgb(var(--color-border) / <alpha-value>)',
accent: 'rgb(var(--color-accent) / <alpha-value>)',
'accent-strong': 'rgb(var(--color-accent-strong) / <alpha-value>)',
'accent-foreground': 'rgb(var(--color-background) / <alpha-value>)',
field: 'rgb(var(--color-surface) / <alpha-value>)',
success: 'rgb(var(--color-success) / <alpha-value>)',
warning: 'rgb(var(--color-warning) / <alpha-value>)',
danger: 'rgb(var(--color-danger) / <alpha-value>)',
canvas: 'rgb(var(--a2-canvas) / <alpha-value>)',
stage: 'rgb(var(--a2-stage) / <alpha-value>)',
rail: 'rgb(var(--a2-rail) / <alpha-value>)',
ink: 'rgb(var(--a2-text) / <alpha-value>)',
'ink-soft': 'rgb(var(--a2-text-soft) / <alpha-value>)',
muted: 'rgb(var(--a2-text-muted) / <alpha-value>)',
copper: 'rgb(var(--a2-copper) / <alpha-value>)',
'copper-strong': 'rgb(var(--a2-copper-strong) / <alpha-value>)',
line: 'rgb(var(--a2-line) / <alpha-value>)',
signal: {
stable: 'rgb(var(--a2-signal-stable) / <alpha-value>)',
warn: 'rgb(var(--a2-signal-warn) / <alpha-value>)',
error: 'rgb(var(--a2-signal-error) / <alpha-value>)',
},
primary: { primary: {
50: 'rgb(var(--color-primary-50) / <alpha-value>)', 50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)', 100: 'rgb(var(--color-primary-100) / <alpha-value>)',

View File

@ -12,8 +12,8 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "Node16", "module": "esnext",
"moduleResolution": "node16", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
@ -41,10 +41,5 @@
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
],
"moduleResolution": [
"node_modules",
".next",
"node"
] ]
} }