diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31fbc9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# ---- Build stage ---- +FROM node:20-alpine AS builder + +# Installer pnpm +RUN npm install -g pnpm + +# Créer le répertoire de travail +WORKDIR /app + +# Copier uniquement les fichiers nécessaires pour les dépendances +COPY package.json pnpm-lock.yaml ./ + +# Installer les dépendances +RUN pnpm install --frozen-lockfile + +# Copier le reste du code +COPY . . + +# Build du frontend +RUN pnpm build + +# ---- Production stage ---- +FROM nginx:alpine AS runner + +# Supprimer la config par défaut de nginx +RUN rm -rf /usr/share/nginx/html/* + +# Copier les fichiers générés par Vite +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copier une config nginx custom (optionnel, sinon il sert juste les fichiers) +# COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/index.html b/index.html index e4b78ea..d4f5378 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + YtbToJelly
diff --git a/src/features/youtube/YoutubeForm.tsx b/src/features/youtube/YoutubeForm.tsx index 3d2a23b..e695a30 100644 --- a/src/features/youtube/YoutubeForm.tsx +++ b/src/features/youtube/YoutubeForm.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { sendYoutubeUrl } from "../../services/apiClient"; interface VideoJob { @@ -6,32 +6,98 @@ interface VideoJob { url: string; format: string; status: "pending" | "processing" | "done" | "error"; + createdAt: number; // timestamp en ms + title?: string; + artist?: string; } +const STORAGE_KEY = "youtube_jobs"; +const EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 1 semaine + export default function YoutubeForm() { const [url, setUrl] = useState(""); - const [format, setFormat] = useState("mp3"); // format par défaut + const [format, setFormat] = useState("mp3"); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const [jobs, setJobs] = useState([]); + // 🔄 Charger jobs depuis localStorage au montage + useEffect(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const parsed: VideoJob[] = JSON.parse(saved); + const now = Date.now(); + + const valid = parsed.filter( + (job) => + typeof job.createdAt === "number" && + now - Number(job.createdAt) < EXPIRATION + ); + + setJobs( + valid.map((job) => ({ + ...job, + createdAt: Number(job.createdAt), + })) + ); + } catch (e) { + console.error("Erreur parsing localStorage:", e); + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + // 💾 Sauvegarder jobs dans localStorage à chaque update (mais pas à vide) + useEffect(() => { + if (jobs.length > 0) { + const normalized = jobs.map((job) => ({ + ...job, + createdAt: Number(job.createdAt), + })); + localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); + } + }, [jobs]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setMessage(null); + const jobId = Date.now().toString(); + const createdAt = Date.now(); + + // Ajoute le job en "processing" + setJobs((prev) => [ + { id: jobId, url, format, status: "processing", createdAt }, + ...prev, + ]); + try { const res = await sendYoutubeUrl(url, format); - const newJob: VideoJob = { - id: res.jobId || Date.now().toString(), - url, - format, - status: "processing", - }; - setJobs((prev) => [newJob, ...prev]); + + // Met à jour en "done" + setJobs((prev) => + prev.map((job) => + job.id === jobId + ? { + ...job, + status: "done", + title: res.title || job.title, + artist: res.artist || job.artist, + } + : job + ) + ); + setMessage(res.message || "Vidéo envoyée avec succès !"); setUrl(""); } catch (err: any) { + setJobs((prev) => + prev.map((job) => + job.id === jobId ? { ...job, status: "error" } : job + ) + ); setMessage(err.message || "Erreur lors de l’envoi."); } finally { setLoading(false); @@ -40,9 +106,18 @@ export default function YoutubeForm() { const clearHistory = () => { setJobs([]); + localStorage.removeItem(STORAGE_KEY); setMessage("Historique vidé ✅"); }; + const formatDate = (ts: number) => { + const date = new Date(ts); + return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}`; + }; + return (

@@ -81,9 +156,11 @@ export default function YoutubeForm() { type="submit" disabled={loading} className={`px-6 py-3 rounded-lg font-semibold transition - ${loading - ? "bg-primary/70 text-black animate-pulse cursor-not-allowed" - : "bg-primary text-black hover:opacity-90 cursor-pointer"}`} + ${ + loading + ? "bg-primary/70 text-black animate-pulse cursor-not-allowed" + : "bg-primary text-black hover:opacity-90 cursor-pointer" + }`} > {loading ? (
@@ -118,11 +195,14 @@ export default function YoutubeForm() { key={job.id} className="p-4 rounded-lg bg-surfaceLight shadow flex justify-between items-center opacity-0 animate-fadeIn" > - +
{job.url} ({job.format}) - +
+ {formatDate(job.createdAt)} +
+
); -} \ No newline at end of file +} diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 049d863..9fed849 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -2,6 +2,9 @@ export interface ApiResponse { success: boolean; message: string; jobId?: string; + file?: string; + title?: string; + artist?: string; } const API_URL = import.meta.env.VITE_API_URL; diff --git a/src/styles/index.css b/src/styles/index.css index 9c5f721..df7254c 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -20,6 +20,23 @@ html, body, #root { animation: fadeIn 0.4s ease-out forwards; } + +@keyframes fadeStatus { + from { + opacity: 0.6; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-fadeStatus { + animation: fadeStatus 0.3s ease-in-out; +} + + @tailwind base; @tailwind components; @tailwind utilities; \ No newline at end of file