(add+update) : UI faite avec tailwind + connexion l'API)
This commit is contained in:
parent
8772d14a62
commit
61376351e2
@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@ -12,17 +12,21 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react-swc": "^4.0.0",
|
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "3.4.17",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"vite": "^7.1.2"
|
"vite": "^7.1.2"
|
||||||
|
|||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
40
src/App.tsx
40
src/App.tsx
@ -1,35 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { Routes, Route } from "react-router-dom";
|
||||||
import reactLogo from './assets/react.svg'
|
import Home from "./pages/Home";
|
||||||
import viteLogo from '/vite.svg'
|
import Layout from "./components/layout/Layout";
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Routes>
|
||||||
<div>
|
<Route path="/" element={<Layout />}>
|
||||||
<a href="https://vite.dev" target="_blank">
|
<Route index element={<Home />} />
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
</Route>
|
||||||
</a>
|
</Routes>
|
||||||
<a href="https://react.dev" target="_blank">
|
);
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<h1>Vite + React</h1>
|
|
||||||
<div className="card">
|
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
11
src/components/layout/Layout.tsx
Normal file
11
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-text flex flex-col">
|
||||||
|
<main className="flex-1 flex items-center justify-center p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/features/youtube/YoutubeForm.tsx
Normal file
144
src/features/youtube/YoutubeForm.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { sendYoutubeUrl } from "../../services/apiClient";
|
||||||
|
|
||||||
|
interface VideoJob {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
format: string;
|
||||||
|
status: "pending" | "processing" | "done" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YoutubeForm() {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [format, setFormat] = useState("mp3"); // format par défaut
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<VideoJob[]>([]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await sendYoutubeUrl(url, format);
|
||||||
|
const newJob: VideoJob = {
|
||||||
|
id: res.jobId || Date.now().toString(),
|
||||||
|
url,
|
||||||
|
format,
|
||||||
|
status: "processing",
|
||||||
|
};
|
||||||
|
setJobs((prev) => [newJob, ...prev]);
|
||||||
|
setMessage(res.message || "Vidéo envoyée avec succès !");
|
||||||
|
setUrl("");
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage(err.message || "Erreur lors de l’envoi.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
setJobs([]);
|
||||||
|
setMessage("Historique vidé ✅");
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
Youtube To Jellyfin
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted text-sm mb-6">
|
||||||
|
Collez un lien YouTube ci-dessous et choisissez le format. La vidéo sera
|
||||||
|
téléchargée, convertie et envoyée automatiquement sur votre serveur.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col sm:flex-row gap-4 w-full"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 rounded-lg bg-surfaceLight text-text
|
||||||
|
placeholder-gray-500 border border-gray-700
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
className="px-4 py-3 rounded-lg bg-surfaceLight text-text border border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="mp3">MP3</option>
|
||||||
|
<option value="flac">FLAC</option>
|
||||||
|
<option value="wav">WAV</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 border-2 border-black border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
Envoi...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Télécharger & Envoyer"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className="mt-4 text-sm text-secondary font-medium">{message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<div className="mt-8 w-full">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-text">Vidéos envoyées</h2>
|
||||||
|
<button
|
||||||
|
onClick={clearHistory}
|
||||||
|
className="text-sm text-red-400 hover:text-red-300 transition"
|
||||||
|
>
|
||||||
|
Vider l’historique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<li
|
||||||
|
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">
|
||||||
|
{job.url} <span className="text-muted">({job.format})</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold px-2 py-1 rounded ${
|
||||||
|
job.status === "done"
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: job.status === "processing"
|
||||||
|
? "bg-yellow-600 text-white"
|
||||||
|
: job.status === "error"
|
||||||
|
? "bg-red-600 text-white"
|
||||||
|
: "bg-gray-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,68 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
src/main.tsx
10
src/main.tsx
@ -1,10 +1,14 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</StrictMode>,
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import YoutubeForm from "../features/youtube/YoutubeForm";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center">
|
||||||
|
<YoutubeForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/services/apiClient.ts
Normal file
25
src/services/apiClient.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export interface ApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
jobId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
const API_KEY = import.meta.env.VITE_API_KEY;
|
||||||
|
|
||||||
|
export async function sendYoutubeUrl(url: string, format: string): Promise<ApiResponse> {
|
||||||
|
const res = await fetch(`${API_URL}/api/download`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url, format }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Erreur serveur : ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
25
src/styles/index.css
Normal file
25
src/styles/index.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/* eslint-disable @tailwindcss/no-custom-at-rules */
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
5
src/types/api.ts
Normal file
5
src/types/api.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface ApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
jobId?: string; // si tu veux suivre le téléchargement
|
||||||
|
}
|
||||||
0
src/types/global.d.ts
vendored
0
src/types/global.d.ts
vendored
28
tailwind.config.js
Normal file
28
tailwind.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: "#121212",
|
||||||
|
surface: "#1E1E1E",
|
||||||
|
surfaceLight: "#2A2A2A",
|
||||||
|
primary: "#BB86FC",
|
||||||
|
secondary: "#03DAC6",
|
||||||
|
text: {
|
||||||
|
DEFAULT: "#E0E0E0",
|
||||||
|
muted: "#A0A0A0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user