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