diff --git a/.gitignore b/.gitignore index c4e6a2d..bce927e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ next-env.d.ts # next-sitemap robots.txt sitemap.xml -sitemap-*.xml \ No newline at end of file +sitemap-*.xml + +config.json \ No newline at end of file diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..38ea13b --- /dev/null +++ b/config.example.json @@ -0,0 +1,3 @@ +{ + "api_site": {} +} diff --git a/next.config.js b/next.config.js index 90a8531..571b5e4 100644 --- a/next.config.js +++ b/next.config.js @@ -12,7 +12,11 @@ const nextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'vip.dytt-img.com', + hostname: '**', + }, + { + protocol: 'http', + hostname: '**', }, ], }, diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts new file mode 100644 index 0000000..e4229fe --- /dev/null +++ b/src/app/api/detail/route.ts @@ -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 { + 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>/); + const titleText = titleMatch ? titleMatch[1].trim() : ''; + + const descMatch = html.match( + /]*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 { + 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 { + 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 } + ); + } +} diff --git a/src/app/api/hello/route.ts b/src/app/api/hello/route.ts deleted file mode 100644 index 95ab332..0000000 --- a/src/app/api/hello/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { NextResponse } from 'next/server'; - -export async function GET() { - return NextResponse.json({ hello: 'Next.js' }); -} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..f08f9cd --- /dev/null +++ b/src/app/api/search/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx new file mode 100644 index 0000000..755a273 --- /dev/null +++ b/src/app/detail/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+
{error}
+
+ ) : !detail ? ( +
+
未找到视频详情
+
+ ) : ( +
+ {/* 主信息区:左图右文 */} +
+ {/* 封面 */} +
+ {detail.videoInfo.title} +
+ {/* 右侧信息 */} +
+
+

+ {detail.videoInfo.title} +

+
+ {detail.videoInfo.remarks && ( + + {detail.videoInfo.remarks} + + )} + {detail.videoInfo.year && ( + {detail.videoInfo.year} + )} + {detail.videoInfo.source_name && ( + {detail.videoInfo.source_name} + )} + {detail.videoInfo.type && ( + {detail.videoInfo.type} + )} +
+
+ + +
+ {detail.videoInfo.desc && ( +
+ {detail.videoInfo.desc} +
+ )} +
+
+
+ {/* 选集按钮区 */} + {detail.episodes.length > 0 && ( +
+
+
选集
+
+ 共 {detail.episodes.length} 集 +
+
+
+ {detail.episodes.map((episode, idx) => ( + + 第{idx + 1}集 + + ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index ca9a43c..bc37984 100644 --- a/src/app/page.tsx +++ b/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 ( -
- - -
+ +
{/* 顶部 Tab 切换 */}
- {/* 继续观看 */} -
-

- 继续观看 -

-
- {[...mockData.recentMovies, ...mockData.recentTvShows] - .slice(0, 4) - .map((item) => ( -
- +
+ {/* 继续观看 */} +
+

+ 继续观看 +

+
+ {[...mockData.recentMovies, ...mockData.recentTvShows] + .slice(0, 4) + .map((item) => ( +
+ +
+ ))} +
+
+ + {/* 最新电影 */} +
+

+ 最新电影 +

+
+ {mockData.recentMovies.map((movie) => ( +
+
))} -
-
+
+
- {/* 最新电影 */} -
-

- 最新电影 -

-
- {mockData.recentMovies.map((movie) => ( -
- -
- ))} -
-
- - {/* 最新电视剧 */} -
-

- 最新电视剧 -

-
- {mockData.recentTvShows.map((show) => ( -
- -
- ))} -
-
-
-
+ {/* 最新电视剧 */} +
+

+ 最新电视剧 +

+
+ {mockData.recentTvShows.map((show) => ( +
+ +
+ ))} +
+
+ + + ); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 0a020ee..daf82d2 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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( - [] - ); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); const [showResults, setShowResults] = useState(false); + const [searchResults, setSearchResults] = useState([]); const searchInputRef = useRef(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 ( -
- + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery.trim()) return; -
+ setIsLoading(true); + setShowResults(true); + // 模拟搜索延迟 + setTimeout(() => { + fetchSearchResults(searchQuery); + }, 1000); + }; + + return ( + +
{/* 搜索框 */}
@@ -94,15 +94,24 @@ export default function SearchPage() {
{/* 搜索结果或搜索历史 */} -
- {showResults ? ( +
+ {isLoading ? ( +
+
+
+ ) : showResults ? ( // 搜索结果 -
+
{searchResults.map((item) => (
))} + {searchResults.length === 0 && ( +
+ 未找到相关结果 +
+ )}
) : mockSearchHistory.length > 0 ? ( // 搜索历史 @@ -127,7 +136,7 @@ export default function SearchPage() { ) : null}
-
-
+ + ); } diff --git a/src/components/card/DemoCard.tsx b/src/components/DemoCard.tsx similarity index 80% rename from src/components/card/DemoCard.tsx rename to src/components/DemoCard.tsx index f71bade..d712cdb 100644 --- a/src/components/card/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -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 (
{/* 海报图片 - 2:3 比例 */} @@ -85,17 +82,11 @@ const DemoCard = ({ title, poster, episodes }: DemoCardProps) => {
- {/* 集数指示器 - 绿色小圆球 */} - {episodes && ( -
- {episodes} -
- )} {/* 信息层 */} -
-
- +
+
+ {title}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx new file mode 100644 index 0000000..11ee878 --- /dev/null +++ b/src/components/VideoCard.tsx @@ -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 ( + + + + + + + ); +} + +function PlayCircleSolid({ + className = '', + fillColor = 'none', +}: { + className?: string; + fillColor?: string; +}) { + return ( + + + + + ); +} + +export default function VideoCard({ + id, + title, + poster, + episodes, + source, + source_name, + progress, +}: VideoCardProps) { + const [playHover, setPlayHover] = useState(false); + + return ( + +
+ {/* 海报图片 - 2:3 比例 */} +
+ {title} + + {/* Hover 效果 */} +
+
+
setPlayHover(true)} + onMouseLeave={() => setPlayHover(false)} + className={`transition-all duration-200 ${ + playHover ? 'scale-110' : '' + }`} + style={{ cursor: 'pointer' }} + > + +
+
+
+ + +
+
+ + {/* 集数指示器 - 绿色小圆球 */} + {episodes && ( +
+ {episodes} +
+ )} + + {/* 播放进度条 */} + {progress !== undefined && ( +
+
+
+ )} +
+ + {/* 信息层 */} +
+
+ + {title} + + {source && ( + + {source_name} + + )} +
+
+
+ + ); +} diff --git a/src/components/card/VideoCard.tsx b/src/components/card/VideoCard.tsx deleted file mode 100644 index 636fe0c..0000000 --- a/src/components/card/VideoCard.tsx +++ /dev/null @@ -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 ( - - - - - - - ); -} - -function PlayCircleSolid({ - className = '', - fillColor = 'none', -}: { - className?: string; - fillColor?: string; -}) { - return ( - - - - - ); -} - -const VideoCard = ({ - title, - poster, - episodes, - source, - showProgress, - progress, -}: VideoCardProps) => { - const [playHover, setPlayHover] = useState(false); - - return ( -
- {/* 海报图片 - 2:3 比例 */} -
- {title} - - {/* Hover 效果 */} -
-
-
setPlayHover(true)} - onMouseLeave={() => setPlayHover(false)} - className={`transition-all duration-200 ${ - playHover ? 'scale-110' : '' - }`} - style={{ cursor: 'pointer' }} - > - -
-
-
- - -
-
- - {/* 集数指示器 - 绿色小圆球 */} - {episodes && ( -
- {episodes} -
- )} - - {/* 播放进度条 */} - {showProgress && progress !== undefined && ( -
-
-
- )} -
- - {/* 信息层 */} -
-
- - {title} - - {/* 数据源信息 */} - {source && ( - - {source} - - )} -
-
-
- ); -}; - -export default VideoCard; diff --git a/src/components/layout/PageLayout.tsx b/src/components/layout/PageLayout.tsx new file mode 100644 index 0000000..ef3cf0c --- /dev/null +++ b/src/components/layout/PageLayout.tsx @@ -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 ( +
+ +
+ {children} +
+
+ ); +}; + +export default PageLayout; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 1242928..835c88e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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({ + 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 ( - +
- + ); }; diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..905525a --- /dev/null +++ b/src/lib/config.ts @@ -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, + })); +}