feat: Implement URL state persistence for search queries, source filters, and type filters, and update search form styling.

This commit is contained in:
kuekhaoyang
2026-01-25 14:55:38 +08:00
parent 2aa90a4d4f
commit 198ca2ddd7
6 changed files with 126 additions and 25 deletions
+7 -1
View File
@@ -18,7 +18,13 @@ export function useHomePage() {
const [currentSortBy, setCurrentSortBy] = useState<SortOption>('default');
const onUrlUpdate = useCallback((q: string) => {
router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false });
const params = new URLSearchParams(window.location.search);
if (q) {
params.set('q', q);
} else {
params.delete('q');
}
router.replace(`/?${params.toString()}`, { scroll: false });
}, [router]);
// Search stream hook
+51 -4
View File
@@ -1,6 +1,5 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import type { SourceBadge } from '@/lib/types';
/**
@@ -10,12 +9,59 @@ import type { SourceBadge } from '@/lib/types';
* - Tracks available video sources
* - Supports filtering by selected sources
* - Auto-cleanup when sources no longer exist
* - Persists state in URL
*/
export function useSourceBadges<T extends { source?: string; sourceName?: string }>(
videos: T[],
availableSources: SourceBadge[]
) {
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isInitialMount = useRef(true);
// Initialize from URL
const [selectedSources, setSelectedSources] = useState<Set<string>>(() => {
const sourcesParam = searchParams.get('sources');
if (sourcesParam) {
return new Set(sourcesParam.split(',').filter(Boolean));
}
return new Set();
});
// Sync state to URL
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
const currentParams = new URLSearchParams(searchParams.toString());
const sourcesParam = Array.from(selectedSources).join(',');
if (sourcesParam) {
currentParams.set('sources', sourcesParam);
} else {
currentParams.delete('sources');
}
const newUrl = `${pathname}?${currentParams.toString()}`;
router.replace(newUrl, { scroll: false });
}, [selectedSources, pathname, router, searchParams]);
// Handle URL changes (e.g., when clicking browser back/forward)
useEffect(() => {
const sourcesParam = searchParams.get('sources');
const urlSources = new Set(sourcesParam ? sourcesParam.split(',').filter(Boolean) : []);
const currentSourcesArr = Array.from(selectedSources);
const urlSourcesArr = Array.from(urlSources);
if (currentSourcesArr.length !== urlSourcesArr.length ||
!currentSourcesArr.every(s => urlSources.has(s))) {
setSelectedSources(urlSources);
}
}, [searchParams]);
// Filter videos by selected sources
const filteredVideos = useMemo(() => {
@@ -43,6 +89,7 @@ export function useSourceBadges<T extends { source?: string; sourceName?: string
// Auto-cleanup: remove selected sources that no longer exist
useEffect(() => {
if (availableSources.length === 0) return;
const availableSourceIds = new Set(availableSources.map(s => s.id));
setSelectedSources(prev => {
+52 -5
View File
@@ -1,6 +1,5 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import type { TypeBadge } from '@/lib/types';
/**
@@ -12,9 +11,56 @@ import type { TypeBadge } from '@/lib/types';
* - Updates dynamically as videos are added/removed
* - Removes badges when count reaches 0
* - Supports filtering by selected types
* - Persists state in URL
*/
export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isInitialMount = useRef(true);
// Initialize from URL
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(() => {
const typesParam = searchParams.get('types');
if (typesParam) {
return new Set(typesParam.split(',').filter(Boolean));
}
return new Set();
});
// Sync state to URL
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
const currentParams = new URLSearchParams(searchParams.toString());
const typesParam = Array.from(selectedTypes).join(',');
if (typesParam) {
currentParams.set('types', typesParam);
} else {
currentParams.delete('types');
}
const newUrl = `${pathname}?${currentParams.toString()}`;
router.replace(newUrl, { scroll: false });
}, [selectedTypes, pathname, router, searchParams]);
// Handle URL changes (e.g., when clicking browser back/forward)
useEffect(() => {
const typesParam = searchParams.get('types');
const urlTypes = new Set(typesParam ? typesParam.split(',').filter(Boolean) : []);
const currentTypesArr = Array.from(selectedTypes);
const urlTypesArr = Array.from(urlTypes);
if (currentTypesArr.length !== urlTypesArr.length ||
!currentTypesArr.every(t => urlTypes.has(t))) {
setSelectedTypes(urlTypes);
}
}, [searchParams]);
// Collect and count type badges from videos
const typeBadges = useMemo<TypeBadge[]>(() => {
@@ -46,7 +92,7 @@ export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
// Toggle type selection - useCallback to prevent re-creation
const toggleType = useCallback((type: string) => {
// Update selected types immediately (high priority)
// Update selected types immediately
setSelectedTypes(prev => {
const newSet = new Set(prev);
if (newSet.has(type)) {
@@ -60,6 +106,7 @@ export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
// Auto-cleanup: remove selected types that no longer exist in badges
useEffect(() => {
if (typeBadges.length === 0) return;
const availableTypes = new Set(typeBadges.map(b => b.type));
setSelectedTypes(prev => {