fix: refactor

This commit is contained in:
shinya
2025-06-19 23:03:37 +08:00
parent 29f729df54
commit 8e4781f44c
23 changed files with 221 additions and 1513 deletions

View File

@@ -1,212 +1,7 @@
import { NextResponse } from 'next/server';
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config';
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 清理 HTML 标签的工具函数
function cleanHtmlTags(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
.replace(/&nbsp;/g, ' ') // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === 'ffzy') {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
matches = Array.from(new Set(matches));
matches = matches.map((link: string) => {
link = link.substring(1, link.length);
const parenIndex = link.indexOf('(');
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
return {
code: 200,
episodes: matches,
detailUrl: detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id: id,
},
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split('$$$');
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split('#');
episodes = episodeList
.map((ep: string) => {
const parts = ep.split('$');
return parts.length > 1 ? parts[1] : '';
})
.filter(
(url: string) =>
url && (url.startsWith('http://') || url.startsWith('https://'))
);
}
}
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
code: 200,
episodes: episodes,
detailUrl: detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year,
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id: id,
},
};
}
export async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) {
throw new Error('缺少视频ID参数');
}
if (!/^[\w-]+$/.test(id)) {
throw new Error('无效的视频ID格式');
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (apiSite.detail) {
return await handleSpecialSourceDetail(id, apiSite);
} else {
return await getDetailFromApi(apiSite, id);
}
}
import { getCacheTime } from '@/lib/config';
import { getVideoDetail } from '@/lib/video';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server';
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config';
import { getVideoDetail } from '../detail/route';
import { getVideoDetail } from '@/lib/video';
export interface SearchResult {
id: string;

View File

@@ -1,17 +0,0 @@
import { Metadata } from 'next';
import * as React from 'react';
import '@/styles/colors.css';
export const metadata: Metadata = {
title: 'Components',
description: 'Pre-built components with awesome default',
};
export default function ComponentsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -1,461 +0,0 @@
'use client';
import clsx from 'clsx';
import {
ArrowRight,
CreditCard,
Laptop,
Phone,
Plus,
Shield,
} from 'lucide-react';
import * as React from 'react';
import Button from '@/components/buttons/Button';
import IconButton from '@/components/buttons/IconButton';
import TextButton from '@/components/buttons/TextButton';
import ArrowLink from '@/components/links/ArrowLink';
import ButtonLink from '@/components/links/ButtonLink';
import PrimaryLink from '@/components/links/PrimaryLink';
import UnderlineLink from '@/components/links/UnderlineLink';
import UnstyledLink from '@/components/links/UnstyledLink';
import NextImage from '@/components/NextImage';
import Skeleton from '@/components/Skeleton';
type Color = (typeof colorList)[number];
export default function ComponentPage() {
const [mode, setMode] = React.useState<'dark' | 'light'>('light');
const [color, setColor] = React.useState<Color>('sky');
function toggleMode() {
return mode === 'dark' ? setMode('light') : setMode('dark');
}
const textColor = mode === 'dark' ? 'text-gray-300' : 'text-gray-600';
return (
<main>
<section
className={clsx(mode === 'dark' ? 'bg-dark' : 'bg-white', color)}
>
<div
className={clsx(
'layout min-h-screen py-20',
mode === 'dark' ? 'text-white' : 'text-black'
)}
>
<h1>Built-in Components</h1>
<ArrowLink direction='left' className='mt-2' href='/'>
Back to Home
</ArrowLink>
<div className='mt-8 flex flex-wrap gap-2'>
<Button
onClick={toggleMode}
variant={mode === 'dark' ? 'light' : 'dark'}
>
Set to {mode === 'dark' ? 'light' : 'dark'}
</Button>
{/* <Button onClick={randomize}>Randomize CSS Variable</Button> */}
</div>
<ol className='mt-8 space-y-6'>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>Customize Colors</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
You can change primary color to any Tailwind CSS colors. See
globals.css to change your color.
</p>
<div className='flex flex-wrap gap-2'>
<select
name='color'
id='color'
value={color}
className={clsx(
'block max-w-xs rounded',
mode === 'dark'
? 'bg-dark border border-gray-600'
: 'border-gray-300 bg-white',
'focus:border-primary-400 focus:ring-primary-400 focus:outline-none focus:ring'
)}
onChange={(e) => setColor(e.target.value as Color)}
>
{colorList.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<ButtonLink href='https://github.com/theodorusclarence/ts-nextjs-tailwind-starter/blob/main/src/styles/colors.css'>
Check list of colors
</ButtonLink>
</div>
<div className='flex flex-wrap gap-2 text-xs font-medium'>
<div className='bg-primary-50 flex h-10 w-10 items-center justify-center rounded text-black'>
50
</div>
<div className='bg-primary-100 flex h-10 w-10 items-center justify-center rounded text-black'>
100
</div>
<div className='bg-primary-200 flex h-10 w-10 items-center justify-center rounded text-black'>
200
</div>
<div className='bg-primary-300 flex h-10 w-10 items-center justify-center rounded text-black'>
300
</div>
<div className='bg-primary-400 flex h-10 w-10 items-center justify-center rounded text-black'>
400
</div>
<div className='bg-primary-500 flex h-10 w-10 items-center justify-center rounded text-black'>
500
</div>
<div className='bg-primary-600 flex h-10 w-10 items-center justify-center rounded text-white'>
600
</div>
<div className='bg-primary-700 flex h-10 w-10 items-center justify-center rounded text-white'>
700
</div>
<div className='bg-primary-800 flex h-10 w-10 items-center justify-center rounded text-white'>
800
</div>
<div className='bg-primary-900 flex h-10 w-10 items-center justify-center rounded text-white'>
900
</div>
<div className='bg-primary-950 flex h-10 w-10 items-center justify-center rounded text-white'>
950
</div>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>UnstyledLink</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
No style applied, differentiate internal and outside links, give
custom cursor for outside links.
</p>
<div className='space-x-2'>
<UnstyledLink href='/'>Internal Links</UnstyledLink>
<UnstyledLink href='https://theodorusclarence.com'>
Outside Links
</UnstyledLink>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>PrimaryLink</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Add styling on top of UnstyledLink, giving a primary color to
the link.
</p>
<div className='space-x-2'>
<PrimaryLink href='/'>Internal Links</PrimaryLink>
<PrimaryLink href='https://theodorusclarence.com'>
Outside Links
</PrimaryLink>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>UnderlineLink</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Add styling on top of UnstyledLink, giving a dotted and animated
underline.
</p>
<div className='space-x-2'>
<UnderlineLink href='/'>Internal Links</UnderlineLink>
<UnderlineLink href='https://theodorusclarence.com'>
Outside Links
</UnderlineLink>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>ArrowLink</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Useful for indicating navigation, I use this quite a lot, so why
not build a component with some whimsy touch?
</p>
<div className='flex flex-wrap items-center gap-4'>
<ArrowLink href='/' direction='left'>
Direction Left
</ArrowLink>
<ArrowLink href='/'>Direction Right</ArrowLink>
<ArrowLink
as={UnstyledLink}
className='inline-flex items-center'
href='/'
>
Polymorphic
</ArrowLink>
<ArrowLink
as={ButtonLink}
variant='light'
className='inline-flex items-center'
href='/'
>
Polymorphic
</ArrowLink>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>ButtonLink</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Button styled link with 3 variants.
</p>
<div className='flex flex-wrap gap-2'>
<ButtonLink
variant='primary'
href='https://theodorusclarence.com'
>
Primary Variant
</ButtonLink>
<ButtonLink
variant='outline'
isDarkBg={mode === 'dark'}
href='https://theodorusclarence.com'
>
Outline Variant
</ButtonLink>
<ButtonLink
variant='ghost'
isDarkBg={mode === 'dark'}
href='https://theodorusclarence.com'
>
Ghost Variant
</ButtonLink>
<ButtonLink variant='dark' href='https://theodorusclarence.com'>
Dark Variant
</ButtonLink>
<ButtonLink
variant='light'
href='https://theodorusclarence.com'
>
Light Variant
</ButtonLink>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>Button</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Ordinary button with style.
</p>
<div className='flex flex-wrap gap-2'>
<Button variant='primary'>Primary Variant</Button>
<Button variant='outline' isDarkBg={mode === 'dark'}>
Outline Variant
</Button>
<Button variant='ghost' isDarkBg={mode === 'dark'}>
Ghost Variant
</Button>
<Button variant='dark'>Dark Variant</Button>
<Button variant='light'>Light Variant</Button>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='primary'
leftIcon={Plus}
rightIcon={ArrowRight}
>
Icon
</Button>
<Button
variant='outline'
leftIcon={Plus}
rightIcon={ArrowRight}
isDarkBg={mode === 'dark'}
>
Icon
</Button>
<Button
variant='ghost'
leftIcon={Plus}
rightIcon={ArrowRight}
isDarkBg={mode === 'dark'}
>
Icon
</Button>
<Button variant='dark' leftIcon={Plus} rightIcon={ArrowRight}>
Icon
</Button>
<Button variant='light' leftIcon={Plus} rightIcon={ArrowRight}>
Icon
</Button>
</div>
<div className='!mt-4 flex flex-wrap gap-2'>
<Button size='sm' variant='primary'>
Small Size
</Button>
<Button size='sm' variant='outline' isDarkBg={mode === 'dark'}>
Small Size
</Button>
<Button size='sm' variant='ghost' isDarkBg={mode === 'dark'}>
Small Size
</Button>
<Button size='sm' variant='dark'>
Small Size
</Button>
<Button size='sm' variant='light'>
Small Size
</Button>
</div>
<div className='flex flex-wrap gap-2'>
<Button
size='sm'
variant='primary'
leftIcon={Plus}
rightIcon={ArrowRight}
>
Icon
</Button>
<Button
size='sm'
variant='outline'
leftIcon={Plus}
rightIcon={ArrowRight}
isDarkBg={mode === 'dark'}
>
Icon
</Button>
<Button
size='sm'
variant='ghost'
leftIcon={Plus}
rightIcon={ArrowRight}
isDarkBg={mode === 'dark'}
>
Icon
</Button>
<Button
size='sm'
variant='dark'
leftIcon={Plus}
rightIcon={ArrowRight}
>
Icon
</Button>
<Button
size='sm'
variant='light'
leftIcon={Plus}
rightIcon={ArrowRight}
>
Icon
</Button>
</div>
<div className='!mt-4 flex flex-wrap gap-2'>
<Button disabled variant='primary'>
Disabled
</Button>
<Button disabled variant='outline' isDarkBg={mode === 'dark'}>
Disabled
</Button>
<Button disabled variant='ghost' isDarkBg={mode === 'dark'}>
Disabled
</Button>
<Button disabled variant='dark'>
Disabled
</Button>
<Button disabled variant='light'>
Disabled
</Button>
</div>
<div className='flex flex-wrap gap-2'>
<Button isLoading variant='primary'>
Disabled
</Button>
<Button isLoading variant='outline' isDarkBg={mode === 'dark'}>
Disabled
</Button>
<Button isLoading variant='ghost' isDarkBg={mode === 'dark'}>
Disabled
</Button>
<Button isLoading variant='dark'>
Disabled
</Button>
<Button isLoading variant='light'>
Disabled
</Button>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>TextButton</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Button with a text style
</p>
<div className='space-x-2'>
<TextButton>Primary Variant</TextButton>
<TextButton variant='basic'>Basic Variant</TextButton>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>IconButton</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Button with only icon inside
</p>
<div className='space-x-2'>
<IconButton icon={Plus} />
<IconButton variant='outline' icon={Laptop} />
<IconButton variant='ghost' icon={Phone} />
<IconButton variant='dark' icon={Shield} />
<IconButton variant='light' icon={CreditCard} />
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>Custom 404 Page</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Styled 404 page with some animation.
</p>
<div className='flex flex-wrap gap-2'>
<ButtonLink href='/404'>Visit the 404 page</ButtonLink>
</div>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>Next Image</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Next Image with default props and skeleton animation
</p>
<NextImage
useSkeleton
className='w-32 md:w-40'
src='/favicon/android-chrome-192x192.png'
width='180'
height='180'
alt='Icon'
/>
</li>
<li className='space-y-2'>
<h2 className='text-lg md:text-xl'>Skeleton</h2>
<p className={clsx('!mt-1 text-sm', textColor)}>
Skeleton with shimmer effect
</p>
<Skeleton className='h-72 w-72' />
</li>
</ol>
</div>
</section>
</main>
);
}
const colorList = [
'slate',
'gray',
'zinc',
'neutral',
'stone',
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
] as const;

View File

@@ -6,10 +6,9 @@ import { useEffect, useState } from 'react';
import type { PlayRecord } from '@/lib/db.client';
import { generateStorageKey, getAllPlayRecords } from '@/lib/db.client';
import { VideoDetail } from '@/lib/video';
import PageLayout from '@/components/layout/PageLayout';
import { VideoDetail } from '../api/detail/route';
import PageLayout from '@/components/PageLayout';
export default function DetailPage() {
const searchParams = useSearchParams();

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react';
import DemoCard from '@/components/DemoCard';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import PageLayout from '@/components/layout/PageLayout';
import PageLayout from '@/components/PageLayout';
// 定义豆瓣数据项类型
interface DoubanItem {

View File

@@ -1,38 +0,0 @@
'use client'; // Error components must be Client Components
import * as React from 'react';
import { RiAlarmWarningFill } from 'react-icons/ri';
import TextButton from '@/components/buttons/TextButton';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
React.useEffect(() => {
// eslint-disable-next-line no-console
console.error(error);
}, [error]);
return (
<main>
<section className='bg-white'>
<div className='layout flex min-h-screen flex-col items-center justify-center text-center text-black'>
<RiAlarmWarningFill
size={60}
className='drop-shadow-glow animate-flicker text-red-500'
/>
<h1 className='mt-8 text-4xl md:text-6xl'>
Oops, something went wrong!
</h1>
<TextButton variant='basic' onClick={reset} className='mt-4'>
Try again
</TextButton>
</div>
</section>
</main>
);
}

View File

@@ -1,24 +0,0 @@
import { Metadata } from 'next';
import * as React from 'react';
import { RiAlarmWarningFill } from 'react-icons/ri';
export const metadata: Metadata = {
title: 'Not Found',
};
export default function NotFound() {
return (
<main>
<section className='bg-white'>
<div className='layout flex min-h-screen flex-col items-center justify-center text-center text-black'>
<RiAlarmWarningFill
size={60}
className='drop-shadow-glow animate-flicker text-red-500'
/>
<h1 className='mt-8 text-4xl md:text-6xl'>Page Not Found</h1>
<a href='/'>Back to home</a>
</div>
</section>
</main>
);
}

View File

@@ -15,7 +15,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
import CollectionCard from '@/components/CollectionCard';
import ContinueWatching from '@/components/ContinueWatching';
import DemoCard from '@/components/DemoCard';
import PageLayout from '@/components/layout/PageLayout';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
interface DoubanItem {

View File

@@ -12,8 +12,7 @@ import {
getAllPlayRecords,
savePlayRecord,
} from '@/lib/db.client';
import { VideoDetail } from '../api/detail/route';
import { VideoDetail } from '@/lib/video';
// 动态导入 Artplayer 和 Hls 以避免 SSR 问题
let Artplayer: any = null;

View File

@@ -4,7 +4,7 @@ import { Search } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import PageLayout from '@/components/layout/PageLayout';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
// 模拟搜索历史数据

View File

@@ -1,160 +0,0 @@
import { LucideIcon } from 'lucide-react';
import * as React from 'react';
import { IconType } from 'react-icons';
import { ImSpinner2 } from 'react-icons/im';
import { cn } from '@/lib/utils';
const ButtonVariant = ['primary', 'outline', 'ghost', 'light', 'dark'] as const;
const ButtonSize = ['sm', 'base'] as const;
type ButtonProps = {
isLoading?: boolean;
isDarkBg?: boolean;
variant?: (typeof ButtonVariant)[number];
size?: (typeof ButtonSize)[number];
leftIcon?: IconType | LucideIcon;
rightIcon?: IconType | LucideIcon;
classNames?: {
leftIcon?: string;
rightIcon?: string;
};
} & React.ComponentPropsWithRef<'button'>;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
className,
disabled: buttonDisabled,
isLoading,
variant = 'primary',
size = 'base',
isDarkBg = false,
leftIcon: LeftIcon,
rightIcon: RightIcon,
classNames,
...rest
},
ref
) => {
const disabled = isLoading || buttonDisabled;
return (
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(
'inline-flex items-center rounded font-medium',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:ring',
'shadow-sm',
'transition-colors duration-75',
//#region //*=========== Size ===========
[
size === 'base' && ['px-3 py-1.5', 'text-sm md:text-base'],
size === 'sm' && ['px-2 py-1', 'text-xs md:text-sm'],
],
//#endregion //*======== Size ===========
//#region //*=========== Variants ===========
[
variant === 'primary' && [
'bg-primary-500 text-white',
'border-primary-600 border',
'hover:bg-primary-600 hover:text-white',
'active:bg-primary-700',
'disabled:bg-primary-700',
],
variant === 'outline' && [
'text-primary-500',
'border-primary-500 border',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'ghost' && [
'text-primary-500',
'shadow-none',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'light' && [
'bg-white text-gray-700',
'border border-gray-300',
'hover:text-dark hover:bg-gray-100',
'active:bg-white/80 disabled:bg-gray-200',
],
variant === 'dark' && [
'bg-gray-900 text-white',
'border border-gray-600',
'hover:bg-gray-800 active:bg-gray-700 disabled:bg-gray-700',
],
],
//#endregion //*======== Variants ===========
'disabled:cursor-not-allowed',
isLoading &&
'relative text-transparent transition-none hover:text-transparent disabled:cursor-wait',
className
)}
{...rest}
>
{isLoading && (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
{
'text-white': ['primary', 'dark'].includes(variant),
'text-black': ['light'].includes(variant),
'text-primary-500': ['outline', 'ghost'].includes(variant),
}
)}
>
<ImSpinner2 className='animate-spin' />
</div>
)}
{LeftIcon && (
<div
className={cn([
size === 'base' && 'mr-1',
size === 'sm' && 'mr-1.5',
])}
>
<LeftIcon
size='1em'
className={cn(
[
size === 'base' && 'md:text-md text-md',
size === 'sm' && 'md:text-md text-sm',
],
classNames?.leftIcon
)}
/>
</div>
)}
{children}
{RightIcon && (
<div
className={cn([
size === 'base' && 'ml-1',
size === 'sm' && 'ml-1.5',
])}
>
<RightIcon
size='1em'
className={cn(
[
size === 'base' && 'text-md md:text-md',
size === 'sm' && 'md:text-md text-sm',
],
classNames?.rightIcon
)}
/>
</div>
)}
</button>
);
}
);
export default Button;

