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
|
||||
*/
|
||||
moduleNameMapper: {
|
||||
'^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^~/(.*)$': '<rootDir>/public/$1',
|
||||
'^.+\\.(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
|
||||
|
|
|
|||
|
|
@ -9,12 +9,6 @@ const nextConfig = {
|
|||
},
|
||||
|
||||
reactStrictMode: false,
|
||||
swcMinify: false,
|
||||
|
||||
experimental: {
|
||||
instrumentationHook: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
// Uncoment to add domain whitelist
|
||||
images: {
|
||||
unoptimized: true,
|
||||
|
|
|
|||
31
package.json
31
package.json
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"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:ws": "node standalone-websocket.js",
|
||||
"test:ws": "node test-websocket-connection.js",
|
||||
|
|
@ -29,8 +30,8 @@
|
|||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@heroui/react": "3.0.5",
|
||||
"@heroui/styles": "3.0.5",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@upstash/redis": "^1.25.0",
|
||||
|
|
@ -45,16 +46,17 @@
|
|||
"hls.js": "^1.6.10",
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"next": "^14.2.30",
|
||||
"next": "^15.5.18",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"redis": "^4.6.7",
|
||||
"swiper": "^11.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "3.2.2",
|
||||
"vidstack": "^0.6.15",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.24.1"
|
||||
|
|
@ -64,19 +66,21 @@
|
|||
"@commitlint/config-conventional": "^16.2.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@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/react": "^15.0.7",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bs58": "^5.0.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/node": "24.0.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.23",
|
||||
"eslint-config-next": "^15.5.18",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
|
|
@ -84,11 +88,12 @@
|
|||
"jest": "^27.5.1",
|
||||
"lint-staged": "^12.5.0",
|
||||
"next-router-mock": "^0.9.0",
|
||||
"playwright": "^1.60.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack-obfuscator": "^3.5.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
@ -101,4 +106,4 @@
|
|||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2260
pnpm-lock.yaml
2260
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
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 = {
|
||||
// 主要操作按钮(蓝色)- 用于配置、设置、确认等
|
||||
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',
|
||||
// 小尺寸主要按钮
|
||||
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',
|
||||
// 小尺寸次要按钮
|
||||
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',
|
||||
// 圆角小按钮(用于表格操作)
|
||||
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',
|
||||
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',
|
||||
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-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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
toggleOff: 'bg-gray-200 dark:bg-gray-700',
|
||||
toggleThumb: 'bg-white',
|
||||
toggleOn: 'bg-accent',
|
||||
toggleOff: 'bg-surface-tertiary',
|
||||
toggleThumb: 'bg-surface',
|
||||
toggleThumbOn: 'translate-x-6',
|
||||
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,
|
||||
}: CollapsibleTabProps) => {
|
||||
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
|
||||
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'>
|
||||
{icon}
|
||||
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
|
||||
<h3 className='text-lg font-semibold text-foreground'>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className='text-gray-500 dark:text-gray-400'>
|
||||
<div className='text-muted'>
|
||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -1255,7 +1255,7 @@ const UserConfig = ({ config, role, refreshConfig, machineCodeUsers, fetchMachin
|
|||
data-table="user-list"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
['scrollbar-color' as any]: '#cbd5e0 transparent'
|
||||
scrollbarColor: '#cbd5e0 transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const target = e.currentTarget;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { getConfig, setCachedConfig, clearCachedConfig } from '@/lib/config';
|
|||
export async function GET() {
|
||||
try {
|
||||
// 创建一个模拟的NextRequest对象来使用getAuthInfoFromCookie
|
||||
const cookieStore = cookies();
|
||||
const cookieStore = await cookies();
|
||||
const authCookie = cookieStore.get('auth');
|
||||
|
||||
if (!authCookie) {
|
||||
|
|
@ -42,7 +42,7 @@ export async function GET() {
|
|||
export async function POST(request: Request) {
|
||||
try {
|
||||
// 获取认证信息
|
||||
const cookieStore = cookies();
|
||||
const cookieStore = await cookies();
|
||||
const authCookie = cookieStore.get('auth');
|
||||
|
||||
if (!authCookie) {
|
||||
|
|
|
|||
|
|
@ -126,3 +126,5 @@
|
|||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -728,7 +728,7 @@ function DoubanPageClient() {
|
|||
|
||||
{/* 选择器组件 */}
|
||||
{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
|
||||
type={type as 'movie' | 'tv' | 'show' | 'anime'}
|
||||
primarySelection={primarySelection}
|
||||
|
|
@ -740,7 +740,7 @@ function DoubanPageClient() {
|
|||
/>
|
||||
</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
|
||||
customCategories={customCategories}
|
||||
primarySelection={primarySelection}
|
||||
|
|
|
|||
2246
src/app/globals.css
2246
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 */
|
||||
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
|
|
@ -13,7 +12,6 @@ import { ThemeProvider } from '../components/ThemeProvider';
|
|||
import { ToastProvider } from '../components/Toast';
|
||||
import GlobalThemeLoader from '../components/GlobalThemeLoader';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// 动态生成 metadata,支持配置更新后的标题变化
|
||||
|
|
@ -166,12 +164,10 @@ export default async function RootLayout({
|
|||
/>
|
||||
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
>
|
||||
<body className='min-h-[100dvh] bg-background text-foreground antialiased'>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
defaultTheme='light'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
'use client';
|
||||
|
||||
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
|
||||
import { Form, Input, Label, TextField } from '@heroui/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ import MachineCode from '@/lib/machine-code';
|
|||
import { useSite } from '@/components/SiteProvider';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import GlobalThemeLoader from '@/components/GlobalThemeLoader';
|
||||
import { AppButton, AppSurface } from '@/components/ui/HeroPrimitives';
|
||||
|
||||
// 版本显示组件
|
||||
function VersionDisplay() {
|
||||
|
|
@ -200,54 +202,36 @@ function LoginPageClient() {
|
|||
<div className='absolute top-4 right-4'>
|
||||
<ThemeToggle />
|
||||
</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'>
|
||||
{siteName}
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className='space-y-8'>
|
||||
<Form onSubmit={handleSubmit} className='space-y-6'>
|
||||
{shouldAskUsername && (
|
||||
<div className='relative'>
|
||||
<input
|
||||
<TextField name='username' className='w-full'>
|
||||
<Label>用户名</Label>
|
||||
<Input
|
||||
id='username'
|
||||
type='text'
|
||||
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='用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<label
|
||||
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>
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
<div className='relative'>
|
||||
<input
|
||||
<TextField name='password' className='w-full'>
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
id='password'
|
||||
type='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='密码'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<label
|
||||
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>
|
||||
</TextField>
|
||||
|
||||
{/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
|
||||
{deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
|
||||
|
|
@ -295,20 +279,21 @@ function LoginPageClient() {
|
|||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
<AppButton
|
||||
type='submit'
|
||||
disabled={
|
||||
fullWidth
|
||||
isDisabled={
|
||||
!password ||
|
||||
loading ||
|
||||
(shouldAskUsername && !username) ||
|
||||
(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 ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</AppButton>
|
||||
</Form>
|
||||
</AppSurface>
|
||||
|
||||
{/* 版本信息显示 */}
|
||||
<VersionDisplay />
|
||||
|
|
|
|||
135
src/app/page.tsx
135
src/app/page.tsx
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -169,9 +169,9 @@ function HomeClient() {
|
|||
|
||||
return (
|
||||
<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 切换 */}
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<div className='mb-10 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
{ label: '首页', value: 'home' },
|
||||
|
|
@ -182,17 +182,20 @@ function HomeClient() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
<div className='mx-auto max-w-[1380px] space-y-10'>
|
||||
{activeTab === 'favorites' ? (
|
||||
// 收藏夹视图
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
我的收藏
|
||||
</h2>
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Saved</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
我的收藏
|
||||
</h2>
|
||||
</div>
|
||||
{favoriteItems.length > 0 && (
|
||||
<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 () => {
|
||||
await clearAllFavorites();
|
||||
setFavoriteItems([]);
|
||||
|
|
@ -202,7 +205,7 @@ function HomeClient() {
|
|||
</button>
|
||||
)}
|
||||
</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) => (
|
||||
<div key={item.id + item.source} className='w-full'>
|
||||
<VideoCard
|
||||
|
|
@ -214,7 +217,7 @@ function HomeClient() {
|
|||
</div>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
|
@ -227,17 +230,19 @@ function HomeClient() {
|
|||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门电影
|
||||
</h2>
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>精选推荐</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
热门电影
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
|
|
@ -248,10 +253,10 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-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>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -275,17 +280,16 @@ function HomeClient() {
|
|||
</section>
|
||||
|
||||
{/* 热门剧集 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=tv'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Series</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
热门剧集
|
||||
</h2>
|
||||
</div>
|
||||
<Link href='/douban?type=tv' className='a2-link-action'>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
|
|
@ -296,10 +300,10 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-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>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -322,17 +326,19 @@ function HomeClient() {
|
|||
</section>
|
||||
|
||||
{/* 每日新番放送 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
新番放送
|
||||
</h2>
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Bangumi</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
新番放送
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
|
|
@ -343,10 +349,10 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-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>
|
||||
))
|
||||
: // 展示当前日期的番剧
|
||||
|
|
@ -398,17 +404,19 @@ function HomeClient() {
|
|||
</section>
|
||||
|
||||
{/* 热门综艺 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门综艺
|
||||
</h2>
|
||||
<section className='rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>Shows</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
热门综艺
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
|
|
@ -419,10 +427,10 @@ function HomeClient() {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-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>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
@ -475,7 +483,7 @@ function HomeClient() {
|
|||
}}
|
||||
>
|
||||
<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) => {
|
||||
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
|
||||
e.stopPropagation();
|
||||
|
|
@ -485,26 +493,27 @@ function HomeClient() {
|
|||
}}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
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='关闭'
|
||||
></button>
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<div className='relative overflow-hidden rounded-lg mb-4 bg-blue-50 dark:bg-blue-900/20'>
|
||||
<div className='absolute inset-y-0 left-0 w-1.5 bg-blue-500 dark:bg-blue-400'></div>
|
||||
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
|
||||
<div className='border-l-4 border-accent pl-4'>
|
||||
<p className='a2-muted-copy'>
|
||||
{announcement}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function SearchPageClient() {
|
|||
const flushTimerRef = useRef<number | null>(null);
|
||||
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||
// 聚合卡片 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());
|
||||
|
||||
// 执行搜索的通用函数
|
||||
|
|
@ -1020,7 +1020,7 @@ function SearchPageClient() {
|
|||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-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
|
||||
id='searchInput'
|
||||
type='text'
|
||||
|
|
@ -1029,7 +1029,7 @@ function SearchPageClient() {
|
|||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧、短剧...'
|
||||
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);
|
||||
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='清除搜索内容'
|
||||
>
|
||||
<X className='h-5 w-5' />
|
||||
|
|
@ -1071,19 +1071,19 @@ function SearchPageClient() {
|
|||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||
{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'>
|
||||
<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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
{isLoading && useFluidSearch && (
|
||||
<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>
|
||||
)}
|
||||
</h2>
|
||||
|
|
@ -1107,7 +1107,7 @@ function SearchPageClient() {
|
|||
</div>
|
||||
{/* 聚合开关 */}
|
||||
<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'>
|
||||
<input
|
||||
type='checkbox'
|
||||
|
|
@ -1115,18 +1115,18 @@ function SearchPageClient() {
|
|||
checked={viewMode === '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='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='w-9 h-5 rounded-full bg-surface-secondary transition-colors peer-checked:bg-accent'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-surface shadow-sm transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{searchResults.length === 0 ? (
|
||||
isLoading ? (
|
||||
<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 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>
|
||||
)
|
||||
|
|
@ -1199,15 +1199,15 @@ function SearchPageClient() {
|
|||
</section>
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
|
||||
<section className='mb-12 rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6'>
|
||||
<h2 className='mb-4 text-left text-xl font-semibold tracking-normal text-foreground'>
|
||||
搜索历史
|
||||
{searchHistory.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
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>
|
||||
|
|
@ -1221,7 +1221,7 @@ function SearchPageClient() {
|
|||
// 直接调用搜索函数
|
||||
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}
|
||||
</button>
|
||||
|
|
@ -1233,7 +1233,7 @@ function SearchPageClient() {
|
|||
e.preventDefault();
|
||||
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' />
|
||||
</button>
|
||||
|
|
@ -1245,7 +1245,7 @@ function SearchPageClient() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 返回顶部悬浮按钮 - 科技风格 */}
|
||||
{/* 返回顶部悬浮按钮 */}
|
||||
<div
|
||||
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'
|
||||
|
|
@ -1254,15 +1254,15 @@ function SearchPageClient() {
|
|||
>
|
||||
<button
|
||||
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)}%)`}
|
||||
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'>
|
||||
<ChevronUp className='w-6 h-6 text-white/90 transition-all duration-300 group-hover:scale-110 group-hover:text-white drop-shadow-lg' />
|
||||
<div className='absolute inset-1 flex items-center justify-center rounded-xl bg-surface/90 backdrop-blur-sm transition-all duration-300 group-hover:bg-accent/15'>
|
||||
<ChevronUp className='w-6 h-6 text-accent transition-all duration-300 group-hover:scale-110' />
|
||||
</div>
|
||||
|
||||
{/* 进度环 */}
|
||||
|
|
@ -1288,25 +1288,25 @@ function SearchPageClient() {
|
|||
/>
|
||||
<defs>
|
||||
<linearGradient id='progressGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
|
||||
<stop offset='0%' stopColor='#3b82f6' />
|
||||
<stop offset='50%' stopColor='#06b6d4' />
|
||||
<stop offset='100%' stopColor='#8b5cf6' />
|
||||
<stop offset='0%' stopColor='rgb(var(--color-accent))' />
|
||||
<stop offset='50%' stopColor='rgb(var(--color-accent))' />
|
||||
<stop offset='100%' stopColor='rgb(var(--color-accent-strong))' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* 悬停时的进度提示 */}
|
||||
<div className='absolute -top-12 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none'>
|
||||
<div className='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'>
|
||||
{Math.round(scrollProgress)}%
|
||||
</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 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>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ function ShortDramaPageClient() {
|
|||
</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
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { AppIconButton } from './ui/HeroPrimitives';
|
||||
|
||||
export function BackButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => 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'
|
||||
<AppIconButton
|
||||
onPress={() => window.history.back()}
|
||||
className='a2-icon-button'
|
||||
aria-label='Back'
|
||||
>
|
||||
<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 {
|
||||
options: { label: string; value: string }[];
|
||||
|
|
@ -15,88 +15,14 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
|||
onChange,
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
|
||||
className || ''
|
||||
}`}
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{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>
|
||||
<AppFilterTabs
|
||||
ariaLabel='内容切换'
|
||||
className={className}
|
||||
items={options.map((opt) => ({ key: opt.value, label: opt.label }))}
|
||||
selectedKey={active}
|
||||
onSelectionChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -86,14 +86,17 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<section className={`mb-8 ${className || ''}`}>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
继续观看
|
||||
</h2>
|
||||
<section className={`rounded-3xl border border-border/70 bg-surface/70 p-5 shadow-sm backdrop-blur sm:p-6 ${className || ''}`}>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='a2-kicker'>最近观看</p>
|
||||
<h2 className='text-2xl font-semibold tracking-normal text-foreground'>
|
||||
继续观看
|
||||
</h2>
|
||||
</div>
|
||||
{!loading && playRecords.length > 0 && (
|
||||
<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 () => {
|
||||
await clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
|
|
@ -111,11 +114,11 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-2xl border border-border bg-surface-secondary animate-pulse'>
|
||||
<div className='absolute inset-0 bg-surface-tertiary'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='mt-1 h-3 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 rounded-lg bg-surface-secondary animate-pulse'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
|
||||
interface CustomCategory {
|
||||
name: string;
|
||||
|
|
@ -18,6 +20,23 @@ interface DoubanCustomSelectorProps {
|
|||
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> = ({
|
||||
customCategories,
|
||||
primarySelection,
|
||||
|
|
@ -25,42 +44,24 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
|
|||
onPrimaryChange,
|
||||
onSecondaryChange,
|
||||
}) => {
|
||||
// 为不同的选择器创建独立的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 });
|
||||
|
||||
// 二级选择器滚动容器的ref
|
||||
const secondaryScrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 根据 customCategories 生成一级选择器选项(按 type 分组,电影优先)
|
||||
const primaryOptions = React.useMemo(() => {
|
||||
const primaryOptions = useMemo(() => {
|
||||
const types = Array.from(new Set(customCategories.map((cat) => cat.type)));
|
||||
// 确保电影类型排在前面
|
||||
const sortedTypes = types.sort((a, b) => {
|
||||
if (a === 'movie' && b !== 'movie') return -1;
|
||||
if (a !== 'movie' && b === 'movie') return 1;
|
||||
return 0;
|
||||
});
|
||||
return sortedTypes.map((type) => ({
|
||||
label: type === 'movie' ? '电影' : '剧集',
|
||||
value: type,
|
||||
}));
|
||||
|
||||
return types
|
||||
.sort((a, b) => {
|
||||
if (a === 'movie' && b !== 'movie') return -1;
|
||||
if (a !== 'movie' && b === 'movie') return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((type) => ({
|
||||
label: type === 'movie' ? '电影' : '剧集',
|
||||
value: type,
|
||||
}));
|
||||
}, [customCategories]);
|
||||
|
||||
// 根据选中的一级选项生成二级选择器选项
|
||||
const secondaryOptions = React.useMemo(() => {
|
||||
const secondaryOptions = useMemo(() => {
|
||||
if (!primarySelection) return [];
|
||||
|
||||
return customCategories
|
||||
.filter((cat) => cat.type === primarySelection)
|
||||
.map((cat) => ({
|
||||
|
|
@ -69,242 +70,34 @@ const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
|
|||
}));
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 两级选择器包装 */}
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>类型</span>
|
||||
<div className='min-w-0'>
|
||||
{renderSelector(
|
||||
'自定义类型',
|
||||
primaryOptions,
|
||||
primarySelection || primaryOptions[0]?.value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
onPrimaryChange
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 */}
|
||||
{secondaryOptions.length > 0 && (
|
||||
<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 ref={secondaryScrollContainerRef} className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>片单</span>
|
||||
<div className='min-w-0'>
|
||||
{renderSelector(
|
||||
'自定义片单',
|
||||
secondaryOptions,
|
||||
secondarySelection || secondaryOptions[0]?.value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
onSecondaryChange
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import MultiLevelSelector from './MultiLevelSelector';
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
import WeekdaySelector from './WeekdaySelector';
|
||||
|
||||
interface SelectorOption {
|
||||
|
|
@ -22,6 +23,84 @@ interface DoubanSelectorProps {
|
|||
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> = ({
|
||||
type,
|
||||
primarySelection,
|
||||
|
|
@ -31,531 +110,139 @@ const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
|||
onMultiLevelChange,
|
||||
onWeekdayChange,
|
||||
}) => {
|
||||
// 为不同的选择器创建独立的refs和状态
|
||||
const primaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const secondaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
// 电影的一级选择器选项
|
||||
const moviePrimaryOptions: SelectorOption[] = [
|
||||
{ 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>) => {
|
||||
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 (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 电影类型 - 显示两级选择器 */}
|
||||
{type === 'movie' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
moviePrimaryOptions,
|
||||
primarySelection || moviePrimaryOptions[0].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'电影分类',
|
||||
moviePrimaryOptions,
|
||||
primarySelection || moviePrimaryOptions[0].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
|
||||
{/* 二级选择器 - 只在非"全部"时显示 */}
|
||||
{primarySelection !== '全部' ? (
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
movieSecondaryOptions,
|
||||
secondarySelection || movieSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='地区'>
|
||||
{renderSelector(
|
||||
'电影地区',
|
||||
movieSecondaryOptions,
|
||||
secondarySelection || movieSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
) : (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<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
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='筛选'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</FilterRow>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 电视剧类型 - 显示两级选择器 */}
|
||||
{type === 'tv' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
tvPrimaryOptions,
|
||||
primarySelection || tvPrimaryOptions[1].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'剧集分类',
|
||||
tvPrimaryOptions,
|
||||
primarySelection || tvPrimaryOptions[1].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
|
||||
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
|
||||
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
tvSecondaryOptions,
|
||||
secondarySelection || tvSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='类型'>
|
||||
{renderSelector(
|
||||
'剧集类型',
|
||||
tvSecondaryOptions,
|
||||
secondarySelection || tvSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<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
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='筛选'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</FilterRow>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 动漫类型 - 显示一级选择器和多级选择器 */}
|
||||
{type === 'anime' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
animePrimaryOptions,
|
||||
primarySelection || animePrimaryOptions[0].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'动漫分类',
|
||||
animePrimaryOptions,
|
||||
primarySelection || animePrimaryOptions[0].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
|
||||
{/* 筛选部分 - 根据一级选择器显示不同内容 */}
|
||||
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
|
||||
// 每日放送分类下显示星期选择器
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='星期'>
|
||||
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
|
||||
</FilterRow>
|
||||
) : (
|
||||
// 其他分类下显示原有的筛选功能
|
||||
<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'>
|
||||
{(primarySelection || animePrimaryOptions[0].value) ===
|
||||
'番剧' ? (
|
||||
<MultiLevelSelector
|
||||
key={`anime-tv-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-tv'
|
||||
/>
|
||||
) : (
|
||||
<MultiLevelSelector
|
||||
key={`anime-movie-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-movie'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='筛选'>
|
||||
{(primarySelection || animePrimaryOptions[0].value) === '番剧' ? (
|
||||
<MultiLevelSelector
|
||||
key={`anime-tv-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-tv'
|
||||
/>
|
||||
) : (
|
||||
<MultiLevelSelector
|
||||
key={`anime-movie-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-movie'
|
||||
/>
|
||||
)}
|
||||
</FilterRow>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 综艺类型 - 显示两级选择器 */}
|
||||
{type === 'show' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
showPrimaryOptions,
|
||||
primarySelection || showPrimaryOptions[1].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='分类'>
|
||||
{renderSelector(
|
||||
'综艺分类',
|
||||
showPrimaryOptions,
|
||||
primarySelection || showPrimaryOptions[1].value,
|
||||
onPrimaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
|
||||
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
|
||||
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<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'>
|
||||
{renderCapsuleSelector(
|
||||
showSecondaryOptions,
|
||||
secondarySelection || showSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='类型'>
|
||||
{renderSelector(
|
||||
'综艺类型',
|
||||
showSecondaryOptions,
|
||||
secondarySelection || showSecondaryOptions[0].value,
|
||||
onSecondaryChange
|
||||
)}
|
||||
</FilterRow>
|
||||
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<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
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FilterRow label='筛选'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</FilterRow>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -359,16 +359,16 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
);
|
||||
|
||||
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 切换 - 无缝融入设计 */}
|
||||
<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 && (
|
||||
<div
|
||||
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'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
? 'border-accent text-foreground'
|
||||
: 'border-transparent text-muted hover:text-muted'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
|
|
@ -377,10 +377,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
)}
|
||||
<div
|
||||
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'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
? 'border-accent text-foreground'
|
||||
: 'border-transparent text-muted hover:text-muted'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
|
|
@ -392,7 +392,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
{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
|
||||
className='flex-1 overflow-x-auto'
|
||||
ref={categoryContainerRef}
|
||||
|
|
@ -409,16 +409,16 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
buttonRefs.current[idx] = el;
|
||||
}}
|
||||
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
|
||||
? 'text-blue-500 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400'
|
||||
? 'text-foreground'
|
||||
: 'text-muted hover:text-muted'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
{label}
|
||||
{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>
|
||||
);
|
||||
|
|
@ -427,7 +427,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
</div>
|
||||
{/* 向上/向下按钮 */}
|
||||
<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={() => {
|
||||
// 切换集数排序(正序/倒序)
|
||||
setDescending((prev) => !prev);
|
||||
|
|
@ -450,7 +450,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
</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 episodes = Array.from({ length: len }, (_, i) =>
|
||||
|
|
@ -463,10 +463,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
<button
|
||||
key={episodeNumber}
|
||||
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
|
||||
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/25 dark:bg-blue-600'
|
||||
: '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-accent bg-accent text-accent-foreground'
|
||||
: 'border-border/70 bg-surface/60 text-muted hover:border-accent/35 hover:text-foreground'
|
||||
}`.trim()}
|
||||
>
|
||||
{(() => {
|
||||
|
|
@ -493,9 +493,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
<div className='flex flex-col h-full mt-4'>
|
||||
{sourceSearchLoading && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div>
|
||||
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
|
||||
搜索中...
|
||||
<div className='h-px w-24 bg-border/70'>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -503,8 +505,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
{sourceSearchError && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='text-center'>
|
||||
<div className='text-red-500 text-2xl mb-2'>⚠️</div>
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
<div className='mb-3 text-xs uppercase tracking-[0.16em] text-danger'>Source error</div>
|
||||
<p className='text-sm text-danger'>
|
||||
{sourceSearchError}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -516,8 +518,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
availableSources.length === 0 && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='text-center'>
|
||||
<div className='text-gray-400 text-2xl mb-2'>📺</div>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
<div className='mb-3 text-xs uppercase tracking-[0.16em] text-muted'>No sources</div>
|
||||
<p className='text-sm text-muted'>
|
||||
暂无可用的换源
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -550,14 +552,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
onClick={() =>
|
||||
!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
|
||||
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30 border'
|
||||
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'cursor-pointer border-border/70 hover:border-accent/30 hover:bg-surface/50'
|
||||
}`.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 && (
|
||||
<img
|
||||
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-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}
|
||||
</h3>
|
||||
{/* 标题级别的 tooltip - 第一个元素不显示 */}
|
||||
{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}
|
||||
<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>
|
||||
|
|
@ -594,7 +596,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
if (videoInfo && videoInfo.quality !== '未知') {
|
||||
if (videoInfo.hasError) {
|
||||
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>
|
||||
);
|
||||
|
|
@ -607,14 +609,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
videoInfo.quality
|
||||
);
|
||||
const textColorClasses = isUltraHigh
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
? 'text-accent'
|
||||
: isHigh
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400';
|
||||
? 'text-success'
|
||||
: 'text-warning';
|
||||
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
|
|
@ -647,10 +649,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||
if (!videoInfo.hasError) {
|
||||
return (
|
||||
<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}
|
||||
</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
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Radio, X } from 'lucide-react';
|
||||
import { Radio } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppButton, AppDrawer, AppScrollShadow } from './ui/HeroPrimitives';
|
||||
|
||||
interface ActionItem {
|
||||
id: string;
|
||||
|
|
@ -17,14 +19,20 @@ interface MobileActionSheetProps {
|
|||
title: string;
|
||||
actions: ActionItem[];
|
||||
poster?: string;
|
||||
sources?: string[]; // 播放源信息
|
||||
isAggregate?: boolean; // 是否为聚合内容
|
||||
sourceName?: string; // 播放源名称
|
||||
currentEpisode?: number; // 当前集数
|
||||
totalEpisodes?: number; // 总集数
|
||||
sources?: string[];
|
||||
isAggregate?: boolean;
|
||||
sourceName?: string;
|
||||
currentEpisode?: number;
|
||||
totalEpisodes?: number;
|
||||
origin?: 'vod' | 'live';
|
||||
}
|
||||
|
||||
const actionToneClass: Record<NonNullable<ActionItem['color']>, string> = {
|
||||
default: 'text-foreground',
|
||||
danger: 'text-danger',
|
||||
primary: 'text-accent',
|
||||
};
|
||||
|
||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
|
@ -38,311 +46,108 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||
totalEpisodes,
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-end justify-center"
|
||||
onTouchMove={(e) => {
|
||||
// 阻止最外层容器的触摸移动,防止背景滚动
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'none', // 禁用所有触摸操作
|
||||
<AppDrawer
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(nextIsOpen) => {
|
||||
if (!nextIsOpen) onClose();
|
||||
}}
|
||||
title={title}
|
||||
description='选择操作'
|
||||
className='max-h-[86dvh]'
|
||||
placement='bottom'
|
||||
>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${isAnimating ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
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">
|
||||
<div className='space-y-4'>
|
||||
{(poster || sourceName) && (
|
||||
<div className='flex items-center gap-3 rounded-lg border border-border/70 bg-surface-secondary/60 p-3'>
|
||||
{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
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className={origin === 'live' ? 'object-contain' : 'object-cover'}
|
||||
loading="lazy"
|
||||
loading='lazy'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{sourceName && (
|
||||
<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">
|
||||
{origin === 'live' && (
|
||||
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
|
||||
)}
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
选择操作
|
||||
</p>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-base font-semibold text-foreground'>{title}</p>
|
||||
{sourceName ? (
|
||||
<span className='a2-data mt-1 inline-flex max-w-full items-center border border-border/70 px-2 py-1 text-[10px] text-muted'>
|
||||
{origin === 'live' ? (
|
||||
<Radio size={12} className='mr-1.5 text-accent' />
|
||||
) : null}
|
||||
<span className='truncate'>{sourceName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作列表 */}
|
||||
<div className="px-4 py-2">
|
||||
{actions.map((action, index) => (
|
||||
<div key={action.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
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' }}
|
||||
<div className='divide-y divide-border/10 overflow-hidden rounded-lg border border-border/70'>
|
||||
{actions.map((action) => (
|
||||
<AppButton
|
||||
key={action.id}
|
||||
variant='tertiary'
|
||||
fullWidth
|
||||
isDisabled={action.disabled}
|
||||
className='h-auto justify-start rounded-none px-3 py-4'
|
||||
onPress={() => {
|
||||
action.onClick();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center ${
|
||||
action.disabled
|
||||
? 'text-muted/60'
|
||||
: actionToneClass[action.color || 'default']
|
||||
}`}
|
||||
>
|
||||
{/* 图标 - 使用线条风格 */}
|
||||
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
|
||||
<span className={`transition-colors duration-150 ${action.disabled
|
||||
? 'text-gray-400 dark:text-gray-600'
|
||||
: getActionColor(action.color)
|
||||
}`}>
|
||||
{action.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文字 */}
|
||||
<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.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 text-left text-base font-medium ${
|
||||
action.disabled ? 'text-muted/60' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{action.label}
|
||||
</span>
|
||||
{action.id === 'play' && currentEpisode && totalEpisodes ? (
|
||||
<span className='a2-data text-xs text-muted'>
|
||||
{currentEpisode}/{totalEpisodes}
|
||||
</span>
|
||||
|
||||
{/* 播放进度 - 只在播放按钮且有播放记录时显示 */}
|
||||
{action.id === 'play' && currentEpisode && totalEpisodes && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||
{currentEpisode}/{totalEpisodes}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
</button>
|
||||
|
||||
{/* 分割线 - 最后一项不显示 */}
|
||||
{index < actions.length - 1 && (
|
||||
<div className="border-b border-gray-100 dark:border-gray-800 ml-10"></div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</AppButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 播放源信息展示区域 */}
|
||||
{isAggregate && sources && sources.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
|
||||
{/* 标题区域 */}
|
||||
<div className="mb-3">
|
||||
<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>
|
||||
{isAggregate && sources && sources.length > 0 ? (
|
||||
<div className='rounded-lg border border-border/70 p-3'>
|
||||
<div className='mb-3'>
|
||||
<h4 className='mb-1 text-sm font-medium text-foreground'>可用播放源</h4>
|
||||
<p className='a2-kicker'>共 {sources.length} 个播放源</p>
|
||||
</div>
|
||||
|
||||
{/* 播放源列表 */}
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sources.map((source, index) => (
|
||||
<AppScrollShadow className='max-h-32'>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={index}
|
||||
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"
|
||||
key={source}
|
||||
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" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
<div className='h-1.5 w-1.5 flex-shrink-0 bg-accent/80' />
|
||||
<span className='truncate text-xs text-muted'>
|
||||
{source}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AppScrollShadow>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||
|
||||
return (
|
||||
<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={{
|
||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||
bottom: 0,
|
||||
|
|
@ -115,19 +115,20 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||
>
|
||||
<Link
|
||||
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
|
||||
className={`h-6 w-6 ${active
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
className={`h-5 w-5 ${active
|
||||
? 'text-accent'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
? 'text-foreground'
|
||||
: 'text-muted'
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ interface MobileHeaderProps {
|
|||
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||
const { siteName } = useSite();
|
||||
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'>
|
||||
<div className='h-12 flex items-center justify-between px-4'>
|
||||
<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='flex h-12 items-center justify-between px-4'>
|
||||
{/* 左侧:搜索按钮、返回按钮和设置按钮 */}
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Link
|
||||
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
|
||||
className='w-full h-full'
|
||||
|
|
@ -47,14 +47,14 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间:Logo(绝对居中) */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Link
|
||||
href='/'
|
||||
className='text-2xl font-bold text-blue-600 tracking-tight hover:opacity-80 transition-opacity'
|
||||
>
|
||||
{siteName}
|
||||
</Link>
|
||||
{/* 中间:Logo(绝对居中) */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Link
|
||||
href='/'
|
||||
className='theme-transition text-lg font-semibold tracking-normal text-foreground hover:text-accent'
|
||||
>
|
||||
{siteName}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Dropdown, Label } from '@heroui/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AppButton } from './ui/HeroPrimitives';
|
||||
|
||||
interface MultiLevelOption {
|
||||
label: string;
|
||||
|
|
@ -24,15 +26,7 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
|||
onChange,
|
||||
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 categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 根据内容类型获取对应的类型选项
|
||||
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) => {
|
||||
// 更新本地状态
|
||||
|
|
@ -419,7 +365,6 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
|||
// 调用父组件的回调,传递处理后的选择值
|
||||
onChange(selectionsForParent);
|
||||
|
||||
setActiveCategory(null);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
|
|
@ -460,131 +405,67 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
|||
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 (
|
||||
<>
|
||||
{/* 胶囊样式筛选栏 */}
|
||||
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.key}
|
||||
ref={(el) => {
|
||||
categoryRefs.current[category.key] = el;
|
||||
}}
|
||||
className='relative'
|
||||
<div className='app-filter-dropdowns'>
|
||||
{categories.map((category) => (
|
||||
<Dropdown key={category.key}>
|
||||
<AppButton
|
||||
aria-label={`${category.label}筛选`}
|
||||
variant='tertiary'
|
||||
className={`app-filter-trigger ${
|
||||
isDefaultValue(category.key)
|
||||
? ''
|
||||
: 'app-filter-trigger-active'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
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>
|
||||
<svg
|
||||
className='ml-0.5 inline-block h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<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'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</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'
|
||||
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)
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-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
|
||||
)}
|
||||
</>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</AppButton>
|
||||
<Dropdown.Popover className='w-[min(92vw,600px)]'>
|
||||
<Dropdown.Menu
|
||||
aria-label={`${category.label}选项`}
|
||||
selectionMode='single'
|
||||
selectedKeys={
|
||||
new Set([
|
||||
values[category.key] ||
|
||||
(category.key === 'sort' ? 'T' : 'all'),
|
||||
])
|
||||
}
|
||||
onAction={(key) => handleOptionSelect(category.key, String(key))}
|
||||
className='grid grid-cols-3 gap-1 p-2 sm:grid-cols-4 sm:gap-2 md:grid-cols-5'
|
||||
>
|
||||
{category.options.map((option) => (
|
||||
<Dropdown.Item
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={
|
||||
isOptionSelected(category.key, option.value)
|
||||
? 'a2-selector-option-active'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Label>{option.label}</Label>
|
||||
<Dropdown.ItemIndicator />
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ interface PageLayoutProps {
|
|||
|
||||
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
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)} />
|
||||
|
||||
{/* 主要布局容器 */}
|
||||
<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'>
|
||||
<Sidebar activePath={activePath} />
|
||||
|
|
@ -27,20 +27,20 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
|||
<div className='relative min-w-0 flex-1 transition-all duration-300'>
|
||||
{/* 桌面端左上角返回按钮 */}
|
||||
{['/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 />
|
||||
</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 />
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
{/* 主内容 */}
|
||||
<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={{
|
||||
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export default function ScrollableRow({
|
|||
>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -128,9 +128,9 @@ export default function ScrollableRow({
|
|||
>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -157,9 +157,9 @@ export default function ScrollableRow({
|
|||
>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { Dropdown, Label, ScrollShadow } from '@heroui/react';
|
||||
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AppButton } from './ui/HeroPrimitives';
|
||||
|
||||
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 [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(() => {
|
||||
return {
|
||||
...DEFAULTS,
|
||||
|
|
@ -43,53 +40,12 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
|
|||
} as Record<SearchFilterKey, string>;
|
||||
}, [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 newValues = {
|
||||
...mergedValues,
|
||||
[categoryKey]: optionValue,
|
||||
} as Record<SearchFilterKey, string>;
|
||||
onChange(newValues);
|
||||
setActiveCategory(null);
|
||||
};
|
||||
|
||||
const getDisplayText = (categoryKey: SearchFilterKey) => {
|
||||
|
|
@ -111,66 +67,57 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
|
|||
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 (
|
||||
<>
|
||||
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
|
||||
<div className='app-search-filter-bar'>
|
||||
{categories.map((category) => (
|
||||
<div key={category.key} ref={(el) => { categoryRefs.current[category.key] = el; }} className='relative'>
|
||||
<button
|
||||
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-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'
|
||||
}`}
|
||||
<Dropdown key={category.key}>
|
||||
<AppButton
|
||||
variant='tertiary'
|
||||
className={`app-search-filter-trigger ${
|
||||
isDefaultValue(category.key) ? '' : 'app-search-filter-trigger-active'
|
||||
}`}
|
||||
>
|
||||
<span>{getDisplayText(category.key)}</span>
|
||||
<svg className={`inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200 ${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' />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</AppButton>
|
||||
<Dropdown.Popover className='app-search-filter-popover'>
|
||||
<ScrollShadow className='app-search-filter-scroll'>
|
||||
<Dropdown.Menu
|
||||
aria-label={`${category.label}筛选`}
|
||||
selectionMode='single'
|
||||
selectedKeys={new Set([mergedValues[category.key]])}
|
||||
onAction={(key) => handleOptionSelect(category.key, String(key))}
|
||||
className='app-search-filter-menu'
|
||||
>
|
||||
{category.options.map((option) => (
|
||||
<Dropdown.Item
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={
|
||||
isOptionSelected(category.key, option.value)
|
||||
? 'app-search-filter-option-active'
|
||||
: 'app-search-filter-option'
|
||||
}
|
||||
>
|
||||
<Label className='app-search-filter-option-label'>
|
||||
{option.label}
|
||||
</Label>
|
||||
<Dropdown.ItemIndicator />
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</ScrollShadow>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
))}
|
||||
{/* 通用年份排序切换按钮 */}
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={() => {
|
||||
<AppButton
|
||||
variant='ghost'
|
||||
onPress={() => {
|
||||
let next;
|
||||
switch (mergedValues.yearOrder) {
|
||||
case 'none':
|
||||
|
|
@ -187,10 +134,11 @@ const SearchResultFilter: React.FC<SearchResultFilterProps> = ({ categories, val
|
|||
}
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
className={`app-search-filter-trigger ${
|
||||
mergedValues.yearOrder === 'none'
|
||||
? ''
|
||||
: 'app-search-filter-trigger-active'
|
||||
}`}
|
||||
aria-label={`按年份${mergedValues.yearOrder === 'none' ? '排序' : mergedValues.yearOrder === 'desc' ? '降序' : '升序'}排序`}
|
||||
>
|
||||
<span>年份</span>
|
||||
|
|
@ -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' />
|
||||
)}
|
||||
</button>
|
||||
</AppButton>
|
||||
</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
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResultFilter;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/* 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 { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
|
||||
interface ShortDramaSelectorProps {
|
||||
selectedCategory: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
|
|
@ -15,14 +18,6 @@ const ShortDramaSelector = ({
|
|||
const [categories, setCategories] = useState<ShortDramaCategory[]>([]);
|
||||
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(() => {
|
||||
const fetchCategories = async () => {
|
||||
|
|
@ -54,52 +49,15 @@ const ShortDramaSelector = ({
|
|||
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 = () => {
|
||||
if (loading) {
|
||||
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) => (
|
||||
<div
|
||||
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>
|
||||
|
|
@ -107,51 +65,26 @@ const ShortDramaSelector = ({
|
|||
}
|
||||
|
||||
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`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<AppFilterTabs
|
||||
ariaLabel='短剧分类'
|
||||
selectedKey={selectedCategory}
|
||||
onSelectionChange={onCategoryChange}
|
||||
items={categories.map((category) => ({
|
||||
key: category.type_id.toString(),
|
||||
label: category.type_name,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 分类选择 */}
|
||||
<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]'>
|
||||
<div className='app-filter-row'>
|
||||
<span className='app-filter-label'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<div className='min-w-0'>
|
||||
{renderCapsuleSelector()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,19 @@
|
|||
|
||||
'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 Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
|
@ -40,7 +52,7 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
|||
return (
|
||||
<button
|
||||
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='点击展开侧边栏'
|
||||
>
|
||||
<Image
|
||||
|
|
@ -57,7 +69,7 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
|||
return (
|
||||
<Link
|
||||
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'>
|
||||
<Image
|
||||
|
|
@ -67,7 +79,7 @@ const Logo = ({ isCollapsed, onClick }: LogoProps) => {
|
|||
height={40}
|
||||
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}
|
||||
</span>
|
||||
</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 (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
{/* 在移动端隐藏侧边栏 */}
|
||||
<div className='hidden md:flex'>
|
||||
<aside
|
||||
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'>
|
||||
{/* 顶部 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'>
|
||||
{isCollapsed ? (
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -233,7 +248,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
{!isCollapsed && (
|
||||
<button
|
||||
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='收起侧边栏'
|
||||
>
|
||||
<Menu className='h-4 w-4' />
|
||||
|
|
@ -242,19 +257,18 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
</div>
|
||||
|
||||
{/* 首页和搜索导航 */}
|
||||
<nav className='px-2 mt-4 space-y-1'>
|
||||
<nav className='mt-6 space-y-1 px-3'>
|
||||
<Link
|
||||
href='/'
|
||||
onClick={() => setActive('/')}
|
||||
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'
|
||||
} gap-3 justify-start`}
|
||||
className={getNavClasses(active === '/')}
|
||||
>
|
||||
<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>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
||||
首页
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -267,14 +281,13 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
setActive('/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'
|
||||
} gap-3 justify-start`}
|
||||
className={getNavClasses(active === '/search')}
|
||||
>
|
||||
<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>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
||||
搜索
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -282,8 +295,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
</nav>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='flex-1 overflow-y-auto px-2 pt-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex-1 overflow-y-auto px-3 pt-6'>
|
||||
<div className='space-y-1 border-t border-border/70 pt-4'>
|
||||
{menuItems.map((item) => {
|
||||
// 检查当前路径是否匹配这个菜单项
|
||||
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||
|
|
@ -304,14 +317,13 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
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'
|
||||
} gap-3 justify-start`}
|
||||
className={getNavClasses(isActive)}
|
||||
>
|
||||
<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>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
<span className='opacity-100 transition-opacity duration-200 whitespace-nowrap'>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -322,20 +334,20 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
</div>
|
||||
|
||||
{/* 致谢信息 */}
|
||||
<div className='px-2 pb-4'>
|
||||
<div className='border-t border-gray-200/50 dark:border-gray-700/50 pt-3'>
|
||||
<div className='px-3 pb-5'>
|
||||
<div className='border-t border-border/70 pt-4'>
|
||||
{!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>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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 项目'
|
||||
>
|
||||
<ExternalLink className='h-3 w-3 inline' />
|
||||
|
|
@ -346,7 +358,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||
<div className='flex justify-center'>
|
||||
<button
|
||||
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 的二次开发'
|
||||
>
|
||||
<ExternalLink className='h-4 w-4' />
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun, MessageCircle } from 'lucide-react';
|
||||
import { MessageCircle, Moon, Sun } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { ChatModal } from './ChatModal';
|
||||
import { AppIconButton } from './ui/HeroPrimitives';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { WebSocketMessage } from '../lib/types';
|
||||
|
||||
|
|
@ -46,10 +47,10 @@ export function ThemeToggle() {
|
|||
if (!meta) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'theme-color';
|
||||
meta.content = theme === 'dark' ? '#0c111c' : '#f9fbfe';
|
||||
meta.content = theme === 'dark' ? '#080707' : '#151212';
|
||||
document.head.appendChild(meta);
|
||||
} 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'}`}>
|
||||
{/* 聊天按钮 - 在登录页面不显示 */}
|
||||
{!isLoginPage && (
|
||||
<button
|
||||
onClick={() => 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`}
|
||||
<AppIconButton
|
||||
onPress={() => setIsChatModalOpen(true)}
|
||||
className={`a2-icon-button relative ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
|
||||
aria-label='Open chat'
|
||||
>
|
||||
<MessageCircle className='w-full h-full' />
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</AppIconButton>
|
||||
)}
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={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`}
|
||||
<AppIconButton
|
||||
onPress={toggleTheme}
|
||||
className={`a2-icon-button ${isMobile ? 'h-8 w-8 p-1.5' : 'h-10 w-10 p-2'}`}
|
||||
aria-label='Toggle theme'
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
|
|
@ -127,7 +128,7 @@ export function ThemeToggle() {
|
|||
) : (
|
||||
<Moon className='w-full h-full' />
|
||||
)}
|
||||
</button>
|
||||
</AppIconButton>
|
||||
</div>
|
||||
|
||||
{/* 聊天模态框 - 在登录页面不渲染 */}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AlertCircle, CheckCircle, Info, X, XCircle } from 'lucide-react';
|
||||
import { Toast as HeroToast } from '@heroui/react';
|
||||
import React, { createContext, useCallback, useContext, useMemo } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
|
|
@ -15,7 +13,7 @@ interface Toast {
|
|||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
showToast: (toast: Toast) => void;
|
||||
showSuccess: (title: string, message?: string) => void;
|
||||
showError: (title: string, message?: string) => void;
|
||||
showWarning: (title: string, message?: string) => void;
|
||||
|
|
@ -36,135 +34,73 @@ interface ToastProviderProps {
|
|||
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 }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
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 showToast = useCallback((toast: Toast) => {
|
||||
showHeroToast(toast);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
const showSuccess = useCallback(
|
||||
(title: string, message?: string) =>
|
||||
showToast({ type: 'success', title, message }),
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast = { ...toast, id };
|
||||
const showError = useCallback(
|
||||
(title: string, message?: string) =>
|
||||
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 duration = toast.duration || 5000;
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}, [removeToast]);
|
||||
const showInfo = useCallback(
|
||||
(title: string, message?: string) =>
|
||||
showToast({ type: 'info', title, message }),
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showSuccess = useCallback((title: string, message?: string) => {
|
||||
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,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
};
|
||||
|
||||
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>
|
||||
const contextValue = useMemo<ToastContextType>(
|
||||
() => ({
|
||||
showToast,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
}),
|
||||
[showError, showInfo, showSuccess, showToast, showWarning]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{mounted && createPortal(toastContainer, document.body)}
|
||||
<HeroToast.Provider placement='top end' />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
Label,
|
||||
} from '@heroui/react';
|
||||
import {
|
||||
Camera,
|
||||
Check,
|
||||
|
|
@ -28,6 +32,7 @@ import { checkForUpdates, UpdateStatus } from '@/lib/version_check';
|
|||
|
||||
import { VersionPanel } from './VersionPanel';
|
||||
import { useToast } from './Toast';
|
||||
import { AppIconButton } from './ui/HeroPrimitives';
|
||||
|
||||
interface AuthInfo {
|
||||
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='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 ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
|
|
@ -710,33 +715,33 @@ export const UserMenu: React.FC = () => {
|
|||
className='object-cover'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center'>
|
||||
<User className='w-6 h-6 text-blue-500 dark:text-blue-400' />
|
||||
<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 className='flex-1 min-w-0'>
|
||||
<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
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${(authInfo?.role || 'user') === 'owner'
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
className={`inline-flex items-center border border-border/70 px-1.5 py-0.5 text-xs font-medium ${(authInfo?.role || 'user') === 'owner'
|
||||
? 'text-accent'
|
||||
: (authInfo?.role || 'user') === 'admin'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
? 'text-foreground'
|
||||
: 'text-success'
|
||||
}`}
|
||||
>
|
||||
{getRoleText(authInfo?.role || 'user')}
|
||||
</span>
|
||||
</div>
|
||||
<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'}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -749,9 +754,9 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 设置按钮 */}
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
|
||||
|
|
@ -759,9 +764,9 @@ export const UserMenu: React.FC = () => {
|
|||
{showAdminPanel && (
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -769,9 +774,9 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 修改头像按钮 */}
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
|
||||
|
|
@ -779,27 +784,27 @@ export const UserMenu: React.FC = () => {
|
|||
{showChangePassword && (
|
||||
<button
|
||||
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>
|
||||
</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
|
||||
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' />
|
||||
<span className='font-medium'>登出</span>
|
||||
</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
|
||||
|
|
@ -807,7 +812,7 @@ export const UserMenu: React.FC = () => {
|
|||
setIsVersionPanelOpen(true);
|
||||
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'>
|
||||
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||
|
|
@ -816,9 +821,9 @@ export const UserMenu: React.FC = () => {
|
|||
updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full -translate-y-2 ${updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'bg-yellow-500'
|
||||
? 'bg-warning'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
? 'bg-green-400'
|
||||
? 'bg-success'
|
||||
: ''
|
||||
}`}
|
||||
></div>
|
||||
|
|
@ -852,7 +857,7 @@ 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-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
|
||||
|
|
@ -866,12 +871,12 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<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>
|
||||
<button
|
||||
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='重置为默认设置'
|
||||
>
|
||||
恢复默认
|
||||
|
|
@ -879,7 +884,7 @@ export const UserMenu: React.FC = () => {
|
|||
</div>
|
||||
<button
|
||||
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'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
|
|
@ -891,10 +896,10 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 豆瓣数据源选择 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
豆瓣数据代理
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
选择获取豆瓣数据的方式
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -903,7 +908,7 @@ export const UserMenu: React.FC = () => {
|
|||
<button
|
||||
type='button'
|
||||
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(
|
||||
|
|
@ -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'>
|
||||
<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>
|
||||
|
||||
{/* 下拉选项列表 */}
|
||||
{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) => (
|
||||
<button
|
||||
key={option.value}
|
||||
|
|
@ -931,14 +936,14 @@ export const UserMenu: React.FC = () => {
|
|||
handleDoubanDataSourceChange(option.value);
|
||||
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
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
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-accent/10 text-accent'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -954,7 +959,7 @@ export const UserMenu: React.FC = () => {
|
|||
onClick={() =>
|
||||
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'>
|
||||
{getThanksInfo(doubanDataSource)!.text}
|
||||
|
|
@ -969,16 +974,16 @@ export const UserMenu: React.FC = () => {
|
|||
{doubanDataSource === 'custom' && (
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
豆瓣代理地址
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
自定义代理服务器地址
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
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='
|
||||
value={doubanProxyUrl}
|
||||
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>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
豆瓣图片代理
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
选择获取豆瓣图片的方式
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1008,7 +1013,7 @@ export const UserMenu: React.FC = () => {
|
|||
!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(
|
||||
|
|
@ -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'>
|
||||
<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>
|
||||
|
||||
{/* 下拉选项列表 */}
|
||||
{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) => (
|
||||
<button
|
||||
key={option.value}
|
||||
|
|
@ -1036,14 +1041,14 @@ export const UserMenu: React.FC = () => {
|
|||
handleDoubanImageProxyTypeChange(option.value);
|
||||
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
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
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-accent/10 text-accent'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -1062,7 +1067,7 @@ export const UserMenu: React.FC = () => {
|
|||
'_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'>
|
||||
{getThanksInfo(doubanImageProxyType)!.text}
|
||||
|
|
@ -1077,16 +1082,16 @@ export const UserMenu: React.FC = () => {
|
|||
{doubanImageProxyType === 'custom' && (
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
豆瓣图片代理地址
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
自定义图片代理服务器地址
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
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='
|
||||
value={doubanImageProxyUrl}
|
||||
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>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
默认聚合搜索结果
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
搜索时默认按标题和年份聚合显示结果
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1117,8 +1122,8 @@ export const UserMenu: React.FC = () => {
|
|||
checked={defaultAggregateSearch}
|
||||
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='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='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 left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -1126,10 +1131,10 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 优选和测速 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
优选和测速
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
如出现播放器劫持问题可关闭
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1141,8 +1146,8 @@ export const UserMenu: React.FC = () => {
|
|||
checked={enableOptimization}
|
||||
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='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='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 left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -1150,10 +1155,10 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 流式搜索 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
流式搜索输出
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
<p className='mt-1 text-xs text-muted'>
|
||||
启用搜索结果实时流式输出,关闭后使用传统一次性搜索
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1165,8 +1170,8 @@ export const UserMenu: React.FC = () => {
|
|||
checked={fluidSearch}
|
||||
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='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='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 left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -1174,10 +1179,10 @@ export const UserMenu: React.FC = () => {
|
|||
{/* 直播视频浏览器直连 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<h4 className='text-sm font-medium text-foreground'>
|
||||
IPTV 视频浏览器直连
|
||||
</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 插件
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1189,16 +1194,16 @@ export const UserMenu: React.FC = () => {
|
|||
checked={liveDirectConnect}
|
||||
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='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='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 left-0.5 top-0.5 h-5 w-5 bg-foreground transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||
<div className='mt-6 border-t border-border/70 pt-4'>
|
||||
<p className='text-center text-xs text-muted'>
|
||||
这些设置保存在本地浏览器中
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1229,7 +1234,7 @@ 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'
|
||||
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
|
||||
|
|
@ -1245,12 +1250,12 @@ export const UserMenu: React.FC = () => {
|
|||
>
|
||||
{/* 标题栏 */}
|
||||
<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>
|
||||
<button
|
||||
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'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
|
|
@ -1261,12 +1266,12 @@ export const UserMenu: React.FC = () => {
|
|||
<div className='space-y-4'>
|
||||
{/* 新密码输入 */}
|
||||
<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>
|
||||
<input
|
||||
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='请输入新密码'
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
|
|
@ -1276,12 +1281,12 @@ export const UserMenu: React.FC = () => {
|
|||
|
||||
{/* 确认密码输入 */}
|
||||
<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>
|
||||
<input
|
||||
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='请再次输入新密码'
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
|
|
@ -1291,24 +1296,24 @@ export const UserMenu: React.FC = () => {
|
|||
|
||||
{/* 错误信息 */}
|
||||
{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}
|
||||
</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
|
||||
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}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
{passwordLoading ? '修改中...' : '确认修改'}
|
||||
|
|
@ -1316,8 +1321,8 @@ export const UserMenu: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* 底部说明 */}
|
||||
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||
<div className='mt-4 border-t border-border/70 pt-4'>
|
||||
<p className='text-center text-xs text-muted'>
|
||||
修改密码后需要重新登录
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1328,33 +1333,116 @@ export const UserMenu: React.FC = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={handleMenuClick}
|
||||
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`}
|
||||
<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<AppIconButton
|
||||
className={`a2-icon-button overflow-hidden ${isMobile ? 'h-8 w-8 p-0.5' : 'h-10 w-10 p-0.5'}`}
|
||||
aria-label='User Menu'
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<div className='w-full h-full rounded-full overflow-hidden relative'>
|
||||
<span className='relative flex h-full w-full items-center justify-center overflow-hidden'>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt="用户头像"
|
||||
alt='用户头像'
|
||||
fill
|
||||
sizes="40px"
|
||||
sizes='40px'
|
||||
className='object-cover'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<User className='w-6 h-6' />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<User className='h-6 w-6' />
|
||||
)}
|
||||
</span>
|
||||
</AppIconButton>
|
||||
<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 className='min-w-0 flex-1'>
|
||||
<p className='truncate text-sm font-semibold text-foreground'>
|
||||
{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 top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
|
||||
<div className='absolute right-[2px] top-[2px] h-2 w-2 bg-warning'></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用 Portal 将菜单面板渲染到 document.body */}
|
||||
{isOpen && mounted && createPortal(menuPanel, document.body)}
|
||||
</Dropdown>
|
||||
|
||||
{/* 使用 Portal 将设置面板渲染到 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='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>
|
||||
<button
|
||||
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'
|
||||
>
|
||||
<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='w-24 h-24 rounded-full overflow-hidden relative'>
|
||||
<div className='relative h-24 w-24 overflow-hidden border border-border/70'>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
|
|
@ -1409,8 +1497,8 @@ export const UserMenu: React.FC = () => {
|
|||
className='object-cover'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center'>
|
||||
<User className='w-12 h-12 text-blue-500 dark:text-blue-400' />
|
||||
<div className='flex h-full w-full items-center justify-center bg-surface-secondary/60'>
|
||||
<User className='h-12 w-12 text-accent' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1428,7 +1516,7 @@ export const UserMenu: React.FC = () => {
|
|||
<button
|
||||
onClick={handleOpenFileSelector}
|
||||
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' />
|
||||
选择图片
|
||||
|
|
@ -1476,14 +1564,14 @@ export const UserMenu: React.FC = () => {
|
|||
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
|
||||
onClick={handleConfirmCrop}
|
||||
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' />
|
||||
{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
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
return (
|
||||
<>
|
||||
<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}
|
||||
{...longPressProps}
|
||||
style={{
|
||||
|
|
@ -568,7 +568,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
>
|
||||
{/* 海报容器 */}
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -615,7 +615,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
{/* 悬浮遮罩 */}
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -645,7 +645,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
<PlayCircleIcon
|
||||
size={50}
|
||||
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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -695,8 +695,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
onClick={handleToggleFavorite}
|
||||
size={20}
|
||||
className={`transition-all duration-300 ease-out ${favorited
|
||||
? 'fill-red-600 stroke-red-600'
|
||||
: 'fill-transparent stroke-white hover:stroke-red-400'
|
||||
? 'fill-danger stroke-danger'
|
||||
: 'fill-transparent stroke-white hover:stroke-accent'
|
||||
} hover:scale-[1.1]`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
|
|
@ -715,7 +715,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
{/* 年份徽章 */}
|
||||
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -733,7 +733,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
{/* 徽章 */}
|
||||
{config.showRating && rate && (
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -750,7 +750,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
{actualEpisodes && actualEpisodes > 1 && (
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -790,7 +790,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -841,7 +841,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
} as React.CSSProperties}
|
||||
>
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -889,7 +889,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -904,7 +904,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
<div className='space-y-0.5 sm:space-y-1'>
|
||||
{displaySources.map((sourceName, index) => (
|
||||
<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}>
|
||||
{sourceName}
|
||||
</span>
|
||||
|
|
@ -914,15 +914,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
{/* 显示更多提示 */}
|
||||
{hasMore && (
|
||||
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
|
||||
<div className='flex items-center justify-center text-gray-400'>
|
||||
<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-muted'>
|
||||
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} 播放源</span>
|
||||
</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>
|
||||
);
|
||||
|
|
@ -936,7 +936,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
{/* 进度条 */}
|
||||
{config.showProgress && progress !== undefined && (
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -948,7 +948,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
<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={{
|
||||
width: `${progress}%`,
|
||||
WebkitUserSelect: 'none',
|
||||
|
|
@ -965,7 +965,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
|
||||
{/* 标题与来源 */}
|
||||
<div
|
||||
className='mt-2 text-center'
|
||||
className='mt-3 text-left'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -985,7 +985,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
} as React.CSSProperties}
|
||||
>
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -1000,7 +1000,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
</span>
|
||||
{/* 自定义 tooltip */}
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -1013,7 +1013,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
>
|
||||
{actualTitle}
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -1024,7 +1024,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
</div>
|
||||
{config.showSourceName && source_name && (
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -1036,7 +1036,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
|
@ -1048,7 +1048,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||
}}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AppFilterTabs } from './ui/HeroPrimitives';
|
||||
|
||||
interface WeekdaySelectorProps {
|
||||
onWeekdayChange: (weekday: string) => void;
|
||||
className?: string;
|
||||
|
|
@ -41,32 +43,19 @@ const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
|
|||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative inline-flex rounded-full p-0.5 sm:p-1 ${className}`}
|
||||
>
|
||||
{weekdays.map((weekday) => {
|
||||
const isActive = selectedWeekday === weekday.value;
|
||||
return (
|
||||
<button
|
||||
key={weekday.value}
|
||||
onClick={() => {
|
||||
setSelectedWeekday(weekday.value);
|
||||
onWeekdayChange(weekday.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>
|
||||
<AppFilterTabs
|
||||
ariaLabel='星期筛选'
|
||||
className={className}
|
||||
items={weekdays.map((weekday) => ({
|
||||
key: weekday.value,
|
||||
label: weekday.shortLabel,
|
||||
}))}
|
||||
selectedKey={selectedWeekday}
|
||||
onSelectionChange={(value) => {
|
||||
setSelectedWeekday(value);
|
||||
onWeekdayChange(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
extend: {
|
||||
screens: {
|
||||
'mobile-landscape': {
|
||||
raw: '(orientation: landscape) and (max-height: 700px)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
primary: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||
primary: ['var(--font-body)', ...defaultTheme.fontFamily.sans],
|
||||
mono: ['var(--font-mono)', ...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
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: {
|
||||
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "Node16",
|
||||
"moduleResolution": "node16",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
|
|
@ -41,10 +41,5 @@
|
|||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"moduleResolution": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue