Stack: Node.js/Express + React/Vite + tRPC + MySQL (Drizzle ORM) Features: Gestion de podcasts, établissements, mots-clés, upload audio S3 Migrations: 0000-0002 (users, etablissements, mots_cles, podcasts, podcast_mots_cles)
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
import { Play, Pause, Clock, Building2 } from "lucide-react";
|
|
import type { PodcastWithRelations } from "../../../server/db";
|
|
|
|
interface Props {
|
|
podcast: PodcastWithRelations;
|
|
isPlaying: boolean;
|
|
onPlay: () => void;
|
|
}
|
|
|
|
// Palette de couleurs pour les artworks (basée sur l'ID du podcast)
|
|
const ARTWORK_COLORS = [
|
|
"from-blue-500 to-indigo-600",
|
|
"from-violet-500 to-purple-700",
|
|
"from-emerald-500 to-teal-600",
|
|
"from-orange-500 to-red-600",
|
|
"from-cyan-500 to-blue-600",
|
|
"from-rose-500 to-pink-600",
|
|
"from-amber-500 to-orange-600",
|
|
"from-indigo-500 to-blue-700",
|
|
];
|
|
|
|
function WaveformIcon() {
|
|
return (
|
|
<div className="flex items-center gap-[3px] h-5">
|
|
{[3, 5, 4, 6, 3, 5, 4].map((h, i) => (
|
|
<span
|
|
key={i}
|
|
className="waveform-bar w-[3px] bg-white"
|
|
style={{ height: `${h * 3}px`, animationDelay: `${i * 0.1}s` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDuration(s?: number | null): string | null {
|
|
if (!s) return null;
|
|
const m = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
export default function PodcastCard({ podcast, isPlaying, onPlay }: Props) {
|
|
const duration = formatDuration(podcast.dureeSecondes);
|
|
const colorClass = ARTWORK_COLORS[podcast.id % ARTWORK_COLORS.length];
|
|
|
|
return (
|
|
<div
|
|
className={`group relative bg-card rounded-2xl overflow-hidden transition-all duration-300 cursor-pointer
|
|
${isPlaying
|
|
? "shadow-xl shadow-primary/20 ring-2 ring-primary/40 -translate-y-0.5"
|
|
: "shadow-sm hover:shadow-lg hover:-translate-y-1 border border-border hover:border-primary/20"
|
|
}`}
|
|
onClick={onPlay}
|
|
>
|
|
{/* Barre colorée en haut */}
|
|
<div className={`h-1 w-full bg-gradient-to-r ${colorClass}`} />
|
|
|
|
<div className="p-4 flex items-start gap-4">
|
|
{/* Artwork */}
|
|
<div className={`relative w-16 h-16 rounded-xl bg-gradient-to-br ${colorClass} flex items-center justify-center shrink-0 shadow-md`}>
|
|
{isPlaying ? (
|
|
<WaveformIcon />
|
|
) : (
|
|
<svg viewBox="0 0 24 24" fill="white" className="w-7 h-7 opacity-90">
|
|
<path d="M12 3a9 9 0 100 18A9 9 0 0012 3zm-1 13V8l6 4-6 4z" />
|
|
</svg>
|
|
)}
|
|
{/* Indicateur "en cours" */}
|
|
{isPlaying && (
|
|
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-emerald-500 rounded-full border-2 border-white animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Contenu */}
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className={`font-semibold text-sm leading-snug line-clamp-2 mb-1 ${isPlaying ? "text-primary" : "text-foreground group-hover:text-primary transition-colors"}`}>
|
|
{podcast.titre}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed mb-2">
|
|
{podcast.resume}
|
|
</p>
|
|
|
|
{/* Meta row */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-full">
|
|
<Building2 className="w-3 h-3 shrink-0" />
|
|
<span className="truncate max-w-[110px]">{podcast.etablissementNom}</span>
|
|
</span>
|
|
{duration && (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Clock className="w-3 h-3" />
|
|
{duration}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mots-clés */}
|
|
{podcast.motsCles.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{podcast.motsCles.slice(0, 3).map((mk) => (
|
|
<span
|
|
key={mk.id}
|
|
className="text-xs bg-primary/8 text-primary px-2 py-0.5 rounded-full font-medium border border-primary/15"
|
|
>
|
|
{mk.label}
|
|
</span>
|
|
))}
|
|
{podcast.motsCles.length > 3 && (
|
|
<span className="text-xs text-muted-foreground px-1">+{podcast.motsCles.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bouton play */}
|
|
{podcast.audioUrl && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onPlay(); }}
|
|
className={`w-11 h-11 rounded-full flex items-center justify-center shrink-0 transition-all duration-200 shadow-md
|
|
${isPlaying
|
|
? "bg-primary text-white scale-110 shadow-primary/40"
|
|
: "bg-primary/10 text-primary hover:bg-primary hover:text-white hover:scale-110 hover:shadow-primary/30"
|
|
}`}
|
|
>
|
|
{isPlaying
|
|
? <Pause className="w-4 h-4" />
|
|
: <Play className="w-4 h-4 ml-0.5" />
|
|
}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|