mirror of https://github.com/djteang/OrangeTV.git
refactor ui with HeroUI refresh
This commit is contained in:
parent
7f87806f6c
commit
eb71c83aa5
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
29
package.json
29
package.json
|
|
@ -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": {
|
||||||
|
|
|
||||||
2260
pnpm-lock.yaml
2260
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,5 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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: () => ({}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,5 @@
|
||||||
// },
|
// },
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
2242
src/app/globals.css
2242
src/app/globals.css
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
115
src/app/page.tsx
115
src/app/page.tsx
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))
|
))
|
||||||
: // 显示真实数据
|
: // 显示真实数据
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +23,84 @@ interface DoubanSelectorProps {
|
||||||
onWeekdayChange: (weekday: string) => void;
|
onWeekdayChange: (weekday: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moviePrimaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: '全部' },
|
||||||
|
{ label: '热门电影', value: '热门' },
|
||||||
|
{ label: '最新电影', value: '最新' },
|
||||||
|
{ label: '豆瓣高分', value: '豆瓣高分' },
|
||||||
|
{ label: '冷门佳片', value: '冷门佳片' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const movieSecondaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: '全部' },
|
||||||
|
{ label: '华语', value: '华语' },
|
||||||
|
{ label: '欧美', value: '欧美' },
|
||||||
|
{ label: '韩国', value: '韩国' },
|
||||||
|
{ label: '日本', value: '日本' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tvPrimaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: '全部' },
|
||||||
|
{ label: '最近热门', value: '最近热门' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tvSecondaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: 'tv' },
|
||||||
|
{ label: '国产', value: 'tv_domestic' },
|
||||||
|
{ label: '欧美', value: 'tv_american' },
|
||||||
|
{ label: '日本', value: 'tv_japanese' },
|
||||||
|
{ label: '韩国', value: 'tv_korean' },
|
||||||
|
{ label: '动漫', value: 'tv_animation' },
|
||||||
|
{ label: '纪录片', value: 'tv_documentary' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const showPrimaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: '全部' },
|
||||||
|
{ label: '最近热门', value: '最近热门' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const showSecondaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: 'show' },
|
||||||
|
{ label: '国内', value: 'show_domestic' },
|
||||||
|
{ label: '国外', value: 'show_foreign' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const animePrimaryOptions: SelectorOption[] = [
|
||||||
|
{ 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> = ({
|
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||||
type,
|
type,
|
||||||
primarySelection,
|
primarySelection,
|
||||||
|
|
@ -31,466 +110,94 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||||
onMultiLevelChange,
|
onMultiLevelChange,
|
||||||
onWeekdayChange,
|
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[] = [
|
|
||||||
{ label: '全部', value: '全部' },
|
|
||||||
{ label: '热门电影', value: '热门' },
|
|
||||||
{ label: '最新电影', value: '最新' },
|
|
||||||
{ label: '豆瓣高分', value: '豆瓣高分' },
|
|
||||||
{ label: '冷门佳片', value: '冷门佳片' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 电影的二级选择器选项
|
|
||||||
const movieSecondaryOptions: SelectorOption[] = [
|
|
||||||
{ label: '全部', value: '全部' },
|
|
||||||
{ label: '华语', value: '华语' },
|
|
||||||
{ label: '欧美', value: '欧美' },
|
|
||||||
{ label: '韩国', value: '韩国' },
|
|
||||||
{ label: '日本', value: '日本' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 电视剧一级选择器选项
|
|
||||||
const tvPrimaryOptions: SelectorOption[] = [
|
|
||||||
{ label: '全部', value: '全部' },
|
|
||||||
{ label: '最近热门', value: '最近热门' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 电视剧二级选择器选项
|
|
||||||
const tvSecondaryOptions: SelectorOption[] = [
|
|
||||||
{ label: '全部', value: 'tv' },
|
|
||||||
{ label: '国产', value: 'tv_domestic' },
|
|
||||||
{ label: '欧美', value: 'tv_american' },
|
|
||||||
{ label: '日本', value: 'tv_japanese' },
|
|
||||||
{ label: '韩国', value: 'tv_korean' },
|
|
||||||
{ label: '动漫', value: 'tv_animation' },
|
|
||||||
{ label: '纪录片', value: 'tv_documentary' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 综艺一级选择器选项
|
|
||||||
const showPrimaryOptions: SelectorOption[] = [
|
|
||||||
{ label: '全部', value: '全部' },
|
|
||||||
{ label: '最近热门', value: '最近热门' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 综艺二级选择器选项
|
|
||||||
const showSecondaryOptions: SelectorOption[] = [
|
|
||||||
{ label: '全部', value: 'show' },
|
|
||||||
{ label: '国内', value: 'show_domestic' },
|
|
||||||
{ label: '国外', value: 'show_foreign' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 动漫一级选择器选项
|
|
||||||
const animePrimaryOptions: SelectorOption[] = [
|
|
||||||
{ label: '每日放送', value: '每日放送' },
|
|
||||||
{ label: '番剧', value: '番剧' },
|
|
||||||
{ label: '剧场版', value: '剧场版' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 处理多级选择器变化
|
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
{/* 聊天模态框 - 在登录页面不渲染 */}
|
{/* 聊天模态框 - 在登录页面不渲染 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
支持 JPG、PNG、GIF 等格式,文件大小不超过 2MB
|
支持 JPG、PNG、GIF 等格式,文件大小不超过 2MB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>)',
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue