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