first commit
This commit is contained in:
@@ -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, ~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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user