(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" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>YtbToJelly</title>
</head>
<body>
<div id="root"></div>

View File

@ -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<string | null>(null);
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) => {
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 lenvoi.");
} 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 (
<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">
@ -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 ? (
<div className="flex items-center gap-2">
@ -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"
>
<span className="truncate text-sm">
<div className="flex-1 truncate text-sm">
{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
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"
? "bg-green-600 text-white"
: job.status === "processing"
@ -141,4 +221,4 @@ export default function YoutubeForm() {
)}
</div>
);
}
}

View File

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

View File

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