View File

@@ -1,116 +0,0 @@
import { LucideIcon } from 'lucide-react';
import * as React from 'react';
import { IconType } from 'react-icons';
import { ImSpinner2 } from 'react-icons/im';
import { cn } from '@/lib/utils';
const IconButtonVariant = [
'primary',
'outline',
'ghost',
'light',
'dark',
] as const;
type IconButtonProps = {
isLoading?: boolean;
isDarkBg?: boolean;
variant?: (typeof IconButtonVariant)[number];
icon?: IconType | LucideIcon;
classNames?: {
icon?: string;
};
} & React.ComponentPropsWithRef<'button'>;
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
(
{
className,
disabled: buttonDisabled,
isLoading,
variant = 'primary',
isDarkBg = false,
icon: Icon,
classNames,
...rest
},
ref
) => {
const disabled = isLoading || buttonDisabled;
return (
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(
'inline-flex items-center justify-center rounded font-medium',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:ring',
'shadow-sm',
'transition-colors duration-75',
'min-h-[28px] min-w-[28px] p-1 md:min-h-[34px] md:min-w-[34px] md:p-2',
//#region //*=========== Variants ===========
[
variant === 'primary' && [
'bg-primary-500 text-white',
'border-primary-600 border',
'hover:bg-primary-600 hover:text-white',
'active:bg-primary-700',
'disabled:bg-primary-700',
],
variant === 'outline' && [
'text-primary-500',
'border-primary-500 border',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'ghost' && [
'text-primary-500',
'shadow-none',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'light' && [
'bg-white text-gray-700',
'border border-gray-300',
'hover:text-dark hover:bg-gray-100',
'active:bg-white/80 disabled:bg-gray-200',
],
variant === 'dark' && [
'bg-gray-900 text-white',
'border border-gray-600',
'hover:bg-gray-800 active:bg-gray-700 disabled:bg-gray-700',
],
],
//#endregion //*======== Variants ===========
'disabled:cursor-not-allowed',
isLoading &&
'relative text-transparent transition-none hover:text-transparent disabled:cursor-wait',
className
)}
{...rest}
>
{isLoading && (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
{
'text-white': ['primary', 'dark'].includes(variant),
'text-black': ['light'].includes(variant),
'text-primary-500': ['outline', 'ghost'].includes(variant),
}
)}
>
<ImSpinner2 className='animate-spin' />
</div>
)}
{Icon && <Icon size='1em' className={cn(classNames?.icon)} />}
</button>
);
}
);
export default IconButton;

