From e95fae42844c9502b4373f75b4273b2ed2c2e384 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Mon, 16 Feb 2026 22:08:25 +0800 Subject: [PATCH] feat: Implement language filtering and display language information across video components. --- components/home/SearchResults.tsx | 25 +++++++- components/player/VideoMetadata.tsx | 6 ++ components/search/LanguageBadges.tsx | 92 +++++++++++++++++++++++++++ components/search/VideoCard.tsx | 5 ++ components/search/VideoGroupCard.tsx | 5 ++ components/ui/icons/utility-icons.tsx | 11 ++++ lib/api/detail-api.ts | 1 + lib/hooks/useLanguageBadges.ts | 75 ++++++++++++++++++++++ lib/types/index.ts | 8 +++ package-lock.json | 4 +- package.json | 2 +- 11 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 components/search/LanguageBadges.tsx create mode 100644 lib/hooks/useLanguageBadges.ts diff --git a/components/home/SearchResults.tsx b/components/home/SearchResults.tsx index cab6a74..edc9c38 100644 --- a/components/home/SearchResults.tsx +++ b/components/home/SearchResults.tsx @@ -2,9 +2,11 @@ import { ResultsHeader } from '@/components/search/ResultsHeader'; import { SourceBadges } from '@/components/search/SourceBadges'; import { TypeBadges } from '@/components/search/TypeBadges'; +import { LanguageBadges } from '@/components/search/LanguageBadges'; import { VideoGrid } from '@/components/search/VideoGrid'; import { useSourceBadges } from '@/lib/hooks/useSourceBadges'; import { useTypeBadges } from '@/lib/hooks/useTypeBadges'; +import { useLanguageBadges } from '@/lib/hooks/useLanguageBadges'; import { Video, SourceBadge } from '@/lib/types'; interface SearchResultsProps { @@ -34,10 +36,19 @@ export function SearchResults({ const { typeBadges, selectedTypes, - filteredVideos: finalFilteredVideos, + filteredVideos: typeFilteredVideos, toggleType, } = 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; 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 && ( + + )} + + {/* Display filtered videos (source, type, and language filters applied) */} )} + {videoData?.vod_lang && ( + + + {videoData.vod_lang} + + )} {videoData?.vod_content && (

diff --git a/components/search/LanguageBadges.tsx b/components/search/LanguageBadges.tsx new file mode 100644 index 0000000..83b643c --- /dev/null +++ b/components/search/LanguageBadges.tsx @@ -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; + 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 ( + +

+
+ + + 语言标签 ({badges.length}): + +
+ + {/* Desktop: flex wrap, Mobile: horizontal scroll */} +
+
+ {badges.map((badge) => ( + onToggleLang(badge.lang)} + /> + ))} +
+
+ +
+
+ {badges.map((badge) => ( + onToggleLang(badge.lang)} + /> + ))} +
+
+
+ + {selectedLangs.size > 0 && ( +
+ +
+ )} + + ); +}); diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx index a178b57..4d79a20 100644 --- a/components/search/VideoCard.tsx +++ b/components/search/VideoCard.tsx @@ -168,6 +168,11 @@ export const VideoCard = memo(({ {video.vod_remarks}

)} + {video.vod_lang && ( +

+ {video.vod_lang} +

+ )} ); })()} diff --git a/components/search/VideoGroupCard.tsx b/components/search/VideoGroupCard.tsx index 0b7a314..32b57ee 100644 --- a/components/search/VideoGroupCard.tsx +++ b/components/search/VideoGroupCard.tsx @@ -202,6 +202,11 @@ export const VideoGroupCard = memo(({ {displayQuality}

)} + {representative.vod_lang && ( +

+ {representative.vod_lang} +

+ )} ); })()} diff --git a/components/ui/icons/utility-icons.tsx b/components/ui/icons/utility-icons.tsx index 98321a8..82779a1 100644 --- a/components/ui/icons/utility-icons.tsx +++ b/components/ui/icons/utility-icons.tsx @@ -172,6 +172,17 @@ export const UtilityIcons = { ), + Languages: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + + + ), + Settings: ({ className = "", size = 24 }: IconProps) => ( diff --git a/lib/api/detail-api.ts b/lib/api/detail-api.ts index 8656105..efafe9e 100644 --- a/lib/api/detail-api.ts +++ b/lib/api/detail-api.ts @@ -73,6 +73,7 @@ export async function getVideoDetail( vod_director: videoData.vod_director, vod_content: videoData.vod_content, type_name: videoData.type_name, + vod_lang: videoData.vod_lang, episodes, source: source.id, source_code: playFrom[selectedIndex] || '', diff --git a/lib/hooks/useLanguageBadges.ts b/lib/hooks/useLanguageBadges.ts new file mode 100644 index 0000000..28dd36d --- /dev/null +++ b/lib/hooks/useLanguageBadges.ts @@ -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(videos: T[]) { + const [selectedLangs, setSelectedLangs] = useState>(new Set()); + + // Collect and count language badges from videos + const languageBadges = useMemo(() => { + const langMap = new Map(); + + 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, + }; +} diff --git a/lib/types/index.ts b/lib/types/index.ts index 0ed25b8..2a52ed5 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -36,6 +36,7 @@ export interface VideoItem { vod_actor?: string; vod_director?: string; vod_content?: string; + vod_lang?: string; source: string; latency?: number; // Response time in milliseconds } @@ -57,6 +58,11 @@ export interface TypeBadge { count: number; } +export interface LanguageBadge { + lang: string; + count: number; +} + // Episode Information export interface Episode { name: string; @@ -76,6 +82,7 @@ export interface VideoDetail { vod_director?: string; vod_content?: string; type_name?: string; + vod_lang?: string; episodes: Episode[]; source: string; source_code: string; @@ -134,6 +141,7 @@ export interface ApiDetailResponse { vod_director?: string; vod_content?: string; type_name?: string; + vod_lang?: string; vod_play_from?: string; vod_play_url?: string; }>; diff --git a/package-lock.json b/package-lock.json index 3aa18ec..43cf3fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.1.2", + "version": "4.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.1.2", + "version": "4.1.3", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index a9a5a31..d8d6cff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.1.2", + "version": "4.1.3", "private": true, "scripts": { "dev": "next dev",