[FEAT] Full function and deployed version

This commit is contained in:
NADAL Jean-Baptiste
2026-02-19 15:15:58 +01:00
parent e7c4768589
commit cf0db69f2d
24 changed files with 6949 additions and 69 deletions
+5
View File
@@ -0,0 +1,5 @@
# Docker Environment Variables
# Copy this to .env and update values
# JWT Secret - IMPORTANT: Change in production!
JWT_SECRET=change_me_in_production_use_openssl_rand_base64_32
+17 -5
View File
@@ -23,6 +23,20 @@ npm run lint # ESLint check
### PHP ### PHP
No build step required. Deploy to PHP-enabled web server. No build step required. Deploy to PHP-enabled web server.
## Deployment
### Create Deploy ZIP
```bash
cd /home/jbnadal/sources/jb/ohmj/ohmj2
./deploy/deploy.sh
```
**Important:**
- Do NOT include `legacy/` folder in the zip
- Do NOT include `tests.php` in the zip
- The `.env` file is included with JWT_SECRET
- Frontend build must be in `frontend/` directory
## Testing ## Testing
### PHP API Tests ### PHP API Tests
@@ -40,6 +54,8 @@ The tests cover:
- **Create Score with Pieces**: Functional tests with pieces verification - **Create Score with Pieces**: Functional tests with pieces verification
- **Files**: Get files tree, delete file error handling - **Files**: Get files tree, delete file error handling
**Note**: tests.php is kept in source for development but must NOT be included in deploy zip.
### Vue/Svelte ### Vue/Svelte
No test framework configured for frontend. No test framework configured for frontend.
@@ -135,11 +151,7 @@ MySQL database connection configured in `api/config/database.php`:
## Important Notes ## Important Notes
- **No external CDN dependencies allowed** - All assets must be local (fonts, JS libraries, etc.) - **NEVER delete files or directories** - Always ask before removing anything
- Use local copies in `static/` folder instead of CDN
## Notes
- This is a legacy codebase with mixed coding styles - This is a legacy codebase with mixed coding styles
- Prefer consistency with existing code over strict style enforcement - Prefer consistency with existing code over strict style enforcement
- Site targets French-speaking users - Site targets French-speaking users
+35 -5
View File
@@ -493,8 +493,38 @@ services:
- `/legacy/Scores` → fichiers PDF - `/legacy/Scores` → fichiers PDF
### Tâches pour déploiement ### Tâches pour déploiement
- [ ] Créer Dockerfile - [x] Créer Dockerfile
- [ ] Créer docker-compose.yml - [x] Créer docker-compose.yml
- [ ] Tester en local - [x] Tester en local
- [ ] Configurer CI/CD (GitHub Actions) - [x] Configurer CI/CD (GitHub Actions)
- [ ] Déployer sur serveur prod - [x] Déployer sur serveur prod
---
## Progression actuelle (2026-02-19)
### Backend (api/)
- ✅ Added `ressource` parameter to updateScore function (ScoreScanner.php)
- ✅ Updated CORS to allow local development (localhost:5173, localhost:4173)
### Frontend - Admin (partitions/)
- ✅ Created inline editing for score info (name, compositor, ressource) with pencil icons
- ✅ Added auto-focus when entering edit mode
- ✅ Added Enter key to save, Escape to cancel
- ✅ Added $effect to auto-set default key when instrument changes
- ✅ Changed "Uploader un PDF" to "Ajouter une partition" with modern card styling
- ✅ Made titles consistent across sections using ohmj-primary color
- ✅ Made partition number (№) bigger and bolder
- ✅ Changed delete buttons to modern trash icon with hover effect
- ✅ Fixed HTML structure issues
- ✅ Admin page at `/admin/[id]` with inline editing
### Déploiement
- ✅ API at ohmj-api.c.nadal-fr.com
- ✅ Frontend at partitions.c.nadal-fr.com
- ✅ Caddy handles reverse proxy for both services
- ✅ TLS/certificate resolved by using ohmj-api.c.nadal-fr.com
- ✅ Deploy zip at _builds/deploy_ohmj.zip
### API Documentation
- ✅ README.md updated with ressource parameter
+2 -1
View File
@@ -1 +1,2 @@
JWT_SECRET=ohmj_test_secret_key_change_in_production_12345 JWT_SECRET=6jh/MWqVplwXQKsiwlKahE19TSavfR1dNCawsQFixus=
SCORES_PATH=/data/scores/
+10 -2
View File
@@ -70,7 +70,8 @@ GET /scores
{ {
"id": "102", "id": "102",
"name": "A Legend from Yao", "name": "A Legend from Yao",
"compositor": "Yeh Shu-Han" "compositor": "Yeh Shu-Han",
"ressource": "https://youtube.com/watch?v=xxx"
}, },
{ {
"id": "390", "id": "390",
@@ -100,6 +101,7 @@ GET /scores/390
"id": "102", "id": "102",
"name": "A Legend from Yao", "name": "A Legend from Yao",
"compositor": "Yeh Shu-Han", "compositor": "Yeh Shu-Han",
"ressource": "https://youtube.com/watch?v=xxx",
"instruments": [ "instruments": [
{ {
"id": "cla", "id": "cla",
@@ -307,10 +309,16 @@ Content-Type: application/json
```json ```json
{ {
"name": "Nouveau nom", "name": "Nouveau nom",
"compositor": "Nouveau compositeur" "compositor": "Nouveau compositeur",
"ressource": "https://youtube.com/watch?v=xxx"
} }
``` ```
**Paramètres optionnels :**
- `name` - Nom du morceau
- `compositor` - Nom du compositeur
- `ressource` - Lien externe (YouTube, site éditeur, etc.)
--- ---
### DELETE /admin/scores/:id ### DELETE /admin/scores/:id
+71 -4
View File
@@ -1,4 +1,22 @@
<?php <?php
// Load .env file if it exists
if (file_exists(__DIR__ . '/.env')) {
$lines = file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0) continue;
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if (!getenv($key)) {
putenv("$key=$value");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
}
// Increase upload limits for this script // Increase upload limits for this script
ini_set('upload_max_filesize', '64M'); ini_set('upload_max_filesize', '64M');
ini_set('post_max_size', '64M'); ini_set('post_max_size', '64M');
@@ -12,11 +30,16 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
// CORS - Restrict to allowed origins // CORS - Restrict to allowed origins
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'https://ohmj2.free.fr', 'https://partitions.ohmj.fr']; $allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Always send CORS headers for preflight requests
if (in_array($origin, $allowedOrigins)) { if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin"); header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true'); header('Access-Control-Allow-Credentials: true');
} else {
// For preflight without matching origin, still allow (for debugging)
header("Access-Control-Allow-Origin: *");
} }
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type'); header('Access-Control-Allow-Headers: Authorization, Content-Type');
@@ -70,7 +93,11 @@ require_once __DIR__ . '/lib/Auth.php';
require_once __DIR__ . '/lib/ScoreScanner.php'; require_once __DIR__ . '/lib/ScoreScanner.php';
$auth = new Auth(); $auth = new Auth();
$scanner = new ScoreScanner('/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/'); $scoresPath = getenv('SCORES_PATH');
if ($scoresPath === false || $scoresPath === '') {
$scoresPath = __DIR__ . '/../legacy/Scores/';
}
$scanner = new ScoreScanner($scoresPath);
// Get Authorization header // Get Authorization header
$token = null; $token = null;
@@ -124,7 +151,7 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
exit; exit;
} }
$basePath = '/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/'; $basePath = getenv('SCORES_PATH') ?: __DIR__ . '/../legacy/Scores/';
$fullPath = $basePath . $filePath; $fullPath = $basePath . $filePath;
// Security: Verify resolved path is within allowed directory // Security: Verify resolved path is within allowed directory
@@ -198,6 +225,11 @@ if ($user === null) {
// GET /scores - List all scores // GET /scores - List all scores
if ($path === 'scores' && $method === 'GET') { if ($path === 'scores' && $method === 'GET') {
$scores = $scanner->listScores(); $scores = $scanner->listScores();
if (isset($scores['error'])) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $scores['error']]);
exit;
}
echo json_encode(['success' => true, 'scores' => $scores]); echo json_encode(['success' => true, 'scores' => $scores]);
exit; exit;
} }
@@ -297,8 +329,9 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT')
$name = $input['name'] ?? null; $name = $input['name'] ?? null;
$compositor = $input['compositor'] ?? null; $compositor = $input['compositor'] ?? null;
$ressource = $input['ressource'] ?? '';
$result = $scanner->updateScore($scoreId, $name, $compositor); $result = $scanner->updateScore($scoreId, $name, $compositor, $ressource);
if ($result['success']) { if ($result['success']) {
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@@ -332,13 +365,47 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE
if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') { if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') {
$scoreId = $matches[1]; $scoreId = $matches[1];
// Check for upload errors
if (!isset($_FILES['file'])) { if (!isset($_FILES['file'])) {
// Check if post_max_size was exceeded
if (empty($_POST) && $_SERVER['CONTENT_LENGTH'] > 0) {
$maxSize = ini_get('post_max_size');
http_response_code(413);
echo json_encode(['error' => "File too large. Maximum size is $maxSize"]);
exit;
}
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'No file uploaded']); echo json_encode(['error' => 'No file uploaded']);
exit; exit;
} }
$file = $_FILES['file']; $file = $_FILES['file'];
// Check for PHP upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
$errorMsg = 'Upload failed';
switch ($file['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$maxSize = ini_get('upload_max_filesize');
$errorMsg = "File too large. Maximum size is $maxSize";
http_response_code(413);
break;
case UPLOAD_ERR_PARTIAL:
$errorMsg = 'File was only partially uploaded';
http_response_code(400);
break;
case UPLOAD_ERR_NO_FILE:
$errorMsg = 'No file was uploaded';
http_response_code(400);
break;
default:
http_response_code(400);
}
echo json_encode(['error' => $errorMsg]);
exit;
}
$piece = $_POST['piece'] ?? '1'; $piece = $_POST['piece'] ?? '1';
$instrument = $_POST['instrument'] ?? ''; $instrument = $_POST['instrument'] ?? '';
$version = $_POST['version'] ?? '1'; $version = $_POST['version'] ?? '1';
+4
View File
@@ -7,6 +7,10 @@ class ScoreScanner {
} }
public function getAllScores() { public function getAllScores() {
if (!is_dir($this->scoresPath)) {
return ['error' => 'Scores directory not found: ' . $this->scoresPath];
}
$scores = []; $scores = [];
$directories = scandir($this->scoresPath); $directories = scandir($this->scoresPath);
+19
View File
@@ -17,6 +17,25 @@ if (file_exists($envFile)) {
} }
} }
// CORS headers - MUST be sent before any other output
$allowedOrigins = ['http://localhost:5173', 'http://localhost:3000', 'http://localhost:4173', 'https://ohmj2.free.fr', 'https://partitions.c.nadal-fr.com', 'https://ohmj-api.c.nadal-fr.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
} else {
header("Access-Control-Allow-Origin: *");
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
// Handle OPTIONS preflight immediately
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Router script for PHP built-in server // Router script for PHP built-in server
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# OHMJ Deployment Script
set -e
cd /home/jbnadal/sources/jb/ohmj/ohmj2
echo "=== OHMJ Deploy Script ==="
# 1. Remove old zip
echo "[1/7] Removing old zip..."
rm -f _builds/deploy_ohmj.zip
# 2. Generate JWT_SECRET
echo "[2/7] Generating JWT_SECRET..."
JWT_SECRET=$(openssl rand -base64 32)
echo "JWT_SECRET=$JWT_SECRET" > api/.env
echo "SCORES_PATH=/data/scores/" >> api/.env
# 3. Set production API URL in frontend .env
echo "[3/7] Setting production API URL..."
echo "VITE_API_URL=https://ohmj-api.c.nadal-fr.com" > partitions/.env
# 4. Clean and build frontend
echo "[4/7] Cleaning and building frontend..."
cd partitions
rm -rf build
npm run build
# Copy .mjs to .js for correct MIME type
cp build/pdf.worker.min.mjs build/pdf.worker.min.js 2>/dev/null || echo "pdf.worker.min.mjs not found"
cd ..
# 5. Create symlink for frontend directory
echo "[5/7] Creating frontend symlink..."
rm -f frontend
ln -sfn partitions/build frontend
# 6. Copy nginx config to frontend
echo "[6/7] Adding nginx config..."
mkdir -p partitions/build
cp /home/jbnadal/sources/jb/ohmj/ohmj2/partitions/nginx.conf partitions/build/nginx.conf 2>/dev/null || echo "nginx.conf not found in partitions/"
# 7. Create zip (without tests.php and legacy)
echo "[7/7] Creating zip..."
zip -r _builds/deploy_ohmj.zip api frontend -x "*.DS_Store" "node_modules/*" ".svelte-kit/*"
echo "=== Done! ==="
echo "Zip created: _builds/deploy_ohmj.zip"
echo ""
echo "To deploy:"
echo " 1. Upload zip to server"
echo " 2. Extract to /var/www/"
echo " 3. Configure web server"
+32
View File
@@ -0,0 +1,32 @@
version: '3.8'
services:
api:
image: php:8.3-cli
container_name: ohmj-api
hostname: api
ports:
- "8000:80"
volumes:
- /mnt/tools/docker/ohmj/api:/var/www/html
- /mnt/tools/docker/ohmj/Scores:/var/www/html/legacy/Scores
environment:
- JWT_SECRET=${JWT_SECRET}
command: php -S 0.0.0.0:80 -t /var/www/html router.php
networks:
- ohmj-network
frontend:
image: nginx:alpine
container_name: ohmj-frontend
hostname: partitions
ports:
- "8080:80"
volumes:
- /mnt/tools/docker/ohmj/frontend:/usr/share/nginx/html:ro
networks:
- ohmj-network
networks:
ohmj-network:
driver: bridge
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
api:
image: php:8.3-cli
container_name: ohmj-api
hostname: api
ports:
- "8000:80"
volumes:
- /mnt/tools-volume/docker/ohmj/api:/var/www/html
- /data/scores:/data/scores
environment:
- JWT_SECRET=${JWT_SECRET}
- SCORES_PATH=/data/scores
command: sh -c "php -d upload_max_filesize=64M -d post_max_size=64M -S 0.0.0.0:80 -t /var/www/html /var/www/html/router.php"
networks:
- ohmj-network
frontend:
image: nginx:alpine
container_name: ohmj-frontend
hostname: partition
ports:
- "8080:80"
volumes:
- /mnt/tools-volume/docker/ohmj/frontend:/usr/share/nginx/html:ro
- /mnt/tools-volume/docker/ohmj/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- ohmj-network
networks:
ohmj-network:
driver: bridge
Symlink
+1
View File
@@ -0,0 +1 @@
partitions/build
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=https://ohmj-api.c.nadal-fr.com
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Cache static assets (including .mjs)
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Force correct MIME type for .mjs files
location ~* \.mjs$ {
add_header Content-Type application/javascript;
expires 1y;
add_header Cache-Control "public, immutable";
}
# All routes should serve index.html (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# 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;
}
+14 -11
View File
@@ -3,10 +3,10 @@ import { auth } from '$lib/stores/auth';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
// Use environment variable or default to localhost const API_BASE_URL_LOCAL = 'http://localhost:8000';
const API_BASE_URL = browser const API_BASE_URL_PROD = 'https://ohmj-api.c.nadal-fr.com';
? (import.meta.env.VITE_API_URL || 'http://localhost:8000')
: (import.meta.env.VITE_API_URL || 'http://localhost:8000'); const API_BASE_URL = browser ? API_BASE_URL_PROD : API_BASE_URL_LOCAL;
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@@ -28,7 +28,8 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError) => { (error: AxiosError) => {
if (error.response?.status === 401) { // Only logout on actual 401 from the server, not network/CORS errors
if (error.response?.status === 401 && !error.message?.includes('Network Error')) {
auth.logout(); auth.logout();
if (browser) { if (browser) {
window.location.href = '/'; window.location.href = '/';
@@ -110,9 +111,11 @@ export const apiService = {
}, },
getDownloadUrl(path: string): string { getDownloadUrl(path: string): string {
// Security: Token is now passed via Authorization header, not URL // Pass token in URL for direct browser access (PDF viewer, iframe, etc.)
// The backend will read the token from the header in the request // Safe over HTTPS
return `${API_BASE_URL}/download/${path}`; const authState = get(auth);
const token = authState.token ? encodeURIComponent(authState.token) : '';
return `${API_BASE_URL}/download/${path}?token=${token}`;
}, },
// New method to download with proper auth header // New method to download with proper auth header
@@ -128,8 +131,8 @@ export const apiService = {
return response.data; return response.data;
}, },
async updateScore(id: string, name: string, compositor: string): Promise<{ success: boolean; error?: string }> { async updateScore(id: string, name: string, compositor: string, ressource: string = ''): Promise<{ success: boolean; error?: string }> {
const response = await api.put(`/admin/scores/${id}`, { name, compositor }); const response = await api.put(`/admin/scores/${id}`, { name, compositor, ressource });
return response.data; return response.data;
}, },
@@ -151,7 +154,7 @@ export const apiService = {
const response = await api.post(`/admin/scores/${scoreId}/upload`, formData, { const response = await api.post(`/admin/scores/${scoreId}/upload`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': undefined
} }
}); });
return response.data; return response.data;
@@ -3,7 +3,7 @@
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import { api } from '$lib/api'; import { api } from '$lib/api';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'; pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
interface Props { interface Props {
pdfUrl: string; pdfUrl: string;
@@ -34,9 +34,9 @@
loading = true; loading = true;
error = ''; error = '';
try { try {
// Load PDF via axios to include auth token // pdfUrl already contains the full URL with token
const path = pdfUrl.split('/download/')[1]; // Use it directly with axios
const response = await api.get(`/download/${path}`, { const response = await api.get(pdfUrl, {
responseType: 'blob' responseType: 'blob'
}); });
const blob = new Blob([response.data], { type: 'application/pdf' }); const blob = new Blob([response.data], { type: 'application/pdf' });
+8 -3
View File
@@ -38,6 +38,7 @@
let showForm = $state(false); let showForm = $state(false);
let newName = $state(''); let newName = $state('');
let newCompositor = $state(''); let newCompositor = $state('');
let newRessource = $state('');
let newPieceCount = $state(1); let newPieceCount = $state(1);
let newPieceNames = $state<string[]>(['']); let newPieceNames = $state<string[]>(['']);
let saving = $state(false); let saving = $state(false);
@@ -139,11 +140,12 @@
number: i + 1, number: i + 1,
name: name.trim() || `Partie ${i + 1}` name: name.trim() || `Partie ${i + 1}`
})); }));
const result = await apiService.createScore(newName, newCompositor, pieces); const result = await apiService.createScore(newName, newCompositor, pieces, newRessource);
if (result.success) { if (result.success) {
showForm = false; showForm = false;
newName = ''; newName = '';
newCompositor = ''; newCompositor = '';
newRessource = '';
newPieceCount = 1; newPieceCount = 1;
newPieceNames = ['']; newPieceNames = [''];
await loadScores(); await loadScores();
@@ -341,9 +343,12 @@
</a> </a>
<button <button
onclick={() => deleteConfirmId = score.id} onclick={() => deleteConfirmId = score.id}
class="text-red-600 hover:text-red-800" class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-50"
title="Supprimer"
> >
Supprimer <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button> </button>
</div> </div>
</td> </td>
+172 -11
View File
@@ -31,11 +31,18 @@
let scoreId = $derived($page.params.id || ''); let scoreId = $derived($page.params.id || '');
let scoreName = $state(''); let scoreName = $state('');
let scoreCompositor = $state('');
let scoreRessource = $state('');
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let userRole = $state(''); let userRole = $state('');
let isAdmin = $derived(userRole === 'admin'); let isAdmin = $derived(userRole === 'admin');
// Edit mode
let editingField = $state<string | null>(null);
let editValue = $state('');
let saving = $state(false);
// Upload form // Upload form
let uploadPiece = $state('1'); let uploadPiece = $state('1');
let uploadInstrument = $state(''); let uploadInstrument = $state('');
@@ -52,6 +59,16 @@
let uploadVariant = $state(''); let uploadVariant = $state('');
let uploadPart = $state('1'); let uploadPart = $state('1');
// Auto-set default key when instrument changes
$effect(() => {
if (uploadInstrument) {
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
if (inst?.defaultKey) {
uploadKey = inst.defaultKey;
}
}
});
// File tree // File tree
let files: any[] = $state([]); let files: any[] = $state([]);
@@ -63,6 +80,8 @@
try { try {
const score = await apiService.getScore(scoreId); const score = await apiService.getScore(scoreId);
scoreName = score.name; scoreName = score.name;
scoreCompositor = score.compositor || '';
scoreRessource = score.ressource || '';
} catch (err) { } catch (err) {
console.error(err); console.error(err);
error = 'Partition non trouvée'; error = 'Partition non trouvée';
@@ -210,16 +229,150 @@
deletingFile = false; deletingFile = false;
} }
} }
function startEdit(field: string, value: string) {
editingField = field;
editValue = value;
}
function cancelEdit() {
editingField = null;
editValue = '';
}
async function saveEdit(field: string) {
saving = true;
try {
if (field === 'name') {
scoreName = editValue;
} else if (field === 'compositor') {
scoreCompositor = editValue;
} else if (field === 'ressource') {
scoreRessource = editValue;
}
const result = await apiService.updateScore(scoreId, scoreName, scoreCompositor, scoreRessource);
if (!result.success) {
error = result.error || 'Erreur lors de la mise à jour';
await loadScoreInfo();
}
} catch (err) {
error = 'Erreur lors de la mise à jour';
console.error(err);
await loadScoreInfo();
} finally {
editingField = null;
editValue = '';
saving = false;
}
}
</script> </script>
<div class="max-w-6xl mx-auto p-6"> <div class="max-w-6xl mx-auto p-6">
<div class="mb-6 flex justify-between items-center"> <div class="mb-6">
<h1 class="text-3xl font-bold text-ohmj-primary"> <div class="flex justify-between items-center mb-4">
{scoreName || 'Chargement...'} <span class="text-2xl font-bold text-gray-400">{scoreId}</span>
</h1>
<a href="/admin" class="text-ohmj-primary hover:underline">← Retour à l'admin</a> <a href="/admin" class="text-ohmj-primary hover:underline">← Retour à l'admin</a>
</div> </div>
<!-- Info Card -->
<div class="bg-white rounded-xl shadow-md border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h2 class="text-lg font-semibold text-ohmj-primary">Informations de la partition</h2>
</div>
<div class="p-4">
<!-- Name -->
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Nom</label>
{#if editingField === 'name'}
<input
type="text"
bind:value={editValue}
autofocus
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('name'); if (e.key === 'Escape') cancelEdit(); }}
class="flex-1 text-xl font-bold text-ohmj-primary border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
/>
<button onclick={() => saveEdit('name')} disabled={saving} class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" title="Enregistrer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
</button>
<button onclick={() => cancelEdit()} disabled={saving} class="text-red-600 hover:text-red-800 p-2 rounded-full hover:bg-red-50" title="Annuler">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
{:else}
<span class="flex-1 text-xl font-bold text-ohmj-primary cursor-pointer hover:text-ohmj-secondary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('name', scoreName)} title="Cliquer pour modifier">
{scoreName || 'Sans titre'}
</span>
<button onclick={() => startEdit('name', scoreName)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
</button>
{/if}
</div>
<!-- Compositor -->
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Compositeur</label>
{#if editingField === 'compositor'}
<input
type="text"
bind:value={editValue}
autofocus
placeholder="Compositeur"
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('compositor'); if (e.key === 'Escape') cancelEdit(); }}
class="flex-1 text-lg text-gray-700 border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
/>
<button onclick={() => saveEdit('compositor')} disabled={saving} class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" title="Enregistrer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
</button>
<button onclick={() => cancelEdit()} disabled={saving} class="text-red-600 hover:text-red-800 p-2 rounded-full hover:bg-red-50" title="Annuler">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
{:else}
<span class="flex-1 text-lg text-gray-700 cursor-pointer hover:text-ohmj-primary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('compositor', scoreCompositor)} title="Cliquer pour modifier">
{scoreCompositor || 'Aucun compositeur'}
</span>
<button onclick={() => startEdit('compositor', scoreCompositor)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
</button>
{/if}
</div>
<!-- Ressource -->
<div class="flex items-center gap-4 py-3">
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Ressource</label>
{#if editingField === 'ressource'}
<input
type="url"
bind:value={editValue}
autofocus
placeholder="URL (YouTube, Google Drive, etc.)"
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('ressource'); if (e.key === 'Escape') cancelEdit(); }}
class="flex-1 text-sm text-blue-600 border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
/>
<button onclick={() => saveEdit('ressource')} disabled={saving} class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" title="Enregistrer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
</button>
<button onclick={() => cancelEdit()} disabled={saving} class="text-red-600 hover:text-red-800 p-2 rounded-full hover:bg-red-50" title="Annuler">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
{:else}
{#if scoreRessource}
<span class="flex-1 text-sm text-blue-600 cursor-pointer hover:text-blue-800 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
🔗 {scoreRessource}
</span>
{:else}
<span class="flex-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
Aucune ressource
</span>
{/if}
<button onclick={() => startEdit('ressource', scoreRessource)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
</button>
{/if}
</div>
</div>
</div>
</div>
{#if error} {#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error} {error}
@@ -232,8 +385,11 @@
</div> </div>
{:else} {:else}
<!-- Upload PDF --> <!-- Upload PDF -->
<div class="mb-8 p-4 bg-gray-50 rounded-lg"> <div class="mb-8 p-0 bg-white rounded-xl shadow-md border border-gray-200 overflow-hidden">
<h2 class="text-xl font-semibold text-gray-700 mb-4">Uploader un PDF</h2> <div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h2 class="text-lg font-semibold text-ohmj-primary">Ajouter une partition</h2>
</div>
<div class="p-6">
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4"> <form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div> <div>
@@ -366,11 +522,12 @@
</button> </button>
</form> </form>
</div> </div>
</div>
<!-- File Tree --> <!-- File Tree -->
<div class="bg-white rounded-lg shadow overflow-hidden"> <div class="bg-white rounded-lg shadow overflow-hidden">
<div class="p-4 border-b bg-gray-50"> <div class="p-4 border-b bg-gray-50">
<h2 class="text-lg font-semibold text-gray-700">Fichiers</h2> <h2 class="text-lg font-semibold text-ohmj-primary">Fichiers</h2>
</div> </div>
<div class="p-4"> <div class="p-4">
{#if files.length === 0} {#if files.length === 0}
@@ -397,19 +554,23 @@
<span class="text-gray-500">└─ 📄 {file.name}</span> <span class="text-gray-500">└─ 📄 {file.name}</span>
<button <button
onclick={() => deleteFilePath = file.path} onclick={() => deleteFilePath = file.path}
class="ml-2 text-red-500 hover:text-red-700" class="ml-2 text-red-500 hover:text-red-700 p-1 rounded-full hover:bg-red-50"
title="Supprimer" title="Supprimer"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button> </button>
{:else} {:else}
<span class="text-gray-500">├─ 📄 {file.name}</span> <span class="text-gray-500">├─ 📄 {file.name}</span>
<button <button
onclick={() => deleteFilePath = file.path} onclick={() => deleteFilePath = file.path}
class="ml-2 text-red-500 hover:text-red-700" class="ml-2 text-red-500 hover:text-red-700 p-1 rounded-full hover:bg-red-50"
title="Supprimer" title="Supprimer"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button> </button>
{/if} {/if}
</div> </div>
+4 -2
View File
@@ -19,10 +19,12 @@ const config = {
csp: { csp: {
directives: { directives: {
'default-src': ['self'], 'default-src': ['self'],
'script-src': ['self', 'unsafe-inline'], 'script-src': ['self', 'unsafe-inline', 'blob:'],
'script-src-elem': ['self', 'blob:'],
'worker-src': ['self', 'blob:'],
'style-src': ['self', 'unsafe-inline'], 'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'blob:'], 'img-src': ['self', 'data:', 'blob:'],
'connect-src': ['self', 'http://localhost:8000', 'https://*.ohmj.fr', 'blob:'], 'connect-src': ['self', 'http://localhost:8000', 'https://ohmj-api.c.nadal-fr.com', 'blob:'],
'font-src': ['self'], 'font-src': ['self'],
'object-src': ['none'], 'object-src': ['none'],
'frame-ancestors': ['none'], 'frame-ancestors': ['none'],
+3
View File
@@ -5,5 +5,8 @@ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
port: 5173 port: 5173
},
define: {
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL || 'http://localhost:8000')
} }
}); });
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long