256 lines
9.7 KiB
TypeScript
256 lines
9.7 KiB
TypeScript
"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, ~8h–9h 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>
|
||
);
|
||
}
|