[FEAT] Full function and deployed version
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
JWT_SECRET=ohmj_test_secret_key_change_in_production_12345
|
JWT_SECRET=6jh/MWqVplwXQKsiwlKahE19TSavfR1dNCawsQFixus=
|
||||||
|
SCORES_PATH=/data/scores/
|
||||||
|
|||||||
+10
-2
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Executable
+55
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=https://ohmj-api.c.nadal-fr.com
|
||||||
@@ -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
@@ -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' });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user