mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 00:44:41 +08:00
feat: search api/page && detail api/page
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,4 +39,6 @@ next-env.d.ts
|
||||
# next-sitemap
|
||||
robots.txt
|
||||
sitemap.xml
|
||||
sitemap-*.xml
|
||||
sitemap-*.xml
|
||||
|
||||
config.json
|
||||
3
config.example.json
Normal file
3
config.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"api_site": {}
|
||||
}
|
||||
@@ -12,7 +12,11 @@ const nextConfig = {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'vip.dytt-img.com',
|
||||
hostname: '**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
219
src/app/api/detail/route.ts
Normal file
219
src/app/api/detail/route.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
|
||||
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
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
|
||||
? descMatch[1].replace(/<[^>]+>/g, ' ').trim()
|
||||
: '';
|
||||
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getVideoDetail(id, sourceCode);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ hello: 'Next.js' });
|
||||
}
|
||||
177
src/app/api/search/route.ts
Normal file
177
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config';
|
||||
|
||||
import { getVideoDetail } from '../detail/route';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes?: number;
|
||||
source: string;
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
interface ApiSearchItem {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic: string;
|
||||
vod_remarks?: string;
|
||||
}
|
||||
|
||||
async function searchFromApi(
|
||||
apiSite: ApiSite,
|
||||
query: string
|
||||
): Promise<SearchResult[]> {
|
||||
try {
|
||||
const apiBaseUrl = apiSite.api;
|
||||
const apiUrl =
|
||||
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
|
||||
const apiName = apiSite.name;
|
||||
|
||||
// 添加超时处理
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 处理第一页结果
|
||||
const results = data.list.map((item: ApiSearchItem) => ({
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
poster: item.vod_pic,
|
||||
episodes: item.vod_remarks ? parseInt(item.vod_remarks) : undefined,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
}));
|
||||
|
||||
// 获取总页数
|
||||
const pageCount = data.pagecount || 1;
|
||||
// 确定需要获取的额外页数
|
||||
const pagesToFetch = Math.min(
|
||||
pageCount - 1,
|
||||
API_CONFIG.search.maxPages - 1
|
||||
);
|
||||
|
||||
// 如果有额外页数,获取更多页的结果
|
||||
if (pagesToFetch > 0) {
|
||||
const additionalPagePromises = [];
|
||||
|
||||
for (let page = 2; page <= pagesToFetch + 1; page++) {
|
||||
const pageUrl =
|
||||
apiBaseUrl +
|
||||
API_CONFIG.search.pagePath
|
||||
.replace('{query}', encodeURIComponent(query))
|
||||
.replace('{page}', page.toString());
|
||||
|
||||
const pagePromise = (async () => {
|
||||
try {
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(
|
||||
() => pageController.abort(),
|
||||
8000
|
||||
);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: pageController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (!pageResponse.ok) return [];
|
||||
|
||||
const pageData = await pageResponse.json();
|
||||
|
||||
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
|
||||
return [];
|
||||
|
||||
return pageData.list.map((item: ApiSearchItem) => ({
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
poster: item.vod_pic,
|
||||
episodes: item.vod_remarks
|
||||
? parseInt(item.vod_remarks)
|
||||
: undefined,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
}));
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
additionalPagePromises.push(pagePromise);
|
||||
}
|
||||
|
||||
// 等待所有额外页的结果
|
||||
const additionalResults = await Promise.all(additionalPagePromises);
|
||||
|
||||
// 合并所有页的结果
|
||||
additionalResults.forEach((pageResults) => {
|
||||
if (pageResults.length > 0) {
|
||||
results.push(...pageResults);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取每个结果的详情
|
||||
const detailPromises = results.map(async (result: SearchResult) => {
|
||||
try {
|
||||
const detail = await getVideoDetail(result.id, result.source);
|
||||
if (detail.episodes.length > 1) {
|
||||
return { ...result, episodes: detail.episodes.length };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
return await Promise.all(detailPromises);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
const apiSites = getApiSites();
|
||||
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
|
||||
|
||||
try {
|
||||
const results = await Promise.all(searchPromises);
|
||||
const flattenedResults = results.flat();
|
||||
|
||||
return NextResponse.json({ results: flattenedResults });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
158
src/app/detail/page.tsx
Normal file
158
src/app/detail/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
|
||||
import { VideoDetail } from '../api/detail/route';
|
||||
|
||||
export default function DetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!source || !id) {
|
||||
setError('缺少必要参数');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
setDetail(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar activePath='/detail' />
|
||||
<main className='flex-1 p-8 flex flex-col items-center'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-red-500'>{error}</div>
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-gray-500'>未找到视频详情</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full max-w-[90%]'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-full md:w-64'>
|
||||
<Image
|
||||
src={detail.videoInfo.cover || '/images/placeholder.png'}
|
||||
alt={detail.videoInfo.title}
|
||||
width={256}
|
||||
height={384}
|
||||
className='w-full rounded-xl object-cover'
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧信息 */}
|
||||
<div className='flex-1 flex flex-col justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center'>
|
||||
{detail.videoInfo.title}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80'>
|
||||
{detail.videoInfo.remarks && (
|
||||
<span className='text-red-500 font-semibold'>
|
||||
{detail.videoInfo.remarks}
|
||||
</span>
|
||||
)}
|
||||
{detail.videoInfo.year && (
|
||||
<span>{detail.videoInfo.year}</span>
|
||||
)}
|
||||
{detail.videoInfo.source_name && (
|
||||
<span>{detail.videoInfo.source_name}</span>
|
||||
)}
|
||||
{detail.videoInfo.type && (
|
||||
<span>{detail.videoInfo.type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-4 mb-4'>
|
||||
<button className='flex items-center justify-center gap-2 px-6 py-2 bg-gray-500/40 hover:bg-[#22c55e] rounded-lg transition-colors text-white'>
|
||||
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
|
||||
<span>播放</span>
|
||||
</button>
|
||||
<button className='flex items-center justify-center w-10 h-10 bg-gray-500/40 hover:bg-[#22c55e] rounded-full transition-colors'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-5 w-5 text-white'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{detail.videoInfo.desc && (
|
||||
<div className='mt-4 text-base leading-relaxed opacity-90 max-h-40 overflow-y-auto pr-2'>
|
||||
{detail.videoInfo.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 选集按钮区 */}
|
||||
{detail.episodes.length > 0 && (
|
||||
<div className='mt-8 bg-transparent rounded-xl p-6'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<div className='text-xl font-semibold'>选集</div>
|
||||
<div className='text-gray-400 ml-2'>
|
||||
共 {detail.episodes.length} 集
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{detail.episodes.map((episode, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={episode}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='bg-gray-500/40 hover:bg-[#22c55e] text-white px-5 py-2 rounded-lg shadow transition-colors text-base font-medium w-24 text-center'
|
||||
>
|
||||
第{idx + 1}集
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/page.tsx
137
src/app/page.tsx
@@ -3,9 +3,9 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import DemoCard from '@/components/card/DemoCard';
|
||||
import VideoCard from '@/components/card/VideoCard';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
const defaultPoster =
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg';
|
||||
@@ -14,57 +14,46 @@ const defaultPoster =
|
||||
const mockData = {
|
||||
recentMovies: [
|
||||
{
|
||||
id: 1,
|
||||
id: '1',
|
||||
title: '流浪地球2',
|
||||
poster: defaultPoster,
|
||||
rating: 8.3,
|
||||
type: 'movie' as const,
|
||||
source: '电影天堂',
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: '2',
|
||||
title: '满江红',
|
||||
poster: defaultPoster,
|
||||
rating: 7.5,
|
||||
type: 'movie' as const,
|
||||
source: '电影天堂',
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
recentTvShows: [
|
||||
{
|
||||
id: 3,
|
||||
id: '3',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
rating: 8.7,
|
||||
type: 'tv' as const,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
source: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
id: '4',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
rating: 8.5,
|
||||
type: 'tv' as const,
|
||||
episodes: 39,
|
||||
source: '电影天堂',
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('home'); // 'home' 或 'favorites'
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar onToggle={setSidebarCollapsed} />
|
||||
|
||||
<main
|
||||
className={`flex-1 px-10 py-8 transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
}`}
|
||||
>
|
||||
<PageLayout>
|
||||
<div className='px-10 py-8'>
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
@@ -77,54 +66,52 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
继续观看
|
||||
</h2>
|
||||
<div className='flex space-x-8 overflow-x-auto pb-2'>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows]
|
||||
.slice(0, 4)
|
||||
.map((item) => (
|
||||
<div key={item.id} className='min-w-[192px] w-48'>
|
||||
<VideoCard
|
||||
{...item}
|
||||
showProgress={true}
|
||||
progress={Math.random() * 100}
|
||||
/>
|
||||
<div className='max-w-[90%] mx-auto'>
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
继续观看
|
||||
</h2>
|
||||
<div className='flex space-x-8 overflow-x-auto pb-2'>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows]
|
||||
.slice(0, 4)
|
||||
.map((item) => (
|
||||
<div key={item.id} className='min-w-[192px] w-48'>
|
||||
<VideoCard {...item} progress={Math.random() * 100} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最新电影 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电影
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentMovies.map((movie) => (
|
||||
<div key={movie.id} className='w-48'>
|
||||
<DemoCard {...movie} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最新电影 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电影
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentMovies.map((movie) => (
|
||||
<div key={movie.id} className='w-48'>
|
||||
<DemoCard {...movie} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最新电视剧 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电视剧
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentTvShows.map((show) => (
|
||||
<div key={show.id} className='w-48'>
|
||||
<DemoCard {...show} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{/* 最新电视剧 */}
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电视剧
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
{mockData.recentTvShows.map((show) => (
|
||||
<div key={show.id} className='w-48'>
|
||||
<DemoCard {...show} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,42 +4,29 @@ import { Search } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import VideoCard from '@/components/card/VideoCard';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
// 模拟搜索历史数据
|
||||
const mockSearchHistory = ['流浪地球', '三体', '狂飙', '满江红'];
|
||||
|
||||
// 模拟搜索结果数据
|
||||
const mockSearchResults = [
|
||||
{
|
||||
id: 1,
|
||||
title: '流浪地球2',
|
||||
poster:
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg',
|
||||
type: 'movie' as const,
|
||||
source: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '三体',
|
||||
poster:
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg',
|
||||
type: 'tv' as const,
|
||||
episodes: 30,
|
||||
source: '电影天堂',
|
||||
},
|
||||
];
|
||||
// 定义搜索结果类型
|
||||
type SearchResult = {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
source: string;
|
||||
source_name: string;
|
||||
episodes?: number;
|
||||
};
|
||||
|
||||
export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||
const [searchResults, setSearchResults] = useState<typeof mockSearchResults>(
|
||||
[]
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,30 +39,43 @@ export default function SearchPage() {
|
||||
const query = searchParams.get('q');
|
||||
if (query) {
|
||||
setSearchQuery(query);
|
||||
// 这里应该调用实际的搜索 API
|
||||
setSearchResults(mockSearchResults);
|
||||
setShowResults(true);
|
||||
fetchSearchResults(query);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
const fetchSearchResults = async (query: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
setSearchResults(data.results);
|
||||
setShowResults(true);
|
||||
} catch (error) {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
<Sidebar onToggle={setSidebarCollapsed} activePath='/search' />
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
<main
|
||||
className={`flex-1 px-10 py-8 transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
}`}
|
||||
>
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
// 模拟搜索延迟
|
||||
setTimeout(() => {
|
||||
fetchSearchResults(searchQuery);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-10 py-8'>
|
||||
{/* 搜索框 */}
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
@@ -94,15 +94,24 @@ export default function SearchPage() {
|
||||
</div>
|
||||
|
||||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-7xl mx-auto mt-12'>
|
||||
{showResults ? (
|
||||
<div className='max-w-[90%] mx-auto mt-12'>
|
||||
{isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
// 搜索结果
|
||||
<div className='grid grid-cols-5 gap-7'>
|
||||
<div className='grid grid-cols-6 gap-7'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-44'>
|
||||
<VideoCard {...item} />
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-5 text-center text-gray-500 py-8'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : mockSearchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
@@ -127,7 +136,7 @@ export default function SearchPage() {
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ import React, { useState } from 'react';
|
||||
interface DemoCardProps {
|
||||
title: string;
|
||||
poster: string;
|
||||
rating?: number;
|
||||
type: 'movie' | 'tv';
|
||||
episodes?: number;
|
||||
}
|
||||
|
||||
function SearchCircle({
|
||||
@@ -55,7 +52,7 @@ function SearchCircle({
|
||||
);
|
||||
}
|
||||
|
||||
const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
|
||||
const DemoCard = ({ title, poster }: DemoCardProps) => {
|
||||
const [hover, setHover] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -65,7 +62,7 @@ const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group relative w-full overflow-hidden rounded-lg bg-white border border-[#e6e6e6] shadow-none flex flex-col cursor-pointer'
|
||||
className='group relative w-full overflow-hidden rounded-lg bg-transparent shadow-none flex flex-col cursor-pointer'
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
@@ -85,17 +82,11 @@ const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 集数指示器 - 绿色小圆球 */}
|
||||
{episodes && (
|
||||
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>{episodes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 信息层 */}
|
||||
<div className='p-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-gray-900 font-semibold truncate flex-1 mr-2'>
|
||||
<div className='p-2 bg-transparent'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center'>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
140
src/components/VideoCard.tsx
Normal file
140
src/components/VideoCard.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface VideoCardProps {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes?: number;
|
||||
source_name: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
function CheckCircleCustom() {
|
||||
return (
|
||||
<span className='inline-flex items-center justify-center'>
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 32 32'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<circle cx='16' cy='16' r='13' stroke='white' strokeWidth='2' />
|
||||
<path
|
||||
d='M11 16.5L15 20L21 13.5'
|
||||
stroke='white'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayCircleSolid({
|
||||
className = '',
|
||||
fillColor = 'none',
|
||||
}: {
|
||||
className?: string;
|
||||
fillColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width='44'
|
||||
height='44'
|
||||
viewBox='0 0 44 44'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={className}
|
||||
>
|
||||
<circle
|
||||
cx='22'
|
||||
cy='22'
|
||||
r='20'
|
||||
stroke='white'
|
||||
strokeWidth='1.5'
|
||||
fill={fillColor}
|
||||
/>
|
||||
<polygon points='19,15 19,29 29,22' fill='white' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoCard({
|
||||
id,
|
||||
title,
|
||||
poster,
|
||||
episodes,
|
||||
source,
|
||||
source_name,
|
||||
progress,
|
||||
}: VideoCardProps) {
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={`/detail?source=${source}&id=${id}`}>
|
||||
<div className='group relative w-full overflow-hidden rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden'>
|
||||
<Image src={poster} alt={title} fill className='object-cover' />
|
||||
|
||||
{/* Hover 效果 */}
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
onMouseLeave={() => setPlayHover(false)}
|
||||
className={`transition-all duration-200 ${
|
||||
playHover ? 'scale-110' : ''
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
|
||||
<CheckCircleCustom />
|
||||
<Heart className='h-5 w-5 text-white/90 stroke-[2]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 集数指示器 - 绿色小圆球 */}
|
||||
{episodes && (
|
||||
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>{episodes}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放进度条 */}
|
||||
{progress !== undefined && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300'>
|
||||
<div
|
||||
className='h-full bg-blue-500 transition-all duration-300'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
<div className='p-2 bg-transparent'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center'>
|
||||
{title}
|
||||
</span>
|
||||
{source && (
|
||||
<span className='text-gray-500 text-xs w-full text-center mt-1'>
|
||||
{source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface VideoCardProps {
|
||||
title: string;
|
||||
poster: string;
|
||||
rating?: number;
|
||||
type: 'movie' | 'tv';
|
||||
episodes?: number;
|
||||
source?: string;
|
||||
showProgress?: boolean;
|
||||
progress?: number; // 0-100
|
||||
}
|
||||
|
||||
function CheckCircleCustom() {
|
||||
return (
|
||||
<span className='inline-flex items-center justify-center'>
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 32 32'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<circle cx='16' cy='16' r='13' stroke='white' strokeWidth='2' />
|
||||
<path
|
||||
d='M11 16.5L15 20L21 13.5'
|
||||
stroke='white'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayCircleSolid({
|
||||
className = '',
|
||||
fillColor = 'none',
|
||||
}: {
|
||||
className?: string;
|
||||
fillColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width='44'
|
||||
height='44'
|
||||
viewBox='0 0 44 44'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={className}
|
||||
>
|
||||
<circle
|
||||
cx='22'
|
||||
cy='22'
|
||||
r='20'
|
||||
stroke='white'
|
||||
strokeWidth='1.5'
|
||||
fill={fillColor}
|
||||
/>
|
||||
<polygon points='19,15 19,29 29,22' fill='white' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const VideoCard = ({
|
||||
title,
|
||||
poster,
|
||||
episodes,
|
||||
source,
|
||||
showProgress,
|
||||
progress,
|
||||
}: VideoCardProps) => {
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='group relative w-full overflow-hidden rounded-lg bg-white border border-[#e6e6e6] shadow-none flex flex-col'>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden'>
|
||||
<Image src={poster} alt={title} fill className='object-cover' />
|
||||
|
||||
{/* Hover 效果 */}
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
onMouseLeave={() => setPlayHover(false)}
|
||||
className={`transition-all duration-200 ${
|
||||
playHover ? 'scale-110' : ''
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
|
||||
<CheckCircleCustom />
|
||||
<Heart className='h-5 w-5 text-white/90 stroke-[2]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 集数指示器 - 绿色小圆球 */}
|
||||
{episodes && (
|
||||
<div className='absolute top-2 right-2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>{episodes}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放进度条 */}
|
||||
{showProgress && progress !== undefined && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-1 bg-gray-300'>
|
||||
<div
|
||||
className='h-full bg-blue-500 transition-all duration-300'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
<div className='p-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-gray-900 font-semibold truncate flex-1 mr-2'>
|
||||
{title}
|
||||
</span>
|
||||
{/* 数据源信息 */}
|
||||
{source && (
|
||||
<span className='text-gray-400 text-xs flex-shrink-0'>
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoCard;
|
||||
26
src/components/layout/PageLayout.tsx
Normal file
26
src/components/layout/PageLayout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useSidebar } from './Sidebar';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
activePath?: string;
|
||||
}
|
||||
|
||||
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
const { isCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-[auto_1fr] min-h-screen'>
|
||||
<Sidebar activePath={activePath} />
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLayout;
|
||||
@@ -1,7 +1,23 @@
|
||||
import { Film, Folder, Home, Menu, Search, Star, Tv } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType>({
|
||||
isCollapsed: false,
|
||||
});
|
||||
|
||||
export const useSidebar = () => useContext(SidebarContext);
|
||||
|
||||
// 可替换为你自己的 logo 图片
|
||||
const Logo = () => (
|
||||
@@ -22,17 +38,31 @@ interface SidebarProps {
|
||||
|
||||
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
const router = useRouter();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const saved = localStorage.getItem('sidebarCollapsed');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [active, setActive] = useState(activePath);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newCollapsed);
|
||||
onToggle?.(newCollapsed);
|
||||
};
|
||||
useEffect(() => {
|
||||
setActive(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
const handleSearchClick = () => {
|
||||
const handleToggle = useCallback(() => {
|
||||
const newState = !isCollapsed;
|
||||
setIsCollapsed(newState);
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
|
||||
onToggle?.(newState);
|
||||
}, [isCollapsed, onToggle]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
router.push('/search');
|
||||
}, [router]);
|
||||
|
||||
const contextValue = {
|
||||
isCollapsed,
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
@@ -45,107 +75,122 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className='relative h-16'>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
|
||||
isCollapsed ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Logo />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`absolute top-1/2 -translate-y-1/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 ${
|
||||
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
|
||||
}`}
|
||||
>
|
||||
<Menu className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 首页和搜索导航 */}
|
||||
<nav className='px-2 mt-4 space-y-1'>
|
||||
<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-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div className='flex'>
|
||||
<aside
|
||||
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 ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* 顶部 Logo 区域 */}
|
||||
<div className='relative h-16'>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
|
||||
isCollapsed ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className='w-[calc(100%-4rem)] flex justify-center'>
|
||||
{!isCollapsed && <Logo />}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`absolute top-1/2 -translate-y-1/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 ${
|
||||
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
|
||||
}`}
|
||||
>
|
||||
<Menu className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
首页
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href='/search'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearchClick();
|
||||
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-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
搜索
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='flex-1 overflow-y-auto px-2 pt-4'>
|
||||
<div className='space-y-1'>
|
||||
{menuItems.map((item) => (
|
||||
{/* 首页和搜索导航 */}
|
||||
<nav className='px-2 mt-4 space-y-1'>
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={active === item.href}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
|
||||
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-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed
|
||||
? 'w-full max-w-none mx-0'
|
||||
: 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
|
||||
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
{item.label}
|
||||
首页
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href='/search'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSearchClick();
|
||||
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-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed
|
||||
? 'w-full max-w-none mx-0'
|
||||
: 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
搜索
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className='flex-1 overflow-y-auto px-2 pt-4'>
|
||||
<div className='space-y-1'>
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setActive(item.href)}
|
||||
data-active={active === item.href}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${
|
||||
isCollapsed
|
||||
? 'w-full max-w-none mx-0'
|
||||
: 'max-w-[220px] mx-auto'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<item.icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 group-data-[active=true]:text-green-700' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
58
src/lib/config.ts
Normal file
58
src/lib/config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
api: string;
|
||||
name: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
api_site: {
|
||||
[key: string]: ApiSite;
|
||||
};
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
search: {
|
||||
path: '?ac=videolist&wd=',
|
||||
pagePath: '?ac=videolist&wd={query}&pg={page}',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
maxPages: 50,
|
||||
},
|
||||
detail: {
|
||||
path: '?ac=videolist&ids=',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let config: Config | null = null;
|
||||
|
||||
export function getConfig(): Config {
|
||||
if (config) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsedConfig = JSON.parse(configContent) as Config;
|
||||
config = parsedConfig;
|
||||
return parsedConfig;
|
||||
}
|
||||
|
||||
export function getApiSites(): ApiSite[] {
|
||||
const config = getConfig();
|
||||
return Object.entries(config.api_site).map(([key, site]) => ({
|
||||
...site,
|
||||
key,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user