[FEAT] Fix some bug and css issues.

This commit is contained in:
NADAL Jean-Baptiste
2026-09-15 11:46:02 +02:00
parent a38fc0bfa9
commit 71d3b71362
26 changed files with 829 additions and 418 deletions
+1
View File
@@ -1,5 +1,6 @@
legacy/Scores
_builds/
_deploy_temp/
missing.xml
partitions/.svelte-kit/
partitions/build/
+48 -2
View File
@@ -109,6 +109,46 @@ npm run preview
- Do NOT include `legacy/` or `api/tests.php` in the deploy archive.
- `api/.env` (with `JWT_SECRET`) is part of the deployment.
- The frontend build must be present in `frontend/`.
- Production: front `https://partitions.c.nadal-fr.com`, API `https://ohmj-api.c.nadal-fr.com`.
- **Cache**: hashed assets under `_app/immutable/` are immutable (1y); the SPA
shell `index.html` MUST be served with `Cache-Control: no-cache` (see
`partitions/nginx.conf`). Otherwise clients keep a stale shell referencing
hashed chunks removed by the new deploy, causing errors on some routes (e.g.
the PDF viewer). The front is served by the `nginx` container (Caddy only
`reverse_proxy`s to it and passes the headers through), so this is configured
in `nginx.conf`, not in the Caddyfile. After a deploy, a hard refresh can be
needed for already-cached clients.
### Testing the deploy archive (recommended)
It is worth testing the produced zip, not just the source tree. `deploy.sh`
generates `_builds/deploy_ohmj.zip`; verify it before uploading:
```bash
# 1. Inspect the archive (no stale/test/residue files)
unzip -l _builds/deploy_ohmj.zip | grep -E 'tests\.php|coverage-|composer\.phar|node_modules|legacy/'
# -> must print nothing
unzip -p _builds/deploy_ohmj.zip api/.env # JWT_SECRET + SCORES_PATH
# 2. Extract and run the artifact end-to-end against the E2E smoke test
rm -rf /tmp/deploy_test && mkdir -p /tmp/deploy_test
unzip -q _builds/deploy_ohmj.zip -d /tmp/deploy_test
# - API: cd /tmp/deploy_test/api
# SCORES_PATH=<repo>/legacy/Scores/ php -c php-upload.ini -S localhost:8000 router.php
# - front: serve /tmp/deploy_test/frontend with an SPA fallback (see below)
API_URL=http://localhost:8000 FRONT_URL=http://localhost:4173 npm run test:e2e --prefix partitions
# 3. Security: .env / config/users.json must NOT be served
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/.env # -> 401
```
Notes:
- `deploy.sh` deletes the previous zip before zipping: `zip -r` alone would
**merge** into an existing archive and keep stale files from earlier deploys.
- The built frontend bakes `VITE_API_URL` **and** the CSP `connect-src`
(`svelte.config.js` only allows `localhost:8000` + the prod API). To test a zip
locally, either reuse `localhost:8000` or rebuild the front with a permitted URL
(`VITE_API_URL=http://localhost:8000 npm run build`).
- The E2E smoke test accepts `API_URL` / `FRONT_URL` so it can target the
extracted artifact instead of the dev servers.
## Testing
@@ -132,7 +172,7 @@ rm -f /tmp/rate_* 2>/dev/null # clear rate limiting fir
php tests.php
```
- Expected: **53/53 tests passed (100%)**.
- Expected: **183/183 tests passed (100%)**.
- Covers: auth (login, bad credentials, missing/invalid token), scores CRUD,
create-score-with-pieces, files tree, path traversal, rate limiting, upload
validation, INI injection, security headers.
@@ -268,6 +308,12 @@ Instrument codes: `dir`, `pic`, `flu`, `cla`, `clb`, `sax`, `sat`, `sab`, `coa`,
`cba`, `cor`, `trp`, `trb`, `tub`, `htb`, `bas`, `per`, `crn`, `eup`, `har`, `pia`,
plus `sup`/`par` for combined parts.
Admin uploads build the filename from the instrument's **ASCII** name
(`instruments.ts` → `api/lib/ScoreScanner.php`), with `-` between fields
(e.g. `clarinette-sib-1.pdf`). For `sup`/`par`, the **"Parties combinées"** editor
builds the real filename base from one or more instruments (with key/clef/part),
joined by `+` (e.g. `contrebasse-sib-clefa-1+baryton-sib-clefa-1`). See `NAMING.md`.
## Code Style
### PHP
@@ -300,7 +346,7 @@ plus `sup`/`par` for combined parts.
- [ ] CORS origins restricted to the real domains (no wildcard fallback).
- [ ] HTTPS enforced in production.
- [ ] Rate limiting active (clear `/tmp/rate_*` only in tests, never in prod).
- [ ] `php tests.php` passes 53/53.
- [ ] `php tests.php` passes 183/183.
- [ ] `npm audit` reviewed.
- [ ] No secrets in code or git history.
+17
View File
@@ -52,6 +52,23 @@ legacy/Scores/<NUM>/score.ini
| `glockenspiel-1+xylophone-1.pdf` | percussion multiple |
| `percussion-1+gong-1+cymbale-1+triangle-1.pdf` | 3 `+` possibles |
> Ordre réel des champs d'un nom simple : `instrument`, puis `tonalité`,
> `variante`, `clef`, et enfin `partie` (ex. `euphonium-sib-clesol-1.pdf`,
> `cornet-sib-solo-1.pdf`, `baryton-clefa-1.pdf`).
### Parties combinées (`sup` / `par`)
Les dossiers `sup/` et `par/` regroupent des partitions **combinées** : le nom du
fichier reprend les **instruments réels** reliés par `+`, pas le code du dossier.
Exemples réels : `331/1/sup/1/contrebasse-1+clarinette-1.pdf`,
`295/1/sup/1/euphonium-sib-clefa-1+baryton-sib-clefa-1.pdf`,
`346/1/par/1/partie-sib-4.pdf`.
À l'upload (admin), un éditeur **« Parties combinées »** apparaît pour `sup`/`par` :
on ajoute un ou plusieurs instruments, chacun avec **tonalité / clé / partie** ; le
nom est alors construit automatiquement, relié par `+`
(ex. `contrebasse-sib-clefa-1+baryton-sib-clefa-1.pdf`).
### Tonalités
`sib` (Si♭), `mib` (Mi♭), `fa` (Fa), `ut` (Do — **`do` s'écrit `ut` dans les
+5 -1
View File
@@ -51,7 +51,7 @@ Détail manuel :
```bash
cd api
rm -f /tmp/rate_* 2>/dev/null
php tests.php # attendu : 181/181
php tests.php # attendu : 183/183
```
Frontend (serveurs API + dev lancés) :
@@ -71,6 +71,10 @@ npm run test:e2e # smoke E2E navigateur (CDP)
Le build frontend doit être présent dans `frontend/`. `legacy/` et `api/tests.php`
ne sont pas inclus dans l'archive.
> Il est recommandé de **tester l'archive** (`_builds/deploy_ohmj.zip`) avant de
> l'uploader : inspection du contenu + lancement du smoke test E2E sur l'artefact
> extrait. Procédure détaillée dans [`AGENTS.md`](AGENTS.md#testing-the-deploy-archive-recommended).
## Documentation
- [`AGENTS.md`](AGENTS.md) — guide de développement (stack, commandes, conventions)
+2 -1
View File
@@ -417,8 +417,9 @@ if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE
$part = $_POST['part'] ?? '1';
$watermark = isset($_POST['watermark']) && $_POST['watermark'] === 'true';
$watermarkPosition = $_POST['watermarkPosition'] ?? 'left';
$combined = $_POST['combined'] ?? '';
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $key, $clef, $variant, $part, $watermark, $watermarkPosition);
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $key, $clef, $variant, $part, $watermark, $watermarkPosition, $combined);
if ($result['success']) {
echo json_encode(['success' => true, 'path' => $result['path']]);
+12 -13
View File
@@ -594,7 +594,7 @@ class ScoreScanner {
rmdir($dir);
}
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1', bool $watermark = false, string $watermarkPosition = 'left'): array {
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1', bool $watermark = false, string $watermarkPosition = 'left', ?string $combined = null): array {
// Security: every input used to build a path or a filename is validated
if (!$this->isValidSegment($scoreId, '/^\d{1,6}$/')) {
return ['success' => false, 'error' => 'Invalid score id'];
@@ -680,20 +680,19 @@ class ScoreScanner {
$instName = $instrumentNames[$instrument] ?? $instrument;
// Build filename: instrument_variant_key_clef_part.pdf
$filenameParts = [$instName];
if (!empty($variant)) {
$filenameParts[] = $variant;
if (in_array($instrument, ['sup', 'par'], true) && $combined !== null && trim($combined) !== '') {
// Combined/superposed parts keep their real instrument names (e.g. "contrebasse-1+clarinette-1")
$combined = strtolower(trim($combined));
if (!$this->isValidSegment($combined, '/^[a-z0-9_+.-]+$/')) {
return ['success' => false, 'error' => 'Invalid combined instruments'];
}
if (!empty($key)) {
$filenameParts[] = $key;
$filename = $combined . '.pdf';
} else {
// NAMING.md: fields separated by '-', compound names/suffixes by '_'
$nameParts = array_values(array_filter([$instName, $key, $variant, $clef], fn($v) => $v !== ''));
$nameParts[] = $part;
$filename = implode('-', $nameParts) . '.pdf';
}
if (!empty($clef)) {
$filenameParts[] = $clef;
}
$filenameParts[] = $part;
$filename = implode('_', $filenameParts) . '.pdf';
$targetPath = $targetDir . '/' . $filename;
+18 -7
View File
@@ -330,7 +330,7 @@ class APITest {
// Get files to verify it was uploaded (recursive tree)
$files = $this->request('GET', "admin/scores/$scoreId/files");
$found = $this->findTreeFile($files['body']['files'] ?? [], 'direction_1.pdf');
$found = $this->findTreeFile($files['body']['files'] ?? [], 'direction-1.pdf');
if ($found) {
require_once __DIR__ . '/vendor/autoload.php';
$check = new \setasign\Fpdi\Fpdi();
@@ -357,7 +357,7 @@ class APITest {
// Clean up all uploaded files (recursive) then the dedicated score
$files = $this->request('GET', "admin/scores/$scoreId/files");
$this->deleteTreeMatching($files['body']['files'] ?? [], $scoreId, 'direction_');
$this->deleteTreeMatching($files['body']['files'] ?? [], $scoreId, 'direction-');
$this->request('DELETE', "admin/scores/$scoreId");
}
@@ -682,6 +682,17 @@ class APITest {
$noFile = $this->request('POST', "admin/scores/$scoreId/upload", ['unexpected' => 'body']);
$this->test('Upload without file part returns 413', $noFile['code'] === 413);
// Combined/superposed parts are named after the combined instruments
$supUp = $this->multipartRequest("admin/scores/$scoreId/upload", [
['name' => 'file', 'file' => $tmpPdf],
['name' => 'piece', 'data' => '1'],
['name' => 'instrument', 'data' => 'sup'],
['name' => 'version', 'data' => '1'],
['name' => 'combined', 'data' => 'contrebasse-1+clarinette-1'],
]);
$this->test('Upload sup combined returns 200', $supUp['code'] === 200);
$this->test('Upload sup combined filename', str_contains($supUp['body']['path'] ?? '', 'contrebasse-1+clarinette-1.pdf'));
// Instruments (list + filter)
$instr = $this->request('GET', "scores/$scoreId/instruments");
$this->test('Instruments returns 200', $instr['code'] === 200);
@@ -693,7 +704,7 @@ class APITest {
$this->test('Pieces returns 200', $pieces['code'] === 200);
// Download success + failures
$path = "$scoreId/1/dir/1/direction_1.pdf";
$path = "$scoreId/1/dir/1/direction-1.pdf";
$ok = $this->request('GET', "download/$path");
$this->test('Download with token returns 200', $ok['code'] === 200);
$noToken = $this->request('GET', "download/$path", null, false);
@@ -742,7 +753,7 @@ class APITest {
}
// Delete uploaded file, then the score
$del = $this->request('DELETE', "admin/scores/$scoreId/files", null, true, ['path' => '1/dir/1/direction_1.pdf']);
$del = $this->request('DELETE', "admin/scores/$scoreId/files", null, true, ['path' => '1/dir/1/direction-1.pdf']);
$this->test('Delete file returns 200', $del['code'] === 200);
$delScore = $this->request('DELETE', "admin/scores/$scoreId");
$this->test('Delete score returns 200', $delScore['code'] === 200);
@@ -950,9 +961,9 @@ class APITest {
@chmod($base . '100/1/flu/1', 0555);
$this->test('Unit: upload copy failure', $scanner->uploadPdf('100', $file, '1', 'flu', '1')['success'] === false);
@chmod($base . '100/1/flu/1', 0755);
@mkdir($base . '100/1/dir/1/direction_1.pdf', 0777, true);
@mkdir($base . '100/1/dir/1/direction-1.pdf', 0777, true);
$this->test('Unit: uploadPdf rename failure', $scanner->uploadPdf('100', $wmFile, '1', 'dir', '1', '', '', '', '1', true, 'left')['success'] === false);
@rmdir($base . '100/1/dir/1/direction_1.pdf');
@rmdir($base . '100/1/dir/1/direction-1.pdf');
$this->test('Unit: getScoreFiles unknown', $scanner->getScoreFiles('999')['success'] === false);
$this->test('Unit: getScoreFiles ok', $scanner->getScoreFiles('100')['success'] === true);
@@ -970,7 +981,7 @@ class APITest {
@mkdir($base . '100/1/cla/1/subdir', 0777, true);
$this->test('Unit: deleteFile directory', $scanner->deleteScoreFile('100', '1/cla/1/subdir')['success'] === false);
@chmod($base . '100/1/flu/1', 0555);
$this->test('Unit: deleteFile unlink failure', $scanner->deleteScoreFile('100', '1/flu/1/flute_ut_clesol_1.pdf')['success'] === false);
$this->test('Unit: deleteFile unlink failure', $scanner->deleteScoreFile('100', '1/flu/1/flute-ut-clesol-1.pdf')['success'] === false);
@chmod($base . '100/1/flu/1', 0755);
// deleteScore + deleteDirectory
+2 -1
View File
@@ -47,8 +47,9 @@ cp partitions/nginx.conf $DEPLOY_DIR/frontend/nginx.conf 2>/dev/null || echo "ng
# 7. Create zip (without tests.php and legacy)
echo "[7/8] Creating zip..."
rm -f ../_builds/deploy_ohmj.zip
cd $DEPLOY_DIR
zip -r ../_builds/deploy_ohmj.zip api frontend -x "*.DS_Store" "node_modules/*" ".svelte-kit/*" "api/tests.php" "api/coverage-prepend.php" "api/coverage-report.php"
zip -r ../_builds/deploy_ohmj.zip api frontend -x "*.DS_Store" "node_modules/*" ".svelte-kit/*" "api/tests.php" "api/coverage-prepend.php" "api/coverage-report.php" "api/composer.phar"
# 8. Cleanup
echo "[8/8] Cleaning up..."
+19 -7
View File
@@ -10,26 +10,38 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers.
# NOTE: nginx does not inherit `add_header` into a location that declares its
# own add_header, so the asset/worker locations below repeat them.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# PDF.js worker: serve the tracked .mjs with a JS MIME type
location = /pdf.worker.min.mjs {
default_type application/javascript;
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
# Cache static assets
# Hashed assets are immutable (safe to cache for a long time)
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
# All routes should serve index.html (SPA)
# SPA shell: never cache it, it references hashed chunks removed on deploy.
# `expires -1` emits `Cache-Control: no-cache` without using add_header, so
# the server-level security headers above are still inherited.
location = /index.html {
expires -1;
}
# All routes fall back to index.html (SPA)
location / {
expires -1;
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
+56 -8
View File
@@ -2,15 +2,63 @@
@tailwind components;
@tailwind utilities;
:root {
--ohmj-primary: #1e3a5f;
--ohmj-secondary: #c9a227;
--ohmj-light: #f5f5f5;
--ohmj-dark: #0a1f33;
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--ohmj-light);
color: #333;
@apply bg-ohmj-light text-gray-800 antialiased;
}
/* Consistent, visible keyboard focus everywhere */
:focus-visible {
outline: 2px solid theme('colors.ohmj.primary');
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply bg-ohmj-primary text-white hover:bg-ohmj-secondary;
}
.btn-ghost {
@apply text-gray-600 hover:bg-gray-100 hover:text-gray-800;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700;
}
.card {
@apply rounded-xl border border-gray-200 bg-white shadow-sm;
}
.input {
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-ohmj-primary focus:outline-none focus:ring-2 focus:ring-ohmj-primary/30;
}
.label {
@apply block text-sm font-medium text-gray-700;
}
.badge {
@apply inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700;
}
.alert-error {
@apply rounded-md border border-red-400 bg-red-100 px-4 py-3 text-red-700;
}
}
+2 -1
View File
@@ -148,7 +148,7 @@ export const apiService = {
return response.data;
},
async uploadPdf(scoreId: string, file: File, piece: string, instrument: string, version: string, key?: string, clef?: string, variant?: string, part?: string, watermark?: boolean, watermarkPosition?: 'left' | 'right'): Promise<{ success: boolean; path?: string; error?: string }> {
async uploadPdf(scoreId: string, file: File, piece: string, instrument: string, version: string, key?: string, clef?: string, variant?: string, part?: string, watermark?: boolean, watermarkPosition?: 'left' | 'right', combined?: string): Promise<{ success: boolean; path?: string; error?: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('piece', piece);
@@ -160,6 +160,7 @@ export const apiService = {
if (part) formData.append('part', part);
if (watermark) formData.append('watermark', 'true');
if (watermarkPosition) formData.append('watermarkPosition', watermarkPosition);
if (combined) formData.append('combined', combined);
const response = await api.post(`/admin/scores/${scoreId}/upload`, formData, {
headers: {
@@ -0,0 +1,7 @@
<script lang="ts">
let { message = '', class: className = '' }: { message?: string; class?: string } = $props();
</script>
{#if message}
<div class="alert-error {className}" role="alert">{message}</div>
{/if}
@@ -0,0 +1,52 @@
<script lang="ts">
let {
href,
label = 'Retour',
variant = 'pill',
class: className = ''
}: {
href: string;
label?: string;
variant?: 'pill' | 'icon' | 'ghost';
class?: string;
} = $props();
</script>
{#if variant === 'icon'}
<a
href={href}
aria-label={label}
title={label}
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-white text-ohmj-primary shadow-sm transition-colors hover:border-ohmj-primary hover:bg-ohmj-light {className}"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12H5m7-7-7 7 7 7" />
</svg>
</a>
{:else if variant === 'ghost'}
<a
href={href}
class="group inline-flex items-center gap-1 text-sm font-medium text-ohmj-primary {className}"
>
<svg
class="h-4 w-4 transition-transform group-hover:-translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12H5m7-7-7 7 7 7" />
</svg>
<span class="group-hover:underline">{label}</span>
</a>
{:else}
<a
href={href}
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3.5 py-1.5 text-sm font-medium text-ohmj-primary shadow-sm transition-all hover:-translate-x-0.5 hover:border-ohmj-primary hover:bg-ohmj-light {className}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12H5m7-7-7 7 7 7" />
</svg>
{label}
</a>
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
import { getInstrumentIcon } from '$lib/instruments';
let {
code,
title = '',
size = 'w-12 h-12'
}: { code: string; title?: string; size?: string } = $props();
const icon = $derived(getInstrumentIcon(code));
</script>
{#if icon}
<img src={icon} alt={title} class="{size} mx-auto object-contain" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="{size} mx-auto text-ohmj-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.6"
aria-hidden="true"
>
{#if code === 'dir'}
<path stroke-linecap="round" stroke-linejoin="round" d="m3 21 9-9M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8 19 13M17.8 6.2 19 5" />
{:else if code === 'pia'}
<rect x="3" y="4" width="18" height="16" rx="2" />
<path stroke-linecap="round" d="M8 4v7M12 4v7M16 4v7M3 11h18" />
{:else if code === 'sup' || code === 'par'}
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2v6h6M8 13h8M8 17h5" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
{/if}
</svg>
{/if}
+25 -21
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy, tick } from 'svelte';
import * as pdfjsLib from 'pdfjs-dist';
import { api } from '$lib/api';
import Spinner from '$lib/components/Spinner.svelte';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
@@ -189,29 +190,30 @@
</div>
</div>
<div class="flex-1 overflow-auto bg-gray-800 flex items-start justify-center p-2 md:p-4">
<div class="flex flex-1 items-start justify-center overflow-auto bg-gray-800 p-2 md: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 class="flex flex-col items-center text-white">
<Spinner light />
<p class="mt-4 text-sm text-gray-300">Chargement du PDF...</p>
</div>
{:else if error}
<div class="text-red-400 text-center">
<p class="text-xl mb-2">⚠️</p>
<div class="text-center text-red-400">
<svg class="mx-auto mb-2 h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<p>{error}</p>
</div>
{:else}
<canvas bind:this={canvas} class="shadow-2xl max-w-full h-auto"></canvas>
<canvas bind:this={canvas} class="h-auto max-w-full shadow-2xl"></canvas>
{/if}
</div>
<div class="bg-ohmj-dark text-white px-4 py-3 flex items-center justify-center gap-4 border-t border-gray-700">
<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
<div class="flex items-center justify-center gap-4 border-t border-gray-700 bg-ohmj-dark px-4 py-3 text-white">
<button type="button" onclick={prevPage} disabled={currentPage <= 1} class="btn bg-gray-700 px-3 py-2 text-white hover:bg-gray-600">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Précédent
</button>
<div class="flex gap-1">
@@ -219,8 +221,11 @@
{@const pageNum = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i}
{#if pageNum <= totalPages}
<button
type="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'}"
class="h-8 w-8 rounded transition-colors {currentPage === pageNum
? 'bg-ohmj-secondary font-bold text-ohmj-dark'
: 'bg-gray-700 hover:bg-gray-600'}"
>
{pageNum}
</button>
@@ -228,12 +233,11 @@
{/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 type="button" onclick={nextPage} disabled={currentPage >= totalPages} class="btn bg-gray-700 px-3 py-2 text-white hover:bg-gray-600">
Suivant
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
@@ -0,0 +1,9 @@
<script lang="ts">
let { size = 'h-12 w-12', light = false }: { size?: string; light?: boolean } = $props();
</script>
<div
class="animate-spin rounded-full border-b-2 {light ? 'border-white' : 'border-ohmj-primary'} {size}"
role="status"
aria-label="Chargement"
></div>
+30 -24
View File
@@ -3,32 +3,34 @@ export interface InstrumentInfo {
name: string;
icon: string;
defaultKey: string;
/** ASCII basename used to build the PDF filename (see NAMING.md). */
file: string;
}
export const INSTRUMENTS: InstrumentInfo[] = [
{ code: 'dir', name: 'Direction', icon: '🎼', defaultKey: '' },
{ code: 'pic', name: 'Piccolo', icon: '/icons/piccolo.png', defaultKey: 'do' },
{ code: 'flu', name: 'Flûte', icon: '/icons/flute.png', defaultKey: 'do' },
{ code: 'cla', name: 'Clarinette', icon: '/icons/clarinette.png', defaultKey: 'sib' },
{ code: 'clb', name: 'Clarinette Basse', icon: '/icons/clarinette_basse.png', defaultKey: 'sib' },
{ code: 'sax', name: 'Sax Alto', icon: '/icons/sax-alto.png', defaultKey: 'mib' },
{ code: 'sat', name: 'Sax Ténor', icon: '/icons/sax-tenor.png', defaultKey: 'sib' },
{ code: 'sab', name: 'Sax Baryton', icon: '/icons/sax-barython.png', defaultKey: 'mib' },
{ code: 'coa', name: 'Cor Anglais', icon: '/icons/cor-anglais.jpg', defaultKey: 'fa' },
{ code: 'cba', name: 'Contrebasse', icon: '/icons/contrebasse.png', defaultKey: 'sib' },
{ code: 'cor', name: 'Cor', icon: '/icons/cor.png', defaultKey: 'fa' },
{ code: 'trp', name: 'Trompette', icon: '/icons/trumpet.png', defaultKey: 'sib' },
{ code: 'crn', name: 'Cornet', icon: '/icons/cornet.png', defaultKey: 'sib' },
{ code: 'trb', name: 'Trombone', icon: '/icons/tronbonne.png', defaultKey: 'do' },
{ code: 'eup', name: 'Euphonium', icon: '/icons/euphonium.png', defaultKey: 'sib' },
{ code: 'tub', name: 'Tuba', icon: '/icons/tuba.png', defaultKey: 'do' },
{ code: 'htb', name: 'Hautbois', icon: '/icons/hautbois.png', defaultKey: 'do' },
{ code: 'bas', name: 'Basson', icon: '/icons/basson.png', defaultKey: 'do' },
{ code: 'per', name: 'Percussions', icon: '/icons/percussion.png', defaultKey: '' },
{ code: 'pia', name: 'Piano', icon: '🎹', defaultKey: '' },
{ code: 'har', name: 'Harpe', icon: '/icons/harpe.png', defaultKey: '' },
{ code: 'sup', name: 'Parties supplémentaires', icon: '📄', defaultKey: '' },
{ code: 'par', name: 'Parties', icon: '📄', defaultKey: '' }
{ code: 'dir', name: 'Direction', icon: '', defaultKey: '', file: 'direction' },
{ code: 'pic', name: 'Piccolo', icon: '/icons/piccolo.png', defaultKey: 'do', file: 'piccolo' },
{ code: 'flu', name: 'Flûte', icon: '/icons/flute.png', defaultKey: 'do', file: 'flute' },
{ code: 'cla', name: 'Clarinette', icon: '/icons/clarinette.png', defaultKey: 'sib', file: 'clarinette' },
{ code: 'clb', name: 'Clarinette Basse', icon: '/icons/clarinette_basse.png', defaultKey: 'sib', file: 'clarinette_basse' },
{ code: 'sax', name: 'Sax Alto', icon: '/icons/sax-alto.png', defaultKey: 'mib', file: 'saxophone_alto' },
{ code: 'sat', name: 'Sax Ténor', icon: '/icons/sax-tenor.png', defaultKey: 'sib', file: 'saxophone_tenor' },
{ code: 'sab', name: 'Sax Baryton', icon: '/icons/sax-barython.png', defaultKey: 'mib', file: 'saxophone_baryton' },
{ code: 'coa', name: 'Cor Anglais', icon: '/icons/cor-anglais.jpg', defaultKey: 'fa', file: 'cor_anglais' },
{ code: 'cba', name: 'Contrebasse', icon: '/icons/contrebasse.png', defaultKey: 'sib', file: 'contrebasse' },
{ code: 'cor', name: 'Cor', icon: '/icons/cor.png', defaultKey: 'fa', file: 'cor' },
{ code: 'trp', name: 'Trompette', icon: '/icons/trumpet.png', defaultKey: 'sib', file: 'trompette' },
{ code: 'crn', name: 'Cornet', icon: '/icons/cornet.png', defaultKey: 'sib', file: 'cornet' },
{ code: 'trb', name: 'Trombone', icon: '/icons/tronbonne.png', defaultKey: 'do', file: 'trombone' },
{ code: 'eup', name: 'Euphonium', icon: '/icons/euphonium.png', defaultKey: 'sib', file: 'euphonium' },
{ code: 'tub', name: 'Tuba', icon: '/icons/tuba.png', defaultKey: 'do', file: 'tuba' },
{ code: 'htb', name: 'Hautbois', icon: '/icons/hautbois.png', defaultKey: 'do', file: 'hautbois' },
{ code: 'bas', name: 'Basson', icon: '/icons/basson.png', defaultKey: 'do', file: 'basson' },
{ code: 'per', name: 'Percussions', icon: '/icons/percussion.png', defaultKey: '', file: 'percussion' },
{ code: 'pia', name: 'Piano', icon: '', defaultKey: '', file: 'piano' },
{ code: 'har', name: 'Harpe', icon: '/icons/harpe.png', defaultKey: '', file: 'harpe' },
{ code: 'sup', name: 'Parties supplémentaires', icon: '', defaultKey: '', file: '' },
{ code: 'par', name: 'Parties', icon: '', defaultKey: '', file: '' }
];
export const INSTRUMENT_NAMES: Record<string, string> = Object.fromEntries(
@@ -44,7 +46,7 @@ export function getInstrumentName(code: string): string {
}
export function getInstrumentIcon(code: string): string {
return INSTRUMENT_ICONS[code] ?? '🎵';
return INSTRUMENT_ICONS[code] ?? '';
}
export function getInstrument(code: string): InstrumentInfo | undefined {
@@ -54,3 +56,7 @@ export function getInstrument(code: string): InstrumentInfo | undefined {
export function getDefaultKey(code: string): string {
return getInstrument(code)?.defaultKey ?? '';
}
export function getInstrumentFile(code: string): string {
return getInstrument(code)?.file ?? code;
}
+7 -10
View File
@@ -25,10 +25,10 @@
}
</script>
<div class="min-h-screen flex flex-col">
<div class="flex h-dvh 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">
<header class="shrink-0 bg-ohmj-primary text-white shadow-md">
<div class="container mx-auto flex items-center justify-between px-4 py-3">
<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">
@@ -37,14 +37,11 @@
</a>
<nav class="flex items-center gap-4">
{#if userRole === 'admin'}
<a href="/admin" class="text-white hover:text-ohmj-secondary text-sm">
<a href="/admin" class="text-sm text-white hover:text-ohmj-secondary">
Admin
</a>
{/if}
<button
onclick={logout}
class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm transition-colors"
>
<button type="button" onclick={logout} class="btn btn-danger px-3 py-1 text-sm">
Déconnexion
</button>
</nav>
@@ -52,12 +49,12 @@
</header>
{/if}
<main class="flex-1">
<main class="min-h-0 flex-1 overflow-y-auto">
{@render children()}
</main>
{#if isAuthenticated && currentPath !== '/'}
<footer class="bg-ohmj-dark text-gray-400 py-4 text-center text-sm">
<footer class="shrink-0 bg-ohmj-dark py-4 text-center text-sm text-gray-400">
<p>© {new Date().getFullYear()} Harmonie de Montpellier-Jacou</p>
</footer>
{/if}
+2 -3
View File
@@ -2,6 +2,7 @@
import { auth } from '$lib/stores/auth';
import { apiService } from '$lib/api';
import { goto } from '$app/navigation';
import Alert from '$lib/components/Alert.svelte';
let username = '';
let password = '';
@@ -64,9 +65,7 @@
<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>
<Alert message={error} />
{/if}
<div>
+43 -53
View File
@@ -4,6 +4,8 @@
import { auth } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import BackLink from '$lib/components/BackLink.svelte';
import Alert from '$lib/components/Alert.svelte';
let scores: Score[] = $state([]);
let loading = $state(true);
@@ -94,74 +96,63 @@
<title>Admin - OHMJ</title>
</svelte:head>
<div class="container mx-auto px-2 md:px-4 py-8">
<div class="mb-6 flex justify-between items-center">
<h1 class="text-3xl font-bold text-ohmj-primary">Administration</h1>
<a href="/scores" class="text-ohmj-primary hover:underline">← Retour aux partitions</a>
<div class="container mx-auto px-4 py-8">
<header class="card mb-6 flex flex-wrap items-center justify-between gap-3 border-l-4 border-l-ohmj-secondary p-5">
<div>
<h1 class="text-2xl font-bold text-ohmj-primary md:text-3xl">Administration</h1>
<p class="mt-1 text-sm text-gray-500">Gestion du catalogue de partitions</p>
</div>
<BackLink href="/scores" label="Retour aux partitions" />
</header>
{#if !isAdmin}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
Accès refusé. Vous devez être administrateur.
</div>
<Alert message="Accès refusé. Vous devez être administrateur." />
{:else}
<!-- Tabs -->
<div class="mb-6 border-b">
<nav class="flex gap-4">
<button
class="px-4 py-2 border-b-2 border-ohmj-primary text-ohmj-primary font-semibold"
>
Partitions
</button>
</nav>
</div>
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
<Alert message={error} class="mb-4" />
{/if}
<!-- Create new score -->
<div class="mb-8">
<button
type="button"
onclick={() => { showForm = !showForm; if (!showForm) { newName = ''; newCompositor = ''; newPieceCount = 1; newPieceNames = ['']; } }}
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary transition-colors"
class="btn btn-primary"
>
{showForm ? 'Annuler' : '+ Nouvelle partition'}
</button>
{#if showForm}
<div class="mt-4 p-4 bg-gray-50 rounded-lg">
<div class="card mt-4 p-4">
<form onsubmit={(e) => { e.preventDefault(); createScore(); }} class="space-y-4">
<div>
<label for="new-name" class="block text-sm font-medium text-gray-700">Nom</label>
<label for="new-name" class="label">Nom</label>
<input
id="new-name"
type="text"
bind:value={newName}
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
class="input mt-1"
placeholder="Nom du morceau"
/>
</div>
<div>
<label for="new-compositor" class="block text-sm font-medium text-gray-700">Compositeur</label>
<label for="new-compositor" class="label">Compositeur</label>
<input
id="new-compositor"
type="text"
bind:value={newCompositor}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
class="input mt-1"
placeholder="Compositeur (optionnel)"
/>
</div>
<div>
<label for="new-piece-count" class="block text-sm font-medium text-gray-700">Nombre de parties</label>
<label for="new-piece-count" class="label">Nombre de parties</label>
<select
id="new-piece-count"
value={newPieceCount}
onchange={(e) => handlePieceCountChange(parseInt((e.target as HTMLSelectElement).value))}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
class="input mt-1"
>
{#each [1,2,3,4,5,6,7,8,9,10] as num}
<option value={num}>{num}</option>
@@ -170,23 +161,19 @@
</div>
{#if newPieceCount > 1}
<fieldset class="space-y-2">
<legend class="block text-sm font-medium text-gray-700">Nom des parties</legend>
<legend class="label">Nom des parties</legend>
{#each newPieceNames as pieceName, i}
<input
type="text"
bind:value={newPieceNames[i]}
aria-label="Nom de la partie {i + 1}"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
class="input"
placeholder="Partie {i + 1}"
/>
{/each}
</fieldset>
{/if}
<button
type="submit"
disabled={saving}
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary disabled:opacity-50"
>
<button type="submit" disabled={saving} class="btn btn-primary">
{saving ? 'Création...' : 'Créer'}
</button>
</form>
@@ -195,36 +182,39 @@
</div>
<!-- List of scores -->
<div class="bg-white rounded-lg shadow overflow-x-auto">
<div class="card overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap w-16">ID</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Compositeur</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase whitespace-nowrap w-24">Actions</th>
<th class="w-16 whitespace-nowrap px-3 py-3 text-left text-xs font-medium uppercase text-gray-500">ID</th>
<th class="px-3 py-3 text-left text-xs font-medium uppercase text-gray-500">Nom</th>
<th class="px-3 py-3 text-left text-xs font-medium uppercase text-gray-500">Compositeur</th>
<th class="w-24 whitespace-nowrap px-3 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-100">
{#each scores as score}
<tr class="hover:bg-gray-50">
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{score.id}</td>
<td class="px-3 py-4 text-sm text-gray-700">{score.name}</td>
<tr class="transition-colors hover:bg-ohmj-light">
<td class="whitespace-nowrap px-3 py-4 text-sm font-medium text-gray-900">{score.id}</td>
<td class="px-3 py-4 text-sm text-gray-700">
<a href="/admin/{score.id}" class="font-medium text-ohmj-primary hover:text-ohmj-secondary hover:underline">
{score.name}
</a>
</td>
<td class="px-3 py-4 text-sm text-gray-500">{score.compositor || '-'}</td>
<td class="px-3 py-4 whitespace-nowrap text-right text-sm">
<div class="flex justify-end gap-3">
<a
href="/admin/{score.id}"
class="text-blue-600 hover:text-blue-800"
>
<td class="whitespace-nowrap px-3 py-4 text-right text-sm">
<div class="flex justify-end gap-2">
<a href="/admin/{score.id}" class="btn btn-ghost px-2 py-1">
Voir
</a>
<button
type="button"
onclick={() => deleteConfirmId = score.id}
class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-50"
class="rounded-full p-1.5 text-red-600 hover:bg-red-50 hover:text-red-800"
title="Supprimer"
aria-label="Supprimer"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
+186 -90
View File
@@ -4,8 +4,10 @@
import { auth } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { INSTRUMENTS, getInstrument } from '$lib/instruments';
import { INSTRUMENTS, getInstrument, getDefaultKey, getInstrumentFile } from '$lib/instruments';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import BackLink from '$lib/components/BackLink.svelte';
import Alert from '$lib/components/Alert.svelte';
let scoreId = $derived(page.params.id || '');
let scoreName = $state('');
@@ -41,6 +43,24 @@
let uploadVariant = $state('');
let uploadPart = $state('1');
let uploadWatermark = $state(false);
// Combined parts (`sup`/`par`): list of instruments with their key/clef/part
type CombinedPart = { instrument: string; key: string; clef: string; part: string };
const KEYS = ['sib', 'mib', 'fa', 'do', 'ut', 'sol', 're', 'mi', 'si'];
const CLEFS = ['clesol', 'clefa', 'cleut'];
let combinedParts = $state<CombinedPart[]>([
{ instrument: '', key: '', clef: '', part: '1' }
]);
let uploadCombined = $derived(
combinedParts
.filter((p) => p.instrument)
.map((p) =>
[getInstrumentFile(p.instrument), p.key, p.clef, p.part]
.filter((v) => v !== '')
.join('-')
)
.join('+')
);
let uploadWatermarkPosition = $state<'left' | 'right'>('left');
// Edit pieces
@@ -128,13 +148,17 @@
function getExpectedFilename(): string {
if (!uploadInstrument) return '';
// sup/par: the file is named after the combined instruments (see NAMING.md)
if (uploadInstrument === 'sup' || uploadInstrument === 'par') {
const combined = uploadCombined.trim().toLowerCase();
return combined ? `${combined}.pdf` : '';
}
const inst = getInstrument(uploadInstrument);
const instName = inst ? inst.name.toLowerCase().replace(/ /g, '_') : uploadInstrument;
const base = inst?.file || uploadInstrument;
const key = uploadKey || inst?.defaultKey || '';
const keyStr = key ? `_${key}` : '';
const clef = uploadClef ? `_${uploadClef}` : '';
const variant = uploadVariant ? `_${uploadVariant}` : '';
return `${instName}${variant}${keyStr}${clef}_${uploadPart}.pdf`;
const parts = [base, key, uploadVariant, uploadClef].filter((v) => v !== '');
parts.push(uploadPart);
return `${parts.join('-')}.pdf`;
}
let expectedFilename = $derived(getExpectedFilename());
@@ -195,7 +219,8 @@
uploadVariant,
uploadPart,
uploadWatermark,
uploadWatermarkPosition
uploadWatermarkPosition,
uploadCombined
);
if (result.success) {
uploadSuccess = 'Fichier uploadé avec succès';
@@ -269,16 +294,14 @@
}
</script>
<div class="max-w-6xl mx-auto p-6">
<div class="container mx-auto px-4 py-8">
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<span class="text-2xl font-bold text-gray-400">№ {scoreId}</span>
<a href="/admin" class="text-ohmj-primary hover:underline">← Retour à l'admin</a>
</div>
<BackLink href="/admin" label="Retour à l'admin" class="mb-4" />
<!-- Info Card -->
<div class="bg-white rounded-xl shadow-md border border-gray-200 overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<div class="card mb-6 overflow-hidden">
<div class="flex items-center gap-3 border-b border-gray-200 bg-gray-50 px-6 py-4">
<span class="badge bg-ohmj-secondary/20 font-semibold text-ohmj-primary">№ {scoreId}</span>
<h2 class="text-lg font-semibold text-ohmj-primary">Informations de la partition</h2>
</div>
<div class="p-4">
@@ -358,11 +381,14 @@
</button>
{:else}
{#if scoreRessource}
<button type="button" class="flex-1 text-left text-sm text-blue-600 cursor-pointer hover:text-blue-800 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
🔗 {scoreRessource}
<button type="button" class="flex flex-1 items-center gap-1.5 px-3 py-2 text-left text-sm text-ohmj-primary hover:underline" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5M10 14 20 4m0 0h-5m5 0v5" />
</svg>
{scoreRessource}
</button>
{:else}
<button type="button" class="flex-1 text-left text-sm text-gray-400 cursor-pointer hover:text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
<button type="button" class="flex-1 rounded-lg px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 hover:text-gray-600" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
Aucune ressource
</button>
{/if}
@@ -375,11 +401,11 @@
</div>
<!-- Parties -->
<div class="bg-white rounded-xl shadow-md border border-gray-200 overflow-hidden mb-6">
<div class="card mb-6 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
<h2 class="text-lg font-semibold text-ohmj-primary">Parties ({selectedScorePieceCount})</h2>
{#if !editingPieces}
<button onclick={() => { piecesEdit = [...selectedScorePieces]; editingPieces = true; }} class="text-ohmj-primary hover:text-ohmj-secondary text-sm font-medium">
<button onclick={() => { piecesEdit = [...selectedScorePieces]; editingPieces = true; }} class="btn btn-ghost">
Modifier
</button>
{/if}
@@ -393,7 +419,7 @@
<input
type="text"
bind:value={piece.name}
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
class="input flex-1"
placeholder="Nom de la partie"
/>
<button onclick={() => piecesEdit = piecesEdit.filter((_, idx) => idx !== i)} class="text-red-500 hover:text-red-700 p-2" title="Supprimer">
@@ -401,12 +427,12 @@
</button>
</div>
{/each}
<button onclick={() => piecesEdit = [...piecesEdit, {id: String(piecesEdit.length + 1), name: ''}]} class="text-ohmj-primary hover:text-ohmj-secondary text-sm font-medium">
<button onclick={() => piecesEdit = [...piecesEdit, {id: String(piecesEdit.length + 1), name: ''}]} class="btn btn-ghost">
+ Ajouter une partie
</button>
<div class="flex gap-2 pt-3 border-t">
<button onclick={() => { piecesEdit = [...selectedScorePieces]; editingPieces = false; }} class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">Annuler</button>
<button onclick={savePieces} disabled={savingPieces} class="px-4 py-2 bg-ohmj-primary text-white rounded hover:bg-ohmj-secondary text-sm disabled:opacity-50">
<button onclick={() => { piecesEdit = [...selectedScorePieces]; editingPieces = false; }} class="btn btn-ghost">Annuler</button>
<button onclick={savePieces} disabled={savingPieces} class="btn btn-primary">
{savingPieces ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
@@ -423,18 +449,14 @@
</div>
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
<Alert message={error} class="mb-4" />
{/if}
{#if !isAdmin}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
Accès refusé. Vous devez être administrateur.
</div>
<Alert message="Accès refusé. Vous devez être administrateur." />
{:else}
<!-- Upload PDF -->
<div class="mb-8 p-0 bg-white rounded-xl shadow-md border border-gray-200 overflow-hidden">
<div class="card mb-8 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h2 class="text-lg font-semibold text-ohmj-primary">Ajouter une partition</h2>
</div>
@@ -442,12 +464,12 @@
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label for="up-instrument" class="block text-sm font-medium text-gray-700">Instrument</label>
<label for="up-instrument" class="label">Instrument</label>
<select
id="up-instrument"
bind:value={uploadInstrument}
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
class="input mt-1"
>
<option value="">Sélectionner...</option>
{#each INSTRUMENTS as inst}
@@ -457,26 +479,90 @@
</div>
</div>
{#if uploadInstrument === 'sup' || uploadInstrument === 'par'}
<div>
<span class="label">Parties combinées</span>
<div class="mt-1 space-y-2">
{#each combinedParts as p, i}
<div class="flex flex-wrap items-center gap-2">
<select
bind:value={p.instrument}
onchange={() => (p.key = getDefaultKey(p.instrument))}
class="input w-44"
aria-label="Instrument {i + 1}"
>
<option value="">Instrument…</option>
{#each INSTRUMENTS.filter((x) => x.file) as inst}
<option value={inst.code}>{inst.name}</option>
{/each}
</select>
<select bind:value={p.key} class="input w-28" aria-label="Tonalité {i + 1}">
<option value="">-</option>
{#each KEYS as k}<option value={k}>{k}</option>{/each}
</select>
<select bind:value={p.clef} class="input w-28" aria-label="Clé {i + 1}">
<option value="">-</option>
{#each CLEFS as c}<option value={c}>{c}</option>{/each}
</select>
<input
bind:value={p.part}
class="input w-20"
placeholder="Partie"
aria-label="Partie {i + 1}"
/>
{#if combinedParts.length > 1}
<button
type="button"
onclick={() => (combinedParts = combinedParts.filter((_, k) => k !== i))}
class="rounded-full p-1.5 text-red-500 hover:bg-red-50 hover:text-red-700"
title="Retirer"
aria-label="Retirer l'instrument {i + 1}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
<button
type="button"
onclick={() => (combinedParts = [...combinedParts, { instrument: '', key: '', clef: '', part: '1' }])}
class="btn btn-ghost mt-2 px-2 py-1"
>
+ Ajouter un instrument
</button>
<p class="mt-1 text-xs text-gray-400">
Chaque instrument avec sa tonalité/clé/partie, reliés par « + » dans le nom
(ex. <code>contrebasse-sib-clefa-1+baryton-sib-clefa-1</code>).
</p>
</div>
{/if}
<div>
<button
type="button"
onclick={() => { showAdvanced = !showAdvanced; if (showAdvanced && !uploadKey) { uploadKey = INSTRUMENTS.find(i => i.code === uploadInstrument)?.defaultKey || ''; } }}
class="text-sm text-gray-600 hover:text-gray-800"
class="btn btn-ghost"
>
{showAdvanced ? '▼ Masquer' : '▶ Options avancées'}
<svg class="h-4 w-4 transition-transform {showAdvanced ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
{showAdvanced ? 'Masquer les options' : 'Options avancées'}
</button>
</div>
{#if showAdvanced}
<div class="grid grid-cols-2 md:grid-cols-7 gap-4 p-3 bg-gray-100 rounded">
<div class="grid grid-cols-2 gap-4 rounded-md border border-gray-200 bg-gray-50 p-3 md:grid-cols-7">
{#if selectedScorePieceCount > 1}
<div>
<label for="up-piece" class="block text-sm font-medium text-gray-700">Pièce</label>
<label for="up-piece" class="label">Pièce</label>
<select
id="up-piece"
bind:value={uploadPiece}
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
class="input mt-1"
>
{#each selectedScorePieces as piece}
<option value={piece.id}>{piece.name}</option>
@@ -485,20 +571,20 @@
</div>
{/if}
<div>
<label for="up-version" class="block text-sm font-medium text-gray-700">Version</label>
<label for="up-version" class="label">Version</label>
<input
id="up-version"
type="number"
min="1"
bind:value={uploadVersion}
required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
class="input mt-1"
placeholder="1"
/>
</div>
<div>
<label for="up-key" class="block text-sm font-medium text-gray-700">Tonalité</label>
<select id="up-key" bind:value={uploadKey} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
<label for="up-key" class="label">Tonalité</label>
<select id="up-key" bind:value={uploadKey} class="input mt-1">
<option value="">-</option>
<option value="sib">Si♭ (B♭)</option>
<option value="mib">Mi♭ (E♭)</option>
@@ -508,8 +594,8 @@
</select>
</div>
<div>
<label for="up-clef" class="block text-sm font-medium text-gray-700">Clé</label>
<select id="up-clef" bind:value={uploadClef} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
<label for="up-clef" class="label">Clé</label>
<select id="up-clef" bind:value={uploadClef} class="input mt-1">
<option value="">-</option>
<option value="clesol">Sol</option>
<option value="clefa">Fa</option>
@@ -517,8 +603,8 @@
</select>
</div>
<div>
<label for="up-part" class="block text-sm font-medium text-gray-700">Partie</label>
<select id="up-part" bind:value={uploadPart} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
<label for="up-part" class="label">Partie</label>
<select id="up-part" bind:value={uploadPart} class="input mt-1">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
@@ -526,19 +612,15 @@
</select>
</div>
<div>
<label for="up-variant" class="block text-sm font-medium text-gray-700">Variante</label>
<select id="up-variant" bind:value={uploadVariant} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
<label for="up-variant" class="label">Variante</label>
<select id="up-variant" bind:value={uploadVariant} class="input mt-1">
<option value="">-</option>
<option value="solo">Solo</option>
<option value="default">Default</option>
</select>
</div>
<div class="flex items-end">
<button
type="button"
onclick={() => showAdvanced = false}
class="text-sm text-gray-500 hover:text-gray-700"
>
<button type="button" onclick={() => showAdvanced = false} class="btn btn-ghost">
Masquer
</button>
</div>
@@ -546,7 +628,7 @@
{/if}
<div>
<label for="file-input" class="block text-sm font-medium text-gray-700">Fichier PDF</label>
<label for="file-input" class="label">Fichier PDF</label>
<div class="flex items-center gap-3 mt-1">
<label class="relative inline-flex items-center cursor-pointer whitespace-nowrap">
<input type="checkbox" bind:checked={uploadWatermark} class="sr-only peer" />
@@ -556,7 +638,7 @@
{#if uploadWatermark}
<select
bind:value={uploadWatermarkPosition}
class="text-sm border border-gray-300 rounded px-2 py-1"
class="input w-auto"
>
<option value="left">Gauche</option>
<option value="right">Droite</option>
@@ -576,15 +658,17 @@
<p class="text-sm text-gray-500">Nom attendu: <code class="bg-gray-100 px-1 rounded">{expectedFilename}</code></p>
{/if}
{#if uploadError}
<p class="text-red-600 text-sm">{uploadError}</p>
<Alert message={uploadError} />
{/if}
{#if uploadSuccess}
<p class="text-green-600 text-sm">{uploadSuccess}</p>
<p class="rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
{uploadSuccess}
</p>
{/if}
<button
type="submit"
disabled={uploading}
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary disabled:opacity-50"
class="btn btn-primary"
>
{uploading ? 'Upload...' : 'Uploader'}
</button>
@@ -593,62 +677,74 @@
</div>
<!-- File Tree -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="p-4 border-b bg-gray-50">
<div class="card overflow-hidden">
<div class="border-b bg-gray-50 p-4">
<h2 class="text-lg font-semibold text-ohmj-primary">Fichiers</h2>
</div>
<div class="p-4">
{#if files.length === 0}
<p class="text-gray-500 text-center py-8">Aucun fichier uploadé</p>
<p class="py-8 text-center text-gray-500">Aucun fichier uploadé</p>
{:else}
<div class="font-mono text-sm">
<div class="space-y-3 text-sm">
{#each files as piece}
<div class="mb-2">
<span class="text-gray-700 font-semibold">📁 {piece.name}/</span>
<div>
<div class="flex items-center gap-1.5 font-medium text-gray-700">
<svg class="h-4 w-4 text-ohmj-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
{piece.name}/
</div>
{#if piece.children}
<div class="ml-2 mt-1.5 space-y-2 border-l border-gray-200 pl-3">
{#each piece.children as instrument}
<div class="ml-4">
<span class="text-gray-600">├─ 📁 {instrument.name}/</span>
<div>
<div class="flex items-center gap-1.5 text-gray-600">
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
{instrument.name}/
</div>
{#if instrument.children}
<div class="ml-2 mt-1.5 space-y-2 border-l border-gray-200 pl-3">
{#each instrument.children as version}
<div class="ml-4">
<span class="text-gray-600">├─ 📁 {version.name}/</span>
<div>
<div class="flex items-center gap-1.5 text-gray-500">
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
{version.name}/
</div>
{#if version.children}
{#each version.children as file, fileIndex}
{@const isLast = fileIndex === version.children.length - 1}
<div class="ml-4">
{#if isLast}
<span class="text-gray-500">└─ 📄 {file.name}</span>
<ul class="ml-2 mt-1 space-y-0.5 border-l border-gray-200 pl-3">
{#each version.children as file}
<li class="flex items-center gap-1.5">
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2v6h6" />
</svg>
<span class="truncate font-mono text-gray-600">{file.name}</span>
<button
onclick={() => deleteFilePath = file.path}
class="ml-2 text-red-500 hover:text-red-700 p-1 rounded-full hover:bg-red-50"
type="button"
onclick={() => (deleteFilePath = file.path)}
class="ml-1 rounded-full p-1 text-red-500 hover:bg-red-50 hover:text-red-700"
title="Supprimer"
aria-label="Supprimer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
{:else}
<span class="text-gray-500">├─ 📄 {file.name}</span>
<button
onclick={() => deleteFilePath = file.path}
class="ml-2 text-red-500 hover:text-red-700 p-1 rounded-full hover:bg-red-50"
title="Supprimer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/each}
{/if}
</div>
{/each}
+34 -21
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiService, type Score } from '$lib/api';
import Spinner from '$lib/components/Spinner.svelte';
import Alert from '$lib/components/Alert.svelte';
let scores = $state<Score[]>([]);
let loading = $state(true);
@@ -68,22 +70,22 @@
</svelte:head>
<div class="container mx-auto px-4 py-8">
<div class="mb-6">
<h1 class="text-3xl font-bold text-ohmj-primary">Répertoire</h1>
<p class="text-gray-600 mt-2">{scores.length} partition{scores.length !== 1 ? 's' : ''} disponible{scores.length !== 1 ? 's' : ''}</p>
</div>
<header class="card mb-6 border-l-4 border-l-ohmj-secondary p-5">
<h1 class="text-2xl font-bold text-ohmj-primary md:text-3xl">Répertoire</h1>
<p class="mt-1 text-sm text-gray-500">
{scores.length} partition{scores.length !== 1 ? 's' : ''} disponible{scores.length !== 1 ? 's' : ''}
</p>
</header>
{#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 class="flex items-center justify-center py-20">
<Spinner />
</div>
{:else if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
<Alert message={error} />
{:else}
<!-- Filter & Sort Bar -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<div class="card mb-4 p-4">
<div class="flex flex-col md:flex-row gap-4">
<!-- Search Input -->
<div class="flex-1">
@@ -135,40 +137,51 @@
</div>
<!-- Results Table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full">
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="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 w-20">
<th class="w-16 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
№
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Nom
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Compositeur
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-100">
{#each filteredScores as score}
<tr class="hover:bg-ohmj-light transition-colors cursor-pointer" onclick={() => window.location.href = '/scores/' + score.id}>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<tr
class="cursor-pointer transition-colors hover:bg-ohmj-light"
onclick={() => (window.location.href = '/scores/' + score.id)}
>
<td class="whitespace-nowrap px-4 py-4 text-sm font-medium text-gray-900">
{score.id}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-ohmj-primary hover:text-ohmj-secondary">
<td class="px-4 py-4 text-sm font-medium">
<a
href="/scores/{score.id}"
class="text-ohmj-primary hover:text-ohmj-secondary hover:underline"
onclick={(e) => e.stopPropagation()}
>
{score.name}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td class="px-4 py-4 text-sm text-gray-500">
{score.compositor || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if filteredScores.length === 0}
<div class="text-center py-12 text-gray-500">
<div class="py-12 text-center text-gray-500">
Aucune partition ne correspond à votre recherche
</div>
{/if}
+46 -32
View File
@@ -1,7 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { apiService, type Score, type Piece, type Instrument } from '$lib/api';
import { getInstrumentIcon, getInstrumentName } from '$lib/instruments';
import { getInstrumentName } from '$lib/instruments';
import BackLink from '$lib/components/BackLink.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import Alert from '$lib/components/Alert.svelte';
import InstrumentIcon from '$lib/components/InstrumentIcon.svelte';
function isSafeUrl(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
@@ -66,44 +70,56 @@
</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>
<BackLink href="/scores" label="Retour aux partitions" class="mb-4" />
{#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 class="flex items-center justify-center py-20">
<Spinner />
</div>
{:else if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
<Alert message={error} />
{:else}
<div class="mb-6">
<h1 class="text-3xl font-bold text-ohmj-primary">{score?.name || ''}</h1>
<header class="card mb-8 border-l-4 border-l-ohmj-secondary p-5 md:p-6">
<span class="badge bg-ohmj-secondary/20 font-semibold text-ohmj-primary">№ {scoreId}</span>
<h1 class="mt-3 text-2xl font-bold leading-tight text-ohmj-primary md:text-3xl">
{score?.name || ''}
</h1>
{#if score?.compositor}
<p class="text-gray-600 mt-1 text-lg">{score.compositor}</p>
<p class="mt-1.5 flex items-center gap-1.5 text-lg text-gray-600">
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM12 14a7 7 0 0 0-7 7h14a7 7 0 0 0-7-7Z" />
</svg>
{score.compositor}
</p>
{/if}
{#if score?.ressource && isSafeUrl(score.ressource)}
<a
href={score.ressource}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center mt-2 text-ohmj-primary hover:underline"
class="mt-4 inline-flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1.5 text-sm font-medium text-ohmj-primary transition-colors hover:border-ohmj-primary hover:bg-ohmj-light"
>
🔗 Ressource externe
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5M10 14 20 4m0 0h-5m5 0v5" />
</svg>
Ressource externe
</a>
{/if}
<p class="text-sm text-gray-400 mt-1">№ {scoreId}</p>
</div>
</header>
{#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">
<h2 class="mb-4 flex items-center gap-2 text-xl font-semibold text-gray-800">
<span class="h-5 w-1.5 rounded-full bg-ohmj-secondary"></span>
Pièces
</h2>
<div class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-5">
{#each pieces as piece}
<button
type="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'}"
class="card border-2 p-4 text-left transition-all hover:-translate-y-0.5 hover:shadow-md {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>
@@ -113,32 +129,30 @@
{/if}
{#if pieces.length <= 1 || selectedPiece}
<h2 class="text-xl font-semibold text-gray-700 mb-4">Instruments disponibles</h2>
<h2 class="mb-4 flex items-center gap-2 text-xl font-semibold text-gray-800">
<span class="h-5 w-1.5 rounded-full bg-ohmj-secondary"></span>
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">
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{#each instruments as instrument}
{@const code = instrument.id || ''}
{@const title = instrument.title || getInstrumentName(code)}
{@const fileCount = instrument.parts?.[0]?.files?.length || 0}
{@const icon = getInstrumentIcon(code)}
<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"
class="card p-6 text-center transition-all hover:-translate-y-0.5 hover:border-ohmj-primary hover:shadow-md"
>
<div class="text-center">
<div class="text-4xl mb-2">
{#if icon.startsWith('/')}
<img src={icon} alt={title} class="w-12 h-12 mx-auto object-contain" />
{:else}
{icon}
{/if}
<div class="mb-2">
<InstrumentIcon {code} {title} />
</div>
<h3 class="font-semibold text-gray-800">{title}</h3>
<p class="text-sm text-gray-500 mt-1">{fileCount} partie{fileCount !== 1 ? 's' : ''}</p>
</div>
<p class="mt-1 text-sm text-gray-500">
{fileCount} partie{fileCount !== 1 ? 's' : ''}
</p>
</a>
{/each}
</div>
@@ -1,7 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { apiService, type Instrument } from '$lib/api';
import { getInstrumentIcon } from '$lib/instruments';
import BackLink from '$lib/components/BackLink.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import Alert from '$lib/components/Alert.svelte';
import InstrumentIcon from '$lib/components/InstrumentIcon.svelte';
let scoreId = $derived(page.params.id || '');
let pieceId = $derived(page.params.piece || '');
@@ -34,40 +37,31 @@
</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>
<BackLink href="/scores/{scoreId}" label="Retour aux pièces" class="mb-4" />
<h1 class="text-2xl font-bold text-ohmj-primary mb-6">Instruments - Pièce {pieceId}</h1>
<h1 class="mb-6 text-2xl font-bold text-ohmj-primary">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 class="flex items-center justify-center py-20">
<Spinner />
</div>
{:else if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
<Alert message={error} />
{:else}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{#each instruments as instrument}
{@const fileCount = instrument.parts?.[0]?.files?.length || 0}
{@const icon = getInstrumentIcon(instrument.id)}
<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"
class="card p-6 text-center transition-shadow hover:border-ohmj-primary hover:shadow-md"
>
<div class="text-center">
<div class="text-4xl mb-2">
{#if icon.startsWith('/')}
<img src={icon} alt={instrument.title} class="w-12 h-12 mx-auto object-contain" />
{:else}
{icon}
{/if}
<div class="mb-2">
<InstrumentIcon code={instrument.id} title={instrument.title} />
</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>
<p class="mt-1 text-sm text-gray-500">
{fileCount} fichier{fileCount !== 1 ? 's' : ''}
</p>
</a>
{/each}
</div>
@@ -2,6 +2,9 @@
import { page } from '$app/state';
import { apiService, type Part, type PdfFile } from '$lib/api';
import { getInstrumentName } from '$lib/instruments';
import BackLink from '$lib/components/BackLink.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import Alert from '$lib/components/Alert.svelte';
import PdfViewer from '$lib/components/PdfViewer.svelte';
let scoreId = $derived(page.params.id || '');
@@ -12,7 +15,7 @@
let loading = $state(true);
let error = $state('');
let selectedFile = $state('');
let showSidebar = $state(true);
let showSidebar = $state(false);
let pieceName = $state('');
let pieceCount = $state(0);
@@ -83,6 +86,7 @@
function openViewer(path: string) {
selectedFile = path;
showSidebar = false;
}
</script>
@@ -90,80 +94,112 @@
<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}?piece={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">
Partition {scoreId} - {instrumentTitle || getInstrumentName(instrumentCode)}{pieceCount > 1 ? (pieceName ? ' - ' + pieceName : ' - Pièce ' + pieceId) : ''}
<div class="flex h-full flex-col">
<div class="shrink-0 px-4 py-4">
<BackLink href="/scores/{scoreId}?piece={pieceId}" label="Retour aux instruments" class="mb-4" />
<header class="card flex items-center gap-3 border-l-4 border-l-ohmj-secondary px-4 py-3">
<span class="badge shrink-0 bg-ohmj-secondary/20 font-semibold text-ohmj-primary">№ {scoreId}</span>
<h1 class="truncate text-xl font-bold text-ohmj-primary md:text-2xl">
{instrumentTitle || getInstrumentName(instrumentCode)}
</h1>
{#if pieceCount > 1}
<span class="ml-auto hidden shrink-0 text-sm text-gray-500 sm:block">
{pieceName || 'Pièce ' + pieceId}
</span>
{/if}
</header>
</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 class="flex flex-1 items-center justify-center">
<Spinner />
</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>
<Alert message={error} />
</div>
{:else}
<div class="flex-1 flex flex-col overflow-hidden">
{#if !showSidebar}
<div class="bg-gray-100 border-b px-4 py-2 flex items-center gap-3">
<button
onclick={() => showSidebar = true}
class="flex items-center gap-2 px-3 py-1.5 bg-ohmj-primary text-white rounded hover:bg-ohmj-secondary transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<!-- Mobile toolbar -->
<div class="flex items-center gap-3 border-b bg-white px-4 py-2 md:hidden">
<button type="button" onclick={() => (showSidebar = true)} class="btn btn-primary px-3 py-1.5">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
Afficher les parties
Parties
</button>
<span class="text-sm text-gray-500">{selectedFile ? pdfFileName : ''}</span>
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{selectedFile ? pdfFileName : ''}</span>
</div>
<div class="relative flex min-h-0 flex-1 overflow-hidden">
{#if showSidebar}
<button
type="button"
aria-label="Fermer"
class="fixed inset-0 z-30 bg-black/40 md:hidden"
onclick={() => (showSidebar = false)}
></button>
{/if}
<div class="flex-1 flex overflow-hidden">
{#if showSidebar}
<div class="w-64 min-w-[200px] max-w-xs bg-white border-r overflow-y-auto p-4 flex-shrink-0">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-700">Parties disponibles</h2>
<button
onclick={() => showSidebar = false}
class="p-1 hover:bg-gray-100 rounded transition-colors"
title="Masquer"
<aside
class="fixed inset-y-0 left-0 z-40 w-72 overflow-y-auto border-r border-gray-200 bg-white p-4 transition-transform duration-200 md:static md:z-auto md:w-64 md:translate-x-0 md:shrink-0 {showSidebar
? 'translate-x-0'
: '-translate-x-full'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<div class="mb-4 flex items-center justify-between">
<h2 class="flex items-center gap-2 font-semibold text-gray-700">
<span class="h-4 w-1 rounded-full bg-ohmj-secondary"></span>
Parties disponibles
</h2>
<button
type="button"
onclick={() => (showSidebar = false)}
class="rounded p-1 text-gray-400 hover:bg-gray-100 md:hidden"
title="Fermer"
aria-label="Fermer"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{#each parts as part}
<div class="mb-4">
{#if parts.length > 1}
<h3 class="font-medium text-gray-800 mb-2">Version {part.id}</h3>
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-400">
Version {part.id}
</h3>
{/if}
<ul class="space-y-1">
{#each part.files as file}
<li class="flex items-stretch gap-1 rounded overflow-hidden border border-gray-200 hover:border-ohmj-primary transition-colors">
<button
onclick={() => openViewer(file.path)}
class="flex-1 text-left px-3 py-2 bg-white hover:bg-ohmj-light text-sm text-ohmj-primary font-medium"
<li
class="flex items-stretch overflow-hidden rounded-md border border-gray-200 transition-colors hover:border-ohmj-primary"
>
📖 {file.name} {getFileInfo(file)}
<button
type="button"
onclick={() => openViewer(file.path)}
class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-ohmj-primary hover:bg-ohmj-light"
>
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2v6h6" />
</svg>
<span class="flex min-w-0 flex-col">
<span class="truncate text-sm font-medium">{file.name}</span>
{#if getFileInfo(file)}
<span class="truncate text-xs font-normal text-gray-400">{getFileInfo(file)}</span>
{/if}
</span>
</button>
<button
type="button"
onclick={() => downloadFile(file.path)}
class="px-3 py-2 bg-gray-50 hover:bg-gray-100 text-gray-600 hover:text-ohmj-primary transition-colors text-sm"
class="border-l border-gray-100 px-3 text-gray-500 transition-colors hover:bg-gray-100 hover:text-ohmj-primary"
title="Télécharger"
aria-label="Télécharger"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</li>
@@ -174,16 +210,15 @@
{#if parts.length === 0}
<p class="text-gray-500">Aucune partie disponible.</p>
{/if}
</div>
{/if}
</aside>
<div class="flex-1 bg-gray-800 flex flex-col">
<div class="flex min-h-0 flex-1 flex-col bg-gray-800">
{#if selectedFile}
{#key selectedFile}
<PdfViewer pdfUrl={pdfViewerUrl} title={pdfFileName} />
{/key}
{:else}
<div class="h-full flex items-center justify-center text-gray-400">
<div class="flex h-full items-center justify-center p-6 text-center text-gray-400">
<p>Sélectionnez une partition pour la visionner</p>
</div>
{/if}
+15
View File
@@ -2,6 +2,10 @@
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
container: {
center: true,
padding: '1rem'
},
extend: {
colors: {
ohmj: {
@@ -10,6 +14,17 @@ export default {
light: '#f5f5f5',
dark: '#0a1f33'
}
},
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'sans-serif'
]
}
}
},