mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement language filtering and display language information across video components.
This commit is contained in:
@@ -2,9 +2,11 @@
|
|||||||
import { ResultsHeader } from '@/components/search/ResultsHeader';
|
import { ResultsHeader } from '@/components/search/ResultsHeader';
|
||||||
import { SourceBadges } from '@/components/search/SourceBadges';
|
import { SourceBadges } from '@/components/search/SourceBadges';
|
||||||
import { TypeBadges } from '@/components/search/TypeBadges';
|
import { TypeBadges } from '@/components/search/TypeBadges';
|
||||||
|
import { LanguageBadges } from '@/components/search/LanguageBadges';
|
||||||
import { VideoGrid } from '@/components/search/VideoGrid';
|
import { VideoGrid } from '@/components/search/VideoGrid';
|
||||||
import { useSourceBadges } from '@/lib/hooks/useSourceBadges';
|
import { useSourceBadges } from '@/lib/hooks/useSourceBadges';
|
||||||
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
|
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
|
||||||
|
import { useLanguageBadges } from '@/lib/hooks/useLanguageBadges';
|
||||||
import { Video, SourceBadge } from '@/lib/types';
|
import { Video, SourceBadge } from '@/lib/types';
|
||||||
|
|
||||||
interface SearchResultsProps {
|
interface SearchResultsProps {
|
||||||
@@ -34,10 +36,19 @@ export function SearchResults({
|
|||||||
const {
|
const {
|
||||||
typeBadges,
|
typeBadges,
|
||||||
selectedTypes,
|
selectedTypes,
|
||||||
filteredVideos: finalFilteredVideos,
|
filteredVideos: typeFilteredVideos,
|
||||||
toggleType,
|
toggleType,
|
||||||
} = useTypeBadges(sourceFilteredVideos);
|
} = useTypeBadges(sourceFilteredVideos);
|
||||||
|
|
||||||
|
// Language badges hook - auto-collects and filters by vod_lang
|
||||||
|
// Apply on type-filtered results for combined filtering
|
||||||
|
const {
|
||||||
|
languageBadges,
|
||||||
|
selectedLangs,
|
||||||
|
filteredVideos: finalFilteredVideos,
|
||||||
|
toggleLang,
|
||||||
|
} = useLanguageBadges(typeFilteredVideos);
|
||||||
|
|
||||||
if (results.length === 0 && !loading) return null;
|
if (results.length === 0 && !loading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +79,17 @@ export function SearchResults({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Display filtered videos (both source and type filters applied) */}
|
{/* Language Badges - Auto-collected from search results */}
|
||||||
|
{languageBadges.length > 0 && (
|
||||||
|
<LanguageBadges
|
||||||
|
badges={languageBadges}
|
||||||
|
selectedLangs={selectedLangs}
|
||||||
|
onToggleLang={toggleLang}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display filtered videos (source, type, and language filters applied) */}
|
||||||
<VideoGrid
|
<VideoGrid
|
||||||
videos={finalFilteredVideos}
|
videos={finalFilteredVideos}
|
||||||
isPremium={isPremium}
|
isPremium={isPremium}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
|||||||
{videoData.vod_area}
|
{videoData.vod_area}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{videoData?.vod_lang && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Icons.Languages size={14} className="mr-1" />
|
||||||
|
{videoData.vod_lang}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{videoData?.vod_content && (
|
{videoData?.vod_content && (
|
||||||
<p className="text-sm sm:text-base text-[var(--text-secondary)]">
|
<p className="text-sm sm:text-base text-[var(--text-secondary)]">
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* LanguageBadges - Language filter badge component
|
||||||
|
* Mirrors TypeBadges pattern, reuses TypeBadgeItem for individual badges
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Icons } from '@/components/ui/Icon';
|
||||||
|
import { TypeBadgeItem } from './TypeBadgeItem';
|
||||||
|
import type { LanguageBadge } from '@/lib/types';
|
||||||
|
|
||||||
|
interface LanguageBadgesProps {
|
||||||
|
badges: LanguageBadge[];
|
||||||
|
selectedLangs: Set<string>;
|
||||||
|
onToggleLang: (lang: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LanguageBadges = memo(function LanguageBadges({
|
||||||
|
badges,
|
||||||
|
selectedLangs,
|
||||||
|
onToggleLang,
|
||||||
|
className = ''
|
||||||
|
}: LanguageBadgesProps) {
|
||||||
|
if (badges.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
selectedLangs.forEach(lang => onToggleLang(lang));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
hover={false}
|
||||||
|
className={`p-4 animate-fade-in bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex items-center gap-2 shrink-0 pt-1">
|
||||||
|
<Icons.Languages size={16} className="text-[var(--accent-color)]" />
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-color)]">
|
||||||
|
语言标签 ({badges.length}):
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: flex wrap, Mobile: horizontal scroll */}
|
||||||
|
<div className="hidden md:flex md:flex-1 -mx-1 px-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap p-1">
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<TypeBadgeItem
|
||||||
|
key={badge.lang}
|
||||||
|
type={badge.lang}
|
||||||
|
count={badge.count}
|
||||||
|
isSelected={selectedLangs.has(badge.lang)}
|
||||||
|
onToggle={() => onToggleLang(badge.lang)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex md:hidden flex-1 -mx-1 px-1 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<TypeBadgeItem
|
||||||
|
key={badge.lang}
|
||||||
|
type={badge.lang}
|
||||||
|
count={badge.count}
|
||||||
|
isSelected={selectedLangs.has(badge.lang)}
|
||||||
|
onToggle={() => onToggleLang(badge.lang)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedLangs.size > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-[var(--glass-border)]">
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||||
|
flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Icons.X size={12} />
|
||||||
|
清除筛选 ({selectedLangs.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -168,6 +168,11 @@ export const VideoCard = memo<VideoCardProps>(({
|
|||||||
{video.vod_remarks}
|
{video.vod_remarks}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{video.vod_lang && (
|
||||||
|
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||||
|
{video.vod_lang}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
|
|||||||
{displayQuality}
|
{displayQuality}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{representative.vod_lang && (
|
||||||
|
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||||
|
{representative.vod_lang}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -172,6 +172,17 @@ export const UtilityIcons = {
|
|||||||
<circle cx="5" cy="12" r="1" />
|
<circle cx="5" cy="12" r="1" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
Languages: ({ className = "", size = 24 }: IconProps) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||||
|
<path d="M5 8l6 6" />
|
||||||
|
<path d="M4 14l6-6 2-3" />
|
||||||
|
<path d="M2 5h12" />
|
||||||
|
<path d="M7 2h1" />
|
||||||
|
<path d="M22 22l-5-10-5 10" />
|
||||||
|
<path d="M14 18h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
|
||||||
Settings: ({ className = "", size = 24 }: IconProps) => (
|
Settings: ({ className = "", size = 24 }: IconProps) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export async function getVideoDetail(
|
|||||||
vod_director: videoData.vod_director,
|
vod_director: videoData.vod_director,
|
||||||
vod_content: videoData.vod_content,
|
vod_content: videoData.vod_content,
|
||||||
type_name: videoData.type_name,
|
type_name: videoData.type_name,
|
||||||
|
vod_lang: videoData.vod_lang,
|
||||||
episodes,
|
episodes,
|
||||||
source: source.id,
|
source: source.id,
|
||||||
source_code: playFrom[selectedIndex] || '',
|
source_code: playFrom[selectedIndex] || '',
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import type { LanguageBadge } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to collect and filter by vod_lang values from video results
|
||||||
|
* Mirrors useTypeBadges pattern
|
||||||
|
*/
|
||||||
|
export function useLanguageBadges<T extends { vod_lang?: string }>(videos: T[]) {
|
||||||
|
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Collect and count language badges from videos
|
||||||
|
const languageBadges = useMemo<LanguageBadge[]>(() => {
|
||||||
|
const langMap = new Map<string, number>();
|
||||||
|
|
||||||
|
videos.forEach(video => {
|
||||||
|
if (video.vod_lang && video.vod_lang.trim()) {
|
||||||
|
const lang = video.vod_lang.trim();
|
||||||
|
langMap.set(lang, (langMap.get(lang) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(langMap.entries())
|
||||||
|
.map(([lang, count]) => ({ lang, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}, [videos]);
|
||||||
|
|
||||||
|
// Filter videos by selected languages
|
||||||
|
const filteredVideos = useMemo(() => {
|
||||||
|
if (selectedLangs.size === 0) {
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return videos.filter(video =>
|
||||||
|
video.vod_lang && selectedLangs.has(video.vod_lang.trim())
|
||||||
|
);
|
||||||
|
}, [videos, selectedLangs]);
|
||||||
|
|
||||||
|
// Toggle language selection
|
||||||
|
const toggleLang = useCallback((lang: string) => {
|
||||||
|
setSelectedLangs(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(lang)) {
|
||||||
|
newSet.delete(lang);
|
||||||
|
} else {
|
||||||
|
newSet.add(lang);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-cleanup: remove selected langs that no longer exist in badges
|
||||||
|
useEffect(() => {
|
||||||
|
const availableLangs = new Set(languageBadges.map(b => b.lang));
|
||||||
|
|
||||||
|
setSelectedLangs(prev => {
|
||||||
|
const filtered = new Set(
|
||||||
|
Array.from(prev).filter(lang => availableLangs.has(lang))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filtered.size !== prev.size) {
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [languageBadges]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
languageBadges,
|
||||||
|
selectedLangs,
|
||||||
|
filteredVideos,
|
||||||
|
toggleLang,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export interface VideoItem {
|
|||||||
vod_actor?: string;
|
vod_actor?: string;
|
||||||
vod_director?: string;
|
vod_director?: string;
|
||||||
vod_content?: string;
|
vod_content?: string;
|
||||||
|
vod_lang?: string;
|
||||||
source: string;
|
source: string;
|
||||||
latency?: number; // Response time in milliseconds
|
latency?: number; // Response time in milliseconds
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,11 @@ export interface TypeBadge {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LanguageBadge {
|
||||||
|
lang: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Episode Information
|
// Episode Information
|
||||||
export interface Episode {
|
export interface Episode {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -76,6 +82,7 @@ export interface VideoDetail {
|
|||||||
vod_director?: string;
|
vod_director?: string;
|
||||||
vod_content?: string;
|
vod_content?: string;
|
||||||
type_name?: string;
|
type_name?: string;
|
||||||
|
vod_lang?: string;
|
||||||
episodes: Episode[];
|
episodes: Episode[];
|
||||||
source: string;
|
source: string;
|
||||||
source_code: string;
|
source_code: string;
|
||||||
@@ -134,6 +141,7 @@ export interface ApiDetailResponse {
|
|||||||
vod_director?: string;
|
vod_director?: string;
|
||||||
vod_content?: string;
|
vod_content?: string;
|
||||||
type_name?: string;
|
type_name?: string;
|
||||||
|
vod_lang?: string;
|
||||||
vod_play_from?: string;
|
vod_play_from?: string;
|
||||||
vod_play_url?: string;
|
vod_play_url?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user