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
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
interface FilterState {
min_score: number;
days: number;
}
interface Props {
filters: FilterState;
onChange: (f: Partial<FilterState>) => void;
total: number;
initialQueries: string[];
onLaunch: (queries: string[]) => void;
launching: boolean;
activeQueryFilters: string[];
onQueryFilterToggle: (q: string) => void;
}
const DAYS_OPTIONS = [7, 30, 90];
export default function FilterPanel({ filters, onChange, total, initialQueries, onLaunch, launching, activeQueryFilters, onQueryFilterToggle }: Props) {
const [queries, setQueries] = useState<string[]>(initialQueries);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
setQueries(initialQueries);
}, [initialQueries]);
const addQuery = () => {
const q = inputValue.trim();
if (q && !queries.includes(q)) {
setQueries([...queries, q]);
setInputValue("");
}
};
const removeQuery = (i: number) => {
setQueries(queries.filter((_, idx) => idx !== i));
};
return (
<aside className="filters">
{/* Brand */}
<div className="filters__brand">
<div className="filters__brand-icon"></div>
<span>YTVeille</span>
</div>
{/* Requêtes de recherche */}
<div className="filters__group">
<p className="filters__label">Requêtes de recherche</p>
<div className="filters__queries">
{queries.map((q, i) => (
<div key={i} className="query-item">
<span className="query-item__text">{q}</span>
<button
className="query-item__remove"
onClick={() => removeQuery(i)}
title="Supprimer"
>
×
</button>
</div>
))}
</div>
<div className="filters__add-query">
<input
type="text"
placeholder='Ex: "Docker production français"'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addQuery()}
className="filters__query-input"
/>
<button className="filters__add-btn" onClick={addQuery} title="Ajouter">+</button>
</div>
<button
className="filters__launch"
onClick={() => onLaunch(queries)}
disabled={launching || queries.length === 0}
>
{launching ? "⟳ Recherche en cours..." : "▶ Lancer la veille"}
</button>
</div>
<div className="filters__divider" />
{/* Filtrer par requête */}
{queries.length > 0 && (
<div className="filters__group">
<p className="filters__label">Filtrer par requête</p>
<div className="filters__chips">
{queries.map((q, i) => (
<button
key={i}
className={`chip${activeQueryFilters.includes(q) ? " chip--active" : ""}`}
onClick={() => onQueryFilterToggle(q)}
>
{q}
</button>
))}
</div>
</div>
)}
{/* Score minimum */}
<div className="filters__group">
<label className="filters__label" htmlFor="slider-score">Score minimum</label>
<div className="filters__score-display">
<span>0</span>
<span style={{ color: "var(--primary)", fontWeight: 700 }}>{filters.min_score}</span>
<span>100</span>
</div>
<input
id="slider-score"
type="range"
min={0}
max={100}
step={5}
value={filters.min_score}
onChange={(e) => onChange({ min_score: Number(e.target.value) })}
className="filters__slider"
/>
</div>
{/* Période */}
<div className="filters__group">
<p className="filters__label">Période</p>
<div className="filters__days">
{DAYS_OPTIONS.map((d) => (
<button
key={d}
id={`days-${d}`}
className={`day-btn${filters.days === d ? " day-btn--active" : ""}`}
onClick={() => onChange({ days: d })}
>
{d}j
</button>
))}
</div>
</div>
{/* Reset filtres */}
<button
id="btn-reset-filters"
className="filters__reset"
onClick={() => onChange({ min_score: 0, days: 30 })}
>
Réinitialiser les filtres
</button>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: "auto" }}>
{total} vidéo{total > 1 ? "s" : ""} trouvée{total > 1 ? "s" : ""}
</p>
</aside>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
export default function Sidebar() {
const navItems = [
{ icon: "▶", label: "Veille", id: "feed", badge: null, active: true },
{ icon: "🔍", label: "Explorer", id: "explore", badge: null, active: false },
{ icon: "⭐", label: "Favoris", id: "favorites", badge: null, active: false },
];
return (
<aside className="sidebar">
<div className="sidebar__logo">
<div className="sidebar__logo-icon"></div>
<span>K8s Veille</span>
</div>
<nav className="sidebar__nav">
{navItems.map((item) => (
<button
key={item.id}
id={`nav-${item.id}`}
className={`sidebar__nav-item${item.active ? " sidebar__nav-item--active" : ""}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
{item.badge && <span className="sidebar__badge">{item.badge}</span>}
</button>
))}
<p className="sidebar__section-title">Topics</p>
{["🔥 Incident", "🏗️ Architecture", "📊 Observabilité", "⚙️ CI/CD", "📈 Scaling"].map((t) => (
<button key={t} className="sidebar__nav-item" style={{ fontSize: "13px" }}>
{t}
</button>
))}
</nav>
<div className="sidebar__footer">
<button className="sidebar__nav-item" style={{ width: "100%", borderRadius: 8 }}>
Paramètres
</button>
</div>
</aside>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { triggerRefresh } from "@/lib/api";
interface Props {
lastUpdated: string | null;
videoCount: number;
onSearch: (q: string) => void;
dark: boolean;
onToggleDark: () => void;
}
export default function TopBar({ lastUpdated, videoCount, onSearch, dark, onToggleDark }: Props) {
const [refreshing, setRefreshing] = useState(false);
const [inputValue, setInputValue] = useState("");
const commitSearch = () => onSearch(inputValue);
const handleRefresh = async () => {
commitSearch();
setRefreshing(true);
try {
await triggerRefresh();
setTimeout(() => window.location.reload(), 3000);
} catch {
// silently fail
} finally {
setTimeout(() => setRefreshing(false), 3000);
}
};
return (
<header className="topbar">
<div className="topbar__search">
<span>🔍</span>
<input
id="search-input"
type="text"
placeholder="Rechercher dans les vidéos... (Entrée pour valider)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && commitSearch()}
/>
</div>
{lastUpdated && (
<p className="topbar__status">
{videoCount} vidéos · mis à jour {new Intl.RelativeTimeFormat("fr", { numeric: "auto" }).format(
-Math.round((Date.now() - new Date(lastUpdated).getTime()) / 3600000),
"hours"
)}
</p>
)}
<button
className="topbar__theme-btn"
onClick={onToggleDark}
title={dark ? "Passer en mode clair" : "Passer en mode sombre"}
aria-label="Basculer le thème"
>
{dark ? "☀️" : "🌙"}
</button>
<button
id="btn-refresh"
className="topbar__btn"
onClick={handleRefresh}
disabled={refreshing}
>
{refreshing ? "⟳ Actualisation..." : "⟳ Actualiser"}
</button>
</header>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import type { Video } from "@/lib/api";
import { formatDuration, formatDate, TOPICS } from "@/lib/api";
import Image from "next/image";
interface Props {
video: Video;
}
export default function VideoCard({ video }: Props) {
const scoreColor =
video.score >= 70 ? "var(--primary)" : video.score >= 40 ? "#f59e0b" : "#6b7280";
return (
<a
href={video.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="video-card"
id={`video-${video.id}`}
aria-label={`Voir la vidéo : ${video.title}`}
>
{/* Thumbnail */}
<div className="video-card__thumb">
{video.thumbnail_url ? (
<Image
src={video.thumbnail_url}
alt={video.title}
fill
style={{ objectFit: "cover" }}
unoptimized
/>
) : (
<div style={{ background: "#e5e7eb", width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 36 }}></div>
)}
{/* Score badge */}
<span className="video-card__score" style={{ background: scoreColor }}>
{video.score.toFixed(0)}/100
</span>
{/* Duration */}
<span className="video-card__duration">{formatDuration(video.duration_seconds)}</span>
{/* Chapters indicator */}
{video.has_chapters && (
<span className="video-card__chapters">📑 Chapitres</span>
)}
</div>
{/* Body */}
<div className="video-card__body">
<h3 className="video-card__title">{video.title}</h3>
<div className="video-card__meta">
<span className="video-card__meta-item">📺 {video.channel}</span>
<span className="video-card__meta-item">📅 {formatDate(video.published_at)}</span>
<span className="video-card__meta-item">👁 {video.view_count.toLocaleString("fr-FR")}</span>
{video.like_count > 0 && (
<span className="video-card__meta-item">👍 {video.like_count.toLocaleString("fr-FR")}</span>
)}
</div>
{/* Topics */}
{video.topics.length > 0 && (
<div className="video-card__topics">
{video.topics.slice(0, 3).map((t) => (
<span key={t} className="topic-tag">
{TOPICS[t] ?? t}
</span>
))}
</div>
)}
</div>
</a>
);
}