commit 45d5343d618dd6c090b10dd3a809743a79db70aa Author: corenthin-lebreton Date: Wed Feb 25 23:38:27 2026 +0100 first commit 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, + }, +})