first commit
This commit is contained in:
commit
45d5343d61
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
147
Dockerfile
Normal file
147
Dockerfile
Normal file
@ -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 <<EOF /etc/nginx/nginx.conf
|
||||||
|
user nginx-app;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '\$remote_addr - \$remote_user [\$time_local] "\$request" '
|
||||||
|
'\$status \$body_bytes_sent "\$http_referer" '
|
||||||
|
'"\$http_user_agent" "\$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Désactiver les tokens de version Nginx
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# Gestion des assets statiques avec cache
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing - toutes les routes vers index.html
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy vers le backend (optionnel, si sur même réseau)
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bloquer l'accès aux fichiers sensibles
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Créer les dossiers nécessaires avec bonnes permissions
|
||||||
|
RUN mkdir -p /var/cache/nginx /var/log/nginx /var/run && \
|
||||||
|
chown -R nginx-app:nginx-app /var/cache/nginx /var/log/nginx /var/run /usr/share/nginx/html && \
|
||||||
|
chmod -R 755 /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Exposer le port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Healthcheck
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||||
|
|
||||||
|
# Passer à l'utilisateur non-root
|
||||||
|
USER nginx-app
|
||||||
|
|
||||||
|
# Démarrer Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
63
README.md
Normal file
63
README.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Yt2Jellyfin - Frontend
|
||||||
|
|
||||||
|
Interface utilisateur pour télécharger et transférer de la musique YouTube vers Jellyfin.
|
||||||
|
|
||||||
|
## Stack Technique
|
||||||
|
|
||||||
|
- **Vite** - Build tool et dev server
|
||||||
|
- **React 18** - Bibliothèque UI
|
||||||
|
- **TypeScript** - Typage statique
|
||||||
|
- **Tailwind CSS** - Framework CSS utility-first
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Créez un fichier `.env` basé sur `.env.example`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Configurez l'URL de l'API backend:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application sera disponible sur `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Build Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers de production seront générés dans le dossier `dist/`.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # Composants React
|
||||||
|
│ ├── Layout.tsx
|
||||||
|
│ ├── InputForm.tsx
|
||||||
|
│ └── StatusConsole.tsx
|
||||||
|
├── types/ # Définitions TypeScript
|
||||||
|
│ └── api.ts
|
||||||
|
├── utils/ # Utilitaires
|
||||||
|
│ ├── api-client.ts
|
||||||
|
│ └── validators.ts
|
||||||
|
├── App.tsx # Composant principal
|
||||||
|
└── main.tsx # Point d'entrée
|
||||||
|
```
|
||||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<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>Yt2Jellyfin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
70
src/App.tsx
Normal file
70
src/App.tsx
Normal file
@ -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<StatusMessage[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Layout>
|
||||||
|
<InputForm onSubmit={handleSubmit} isLoading={isLoading} />
|
||||||
|
<StatusConsole messages={messages} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
128
src/components/InputForm.tsx
Normal file
128
src/components/InputForm.tsx
Normal file
@ -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<AudioFormat>('mp3');
|
||||||
|
const [urlError, setUrlError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg shadow-xl p-8 mb-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="url" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
URL YouTube
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="url"
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<p className="mt-2 text-sm text-red-400">{urlError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="filename" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Nom du fichier (optionnel)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filename"
|
||||||
|
type="text"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
Si non spécifié, le titre de la vidéo sera utilisé
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
|
Format audio
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value="mp3"
|
||||||
|
checked={format === 'mp3'}
|
||||||
|
onChange={(e) => setFormat(e.target.value as AudioFormat)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 focus:ring-blue-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-gray-300">MP3 (Compression)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value="flac"
|
||||||
|
checked={format === 'flac'}
|
||||||
|
onChange={(e) => setFormat(e.target.value as AudioFormat)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 focus:ring-blue-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-gray-300">FLAC (Sans perte)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Traitement en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Télécharger et transférer'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/Layout.tsx
Normal file
25
src/components/Layout.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<header className="text-center mb-12">
|
||||||
|
<h1 className="text-5xl font-bold text-white mb-2">
|
||||||
|
Yt2Jellyfin
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-lg">
|
||||||
|
Téléchargez et transférez votre musique vers Jellyfin
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-3xl mx-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/StatusConsole.tsx
Normal file
62
src/components/StatusConsole.tsx
Normal file
@ -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<StatusType, string> = {
|
||||||
|
info: '⏳',
|
||||||
|
success: '✓',
|
||||||
|
error: '✗',
|
||||||
|
warning: '⚠',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<StatusType, string> = {
|
||||||
|
info: 'text-blue-400',
|
||||||
|
success: 'text-green-400',
|
||||||
|
error: 'text-red-400',
|
||||||
|
warning: 'text-yellow-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusConsole({ messages }: StatusConsoleProps) {
|
||||||
|
const consoleEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-4">Console de statut</h2>
|
||||||
|
<div className="bg-gray-900 rounded-lg p-4 max-h-96 overflow-y-auto font-mono text-sm">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className="mb-2 flex items-start gap-2">
|
||||||
|
<span className={STATUS_COLORS[msg.type]}>
|
||||||
|
{STATUS_ICONS[msg.type]}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-xs min-w-[80px]">
|
||||||
|
{msg.timestamp.toLocaleTimeString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
<span className={`flex-1 ${STATUS_COLORS[msg.type]}`}>
|
||||||
|
{msg.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={consoleEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/env.d.ts
vendored
Normal file
9
src/env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
30
src/index.css
Normal file
30
src/index.css
Normal file
@ -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%;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
19
src/types/api.ts
Normal file
19
src/types/api.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
34
src/utils/api-client.ts
Normal file
34
src/utils/api-client.ts
Normal file
@ -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<ProcessResponse | ProcessError> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/utils/validators.ts
Normal file
16
src/utils/validators.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user