[FEAT] First functional version.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
legacy/Scores
|
legacy/Scores
|
||||||
_builds/
|
_builds/
|
||||||
missing.xml
|
missing.xml
|
||||||
|
partitions/.svelte-kit/
|
||||||
|
partitions/build/
|
||||||
|
partitions/node_modules/
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ MySQL database connection configured in `api/config/database.php`:
|
|||||||
- FPDF library for PDF generation
|
- FPDF library for PDF generation
|
||||||
- MySQL extension (legacy)
|
- MySQL extension (legacy)
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **No external CDN dependencies allowed** - All assets must be local (fonts, JS libraries, etc.)
|
||||||
|
- Use local copies in `static/` folder instead of CDN
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This is a legacy codebase with mixed coding styles
|
- This is a legacy codebase with mixed coding styles
|
||||||
|
|||||||
154
PLAN.md
154
PLAN.md
@@ -45,29 +45,45 @@
|
|||||||
|
|
||||||
### 1.1 ScoreScanner.php ✓ (FAIT)
|
### 1.1 ScoreScanner.php ✓ (FAIT)
|
||||||
- [x] Classe pour scanner les partitions
|
- [x] Classe pour scanner les partitions
|
||||||
- [x] Lecture des fichiers score.ini
|
- [x] Lecture des fichiers score.ini (supporte sections [info], [pieces])
|
||||||
- [x] Scan récursif instruments → parties → fichiers PDF
|
- [x] Scan récursif instruments → parties → fichiers PDF
|
||||||
- [x] Mapping codes instruments vers noms
|
- [x] Mapping codes instruments vers noms
|
||||||
|
- [x] Retourne `piece` pour chaque instrument
|
||||||
|
- [x] Parse les noms de fichiers : `part`, `key`, `clef`, `variant`
|
||||||
|
- [x] Retourne `ressource` si présent dans score.ini
|
||||||
|
- [x] Supporte les partitions multi-morceaux (ex: score 390)
|
||||||
|
|
||||||
### 1.2 Auth.php
|
### 1.2 Auth.php
|
||||||
- [ ] Classe JWT pour authentification
|
- [x] Classe JWT pour authentification
|
||||||
- [ ] Génération token JWT
|
- [x] Génération token JWT
|
||||||
- [ ] Vérification token JWT
|
- [x] Vérification token JWT
|
||||||
- [ ] Lecture users.json
|
- [x] Lecture users.json
|
||||||
- [ ] Vérification password hashé (bcrypt)
|
- [x] Vérification password hashé (bcrypt)
|
||||||
|
- [ ] Tests unitaires Auth.php (PHP non installé)
|
||||||
|
|
||||||
### 1.3 users.json
|
### 1.3 users.json
|
||||||
- [ ] Fichier JSON avec structure users
|
- [x] Fichier JSON avec structure users
|
||||||
- [ ] Passwords hashés avec bcrypt
|
- [ ] Passwords hashés avec bcrypt (password: password)
|
||||||
- [ ] Script pour ajouter/modifier users
|
- [ ] Script pour ajouter/modifier users
|
||||||
|
|
||||||
### 1.4 index.php (Router API)
|
### 1.4 index.php (Router API)
|
||||||
- [ ] CORS headers
|
- [x] CORS headers
|
||||||
- [ ] Routing : POST /login
|
- [x] POST /login
|
||||||
- [ ] Routing : GET /scores (protégé JWT)
|
- [x] GET /scores (protégé JWT)
|
||||||
- [ ] Routing : GET /scores/:id (protégé JWT)
|
- [x] GET /scores/:id (protégé JWT)
|
||||||
- [ ] Routing : GET /download/:path (protégé JWT, stream PDF)
|
- [x] GET /pieces/:scoreId (protégé JWT)
|
||||||
|
- [x] GET /download/:path (protégé JWT, stream PDF)
|
||||||
|
- [x] POST /admin/scores (protégé JWT, créer partition)
|
||||||
|
- [x] PUT /admin/scores/:id (protégé JWT, modifier partition)
|
||||||
|
- [x] DELETE /admin/scores/:id (protégé JWT, supprimer partition)
|
||||||
|
- [x] POST /admin/scores/:id/upload (protégé JWT, upload PDF)
|
||||||
- [ ] Gestion erreurs JSON
|
- [ ] Gestion erreurs JSON
|
||||||
|
- [ ] Tests toutes les routes (PHP non installé)
|
||||||
|
|
||||||
|
### 1.5 Tests API
|
||||||
|
- [ ] Tests unitaires Auth.php (PHP non installé)
|
||||||
|
- [ ] Tests intégration toutes les routes (PHP non installé)
|
||||||
|
- [ ] Tests authentification JWT (PHP non installé)
|
||||||
|
|
||||||
## Phase 2 : Frontend SvelteKit (partitions/)
|
## Phase 2 : Frontend SvelteKit (partitions/)
|
||||||
|
|
||||||
@@ -370,3 +386,115 @@ count = 5
|
|||||||
- ✅ Mots composés: `sax_alto`, `clarinette_basse`
|
- ✅ Mots composés: `sax_alto`, `clarinette_basse`
|
||||||
- ✅ Pas de `-et-` (0 occurrences)
|
- ✅ Pas de `-et-` (0 occurrences)
|
||||||
- ✅ Pas de `--` (0 occurrences)
|
- ✅ Pas de `--` (0 occurrences)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase future : Liens externes
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
|
||||||
|
Ajouter des liens externes dans les partitions (YouTube, site éditeur, etc.)
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. **Modification score.ini** (FAIT)
|
||||||
|
```ini
|
||||||
|
[info]
|
||||||
|
name = Le morceau
|
||||||
|
compositor = Compositeur
|
||||||
|
ressource = https://youtube.com/watch?v=xxx
|
||||||
|
# ou
|
||||||
|
ressource = https://www.musicdiffusion.com/...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **API** (FAIT)
|
||||||
|
- GET /scores → retourner `ressource` si présent
|
||||||
|
- GET /scores/:id → retourner `ressource` aussi
|
||||||
|
|
||||||
|
3. **Frontend**
|
||||||
|
- Page liste partitions : afficher icône lien externe si présent
|
||||||
|
- Page détail partition : afficher les liens sous le titre
|
||||||
|
|
||||||
|
### Ordre de priorité
|
||||||
|
- Implémenté, en attente frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO: Icônes instruments
|
||||||
|
|
||||||
|
- Installer lucide-svelte (fait)
|
||||||
|
- Personnaliser chaque instrument avec la bonne icône
|
||||||
|
|
||||||
|
### Code IconeDesired
|
||||||
|
- pic flute traversiere
|
||||||
|
- flu flute traversiere
|
||||||
|
- cla clarinette
|
||||||
|
- clb clarinette basse
|
||||||
|
- sax OK
|
||||||
|
- sat OK
|
||||||
|
- sab OK
|
||||||
|
- coa cor anglais
|
||||||
|
- cba OK
|
||||||
|
- cor cor
|
||||||
|
- trp OK
|
||||||
|
- trb OK
|
||||||
|
- tub OK
|
||||||
|
- htb hautbois
|
||||||
|
- bas basson
|
||||||
|
- per OK
|
||||||
|
- crn OK
|
||||||
|
- eup OK
|
||||||
|
- har OK
|
||||||
|
- pia OK
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO: Déploiement
|
||||||
|
|
||||||
|
### Solution idéale: Docker
|
||||||
|
|
||||||
|
**Services à conteneriser:**
|
||||||
|
1. **PHP-FPM** - API backend
|
||||||
|
2. **Nginx** - Servir static + proxy PHP
|
||||||
|
3. **Node.js** - Frontend SvelteKit (ou build static)
|
||||||
|
|
||||||
|
**docker-compose.yml:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/build:/usr/share/nginx/html
|
||||||
|
- ./api:/var/www/html
|
||||||
|
- ./legacy/Scores:/var/www/html/Scores
|
||||||
|
depends_on:
|
||||||
|
- php
|
||||||
|
|
||||||
|
php:
|
||||||
|
image: php:8.2-fpm
|
||||||
|
volumes:
|
||||||
|
- ./api:/var/www/html
|
||||||
|
|
||||||
|
node:
|
||||||
|
build: ./partitions
|
||||||
|
command: npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution alternative: Serveur classique
|
||||||
|
|
||||||
|
**Si hébergement mutualisé:**
|
||||||
|
- Build SvelteKit → dossier `build/`
|
||||||
|
- PHP-FPM pour API
|
||||||
|
- Dossiers:
|
||||||
|
- `/` → frontend static
|
||||||
|
- `/api` → PHP
|
||||||
|
- `/legacy/Scores` → fichiers PDF
|
||||||
|
|
||||||
|
### Tâches pour déploiement
|
||||||
|
- [ ] Créer Dockerfile
|
||||||
|
- [ ] Créer docker-compose.yml
|
||||||
|
- [ ] Tester en local
|
||||||
|
- [ ] Configurer CI/CD (GitHub Actions)
|
||||||
|
- [ ] Déployer sur serveur prod
|
||||||
|
|||||||
386
api/README.md
Normal file
386
api/README.md
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
# OHMJ API Documentation
|
||||||
|
|
||||||
|
API REST pour la gestion des partitions de l'Harmonie de Montpellier-Jacou.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
L'API utilise JWT (JSON Web Token) pour l'authentification.
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /login
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": {
|
||||||
|
"username": "admin",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rôles disponibles :**
|
||||||
|
- `admin` - Accès complet
|
||||||
|
- `user` - Accès lecture seule
|
||||||
|
|
||||||
|
### Utilisation du token
|
||||||
|
|
||||||
|
Inclure le token dans l'en-tête `Authorization` :
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /scores
|
||||||
|
|
||||||
|
Liste toutes les partitions.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /scores
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"scores": [
|
||||||
|
{
|
||||||
|
"id": "102",
|
||||||
|
"name": "A Legend from Yao",
|
||||||
|
"compositor": "Yeh Shu-Han"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "390",
|
||||||
|
"name": "La part d Euterpe",
|
||||||
|
"compositor": "Michael CUVILLON"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /scores/:id
|
||||||
|
|
||||||
|
Détails d'une partition. Pour les partitions multi-morceaux (ex: 390), retourne tous les instruments de toutes les pièces.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /scores/102
|
||||||
|
GET /scores/390
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (score simple) :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"score": {
|
||||||
|
"id": "102",
|
||||||
|
"name": "A Legend from Yao",
|
||||||
|
"compositor": "Yeh Shu-Han",
|
||||||
|
"instruments": [
|
||||||
|
{
|
||||||
|
"id": "cla",
|
||||||
|
"title": "Clarinette",
|
||||||
|
"piece": "1",
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "clarinette-sib-1",
|
||||||
|
"filename": "clarinette-sib-1.pdf",
|
||||||
|
"path": "102/1/cla/1/clarinette-sib-1.pdf",
|
||||||
|
"part": "1",
|
||||||
|
"key": "sib",
|
||||||
|
"clef": null,
|
||||||
|
"variant": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse (partition multi-morceaux comme 390) :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"score": {
|
||||||
|
"id": "390",
|
||||||
|
"name": "La part d Euterpe",
|
||||||
|
"compositor": "Michael CUVILLON",
|
||||||
|
"instruments": [
|
||||||
|
{
|
||||||
|
"id": "cla",
|
||||||
|
"title": "Clarinette",
|
||||||
|
"piece": "1",
|
||||||
|
"parts": [...]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sax",
|
||||||
|
"title": "Sax Alto",
|
||||||
|
"piece": "2",
|
||||||
|
"parts": [...]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /scores/:id/instruments
|
||||||
|
|
||||||
|
Liste les instruments d'une partition. Pour les partitions multi-morceaux (ex: 390), peut filtrer par pièce.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /scores/102/instruments
|
||||||
|
GET /scores/390/instruments?piece=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paramètre optionnel :**
|
||||||
|
- `piece` - Numéro de la pièce (pour partitions multi-morceaux)
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"instruments": [
|
||||||
|
{
|
||||||
|
"id": "cla",
|
||||||
|
"title": "Clarinette",
|
||||||
|
"piece": "1",
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "clarinette-sib-1",
|
||||||
|
"filename": "clarinette-sib-1.pdf",
|
||||||
|
"path": "102/1/cla/1/clarinette-sib-1.pdf",
|
||||||
|
"part": "1",
|
||||||
|
"key": "sib",
|
||||||
|
"clef": null,
|
||||||
|
"variant": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Champs des fichiers :**
|
||||||
|
- `name` - Nom du fichier sans extension
|
||||||
|
- `filename` - Nom du fichier avec extension
|
||||||
|
- `path` - Chemin relatif pour download
|
||||||
|
- `part` - Numéro de partie (ex: "1", "2_3")
|
||||||
|
- `key` - Tonalité (ex: "sib", "mib", "fa")
|
||||||
|
- `clef` - Clé (ex: "clesol", "clefa")
|
||||||
|
- `variant` - Variante (ex: "solo", "default")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /pieces/:scoreId
|
||||||
|
|
||||||
|
Liste les pièces d'une partition.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /pieces/102
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"pieces": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Pièce 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /download/:path
|
||||||
|
|
||||||
|
Télécharge un fichier PDF.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /download/102/1/cla/1/clarinette-sib-1.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse :** Fichier PDF (Content-Type: application/pdf)
|
||||||
|
|
||||||
|
**Structure des chemins :**
|
||||||
|
```
|
||||||
|
NUM/PIECE/INSTRUMENT/VERSION/PARTIE.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Instruments disponibles :**
|
||||||
|
| Code | Instrument |
|
||||||
|
|------|------------|
|
||||||
|
| dir | Direction |
|
||||||
|
| pic | Piccolo |
|
||||||
|
| flu | Flûte |
|
||||||
|
| cla | Clarinette |
|
||||||
|
| clb | Clarinette basse |
|
||||||
|
| sax | Saxophone alto |
|
||||||
|
| sab | Saxophone baryton |
|
||||||
|
| sat | Saxophone ténor |
|
||||||
|
| cba | Contrebasse |
|
||||||
|
| cor | Cor |
|
||||||
|
| trp | Trompette |
|
||||||
|
| trb | Trombone |
|
||||||
|
| tub | Tuba |
|
||||||
|
| htb | Hautbois |
|
||||||
|
| bas | Basson |
|
||||||
|
| per | Percussion |
|
||||||
|
| crn | Cornet |
|
||||||
|
| eup | Euphonium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes Admin
|
||||||
|
|
||||||
|
### POST /admin/scores
|
||||||
|
|
||||||
|
Créer une nouvelle partition.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /admin/scores
|
||||||
|
Authorization: Bearer <token_admin>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "200",
|
||||||
|
"name": "Nouvelle partition",
|
||||||
|
"compositor": "Compositeur"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PUT /admin/scores/:id
|
||||||
|
|
||||||
|
Mettre à jour une partition.
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /admin/scores/102
|
||||||
|
Authorization: Bearer <token_admin>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Nouveau nom",
|
||||||
|
"compositor": "Nouveau compositeur"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /admin/scores/:id
|
||||||
|
|
||||||
|
Supprimer une partition.
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /admin/scores/102
|
||||||
|
Authorization: Bearer <token_admin>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /admin/upload
|
||||||
|
|
||||||
|
Uploader un fichier PDF.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /admin/upload
|
||||||
|
Authorization: Bearer <token_admin>
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Corps de la requête :**
|
||||||
|
```
|
||||||
|
file: <fichier_pdf>
|
||||||
|
scoreId: 102
|
||||||
|
pieceId: 1
|
||||||
|
instrument: cla
|
||||||
|
version: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codes d'erreur
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 200 | Succès |
|
||||||
|
| 401 | Non authentifié |
|
||||||
|
| 403 | Accès interdit (rôle insufficient) |
|
||||||
|
| 404 | Ressource non trouvée |
|
||||||
|
| 500 | Erreur serveur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lancer le serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
php -S localhost:8000 router.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure des fichiers
|
||||||
|
|
||||||
|
```
|
||||||
|
legacy/Scores/
|
||||||
|
├── 102/
|
||||||
|
│ ├── 1/
|
||||||
|
│ │ ├── cla/
|
||||||
|
│ │ │ ├── 1/
|
||||||
|
│ │ │ │ ├── clarinette-sib-1.pdf
|
||||||
|
│ │ │ │ ├── clarinette-sib-2.pdf
|
||||||
|
│ │ │ │ └── clarinette-mib-1.pdf
|
||||||
|
│ │ │ └── 2/
|
||||||
|
│ │ │ └── clarinette-sib-1.pdf
|
||||||
|
│ │ ├── flu/
|
||||||
|
│ │ └── sax/
|
||||||
|
│ └── 2/
|
||||||
|
└── score.ini
|
||||||
|
```
|
||||||
14
api/config/users.json
Normal file
14
api/config/users.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
|
||||||
|
"role": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "user",
|
||||||
|
"password": "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
245
api/index.php
Normal file
245
api/index.php
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/lib/Auth.php';
|
||||||
|
require_once __DIR__ . '/lib/ScoreScanner.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$scanner = new ScoreScanner('/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/');
|
||||||
|
|
||||||
|
// Get Authorization header
|
||||||
|
$token = null;
|
||||||
|
$headers = getallheaders();
|
||||||
|
if (isset($headers['Authorization'])) {
|
||||||
|
$authHeader = $headers['Authorization'];
|
||||||
|
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$path = trim($uri, '/');
|
||||||
|
|
||||||
|
// GET /download/:path - Download PDF (BEFORE auth check)
|
||||||
|
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||||
|
$filePath = urldecode($matches[1]);
|
||||||
|
|
||||||
|
// Check token from header or query parameter
|
||||||
|
$downloadToken = $_GET['token'] ?? $token;
|
||||||
|
$downloadToken = urldecode($downloadToken ?? '');
|
||||||
|
|
||||||
|
if ($downloadToken === null || $downloadToken === '') {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Authorization required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $auth->verifyToken($downloadToken);
|
||||||
|
if ($user === null) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Invalid token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = '/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/' . $filePath;
|
||||||
|
|
||||||
|
if (!file_exists($fullPath) || !is_file($fullPath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'File not found', 'path' => $fullPath]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/pdf');
|
||||||
|
header('Content-Length: ' . filesize($fullPath));
|
||||||
|
header('Content-Disposition: attachment; filename="' . basename($fullPath) . '"');
|
||||||
|
readfile($fullPath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route matching
|
||||||
|
if ($path === 'login' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$username = $input['username'] ?? '';
|
||||||
|
$password = $input['password'] ?? '';
|
||||||
|
|
||||||
|
$result = $auth->login($username, $password);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'token' => $result['token'],
|
||||||
|
'user' => $result['user']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require auth
|
||||||
|
if ($token === null) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Authorization required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $auth->verifyToken($token);
|
||||||
|
if ($user === null) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Invalid token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /scores - List all scores
|
||||||
|
if ($path === 'scores' && $method === 'GET') {
|
||||||
|
$scores = $scanner->listScores();
|
||||||
|
echo json_encode(['success' => true, 'scores' => $scores]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /scores/:id - Get score details
|
||||||
|
if (preg_match('#^scores/(\d+)$#', $path, $matches) && $method === 'GET') {
|
||||||
|
$scoreId = $matches[1];
|
||||||
|
$score = $scanner->getScore($scoreId);
|
||||||
|
|
||||||
|
if ($score === null) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Score not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'score' => $score]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /scores/:id/instruments - Get instruments for a score
|
||||||
|
// GET /scores/:id/instruments?piece=1 - Get instruments for a specific piece
|
||||||
|
if (preg_match('#^scores/(\d+)/instruments$#', $path, $matches) && $method === 'GET') {
|
||||||
|
$scoreId = $matches[1];
|
||||||
|
$pieceId = $_GET['piece'] ?? null;
|
||||||
|
$instruments = $scanner->getInstruments($scoreId, $pieceId);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'instruments' => $instruments]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /pieces/:scoreId - Get pieces for a score
|
||||||
|
if (preg_match('#^pieces/(\d+)$#', $path, $matches) && $method === 'GET') {
|
||||||
|
$scoreId = $matches[1];
|
||||||
|
$pieces = $scanner->getPieces($scoreId);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'pieces' => $pieces]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN ROUTES - require admin role
|
||||||
|
if ($user['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Admin access required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/scores - Create new score
|
||||||
|
if ($path === 'admin/scores' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$id = $input['id'] ?? null;
|
||||||
|
$name = $input['name'] ?? '';
|
||||||
|
$compositor = $input['compositor'] ?? '';
|
||||||
|
|
||||||
|
if (empty($id) || empty($name)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'ID and name required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $scanner->createScore($id, $name, $compositor);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
echo json_encode(['success' => true, 'score' => $result['score']]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $result['error']]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/scores/:id - Update score
|
||||||
|
if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT') {
|
||||||
|
$scoreId = $matches[1];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$name = $input['name'] ?? null;
|
||||||
|
$compositor = $input['compositor'] ?? null;
|
||||||
|
|
||||||
|
$result = $scanner->updateScore($scoreId, $name, $compositor);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $result['error']]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /admin/scores/:id - Delete score
|
||||||
|
if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE') {
|
||||||
|
$scoreId = $matches[1];
|
||||||
|
|
||||||
|
$result = $scanner->deleteScore($scoreId);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $result['error']]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/scores/:id/upload - Upload PDF
|
||||||
|
if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') {
|
||||||
|
$scoreId = $matches[1];
|
||||||
|
|
||||||
|
if (!isset($_FILES['file'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'No file uploaded']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
$piece = $_POST['piece'] ?? '1';
|
||||||
|
$instrument = $_POST['instrument'] ?? '';
|
||||||
|
$version = $_POST['version'] ?? '1';
|
||||||
|
$filename = $_POST['filename'] ?? '';
|
||||||
|
|
||||||
|
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $filename);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
echo json_encode(['success' => true, 'path' => $result['path']]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $result['error']]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 Not Found
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Not found']);
|
||||||
122
api/lib/Auth.php
Normal file
122
api/lib/Auth.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Auth {
|
||||||
|
private const JWT_SECRET = 'ohmj_secret_key_change_in_production';
|
||||||
|
private const JWT_ALGO = 'HS256';
|
||||||
|
private const JWT_EXPIRY = 3600; // 1 hour
|
||||||
|
|
||||||
|
private string $usersFile;
|
||||||
|
|
||||||
|
public function __construct(string $usersFile = null) {
|
||||||
|
$this->usersFile = $usersFile ?? __DIR__ . '/../config/users.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $username, string $password): array {
|
||||||
|
$users = $this->loadUsers();
|
||||||
|
|
||||||
|
foreach ($users['users'] as $user) {
|
||||||
|
if ($user['username'] === $username) {
|
||||||
|
if (password_verify($password, $user['password'])) {
|
||||||
|
$token = $this->generateToken($user);
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'token' => $token,
|
||||||
|
'user' => [
|
||||||
|
'username' => $user['username'],
|
||||||
|
'role' => $user['role']
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return ['success' => false, 'error' => 'Invalid password'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => false, 'error' => 'User not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyToken(string $token): ?array {
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
|
||||||
|
if (count($parts) !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
list($header, $payload, $signature) = $parts;
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
$expectedSignature = base64_encode(
|
||||||
|
hash_hmac('sha256', "$header.$payload", self::JWT_SECRET, true)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hash_equals($expectedSignature, $signature)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode payload
|
||||||
|
$payloadData = json_decode(base64_decode($payload), true);
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (isset($payloadData['exp']) && $payloadData['exp'] < time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payloadData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireAuth(string $token): array {
|
||||||
|
$payload = $this->verifyToken($token);
|
||||||
|
|
||||||
|
if ($payload === null) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireAdmin(string $token): array {
|
||||||
|
$payload = $this->requireAuth($token);
|
||||||
|
|
||||||
|
if ($payload['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Forbidden']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateToken(array $user): string {
|
||||||
|
$header = base64_encode(json_encode([
|
||||||
|
'alg' => self::JWT_ALGO,
|
||||||
|
'typ' => 'JWT'
|
||||||
|
]));
|
||||||
|
|
||||||
|
$payload = base64_encode(json_encode([
|
||||||
|
'username' => $user['username'],
|
||||||
|
'role' => $user['role'],
|
||||||
|
'iat' => time(),
|
||||||
|
'exp' => time() + self::JWT_EXPIRY
|
||||||
|
]));
|
||||||
|
|
||||||
|
$signature = base64_encode(
|
||||||
|
hash_hmac('sha256', "$header.$payload", self::JWT_SECRET, true)
|
||||||
|
);
|
||||||
|
|
||||||
|
return "$header.$payload.$signature";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadUsers(): array {
|
||||||
|
if (!file_exists($this->usersFile)) {
|
||||||
|
return ['users' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($this->usersFile);
|
||||||
|
return json_decode($content, true) ?? ['users' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hashPassword(string $password): string {
|
||||||
|
return password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ class ScoreScanner {
|
|||||||
foreach ($directories as $dir) {
|
foreach ($directories as $dir) {
|
||||||
if ($dir === '.' || $dir === '..') continue;
|
if ($dir === '.' || $dir === '..') continue;
|
||||||
|
|
||||||
|
// Ignorer les fichiers cachés et non-numériques
|
||||||
|
if (strpos($dir, '.') === 0) continue;
|
||||||
|
if (!is_numeric($dir)) continue;
|
||||||
|
|
||||||
$scorePath = $this->scoresPath . $dir;
|
$scorePath = $this->scoresPath . $dir;
|
||||||
if (!is_dir($scorePath)) continue;
|
if (!is_dir($scorePath)) continue;
|
||||||
|
|
||||||
@@ -38,12 +42,16 @@ class ScoreScanner {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ini = parse_ini_file($iniFile);
|
$ini = @parse_ini_file($iniFile, true);
|
||||||
|
if ($ini === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'name' => $ini['name'] ?? 'Inconnu',
|
'name' => $ini['info']['name'] ?? 'Inconnu',
|
||||||
'compositor' => $ini['compositor'] ?? 'Inconnu'
|
'compositor' => $ini['info']['compositor'] ?? 'Inconnu',
|
||||||
|
'ressource' => $ini['info']['ressource'] ?? null
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,17 +68,28 @@ class ScoreScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$instruments = [];
|
$instruments = [];
|
||||||
$entries = scandir($scoreDir);
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||||
if ($entry === '.' || $entry === '..' || $entry === 'score.ini') continue;
|
// First get all piece directories
|
||||||
|
$pieceDirs = scandir($scoreDir);
|
||||||
|
|
||||||
|
foreach ($pieceDirs as $pieceDir) {
|
||||||
|
if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue;
|
||||||
|
if (strpos($pieceDir, '.') === 0) continue;
|
||||||
|
if (!is_dir($scoreDir . '/' . $pieceDir)) continue;
|
||||||
|
|
||||||
$instrumentPath = $scoreDir . '/' . $entry;
|
// Then get instrument directories
|
||||||
if (!is_dir($instrumentPath)) continue;
|
$instrumentDirs = scandir($scoreDir . '/' . $pieceDir);
|
||||||
|
|
||||||
$instrument = $this->getInstrumentInfo($id, $entry);
|
foreach ($instrumentDirs as $instrumentId) {
|
||||||
if ($instrument) {
|
if ($instrumentId === '.' || $instrumentId === '..') continue;
|
||||||
$instruments[] = $instrument;
|
if (strpos($instrumentId, '.') === 0) continue;
|
||||||
|
if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue;
|
||||||
|
|
||||||
|
$instrument = $this->getInstrumentInfo($id, $instrumentId, $pieceDir);
|
||||||
|
if ($instrument) {
|
||||||
|
$instruments[] = $instrument;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +101,13 @@ class ScoreScanner {
|
|||||||
return array_merge($basicInfo, ['instruments' => $instruments]);
|
return array_merge($basicInfo, ['instruments' => $instruments]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getInstrumentInfo($scoreId, $instrumentId) {
|
private function getInstrumentInfo($scoreId, $instrumentId, $pieceId = null) {
|
||||||
$instrumentPath = $this->scoresPath . $scoreId . '/' . $instrumentId;
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||||
|
if ($pieceId !== null) {
|
||||||
|
$instrumentPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId;
|
||||||
|
} else {
|
||||||
|
$instrumentPath = $this->scoresPath . $scoreId . '/' . $instrumentId;
|
||||||
|
}
|
||||||
|
|
||||||
if (!is_dir($instrumentPath)) {
|
if (!is_dir($instrumentPath)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -95,11 +119,12 @@ class ScoreScanner {
|
|||||||
$entries = scandir($instrumentPath);
|
$entries = scandir($instrumentPath);
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (strpos($entry, '.') === 0) continue;
|
||||||
|
|
||||||
$partPath = $instrumentPath . '/' . $entry;
|
$partPath = $instrumentPath . '/' . $entry;
|
||||||
if (!is_dir($partPath)) continue;
|
if (!is_dir($partPath)) continue;
|
||||||
|
|
||||||
$part = $this->getPartInfo($scoreId, $instrumentId, $entry);
|
$part = $this->getPartInfo($scoreId, $instrumentId, $entry, $pieceId);
|
||||||
if ($part) {
|
if ($part) {
|
||||||
$parts[] = $part;
|
$parts[] = $part;
|
||||||
}
|
}
|
||||||
@@ -117,12 +142,18 @@ class ScoreScanner {
|
|||||||
return [
|
return [
|
||||||
'id' => $instrumentId,
|
'id' => $instrumentId,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
|
'piece' => $pieceId,
|
||||||
'parts' => $parts
|
'parts' => $parts
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getPartInfo($scoreId, $instrumentId, $partId) {
|
private function getPartInfo($scoreId, $instrumentId, $partId, $pieceId = null) {
|
||||||
$partPath = $this->scoresPath . $scoreId . '/' . $instrumentId . '/' . $partId;
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||||
|
if ($pieceId !== null) {
|
||||||
|
$partPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId . '/' . $partId;
|
||||||
|
} else {
|
||||||
|
$partPath = $this->scoresPath . $scoreId . '/' . $instrumentId . '/' . $partId;
|
||||||
|
}
|
||||||
|
|
||||||
if (!is_dir($partPath)) {
|
if (!is_dir($partPath)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -133,6 +164,7 @@ class ScoreScanner {
|
|||||||
|
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (strpos($entry, '.') === 0) continue;
|
||||||
|
|
||||||
$filePath = $partPath . '/' . $entry;
|
$filePath = $partPath . '/' . $entry;
|
||||||
if (!is_file($filePath)) continue;
|
if (!is_file($filePath)) continue;
|
||||||
@@ -140,10 +172,20 @@ class ScoreScanner {
|
|||||||
// Vérifier que c'est un PDF
|
// Vérifier que c'est un PDF
|
||||||
if (strtolower(pathinfo($entry, PATHINFO_EXTENSION)) !== 'pdf') continue;
|
if (strtolower(pathinfo($entry, PATHINFO_EXTENSION)) !== 'pdf') continue;
|
||||||
|
|
||||||
|
$relativePath = $pieceId !== null
|
||||||
|
? "$scoreId/$pieceId/$instrumentId/$partId/$entry"
|
||||||
|
: "$scoreId/$instrumentId/$partId/$entry";
|
||||||
|
|
||||||
|
$parsed = $this->parseFilename(pathinfo($entry, PATHINFO_FILENAME));
|
||||||
|
|
||||||
$files[] = [
|
$files[] = [
|
||||||
'name' => pathinfo($entry, PATHINFO_FILENAME),
|
'name' => pathinfo($entry, PATHINFO_FILENAME),
|
||||||
'filename' => $entry,
|
'filename' => $entry,
|
||||||
'path' => $scoreId . '/' . $instrumentId . '/' . $partId . '/' . $entry
|
'path' => $relativePath,
|
||||||
|
'part' => $parsed['part'],
|
||||||
|
'key' => $parsed['key'],
|
||||||
|
'clef' => $parsed['clef'],
|
||||||
|
'variant' => $parsed['variant']
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,4 +233,272 @@ class ScoreScanner {
|
|||||||
|
|
||||||
return $names[$code] ?? $code;
|
return $names[$code] ?? $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseFilename(string $filename): array {
|
||||||
|
$result = [
|
||||||
|
'part' => null,
|
||||||
|
'key' => null,
|
||||||
|
'clef' => null,
|
||||||
|
'variant' => null
|
||||||
|
];
|
||||||
|
|
||||||
|
// Normalize: replace - by _ and lowercase
|
||||||
|
$name = strtolower(str_replace('-', '_', $filename));
|
||||||
|
|
||||||
|
// Extract clef (clesol, clefa)
|
||||||
|
if (preg_match('/(clesol|clefa)$/', $name, $m)) {
|
||||||
|
$result['clef'] = $m[1];
|
||||||
|
$name = preg_replace('/_(clesol|clefa)$/', '', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract variant (solo, etc.)
|
||||||
|
$variants = ['solo', 'default'];
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
if (strpos($name, $variant) !== false) {
|
||||||
|
$result['variant'] = $variant;
|
||||||
|
$name = str_replace('_' . $variant, '', $name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key (tonalités: sib, mib, fa, do, etc.)
|
||||||
|
$keys = ['sib', 'mib', 'reb', 'dob', 'solb', 'lab', 'mibemol', 'sibemol'];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (preg_match('/_' . $key . '/', $name)) {
|
||||||
|
$result['key'] = $key;
|
||||||
|
$name = preg_replace('/_' . $key . '/', '', $name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no compound keys found, try single keys
|
||||||
|
if ($result['key'] === null) {
|
||||||
|
$singleKeys = ['fa', 'do', 'ut', 'sol', 're', 'mi', 'si'];
|
||||||
|
foreach ($singleKeys as $key) {
|
||||||
|
if (preg_match('/_' . $key . '(?:_|$)/', $name)) {
|
||||||
|
$result['key'] = $key;
|
||||||
|
$name = preg_replace('/_' . $key . '/', '', $name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract part number (1, 2, 1_et_2, etc.) - should be last
|
||||||
|
// Handle formats: 1, 2, 1_et_2
|
||||||
|
if (preg_match('/_(\d+(?:_\w+)*)$/', $name, $m)) {
|
||||||
|
$result['part'] = $m[1];
|
||||||
|
$name = preg_replace('/_\d+(?:_\w+)*$/', '', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
|
||||||
|
public function listScores(): array {
|
||||||
|
return $this->getAllScores();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getScore(string $id): ?array {
|
||||||
|
return $this->getScoreDetail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPieces(string $scoreId): array {
|
||||||
|
$scoreDir = $this->scoresPath . $scoreId;
|
||||||
|
$iniFile = $scoreDir . '/score.ini';
|
||||||
|
|
||||||
|
if (!file_exists($iniFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ini = @parse_ini_file($iniFile, true);
|
||||||
|
if ($ini === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieces = [];
|
||||||
|
|
||||||
|
// Check if [pieces] section exists
|
||||||
|
if (isset($ini['pieces'])) {
|
||||||
|
$piecesSection = $ini['pieces'];
|
||||||
|
$count = intval($piecesSection['count'] ?? 1);
|
||||||
|
|
||||||
|
for ($i = 1; $i <= $count; $i++) {
|
||||||
|
$pieces[] = [
|
||||||
|
'id' => $i,
|
||||||
|
'name' => $piecesSection[$i] ?? "Pièce $i"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInstruments(string $scoreId, ?string $pieceId = null): array {
|
||||||
|
$scoreDir = $this->scoresPath . $scoreId;
|
||||||
|
|
||||||
|
if (!is_dir($scoreDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$instruments = [];
|
||||||
|
|
||||||
|
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||||
|
$pieceDirs = scandir($scoreDir);
|
||||||
|
|
||||||
|
foreach ($pieceDirs as $pieceDir) {
|
||||||
|
if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue;
|
||||||
|
if (strpos($pieceDir, '.') === 0) continue;
|
||||||
|
if (!is_dir($scoreDir . '/' . $pieceDir)) continue;
|
||||||
|
|
||||||
|
// Filter by piece if specified
|
||||||
|
if ($pieceId !== null && $pieceDir !== $pieceId) continue;
|
||||||
|
|
||||||
|
$instrumentDirs = scandir($scoreDir . '/' . $pieceDir);
|
||||||
|
|
||||||
|
foreach ($instrumentDirs as $instrumentId) {
|
||||||
|
if ($instrumentId === '.' || $instrumentId === '..') continue;
|
||||||
|
if (strpos($instrumentId, '.') === 0) continue;
|
||||||
|
if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue;
|
||||||
|
|
||||||
|
$instrument = $this->getInstrumentInfo($scoreId, $instrumentId, $pieceDir);
|
||||||
|
if ($instrument) {
|
||||||
|
$instruments[] = $instrument;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by title
|
||||||
|
usort($instruments, function($a, $b) {
|
||||||
|
return strcmp($a['title'], $b['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $instruments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createScore(string $id, string $name, string $compositor): array {
|
||||||
|
$scoreDir = $this->scoresPath . $id;
|
||||||
|
|
||||||
|
if (is_dir($scoreDir)) {
|
||||||
|
return ['success' => false, 'error' => 'Score already exists'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mkdir($scoreDir, 0755, true)) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to create directory'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$iniContent = "[info]\nname = $name\ncompositor = $compositor\n\n[pieces]\ncount = 1\n";
|
||||||
|
|
||||||
|
if (file_put_contents($scoreDir . '/score.ini', $iniContent) === false) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to create score.ini'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'score' => [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'compositor' => $compositor
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateScore(string $scoreId, ?string $name, ?string $compositor): array {
|
||||||
|
$scoreDir = $this->scoresPath . $scoreId;
|
||||||
|
$iniFile = $scoreDir . '/score.ini';
|
||||||
|
|
||||||
|
if (!file_exists($iniFile)) {
|
||||||
|
return ['success' => false, 'error' => 'Score not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ini = @parse_ini_file($iniFile, true);
|
||||||
|
if ($ini === false) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to parse score.ini'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update values
|
||||||
|
if ($name !== null) {
|
||||||
|
$ini['info']['name'] = $name;
|
||||||
|
}
|
||||||
|
if ($compositor !== null) {
|
||||||
|
$ini['info']['compositor'] = $compositor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild ini content
|
||||||
|
$content = "[info]\n";
|
||||||
|
$content .= "name = " . ($ini['info']['name'] ?? '') . "\n";
|
||||||
|
$content .= "compositor = " . ($ini['info']['compositor'] ?? '') . "\n\n";
|
||||||
|
$content .= "[pieces]\n";
|
||||||
|
|
||||||
|
if (isset($ini['pieces'])) {
|
||||||
|
foreach ($ini['pieces'] as $key => $value) {
|
||||||
|
$content .= "$key = $value\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($iniFile, $content) === false) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to write score.ini'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteScore(string $scoreId): array {
|
||||||
|
$scoreDir = $this->scoresPath . $scoreId;
|
||||||
|
|
||||||
|
if (!is_dir($scoreDir)) {
|
||||||
|
return ['success' => false, 'error' => 'Score not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive delete
|
||||||
|
$this->deleteDirectory($scoreDir);
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteDirectory(string $dir): void {
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
|
||||||
|
$items = scandir($dir);
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($item === '.' || $item === '..') continue;
|
||||||
|
|
||||||
|
$path = $dir . '/' . $item;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$this->deleteDirectory($path);
|
||||||
|
} else {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $filename): array {
|
||||||
|
$scoreDir = $this->scoresPath . $scoreId;
|
||||||
|
|
||||||
|
if (!is_dir($scoreDir)) {
|
||||||
|
return ['success' => false, 'error' => 'Score not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory structure: scoreId/piece/instrument/version
|
||||||
|
$targetDir = $scoreDir . '/' . $piece . '/' . $instrument . '/' . $version;
|
||||||
|
|
||||||
|
if (!mkdir($targetDir, 0755, true)) {
|
||||||
|
return ['success' => false, 'error' => 'Failed to create directory'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine filename
|
||||||
|
if (empty($filename)) {
|
||||||
|
$filename = $file['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = $targetDir . '/' . $filename;
|
||||||
|
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||||
|
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
api/router.php
Normal file
12
api/router.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Router script for PHP built-in server
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
|
||||||
|
// Serve existing files directly
|
||||||
|
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route everything through index.php
|
||||||
|
require __DIR__ . '/index.php';
|
||||||
3035
partitions/package-lock.json
generated
Normal file
3035
partitions/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
partitions/package.json
Normal file
26
partitions/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "partitions",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.0",
|
||||||
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"lucide-svelte": "^0.574.0",
|
||||||
|
"pdfjs-dist": "^4.0.379"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
partitions/postcss.config.js
Normal file
6
partitions/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
16
partitions/src/app.css
Normal file
16
partitions/src/app.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ohmj-primary: #1e3a5f;
|
||||||
|
--ohmj-secondary: #c9a227;
|
||||||
|
--ohmj-light: #f5f5f5;
|
||||||
|
--ohmj-dark: #0a1f33;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background-color: var(--ohmj-light);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
14
partitions/src/app.d.ts
vendored
Normal file
14
partitions/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user?: {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
13
partitions/src/app.html
Normal file
13
partitions/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OHMJ - Partitions</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
113
partitions/src/lib/api.ts
Normal file
113
partitions/src/lib/api.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import axios, { type AxiosError } from 'axios';
|
||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
const API_BASE_URL = browser ? 'http://localhost:8000' : 'http://localhost:8000';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { api };
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const authState = get(auth);
|
||||||
|
if (authState.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${authState.token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
auth.logout();
|
||||||
|
if (browser) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface Score {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
compositor: string;
|
||||||
|
ressource?: string | null;
|
||||||
|
instruments?: Instrument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Piece {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Instrument {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
piece: string;
|
||||||
|
parts: Part[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Part {
|
||||||
|
id: string;
|
||||||
|
files: PdfFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfFile {
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
part: string | null;
|
||||||
|
key: string | null;
|
||||||
|
clef: string | null;
|
||||||
|
variant: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiService = {
|
||||||
|
async login(username: string, password: string): Promise<{ token: string; user: { username: string; role: string } }> {
|
||||||
|
const response = await api.post('/login', { username, password });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getScores(): Promise<Score[]> {
|
||||||
|
const response = await api.get('/scores');
|
||||||
|
return response.data.scores;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getScore(id: string): Promise<Score> {
|
||||||
|
const response = await api.get(`/scores/${id}`);
|
||||||
|
return response.data.score;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getInstruments(scoreId: string): Promise<Instrument[]> {
|
||||||
|
const response = await api.get(`/scores/${scoreId}/instruments`);
|
||||||
|
return response.data.instruments;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPieces(scoreId: string): Promise<Piece[]> {
|
||||||
|
const response = await api.get(`/pieces/${scoreId}`);
|
||||||
|
return response.data.pieces;
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadPdf(path: string): Promise<Blob> {
|
||||||
|
const response = await api.get(`/download/${path}`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDownloadUrl(path: string): string {
|
||||||
|
let token = '';
|
||||||
|
auth.subscribe((state) => {
|
||||||
|
token = state.token || '';
|
||||||
|
})();
|
||||||
|
return `${API_BASE_URL}/download/${path}?token=${token}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
206
partitions/src/lib/components/PdfViewer.svelte
Normal file
206
partitions/src/lib/components/PdfViewer.svelte
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pdfUrl: string;
|
||||||
|
title?: string;
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pdfUrl, title = 'Partition', key = '' }: Props = $props();
|
||||||
|
|
||||||
|
// Force re-render when key changes
|
||||||
|
$effect(() => {
|
||||||
|
if (key) {
|
||||||
|
loadPdf();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let totalPages = $state(0);
|
||||||
|
let scale = $state(1.2);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let rendering = $state(false);
|
||||||
|
|
||||||
|
async function loadPdf() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
// Load PDF via axios to include auth token
|
||||||
|
const path = pdfUrl.split('/download/')[1];
|
||||||
|
const response = await api.get(`/download/${path}`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const loadingTask = pdfjsLib.getDocument(url);
|
||||||
|
pdfDoc = await loadingTask.promise;
|
||||||
|
totalPages = pdfDoc.numPages;
|
||||||
|
await renderPage(currentPage);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading PDF:', err);
|
||||||
|
error = 'Erreur lors du chargement du PDF';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(pageNum: number) {
|
||||||
|
if (!pdfDoc || rendering) return;
|
||||||
|
rendering = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdfDoc.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport
|
||||||
|
}).promise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error rendering page:', err);
|
||||||
|
} finally {
|
||||||
|
rendering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
renderPage(currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
renderPage(currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
scale = Math.min(scale + 0.2, 3);
|
||||||
|
renderPage(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
scale = Math.max(scale - 0.2, 0.5);
|
||||||
|
renderPage(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
prevPage();
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
nextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadPdf();
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
if (pdfDoc) {
|
||||||
|
pdfDoc.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pdfUrl) {
|
||||||
|
loadPdf();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="bg-ohmj-dark text-white px-4 py-2 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold">{title}</span>
|
||||||
|
{#if totalPages > 0}
|
||||||
|
<span class="text-gray-400">| Page {currentPage} / {totalPages}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={zoomOut}
|
||||||
|
class="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
||||||
|
title="Zoom -"
|
||||||
|
>
|
||||||
|
➖
|
||||||
|
</button>
|
||||||
|
<span class="text-sm w-16 text-center">{Math.round(scale * 100)}%</span>
|
||||||
|
<button
|
||||||
|
onclick={zoomIn}
|
||||||
|
class="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
||||||
|
title="Zoom +"
|
||||||
|
>
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto bg-gray-800 flex items-start justify-center p-4">
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-white">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||||
|
<p>Chargement du PDF...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-red-400 text-center">
|
||||||
|
<p class="text-xl mb-2">⚠️</p>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<canvas bind:this={canvas} class="shadow-2xl"></canvas>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-ohmj-dark text-white px-4 py-3 flex items-center justify-center gap-4">
|
||||||
|
<button
|
||||||
|
onclick={prevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
class="px-4 py-2 bg-ohmj-primary hover:bg-ohmj-secondary disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
|
>
|
||||||
|
◀ Précédent
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#each Array(Math.min(5, totalPages)) as _, i}
|
||||||
|
{@const pageNum = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i}
|
||||||
|
{#if pageNum <= totalPages}
|
||||||
|
<button
|
||||||
|
onclick={() => { currentPage = pageNum; renderPage(pageNum); }}
|
||||||
|
class="w-8 h-8 rounded {currentPage === pageNum ? 'bg-ohmj-secondary text-ohmj-dark font-bold' : 'bg-gray-700 hover:bg-gray-600'}"
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={nextPage}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
class="px-4 py-2 bg-ohmj-primary hover:bg-ohmj-secondary disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
|
>
|
||||||
|
Suivant ▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
43
partitions/src/lib/stores/auth.ts
Normal file
43
partitions/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuthStore() {
|
||||||
|
const stored = browser ? localStorage.getItem('auth') : null;
|
||||||
|
const initial: AuthState = stored ? JSON.parse(stored) : { token: null, user: null };
|
||||||
|
|
||||||
|
const { subscribe, set, update } = writable<AuthState>(initial);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
login: (token: string, user: User) => {
|
||||||
|
const state = { token, user };
|
||||||
|
set(state);
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('auth', JSON.stringify(state));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
set({ token: null, user: null });
|
||||||
|
if (browser) {
|
||||||
|
localStorage.removeItem('auth');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getToken: () => {
|
||||||
|
let token: string | null = null;
|
||||||
|
update((s) => (token = s.token));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = createAuthStore();
|
||||||
74
partitions/src/routes/+layout.svelte
Normal file
74
partitions/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const publicRoutes = ['/'];
|
||||||
|
|
||||||
|
let isAuthenticated = false;
|
||||||
|
let currentPath = '/';
|
||||||
|
|
||||||
|
auth.subscribe((state) => {
|
||||||
|
isAuthenticated = !!state.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
page.subscribe(($page) => {
|
||||||
|
currentPath = $page.url.pathname;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = page.subscribe(($page) => {
|
||||||
|
const path = $page.url.pathname;
|
||||||
|
const isPublic = publicRoutes.includes(path);
|
||||||
|
|
||||||
|
if (!isPublic && !isAuthenticated) {
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
auth.logout();
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
{#if isAuthenticated && currentPath !== '/'}
|
||||||
|
<header class="bg-ohmj-primary text-white shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||||
|
<a href="/scores" class="flex items-center gap-3">
|
||||||
|
<img src="/logo.png" alt="OHMJ" class="h-10" />
|
||||||
|
<span class="text-xl font-bold text-ohmj-secondary hidden md:inline">
|
||||||
|
Partitions
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav class="flex items-center gap-4">
|
||||||
|
<a href="/scores" class="hover:text-ohmj-secondary transition-colors">
|
||||||
|
Mes Partitions
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={logout}
|
||||||
|
class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<main class="flex-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if isAuthenticated && currentPath !== '/'}
|
||||||
|
<footer class="bg-ohmj-dark text-gray-400 py-4 text-center text-sm">
|
||||||
|
<p>© {new Date().getFullYear()} Harmonie de Montpellier-Jacou</p>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
126
partitions/src/routes/+page.svelte
Normal file
126
partitions/src/routes/+page.svelte
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import { apiService } from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let username = '';
|
||||||
|
let password = '';
|
||||||
|
let error = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!username || !password) {
|
||||||
|
error = 'Veuillez entrer un nom d\'utilisateur et un mot de passe';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiService.login(username, password);
|
||||||
|
auth.login(result.token, result.user);
|
||||||
|
goto('/scores');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
error = 'Nom d\'utilisateur ou mot de passe incorrect';
|
||||||
|
} else {
|
||||||
|
error = 'Erreur de connexion au serveur';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Connexion - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||||
|
<!-- Background image with blur -->
|
||||||
|
<div class="absolute inset-0 z-0">
|
||||||
|
<img src="/bg-login.jpg" alt="Background" class="w-full h-full object-cover" />
|
||||||
|
<div class="absolute inset-0 bg-ohmj-primary/50 backdrop-blur-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-md px-4 z-10">
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<img src="/logo.png" alt="OHMJ" class="h-24 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Harmonie de Montpellier-Jacou
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
|
Connexion à l'espace partitions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-6">
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-gray-700 text-sm font-bold mb-2">
|
||||||
|
Nom d'utilisateur
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
bind:value={username}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-ohmj-primary focus:border-transparent"
|
||||||
|
placeholder="musicien"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-gray-700 text-sm font-bold mb-2">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
bind:value={password}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-ohmj-primary focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full bg-ohmj-primary hover:bg-ohmj-dark text-white font-bold py-3 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="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>
|
||||||
|
Connexion...
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Se connecter
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-gray-400 text-xs mt-6">
|
||||||
|
Accès réservé aux membres de l'OHMJ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
115
partitions/src/routes/scores/+page.svelte
Normal file
115
partitions/src/routes/scores/+page.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiService, type Score } from '$lib/api';
|
||||||
|
|
||||||
|
let scores: Score[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let sortBy: 'id' | 'name' | 'compositor' = 'id';
|
||||||
|
let sortOrder: 'asc' | 'desc' = 'asc';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
scores = await apiService.getScores();
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors du chargement des partitions';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sortScores(key: 'id' | 'name' | 'compositor') {
|
||||||
|
if (sortBy === key) {
|
||||||
|
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortBy = key;
|
||||||
|
sortOrder = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: sortedScores = [...scores].sort((a, b) => {
|
||||||
|
let aVal = a[sortBy] || '';
|
||||||
|
let bVal = b[sortBy] || '';
|
||||||
|
if (sortBy === 'id') {
|
||||||
|
aVal = a.id;
|
||||||
|
bVal = b.id;
|
||||||
|
}
|
||||||
|
const cmp = aVal.localeCompare(bVal);
|
||||||
|
return sortOrder === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Mes Partitions - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-ohmj-primary">Mes Partitions</h1>
|
||||||
|
<p class="text-gray-600 mt-2">{scores.length} partitions disponibles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-ohmj-primary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
onclick={() => sortScores('id')}
|
||||||
|
>
|
||||||
|
№ {sortBy === 'id' ? (sortOrder === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
onclick={() => sortScores('name')}
|
||||||
|
>
|
||||||
|
Nom {sortBy === 'name' ? (sortOrder === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
onclick={() => sortScores('compositor')}
|
||||||
|
>
|
||||||
|
Compositeur {sortBy === 'compositor' ? (sortOrder === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
{#each sortedScores as score}
|
||||||
|
<tr class="hover:bg-gray-50 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{score.id}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||||
|
{score.name}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{score.compositor || '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<a
|
||||||
|
href="/scores/{score.id}"
|
||||||
|
class="inline-flex items-center px-3 py-1 border border-ohmj-primary text-sm font-medium rounded text-ohmj-primary hover:bg-ohmj-primary hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Voir →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
148
partitions/src/routes/scores/[id]/+page.svelte
Normal file
148
partitions/src/routes/scores/[id]/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { apiService, type Score, type Piece } from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
const INSTRUMENT_ICONS: Record<string, string> = {
|
||||||
|
dir: '🎼', pic: '🎺', flu: '🎵', cla: '🎵', clb: '🎵',
|
||||||
|
sax: '🎷', sab: '🎷', sat: '🎷', coa: '🎵', cba: '🎸',
|
||||||
|
cor: '🥇', trp: '🎺', trb: '🎺', tub: '🎺', htb: '🎵',
|
||||||
|
bas: '🎻', per: '🥁', crn: '🎺', eup: '🎺', har: '🎵',
|
||||||
|
pia: '🎹', sup: '📄', par: '📄'
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSTRUMENT_NAMES: Record<string, string> = {
|
||||||
|
dir: 'Direction', pic: 'Piccolo', flu: 'Flûte', cla: 'Clarinette',
|
||||||
|
clb: 'Clarinette Basse', sax: 'Sax Alto', sat: 'Sax Ténor', sab: 'Sax Baryton',
|
||||||
|
coa: 'Cor Anglais', cba: 'Contrebasse', cor: 'Cor', trp: 'Trompette',
|
||||||
|
trb: 'Trombone', tub: 'Tuba', htb: 'Hautbois', bas: 'Basson',
|
||||||
|
per: 'Percussions', crn: 'Cornet', eup: 'Euphonium', har: 'Harpe',
|
||||||
|
pia: 'Piano', sup: 'Parties supplementaires', par: 'Parties'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInstrumentIcon(code: string): string {
|
||||||
|
return INSTRUMENT_ICONS[code] || '🎵';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstrumentName(code: string): string {
|
||||||
|
return INSTRUMENT_NAMES[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scoreId = '';
|
||||||
|
let score: Score | null = null;
|
||||||
|
let pieces: Piece[] = [];
|
||||||
|
let instruments: any[] = [];
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let selectedPiece = $state(1);
|
||||||
|
|
||||||
|
page.subscribe(($page) => {
|
||||||
|
scoreId = $page.params.id || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
score = await apiService.getScore(scoreId);
|
||||||
|
pieces = await apiService.getPieces(scoreId);
|
||||||
|
|
||||||
|
if (pieces.length > 1) {
|
||||||
|
// Multi-pieces score - use instruments from API
|
||||||
|
instruments = score?.instruments || [];
|
||||||
|
loading = false;
|
||||||
|
} else {
|
||||||
|
// Single piece - load instruments
|
||||||
|
selectedPiece = 1;
|
||||||
|
instruments = score?.instruments || [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors du chargement';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectPiece(pieceId: number) {
|
||||||
|
goto(`/scores/${scoreId}/${pieceId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{score?.name || 'Partition'} - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<a href="/scores" class="inline-flex items-center text-ohmj-primary hover:underline mb-4">
|
||||||
|
← Retour aux partitions
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-ohmj-primary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-ohmj-primary">{score?.name || ''}</h1>
|
||||||
|
{#if score?.compositor}
|
||||||
|
<p class="text-gray-600 mt-1 text-lg">{score.compositor}</p>
|
||||||
|
{/if}
|
||||||
|
{#if score?.ressource}
|
||||||
|
<a
|
||||||
|
href={score.ressource}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center mt-2 text-ohmj-primary hover:underline"
|
||||||
|
>
|
||||||
|
🔗 Ressource externe
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm text-gray-400 mt-1">№ {scoreId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pieces.length > 1}
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700 mb-4">Pièces</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
{#each pieces as piece}
|
||||||
|
<button
|
||||||
|
onclick={() => selectPiece(piece.id)}
|
||||||
|
class="bg-white p-4 rounded-lg shadow hover:shadow-lg transition-shadow border-2 {selectedPiece === piece.id ? 'border-ohmj-primary' : 'border-transparent hover:border-ohmj-primary'}"
|
||||||
|
>
|
||||||
|
<p class="font-semibold text-gray-800">{piece.name}</p>
|
||||||
|
<p class="text-sm text-gray-500">№ {piece.id}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pieces.length <= 1 || selectedPiece}
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700 mb-4">Instruments disponibles</h2>
|
||||||
|
|
||||||
|
{#if instruments.length === 0}
|
||||||
|
<p class="text-gray-500">Aucun instrument trouvé.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{#each instruments as instrument}
|
||||||
|
{@const code = instrument.id || instrument.code || ''}
|
||||||
|
{@const title = instrument.title || getInstrumentName(code)}
|
||||||
|
{@const fileCount = instrument.parts?.[0]?.files?.length || 0}
|
||||||
|
<a
|
||||||
|
href="/scores/{scoreId}/{selectedPiece}/{code}"
|
||||||
|
class="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow border border-gray-200 hover:border-ohmj-primary"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl mb-2">{getInstrumentIcon(code)}</div>
|
||||||
|
<h3 class="font-semibold text-gray-800">{title}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{fileCount} fichier{fileCount !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
79
partitions/src/routes/scores/[id]/[piece]/+page.svelte
Normal file
79
partitions/src/routes/scores/[id]/[piece]/+page.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { apiService, type Score, type Instrument } from '$lib/api';
|
||||||
|
|
||||||
|
const INSTRUMENT_ICONS: Record<string, string> = {
|
||||||
|
dir: '🎼', pic: '🎺', flu: '🎵', cla: '🎵', clb: '🎵',
|
||||||
|
sax: '🎷', sab: '🎷', sat: '🎷', coa: '🎵', cba: '🎸',
|
||||||
|
cor: '🥇', trp: '🎺', trb: '🎺', tub: '🎺', htb: '🎵',
|
||||||
|
bas: '🎻', per: '🥁', crn: '🎺', eup: '🎺', har: '🎵',
|
||||||
|
pia: '🎹', sup: '📄', par: '📄'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInstrumentIcon(code: string): string {
|
||||||
|
return INSTRUMENT_ICONS[code] || '🎵';
|
||||||
|
}
|
||||||
|
|
||||||
|
let scoreId = '';
|
||||||
|
let pieceId = '';
|
||||||
|
let instruments: Instrument[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
page.subscribe(($page) => {
|
||||||
|
scoreId = $page.params.id || '';
|
||||||
|
pieceId = $page.params.piece || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const score = await apiService.getScore(scoreId);
|
||||||
|
// Filter instruments by piece
|
||||||
|
instruments = (score.instruments || []).filter(i => i.piece === pieceId);
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors du chargement';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Pièce {pieceId} - {scoreId} - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<a href="/scores/{scoreId}" class="inline-flex items-center text-ohmj-primary hover:underline mb-4">
|
||||||
|
← Retour aux pièces
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-ohmj-primary mb-6">Instruments - Pièce {pieceId}</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-ohmj-primary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{#each instruments as instrument}
|
||||||
|
{@const fileCount = instrument.parts?.[0]?.files?.length || 0}
|
||||||
|
<a
|
||||||
|
href="/scores/{scoreId}/{pieceId}/{instrument.id}"
|
||||||
|
class="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow border border-gray-200 hover:border-ohmj-primary"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl mb-2">{getInstrumentIcon(instrument.id)}</div>
|
||||||
|
<h3 class="font-semibold text-gray-800">{instrument.title}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{fileCount} fichier{fileCount !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { apiService, type Part, type PdfFile } from '$lib/api';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
|
||||||
|
const INSTRUMENT_NAMES: Record<string, string> = {
|
||||||
|
dir: 'Direction',
|
||||||
|
pic: 'Piccolo',
|
||||||
|
flu: 'Flûte',
|
||||||
|
cla: 'Clarinette',
|
||||||
|
clb: 'Clarinette basse',
|
||||||
|
sax: 'Saxophone',
|
||||||
|
sab: 'Saxophone baryton',
|
||||||
|
sat: 'Saxophone ténor',
|
||||||
|
cba: 'Contrebasse',
|
||||||
|
cor: 'Cor',
|
||||||
|
trp: 'Trompette',
|
||||||
|
trb: 'Trombone',
|
||||||
|
tub: 'Tuba',
|
||||||
|
htb: 'Hautbois',
|
||||||
|
bas: 'Basson',
|
||||||
|
per: 'Percussion',
|
||||||
|
crn: 'Cornet',
|
||||||
|
eup: 'Euphonium',
|
||||||
|
har: 'Harpe',
|
||||||
|
pia: 'Piano'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInstrumentName(code: string): string {
|
||||||
|
return INSTRUMENT_NAMES[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scoreId = '';
|
||||||
|
let pieceId = '';
|
||||||
|
let instrumentCode = '';
|
||||||
|
let instrumentTitle = '';
|
||||||
|
let parts: Part[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let selectedFile = $state('');
|
||||||
|
let showSidebar = $state(true);
|
||||||
|
|
||||||
|
let pdfViewerUrl = $derived(selectedFile ? apiService.getDownloadUrl(selectedFile) : '');
|
||||||
|
let pdfFileName = $derived(selectedFile ? selectedFile.split('/').pop() || 'Partition' : 'Partition');
|
||||||
|
|
||||||
|
page.subscribe(($page) => {
|
||||||
|
scoreId = $page.params.id || '';
|
||||||
|
pieceId = $page.params.piece || '';
|
||||||
|
instrumentCode = $page.params.instrument || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const score = await apiService.getScore(scoreId);
|
||||||
|
const instrument = (score.instruments || []).find(
|
||||||
|
i => i.id === instrumentCode && i.piece === pieceId
|
||||||
|
);
|
||||||
|
if (instrument) {
|
||||||
|
instrumentTitle = instrument.title;
|
||||||
|
parts = instrument.parts || [];
|
||||||
|
}
|
||||||
|
if (parts.length > 0 && parts[0].files.length > 0) {
|
||||||
|
selectedFile = parts[0].files[0].path;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors du chargement des parties';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getFileInfo(file: PdfFile): string {
|
||||||
|
const info = [];
|
||||||
|
if (file.part) info.push(`Partie ${file.part}`);
|
||||||
|
if (file.key) info.push(file.key);
|
||||||
|
if (file.clef) info.push(file.clef);
|
||||||
|
if (file.variant) info.push(file.variant);
|
||||||
|
return info.length > 0 ? `(${info.join(', ')})` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(path: string) {
|
||||||
|
try {
|
||||||
|
const blob = await apiService.downloadPdf(path);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = path.split('/').pop() || 'partition.pdf';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openViewer(path: string) {
|
||||||
|
selectedFile = path;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{instrumentTitle || instrumentCode} - {scoreId} - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="h-[calc(100vh-64px)] flex flex-col">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<a href="/scores/{scoreId}/{pieceId}" class="inline-flex items-center text-ohmj-primary hover:underline mb-4">
|
||||||
|
← Retour aux instruments
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-ohmj-primary">
|
||||||
|
{instrumentTitle || getInstrumentName(instrumentCode)} - Partition {scoreId} - Pièce {pieceId}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-ohmj-primary"></div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 flex overflow-hidden relative">
|
||||||
|
<!-- Toggle button -->
|
||||||
|
<button
|
||||||
|
onclick={() => showSidebar = !showSidebar}
|
||||||
|
class="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 bg-ohmj-primary text-white px-2 py-4 rounded-r shadow hover:bg-ohmj-secondary transition-colors"
|
||||||
|
title={showSidebar ? 'Masquer' : 'Afficher'}
|
||||||
|
>
|
||||||
|
{showSidebar ? '◀' : '▶'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Liste des parties -->
|
||||||
|
{#if showSidebar}
|
||||||
|
<div class="w-64 min-w-[200px] max-w-xs bg-white border-r overflow-y-auto p-4 flex-shrink-0 ml-8">
|
||||||
|
<h2 class="font-semibold text-gray-700 mb-4">Parties disponibles</h2>
|
||||||
|
|
||||||
|
{#each parts as part}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-medium text-gray-800 mb-2">Version {part.id}</h3>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each part.files as file}
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openViewer(file.path)}
|
||||||
|
class="flex-1 text-left px-3 py-2 rounded hover:bg-ohmj-light border border-transparent hover:border-ohmj-primary transition-colors text-sm {selectedFile === file.path ? 'bg-ohmj-primary text-white' : 'text-ohmj-primary'}"
|
||||||
|
>
|
||||||
|
📖 {file.name} {getFileInfo(file)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => downloadFile(file.path)}
|
||||||
|
class="px-3 py-2 text-gray-500 hover:text-ohmj-secondary transition-colors text-sm"
|
||||||
|
title="Télécharger"
|
||||||
|
>
|
||||||
|
⬇️
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if parts.length === 0}
|
||||||
|
<p class="text-gray-500">Aucune partie disponible.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Visionneuse PDF -->
|
||||||
|
<div class="flex-1 bg-gray-800 flex flex-col ml-8">
|
||||||
|
{#if selectedFile}
|
||||||
|
{#key selectedFile}
|
||||||
|
<PdfViewer pdfUrl={pdfViewerUrl} title={pdfFileName} />
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<div class="h-full flex items-center justify-center text-gray-400">
|
||||||
|
<p>Sélectionnez une partition pour la visionner</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
77
partitions/src/routes/test-icons/+page.svelte
Normal file
77
partitions/src/routes/test-icons/+page.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const INSTRUMENT_ICONS: Record<string, string> = {
|
||||||
|
dir: '🎼',
|
||||||
|
pic: '🎵',
|
||||||
|
flu: '🎵',
|
||||||
|
cla: '🎵',
|
||||||
|
clb: '🎵',
|
||||||
|
sax: '🎷',
|
||||||
|
sab: '🎷',
|
||||||
|
sat: '🎷',
|
||||||
|
coa: '🎵',
|
||||||
|
cba: '🎸',
|
||||||
|
cor: '🥇',
|
||||||
|
trp: '🎺',
|
||||||
|
trb: '🎺',
|
||||||
|
tub: '🎺',
|
||||||
|
htb: '🎵',
|
||||||
|
bas: '🎻',
|
||||||
|
per: '🥁',
|
||||||
|
crn: '🎺',
|
||||||
|
eup: '🎺',
|
||||||
|
har: '🎵',
|
||||||
|
pia: '🎹',
|
||||||
|
sup: '📄',
|
||||||
|
par: '📄'
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSTRUMENT_NAMES: Record<string, string> = {
|
||||||
|
dir: 'Direction',
|
||||||
|
pic: 'Piccolo',
|
||||||
|
flu: 'Flûte',
|
||||||
|
cla: 'Clarinette',
|
||||||
|
clb: 'Clarinette Basse',
|
||||||
|
sax: 'Sax Alto',
|
||||||
|
sat: 'Sax Ténor',
|
||||||
|
sab: 'Sax Baryton',
|
||||||
|
coa: 'Cor Anglais',
|
||||||
|
cba: 'Contrebasse',
|
||||||
|
cor: 'Cor',
|
||||||
|
trp: 'Trompette',
|
||||||
|
trb: 'Trombone',
|
||||||
|
tub: 'Tuba',
|
||||||
|
htb: 'Hautbois',
|
||||||
|
bas: 'Basson',
|
||||||
|
per: 'Percussions',
|
||||||
|
crn: 'Cornet',
|
||||||
|
eup: 'Euphonium',
|
||||||
|
har: 'Harpe',
|
||||||
|
pia: 'Piano',
|
||||||
|
sup: 'Parties supplementaires',
|
||||||
|
par: 'Parties'
|
||||||
|
};
|
||||||
|
|
||||||
|
const instruments = Object.entries(INSTRUMENT_NAMES).map(([code, name]) => ({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
icon: INSTRUMENT_ICONS[code] || '❓'
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Test icônes instruments - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-3xl font-bold text-ohmj-primary mb-8">Test icônes instruments</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{#each instruments as inst}
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow border border-gray-200 text-center">
|
||||||
|
<div class="text-5xl mb-2">{inst.icon}</div>
|
||||||
|
<p class="font-semibold">{inst.name}</p>
|
||||||
|
<p class="text-sm text-gray-400">{inst.code}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
BIN
partitions/static/bg-login.jpg
Normal file
BIN
partitions/static/bg-login.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
BIN
partitions/static/logo.png
Normal file
BIN
partitions/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
21
partitions/static/pdf.worker.min.mjs
Normal file
21
partitions/static/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
21
partitions/svelte.config.js
Normal file
21
partitions/svelte.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: true
|
||||||
|
}),
|
||||||
|
alias: {
|
||||||
|
$lib: './src/lib'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
17
partitions/tailwind.config.js
Normal file
17
partitions/tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
ohmj: {
|
||||||
|
primary: '#1e3a5f',
|
||||||
|
secondary: '#c9a227',
|
||||||
|
light: '#f5f5f5',
|
||||||
|
dark: '#0a1f33'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
14
partitions/tsconfig.json
Normal file
14
partitions/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
partitions/vite.config.ts
Normal file
9
partitions/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user