Files
YTVeille/frontend/app/page.tsx
T
2026-05-22 09:17:28 +02:00

256 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import TopBar from "@/components/TopBar";
import VideoCard from "@/components/VideoCard";
import FilterPanel from "@/components/FilterPanel";
import { fetchVideos, fetchStatus, fetchConfig, triggerRefresh, type Video, type Filters } from "@/lib/api";
interface FilterState {
min_score: number;
days: number;
}
const DEFAULT_FILTERS: FilterState = { min_score: 0, days: 30 };
const PAGE_SIZE = 18;
export default function HomePage() {
const [videos, setVideos] = useState<Video[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [loading, setLoading] = useState(true);
const [initialQueries, setInitialQueries] = useState<string[]>([]);
const [launching, setLaunching] = useState(false);
const [activeQueryFilters, setActiveQueryFilters] = useState<string[]>([]);
const [dark, setDark] = useState(false);
// Lire la préférence stockée au montage
useEffect(() => {
const stored = localStorage.getItem("theme");
const isDark = stored === "dark" || (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
setDark(isDark);
document.documentElement.classList.toggle("dark", isDark);
}, []);
const handleToggleDark = () => {
setDark((prev) => {
const next = !prev;
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
return next;
});
};
const [status, setStatus] = useState<{ video_count: number; last_updated: string | null; refresh_running: boolean; quota_exceeded: boolean; quota_exceeded_at: string | null }>({
video_count: 0,
last_updated: null,
refresh_running: false,
quota_exceeded: false,
quota_exceeded_at: null,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadVideos = useCallback(async (f: FilterState, p: number, q: string, sqFilters: string[]) => {
setLoading(true);
try {
const params: Filters = {
q: q || undefined,
min_score: f.min_score,
days: f.days,
page: p,
page_size: PAGE_SIZE,
source_queries: sqFilters.length > 0 ? sqFilters : undefined,
};
const result = await fetchVideos(params);
setVideos(result.items);
setTotal(result.total);
} catch {
setVideos([]);
} finally {
setLoading(false);
}
}, []);
// Charger config + statut au montage
useEffect(() => {
fetchConfig().then((c) => setInitialQueries(c.queries)).catch(() => {});
fetchStatus().then(setStatus).catch(() => {});
}, []);
// Debounce de la recherche texte (300 ms)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Charger vidéos à chaque changement de filtres / page / recherche / chips
useEffect(() => {
loadVideos(filters, page, debouncedSearch, activeQueryFilters);
}, [filters, page, debouncedSearch, activeQueryFilters, loadVideos]);
const handleFilterChange = (partial: Partial<FilterState>) => {
setFilters((prev) => ({ ...prev, ...partial }));
setPage(1);
};
const handleQueryFilterToggle = (q: string) => {
setActiveQueryFilters((prev) =>
prev.includes(q) ? prev.filter((x) => x !== q) : [...prev, q]
);
setPage(1);
};
const handleLaunch = async (queries: string[]) => {
setLaunching(true);
try {
await triggerRefresh(queries);
// Polling jusqu'à la fin du refresh
pollRef.current = setInterval(async () => {
try {
const s = await fetchStatus();
setStatus(s);
if (!s.refresh_running) {
clearInterval(pollRef.current!);
pollRef.current = null;
setLaunching(false);
setPage(1);
loadVideos(filters, 1, debouncedSearch, activeQueryFilters);
}
} catch {
clearInterval(pollRef.current!);
pollRef.current = null;
setLaunching(false);
}
}, 2000);
} catch {
setLaunching(false);
}
};
// Nettoyage du polling au démontage
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const totalPages = Math.ceil(total / PAGE_SIZE);
// Tous les filtres (texte + chips) sont gérés côté backend.
const displayedVideos = videos;
return (
<div className="app-shell">
<TopBar
lastUpdated={status.last_updated}
videoCount={status.video_count}
onSearch={setSearch}
dark={dark}
onToggleDark={handleToggleDark}
/>
<main className="main">
<div className="main__header">
<h1 className="main__title"> YTVeille</h1>
<span className="main__count">{total} vidéo{total > 1 ? "s" : ""}</span>
</div>
{launching && (
<div className="main__launching">
<span> Recherche YouTube en cours, veuillez patienter...</span>
</div>
)}
{status.quota_exceeded && !launching && (
<div className="main__quota-banner">
<span className="main__quota-icon"></span>
<div>
<strong>Quota YouTube API dépassé</strong>
{status.quota_exceeded_at && (
<> depuis le {new Intl.DateTimeFormat("fr-FR", {
day: "numeric", month: "short",
hour: "2-digit", minute: "2-digit",
}).format(new Date(status.quota_exceeded_at))}</>
)}
<br />
<span>Le quota se renouvelle chaque jour à minuit (heure du Pacifique, ~8h9h UTC). Réessayez demain.</span>
</div>
</div>
)}
{loading ? (
<div className="main__grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton skeleton-card" />
))}
</div>
) : displayedVideos.length === 0 ? (
<div className="main__empty">
<p style={{ fontSize: 40, marginBottom: 12 }}></p>
<p>Aucune vidéo trouvée.</p>
<p style={{ marginTop: 8, fontSize: 13 }}>
Configurez vos requêtes et lancez la veille depuis le panneau gauche.
</p>
</div>
) : (
<div className="main__grid">
{displayedVideos.map((v) => (
<VideoCard key={v.id} video={v} />
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<nav className="pagination" aria-label="Pagination">
<button
id="btn-prev-page"
className="page-btn"
disabled={page === 1}
onClick={() => setPage((p) => p - 1)}
>
Précédent
</button>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const p = i + 1;
return (
<button
key={p}
id={`btn-page-${p}`}
className={`page-btn${page === p ? " page-btn--active" : ""}`}
onClick={() => setPage(p)}
>
{p}
</button>
);
})}
<button
id="btn-next-page"
className="page-btn"
disabled={page === totalPages}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</button>
</nav>
)}
</main>
<FilterPanel
filters={filters}
onChange={handleFilterChange}
total={total}
initialQueries={initialQueries}
onLaunch={handleLaunch}
launching={launching}
activeQueryFilters={activeQueryFilters}
onQueryFilterToggle={handleQueryFilterToggle}
/>
</div>
);
}