From 45d5343d618dd6c090b10dd3a809743a79db70aa Mon Sep 17 00:00:00 2001 From: corenthin-lebreton Date: Wed, 25 Feb 2026 23:38:27 +0100 Subject: [PATCH] first commit --- .env.example | 1 + Dockerfile | 147 +++++++++++++++++++++++++++++++ README.md | 63 +++++++++++++ eslint.config.js | 28 ++++++ index.html | 13 +++ package.json | 32 +++++++ postcss.config.js | 6 ++ src/App.tsx | 70 +++++++++++++++ src/components/InputForm.tsx | 128 +++++++++++++++++++++++++++ src/components/Layout.tsx | 25 ++++++ src/components/StatusConsole.tsx | 62 +++++++++++++ src/env.d.ts | 9 ++ src/index.css | 30 +++++++ src/main.tsx | 10 +++ src/types/api.ts | 19 ++++ src/utils/api-client.ts | 34 +++++++ src/utils/validators.ts | 16 ++++ src/vite-env.d.ts | 1 + tailwind.config.js | 11 +++ tsconfig.json | 23 +++++ tsconfig.node.json | 21 +++++ vite.config.ts | 10 +++ 22 files changed, 759 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/App.tsx create mode 100644 src/components/InputForm.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/StatusConsole.tsx create mode 100644 src/env.d.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/types/api.ts create mode 100644 src/utils/api-client.ts create mode 100644 src/utils/validators.ts create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99cbcd5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:3000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..28f5621 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,147 @@ +# Stage 1: Build +FROM node:20-alpine AS builder + +# Métadonnées +LABEL maintainer="Yt2Jellyfin" +LABEL description="Frontend React pour Yt2Jellyfin" + +# Définir le répertoire de travail +WORKDIR /app + +# Copier les fichiers de dépendances +COPY package*.json ./ + +# Installer les dépendances (avec cache npm) +RUN npm ci --only=production=false + +# Copier le code source +COPY . . + +# Build de l'application +RUN npm run build + +# Stage 2: Production avec Nginx +FROM nginx:1.25-alpine AS production + +# Installer des outils de sécurité et nettoyer +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + && rm -rf /var/cache/apk/* + +# Créer un utilisateur non-root +RUN addgroup -g 1001 -S nginx-app && \ + adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx-app -g nginx-app nginx-app + +# Copier les fichiers buildés depuis le stage builder +COPY --from=builder --chown=nginx-app:nginx-app /app/dist /usr/share/nginx/html + +# Configuration Nginx sécurisée +COPY --chown=nginx-app:nginx-app < + + + + + + Yt2Jellyfin + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..72aced1 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "yt2jellyfin-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.3" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..7d5cccf --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { Layout } from './components/Layout'; +import { InputForm } from './components/InputForm'; +import { StatusConsole, type StatusMessage } from './components/StatusConsole'; +import { processYoutubeUrl } from './utils/api-client'; +import type { ProcessRequest } from './types/api'; + +function App() { + const [isLoading, setIsLoading] = useState(false); + const [messages, setMessages] = useState([]); + + const addMessage = (message: string, type: StatusMessage['type']) => { + setMessages((prev) => [ + ...prev, + { + id: `${Date.now()}-${Math.random()}`, + type, + message, + timestamp: new Date(), + }, + ]); + }; + + const handleSubmit = async (request: ProcessRequest) => { + setIsLoading(true); + setMessages([]); + + addMessage(`Démarrage du traitement pour: ${request.url}`, 'info'); + addMessage(`Format sélectionné: ${request.format.toUpperCase()}`, 'info'); + + if (request.filename) { + addMessage(`Nom personnalisé: ${request.filename}`, 'info'); + } + + addMessage('Téléchargement en cours...', 'info'); + + try { + const response = await processYoutubeUrl(request); + + if (response.success) { + addMessage('Téléchargement terminé', 'success'); + addMessage('Conversion en cours...', 'info'); + addMessage('Conversion terminée', 'success'); + addMessage('Transfert vers Jellyfin...', 'info'); + addMessage(`Succès ! Fichier transféré: ${response.filename || 'audio'}`, 'success'); + } else { + addMessage(`Erreur: ${'error' in response ? response.error : 'Erreur inconnue'}`, 'error'); + if ('details' in response && response.details) { + addMessage(`Détails: ${response.details}`, 'error'); + } + } + } catch (error) { + addMessage( + `Erreur inattendue: ${error instanceof Error ? error.message : String(error)}`, + 'error' + ); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + ); +} + +export default App; diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx new file mode 100644 index 0000000..12ed218 --- /dev/null +++ b/src/components/InputForm.tsx @@ -0,0 +1,128 @@ +import { useState, type FormEvent } from 'react'; +import type { AudioFormat, ProcessRequest } from '../types/api'; +import { isValidYoutubeUrl, sanitizeFilename } from '../utils/validators'; + +interface InputFormProps { + onSubmit: (request: ProcessRequest) => void; + isLoading: boolean; +} + +export function InputForm({ onSubmit, isLoading }: InputFormProps) { + const [url, setUrl] = useState(''); + const [filename, setFilename] = useState(''); + const [format, setFormat] = useState('mp3'); + const [urlError, setUrlError] = useState(''); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + setUrlError(''); + + if (!isValidYoutubeUrl(url)) { + setUrlError('URL YouTube invalide. Veuillez entrer une URL valide.'); + return; + } + + const sanitizedFilename = filename.trim() + ? sanitizeFilename(filename) + : undefined; + + onSubmit({ + url: url.trim(), + filename: sanitizedFilename, + format, + }); + }; + + return ( +
+
+
+ + setUrl(e.target.value)} + placeholder="https://www.youtube.com/watch?v=..." + className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + disabled={isLoading} + required + /> + {urlError && ( +

{urlError}

+ )} +
+ +
+ + setFilename(e.target.value)} + placeholder="Ma chanson préférée" + className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + disabled={isLoading} + /> +