View File

@@ -1,52 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const TextButtonVariant = ['primary', 'basic'] as const;
type TextButtonProps = {
variant?: (typeof TextButtonVariant)[number];
} & React.ComponentPropsWithRef<'button'>;
const TextButton = React.forwardRef<HTMLButtonElement, TextButtonProps>(
(
{
children,
className,
variant = 'primary',
disabled: buttonDisabled,
...rest
},
ref
) => {
return (
<button
ref={ref}
type='button'
disabled={buttonDisabled}
className={cn(
'button inline-flex items-center justify-center font-semibold',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:ring',
'transition duration-100',
//#region //*=========== Variant ===========
variant === 'primary' && [
'text-primary-500 hover:text-primary-600 active:text-primary-700',
'disabled:text-primary-200',
],
variant === 'basic' && [
'text-black hover:text-gray-600 active:text-gray-800',
'disabled:text-gray-300',
],
//#endregion //*======== Variant ===========
'disabled:cursor-not-allowed disabled:brightness-105 disabled:hover:underline',
className
)}
{...rest}
>
{children}
</button>
);
}
);
export default TextButton;

View File

@@ -1,64 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import UnderlineLink from '@/components/links/UnderlineLink';
import { UnstyledLinkProps } from '@/components/links/UnstyledLink';
type ArrowLinkProps<C extends React.ElementType> = {
as?: C;
direction?: 'left' | 'right';
} & UnstyledLinkProps &
React.ComponentProps<C>;
export default function ArrowLink<C extends React.ElementType>({
children,
className,
direction = 'right',
as,
...rest
}: ArrowLinkProps<C>) {
const Component = as || UnderlineLink;
return (
<Component
{...rest}
className={cn(
'group gap-[0.25em]',
direction === 'left' && 'flex-row-reverse',
className
)}
>
<span>{children}</span>
<svg
viewBox='0 0 16 16'
height='1em'
width='1em'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn(
'relative',
'transition-transform duration-200',
direction === 'right' ? 'motion-safe:-translate-x-1' : 'rotate-180',
'group-hover:translate-x-0'
)}
>
<path
fill='currentColor'
d='M7.28033 3.21967C6.98744 2.92678 6.51256 2.92678 6.21967 3.21967C5.92678 3.51256 5.92678 3.98744 6.21967 4.28033L7.28033 3.21967ZM11 8L11.5303 8.53033C11.8232 8.23744 11.8232 7.76256 11.5303 7.46967L11 8ZM6.21967 11.7197C5.92678 12.0126 5.92678 12.4874 6.21967 12.7803C6.51256 13.0732 6.98744 13.0732 7.28033 12.7803L6.21967 11.7197ZM6.21967 4.28033L10.4697 8.53033L11.5303 7.46967L7.28033 3.21967L6.21967 4.28033ZM10.4697 7.46967L6.21967 11.7197L7.28033 12.7803L11.5303 8.53033L10.4697 7.46967Z'
/>
<path
stroke='currentColor'
d='M1.75 8H11'
strokeWidth='1.5'
strokeLinecap='round'
className={cn(
'origin-left transition-all duration-200',
'opacity-0 motion-safe:-translate-x-1',
'group-hover:translate-x-0 group-hover:opacity-100'
)}
/>
</svg>
</Component>
);
}

