first commit

This commit is contained in:
2026-05-22 09:17:28 +02:00
commit 26b3ed6994
33 changed files with 2943 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
"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>
);
}