(update): final 1.0 version

This commit is contained in:
root 2025-09-14 21:12:31 +00:00
parent 1964347da6
commit df8e52e853
5 changed files with 153 additions and 17 deletions

36
Dockerfile Normal file
View File

@ -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;"]

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>YtbToJelly</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { sendYoutubeUrl } from "../../services/apiClient"; import { sendYoutubeUrl } from "../../services/apiClient";
interface VideoJob { interface VideoJob {
@ -6,32 +6,98 @@ interface VideoJob {
url: string; url: string;
format: string; format: string;
status: "pending" | "processing" | "done" | "error"; 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() { export default function YoutubeForm() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [format, setFormat] = useState("mp3"); // format par défaut const [format, setFormat] = useState("mp3");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [jobs, setJobs] = useState<VideoJob[]>([]); const [jobs, setJobs] = useState<VideoJob[]>([]);
// 🔄 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setMessage(null); 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 { try {
const res = await sendYoutubeUrl(url, format); const res = await sendYoutubeUrl(url, format);
const newJob: VideoJob = {
id: res.jobId || Date.now().toString(), // Met à jour en "done"
url, setJobs((prev) =>
format, prev.map((job) =>
status: "processing", job.id === jobId
}; ? {
setJobs((prev) => [newJob, ...prev]); ...job,
status: "done",
title: res.title || job.title,
artist: res.artist || job.artist,
}
: job
)
);
setMessage(res.message || "Vidéo envoyée avec succès !"); setMessage(res.message || "Vidéo envoyée avec succès !");
setUrl(""); setUrl("");
} catch (err: any) { } catch (err: any) {
setJobs((prev) =>
prev.map((job) =>
job.id === jobId ? { ...job, status: "error" } : job
)
);
setMessage(err.message || "Erreur lors de lenvoi."); setMessage(err.message || "Erreur lors de lenvoi.");
} finally { } finally {
setLoading(false); setLoading(false);
@ -40,9 +106,18 @@ export default function YoutubeForm() {
const clearHistory = () => { const clearHistory = () => {
setJobs([]); setJobs([]);
localStorage.removeItem(STORAGE_KEY);
setMessage("Historique vidé ✅"); setMessage("Historique vidé ✅");
}; };
const formatDate = (ts: number) => {
const date = new Date(ts);
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}`;
};
return ( return (
<div className="flex flex-col items-center w-full max-w-2xl mx-auto bg-surface p-8 rounded-xl shadow-lg"> <div className="flex flex-col items-center w-full max-w-2xl mx-auto bg-surface p-8 rounded-xl shadow-lg">
<h1 className="text-2xl font-bold text-primary mb-4"> <h1 className="text-2xl font-bold text-primary mb-4">
@ -81,9 +156,11 @@ export default function YoutubeForm() {
type="submit" type="submit"
disabled={loading} disabled={loading}
className={`px-6 py-3 rounded-lg font-semibold transition className={`px-6 py-3 rounded-lg font-semibold transition
${loading ${
? "bg-primary/70 text-black animate-pulse cursor-not-allowed" loading
: "bg-primary text-black hover:opacity-90 cursor-pointer"}`} ? "bg-primary/70 text-black animate-pulse cursor-not-allowed"
: "bg-primary text-black hover:opacity-90 cursor-pointer"
}`}
> >
{loading ? ( {loading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -118,11 +195,14 @@ export default function YoutubeForm() {
key={job.id} key={job.id}
className="p-4 rounded-lg bg-surfaceLight shadow flex justify-between items-center opacity-0 animate-fadeIn" className="p-4 rounded-lg bg-surfaceLight shadow flex justify-between items-center opacity-0 animate-fadeIn"
> >
<span className="truncate text-sm"> <div className="flex-1 truncate text-sm">
{job.url} <span className="text-muted">({job.format})</span> {job.url} <span className="text-muted">({job.format})</span>
</span> <div className="text-xs text-gray-400 mt-1">
{formatDate(job.createdAt)}
</div>
</div>
<span <span
className={`text-xs font-semibold px-2 py-1 rounded ${ className={`ml-4 text-xs font-semibold px-2 py-1 rounded transition-colors duration-500 animate-fadeStatus ${
job.status === "done" job.status === "done"
? "bg-green-600 text-white" ? "bg-green-600 text-white"
: job.status === "processing" : job.status === "processing"
@ -141,4 +221,4 @@ export default function YoutubeForm() {
)} )}
</div> </div>
); );
} }

View File

@ -2,6 +2,9 @@ export interface ApiResponse {
success: boolean; success: boolean;
message: string; message: string;
jobId?: string; jobId?: string;
file?: string;
title?: string;
artist?: string;
} }
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;

View File

@ -20,6 +20,23 @@ html, body, #root {
animation: fadeIn 0.4s ease-out forwards; 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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;