(update): final 1.0 version
This commit is contained in:
parent
1964347da6
commit
df8e52e853
36
Dockerfile
Normal file
36
Dockerfile
Normal 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;"]
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 l’envoi.");
|
setMessage(err.message || "Erreur lors de l’envoi.");
|
||||||
} 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user