View File

@@ -1,146 +0,0 @@
import { LucideIcon } from 'lucide-react';
import * as React from 'react';
import { IconType } from 'react-icons';
import { cn } from '@/lib/utils';
import UnstyledLink, {
UnstyledLinkProps,
} from '@/components/links/UnstyledLink';
const ButtonLinkVariant = [
'primary',
'outline',
'ghost',
'light',
'dark',
] as const;
const ButtonLinkSize = ['sm', 'base'] as const;
type ButtonLinkProps = {
isDarkBg?: boolean;
variant?: (typeof ButtonLinkVariant)[number];
size?: (typeof ButtonLinkSize)[number];
leftIcon?: IconType | LucideIcon;
rightIcon?: IconType | LucideIcon;
classNames?: {
leftIcon?: string;
rightIcon?: string;
};
} & UnstyledLinkProps;
const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
(
{
children,
className,
variant = 'primary',
size = 'base',
isDarkBg = false,
leftIcon: LeftIcon,
rightIcon: RightIcon,
classNames,
...rest
},
ref
) => {
return (
<UnstyledLink
ref={ref}
{...rest}
className={cn(
'inline-flex items-center rounded font-medium',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:ring',
'shadow-sm',
'transition-colors duration-75',
//#region //*=========== Size ===========
[
size === 'base' && ['px-3 py-1.5', 'text-sm md:text-base'],
size === 'sm' && ['px-2 py-1', 'text-xs md:text-sm'],
],
//#endregion //*======== Size ===========
//#region //*=========== Variants ===========
[
variant === 'primary' && [
'bg-primary-500 text-white',
'border-primary-600 border',
'hover:bg-primary-600 hover:text-white',
'active:bg-primary-700',
'disabled:bg-primary-700',
],
variant === 'outline' && [
'text-primary-500',
'border-primary-500 border',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'ghost' && [
'text-primary-500',
'shadow-none',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'light' && [
'bg-white text-gray-700',
'border border-gray-300',
'hover:text-dark hover:bg-gray-100',
'active:bg-white/80 disabled:bg-gray-200',
],
variant === 'dark' && [
'bg-gray-900 text-white',
'border border-gray-600',
'hover:bg-gray-800 active:bg-gray-700 disabled:bg-gray-700',
],
],
//#endregion //*======== Variants ===========
'disabled:cursor-not-allowed',
className
)}
>
{LeftIcon && (
<div
className={cn([
size === 'base' && 'mr-1',
size === 'sm' && 'mr-1.5',
])}
>
<LeftIcon
size='1em'
className={cn(
[
size === 'base' && 'md:text-md text-md',
size === 'sm' && 'md:text-md text-sm',
],
classNames?.leftIcon
)}
/>
</div>
)}
{children}
{RightIcon && (
<div
className={cn([
size === 'base' && 'ml-1',
size === 'sm' && 'ml-1.5',
])}
>
<RightIcon
size='1em'
className={cn(
[
size === 'base' && 'text-md md:text-md',
size === 'sm' && 'md:text-md text-sm',
],
classNames?.rightIcon
)}
/>
</div>
)}
</UnstyledLink>
);
}
);
export default ButtonLink;

View File

@@ -1,97 +0,0 @@
import { LucideIcon } from 'lucide-react';
import * as React from 'react';
import { IconType } from 'react-icons';
import { cn } from '@/lib/utils';
import UnstyledLink, {
UnstyledLinkProps,
} from '@/components/links/UnstyledLink';
const IconLinkVariant = [
'primary',
'outline',
'ghost',
'light',
'dark',
] as const;
type IconLinkProps = {
isDarkBg?: boolean;
variant?: (typeof IconLinkVariant)[number];
icon?: IconType | LucideIcon;
classNames?: {
icon?: string;
};
} & Omit<UnstyledLinkProps, 'children'>;
const IconLink = React.forwardRef<HTMLAnchorElement, IconLinkProps>(
(
{
className,
icon: Icon,
variant = 'outline',
isDarkBg = false,
classNames,
...rest
},
ref
) => {
return (
<UnstyledLink
ref={ref}
type='button'
className={cn(
'inline-flex items-center justify-center rounded font-medium',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:ring',
'shadow-sm',
'transition-colors duration-75',
'min-h-[28px] min-w-[28px] p-1 md:min-h-[34px] md:min-w-[34px] md:p-2',
//#region //*=========== Variants ===========
[
variant === 'primary' && [
'bg-primary-500 text-white',
'border-primary-600 border',
'hover:bg-primary-600 hover:text-white',
'active:bg-primary-700',
'disabled:bg-primary-700',
],
variant === 'outline' && [
'text-primary-500',
'border-primary-500 border',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'ghost' && [
'text-primary-500',
'shadow-none',
'hover:bg-primary-50 active:bg-primary-100 disabled:bg-primary-100',
isDarkBg &&
'hover:bg-gray-900 active:bg-gray-800 disabled:bg-gray-800',
],
variant === 'light' && [
'bg-white text-gray-700',
'border border-gray-300',
'hover:text-dark hover:bg-gray-100',
'active:bg-white/80 disabled:bg-gray-200',
],
variant === 'dark' && [
'bg-gray-900 text-white',
'border border-gray-600',
'hover:bg-gray-800 active:bg-gray-700 disabled:bg-gray-700',
],
],
//#endregion //*======== Variants ===========
'disabled:cursor-not-allowed',
className
)}
{...rest}
>
{Icon && <Icon size='1em' className={cn(classNames?.icon)} />}
</UnstyledLink>
);
}
);
export default IconLink;

View File

@@ -1,43 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import UnstyledLink, {
UnstyledLinkProps,
} from '@/components/links/UnstyledLink';
const PrimaryLinkVariant = ['primary', 'basic'] as const;
type PrimaryLinkProps = {
variant?: (typeof PrimaryLinkVariant)[number];
} & UnstyledLinkProps;
const PrimaryLink = React.forwardRef<HTMLAnchorElement, PrimaryLinkProps>(
({ className, children, variant = 'primary', ...rest }, ref) => {
return (
<UnstyledLink
ref={ref}
{...rest}
className={cn(
'inline-flex items-center',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:rounded focus-visible:ring focus-visible:ring-offset-2',
'font-medium',
//#region //*=========== Variant ===========
variant === 'primary' && [
'text-primary-500 hover:text-primary-600 active:text-primary-700',
'disabled:text-primary-200',
],
variant === 'basic' && [
'text-black hover:text-gray-600 active:text-gray-800',
'disabled:text-gray-300',
],
//#endregion //*======== Variant ===========
className
)}
>
{children}
</UnstyledLink>
);
}
);
export default PrimaryLink;

View File

@@ -1,28 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import UnstyledLink, {
UnstyledLinkProps,
} from '@/components/links/UnstyledLink';
const UnderlineLink = React.forwardRef<HTMLAnchorElement, UnstyledLinkProps>(
({ children, className, ...rest }, ref) => {
return (
<UnstyledLink
ref={ref}
{...rest}
className={cn(
'animated-underline custom-link inline-flex items-center font-medium',
'focus-visible:ring-primary-500 focus:outline-none focus-visible:rounded focus-visible:ring focus-visible:ring-offset-2',
'border-dark border-b border-dotted hover:border-black/0',
className
)}
>
{children}
</UnstyledLink>
);
}
);
export default UnderlineLink;

View File

@@ -1,50 +0,0 @@
import Link, { LinkProps } from 'next/link';
import * as React from 'react';
import { cn } from '@/lib/utils';
export type UnstyledLinkProps = {
href: string;
children: React.ReactNode;
openNewTab?: boolean;
className?: string;
nextLinkProps?: Omit<LinkProps, 'href'>;
} & React.ComponentPropsWithRef<'a'>;
const UnstyledLink = React.forwardRef<HTMLAnchorElement, UnstyledLinkProps>(
({ children, href, openNewTab, className, nextLinkProps, ...rest }, ref) => {
const isNewTab =
openNewTab !== undefined
? openNewTab
: href && !href.startsWith('/') && !href.startsWith('#');
if (!isNewTab) {
return (
<Link
href={href}
ref={ref}
className={className}
{...rest}
{...nextLinkProps}
>
{children}
</Link>
);
}
return (
<a
ref={ref}
target='_blank'
rel='noopener noreferrer'
href={href}
{...rest}
className={cn('cursor-newtab', className)}
>
{children}
</a>
);
}
);
export default UnstyledLink;

212
src/lib/video.ts Normal file
View File

@@ -0,0 +1,212 @@
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
// 匹配 m3u8 链接的正则
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 清理 HTML 标签的工具函数
function cleanHtmlTags(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
.replace(/&nbsp;/g, ' ') // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === 'ffzy') {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
// 去重并清理链接前缀
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf('(');
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
// 提取标题
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
// 提取描述
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
// 提取封面
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
return {
code: 200,
episodes: matches,
detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
// 处理播放源拆分
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split('$$$');
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split('#');
episodes = episodeList
.map((ep: string) => {
const parts = ep.split('$');
return parts.length > 1 ? parts[1] : '';
})
.filter(
(url: string) =>
url && (url.startsWith('http://') || url.startsWith('https://'))
);
}
}
// 如果播放源为空,则尝试从内容中解析 m3u8
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
code: 200,
episodes,
detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year,
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
// 对外导出统一的获取详情方法
export async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) {
throw new Error('缺少视频ID参数');
}
if (!/^[\w-]+$/.test(id)) {
throw new Error('无效的视频ID格式');
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
return getDetailFromApi(apiSite, id);
}