+ Si non spécifié, le titre de la vidéo sera utilisé +

+
+ +
+ +
+ + +
+
+ + +
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..7dacee9 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +interface LayoutProps { + children: ReactNode; +} + +export function Layout({ children }: LayoutProps) { + return ( +
+
+
+

+ Yt2Jellyfin +

+

+ Téléchargez et transférez votre musique vers Jellyfin +

+
+
+ {children} +
+
+
+ ); +} diff --git a/src/components/StatusConsole.tsx b/src/components/StatusConsole.tsx new file mode 100644 index 0000000..cf23774 --- /dev/null +++ b/src/components/StatusConsole.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; + +export type StatusType = 'info' | 'success' | 'error' | 'warning'; + +export interface StatusMessage { + id: string; + type: StatusType; + message: string; + timestamp: Date; +} + +interface StatusConsoleProps { + messages: StatusMessage[]; +} + +const STATUS_ICONS: Record = { + info: '⏳', + success: '✓', + error: '✗', + warning: '⚠', +}; + +const STATUS_COLORS: Record = { + info: 'text-blue-400', + success: 'text-green-400', + error: 'text-red-400', + warning: 'text-yellow-400', +}; + +export function StatusConsole({ messages }: StatusConsoleProps) { + const consoleEndRef = useRef(null); + + useEffect(() => { + consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + if (messages.length === 0) { + return null; + } + + return ( +
+

Console de statut

+
+ {messages.map((msg) => ( +
+ + {STATUS_ICONS[msg.type]} + + + {msg.timestamp.toLocaleTimeString('fr-FR')} + + + {msg.message} + +
+ ))} +
+
+
+ ); +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..8304c33 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..55977c6 --- /dev/null +++ b/src/index.css @@ -0,0 +1,30 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: Inter, 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; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +#root { + width: 100%; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..3d7150d --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..16a6419 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,19 @@ +export type AudioFormat = 'mp3' | 'flac'; + +export interface ProcessRequest { + url: string; + filename?: string; + format: AudioFormat; +} + +export interface ProcessResponse { + success: boolean; + message: string; + filename?: string; +} + +export interface ProcessError { + success: false; + error: string; + details?: string; +} diff --git a/src/utils/api-client.ts b/src/utils/api-client.ts new file mode 100644 index 0000000..9de4f29 --- /dev/null +++ b/src/utils/api-client.ts @@ -0,0 +1,34 @@ +import type { ProcessRequest, ProcessResponse, ProcessError } from '../types/api'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + +export async function processYoutubeUrl( + request: ProcessRequest +): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/process`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.error || `HTTP ${response.status}: ${response.statusText}`, + details: errorData.details, + }; + } + + return await response.json(); + } catch (error) { + return { + success: false, + error: 'Erreur de connexion au serveur', + details: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..8201c95 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,16 @@ +const YOUTUBE_URL_REGEX = /^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|shorts\/)|youtu\.be\/)[\w-]+/; + +export function isValidYoutubeUrl(url: string): boolean { + if (!url || typeof url !== 'string') { + return false; + } + return YOUTUBE_URL_REGEX.test(url.trim()); +} + +export function sanitizeFilename(filename: string): string { + return filename + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '') + .replace(/\s+/g, '_') + .substring(0, 255); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dad2706 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..ace4e2d --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee788fd --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + strictPort: true, + }, +})