[FIX] Uge session of debugging and code coverage.

This commit is contained in:
NADAL Jean-Baptiste
2026-09-14 17:22:18 +02:00
parent e79c865f8c
commit a38fc0bfa9
35 changed files with 2315 additions and 1092 deletions
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=http://localhost:8000
+5 -5
View File
@@ -10,15 +10,15 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Cache static assets (including .mjs)
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# 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";
}
# Force correct MIME type for .mjs files
location ~* \.mjs$ {
add_header Content-Type application/javascript;
# Cache static assets
location ~* \.(js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
+406 -230
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -5,7 +5,9 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test:e2e": "node tests/e2e-smoke.mjs"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
@@ -14,13 +16,16 @@
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"svelte": "^5.0.0",
"svelte-check": "^4.7.6",
"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"
},
"allowScripts": {
"esbuild@0.21.5": true
}
}
+16 -18
View File
@@ -6,7 +6,12 @@ import { get } from 'svelte/store';
const API_BASE_URL_LOCAL = 'http://localhost:8000';
const API_BASE_URL_PROD = 'https://ohmj-api.c.nadal-fr.com';
const API_BASE_URL = import.meta.env.DEV ? API_BASE_URL_LOCAL : API_BASE_URL_PROD;
const API_BASE_URL =
import.meta.env.VITE_API_URL || (import.meta.env.DEV ? API_BASE_URL_LOCAL : API_BASE_URL_PROD);
function encodePath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/');
}
const api = axios.create({
baseURL: API_BASE_URL,
@@ -30,9 +35,13 @@ api.interceptors.response.use(
(error: AxiosError) => {
// Only logout on actual 401 from the server, not network/CORS errors
if (error.response?.status === 401 && !error.message?.includes('Network Error')) {
auth.logout();
if (browser) {
window.location.href = '/';
const url = error.config?.url || '';
console.error('[API 401]', url, error.response?.data);
if (!url.includes('/login')) {
auth.logout();
if (browser) {
window.location.href = '/';
}
}
}
return Promise.reject(error);
@@ -104,26 +113,15 @@ export const apiService = {
},
async downloadPdf(path: string): Promise<Blob> {
const response = await api.get(`/download/${path}`, {
const response = await api.get(`/download/${encodePath(path)}`, {
responseType: 'blob'
});
return response.data;
},
getDownloadUrl(path: string): string {
// Pass token in URL for direct browser access (PDF viewer, iframe, etc.)
// Safe over HTTPS
const authState = get(auth);
const token = authState.token ? encodeURIComponent(authState.token) : '';
return `${API_BASE_URL}/download/${path}?token=${token}`;
},
// New method to download with proper auth header
async downloadFileWithAuth(path: string): Promise<Blob> {
const response = await api.get(`/download/${path}`, {
responseType: 'blob'
});
return response.data;
// Full URL used by the PDF viewer; auth is sent via the axios interceptor
return `${API_BASE_URL}/download/${encodePath(path)}`;
},
async createScore(name: string, compositor: string, pieces: { number: number; name: string }[] = []): Promise<{ success: boolean; score?: any; error?: string }> {
@@ -0,0 +1,75 @@
<script lang="ts">
interface Props {
open: boolean;
title: string;
message?: string;
detail?: string;
confirmLabel?: string;
busy?: boolean;
onconfirm: () => void;
oncancel: () => void;
}
let {
open,
title,
message = '',
detail = '',
confirmLabel = 'Supprimer',
busy = false,
onconfirm,
oncancel
}: Props = $props();
$effect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') oncancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
class="absolute inset-0 w-full h-full bg-black bg-opacity-50 cursor-default"
aria-label="Fermer"
onclick={oncancel}
></button>
<div
class="relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
>
<h3 id="confirm-dialog-title" class="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
{#if message}
<p class="text-gray-600 mb-6">{message}</p>
{/if}
{#if detail}
<p class="text-sm text-gray-500 mb-6 font-mono bg-gray-100 p-2 rounded">{detail}</p>
{/if}
<div class="flex gap-3 justify-end">
<button
type="button"
onclick={oncancel}
disabled={busy}
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
>
Annuler
</button>
<button
type="button"
onclick={onconfirm}
disabled={busy}
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors disabled:opacity-50"
>
{busy ? 'Suppression...' : confirmLabel}
</button>
</div>
</div>
</div>
{/if}
+28 -21
View File
@@ -1,27 +1,20 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import * as pdfjsLib from 'pdfjs-dist';
import { api } from '$lib/api';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
interface Props {
pdfUrl: string;
title?: string;
key?: string;
}
let { pdfUrl, title = 'Partition', key = '' }: Props = $props();
let { pdfUrl, title = 'Partition' }: Props = $props();
// Force re-render when key changes
$effect(() => {
if (key) {
loadPdf();
}
});
let canvas: HTMLCanvasElement;
let canvas = $state<HTMLCanvasElement>();
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
let objectUrl: string | null = null;
let currentPage = $state(1);
let totalPages = $state(0);
let scale = $state(1.2);
@@ -33,29 +26,41 @@
async function loadPdf() {
loading = true;
error = '';
// Clean up any previous document / object URL
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
try {
// pdfUrl already contains the full URL with token
// Use it directly with axios
const response = await api.get(pdfUrl, {
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;
objectUrl = URL.createObjectURL(blob);
pdfDoc = await pdfjsLib.getDocument(objectUrl).promise;
totalPages = pdfDoc.numPages;
currentPage = 1;
// The canvas only exists once `loading` is false: render after mount
loading = false;
await tick();
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;
if (!pdfDoc || !canvas || rendering) return;
rendering = true;
try {
@@ -118,7 +123,6 @@
}
onMount(() => {
loadPdf();
window.addEventListener('keydown', handleKeydown);
});
@@ -127,6 +131,9 @@
if (pdfDoc) {
pdfDoc.destroy();
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
});
$effect(() => {
+56
View File
@@ -0,0 +1,56 @@
export interface InstrumentInfo {
code: string;
name: string;
icon: string;
defaultKey: 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: '' }
];
export const INSTRUMENT_NAMES: Record<string, string> = Object.fromEntries(
INSTRUMENTS.map((i) => [i.code, i.name])
);
export const INSTRUMENT_ICONS: Record<string, string> = Object.fromEntries(
INSTRUMENTS.map((i) => [i.code, i.icon])
);
export function getInstrumentName(code: string): string {
return INSTRUMENT_NAMES[code] ?? code;
}
export function getInstrumentIcon(code: string): string {
return INSTRUMENT_ICONS[code] ?? '🎵';
}
export function getInstrument(code: string): InstrumentInfo | undefined {
return INSTRUMENTS.find((i) => i.code === code);
}
export function getDefaultKey(code: string): string {
return getInstrument(code)?.defaultKey ?? '';
}
+1 -6
View File
@@ -40,7 +40,7 @@ function createAuthStore() {
}
}
const { subscribe, set, update } = writable<AuthState>(initial);
const { subscribe, set } = writable<AuthState>(initial);
return {
subscribe,
@@ -56,11 +56,6 @@ function createAuthStore() {
if (browser) {
localStorage.removeItem('auth');
}
},
getToken: () => {
let token: string | null = null;
update((s) => (token = s.token));
return token;
}
};
}
+12 -31
View File
@@ -2,40 +2,21 @@
import '../app.css';
import { auth } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { page } from '$app/state';
let { children } = $props();
const publicRoutes = ['/'];
let isAuthenticated = false;
let currentPath = '/';
let userRole = '';
let isAuthenticated = $derived(!!$auth.token);
let userRole = $derived($auth.user?.role || '');
let currentPath = $derived(page.url.pathname);
auth.subscribe((state) => {
isAuthenticated = !!state.token;
userRole = state.user?.role || '';
});
page.subscribe(($page) => {
currentPath = $page.url.pathname;
});
onMount(() => {
const unsubscribe = page.subscribe(($page) => {
const path = $page.url.pathname;
const isPublic = publicRoutes.includes(path);
// Get current auth state directly
const authState = get(auth);
const authenticated = !!authState.token;
if (!isPublic && !authenticated) {
goto('/');
}
});
return unsubscribe;
$effect(() => {
const isPublic = publicRoutes.includes(currentPath);
if (!isPublic && !isAuthenticated) {
goto('/');
}
});
function logout() {
@@ -72,7 +53,7 @@
{/if}
<main class="flex-1">
<slot />
{@render children()}
</main>
{#if isAuthenticated && currentPath !== '/'}
+29 -170
View File
@@ -3,119 +3,34 @@
import { apiService, type Score } from '$lib/api';
import { auth } from '$lib/stores/auth';
import { goto } from '$app/navigation';
const INSTRUMENTS = [
{ code: 'dir', name: 'Direction', defaultKey: '' },
{ code: 'pic', name: 'Piccolo', defaultKey: 'do' },
{ code: 'flu', name: 'Flûte', defaultKey: 'do' },
{ code: 'cla', name: 'Clarinette', defaultKey: 'sib' },
{ code: 'clb', name: 'Clarinette Basse', defaultKey: 'sib' },
{ code: 'sax', name: 'Saxophone Alto', defaultKey: 'mib' },
{ code: 'sat', name: 'Saxophone Ténor', defaultKey: 'sib' },
{ code: 'sab', name: 'Saxophone Baryton', defaultKey: 'mib' },
{ code: 'coa', name: 'Cor Anglais', defaultKey: 'fa' },
{ code: 'htb', name: 'Hautbois', defaultKey: 'do' },
{ code: 'bas', name: 'Basson', defaultKey: 'do' },
{ code: 'cor', name: 'Cor', defaultKey: 'fa' },
{ code: 'trp', name: 'Trompette', defaultKey: 'sib' },
{ code: 'crn', name: 'Cornet', defaultKey: 'sib' },
{ code: 'trb', name: 'Trombone', defaultKey: 'do' },
{ code: 'eup', name: 'Euphonium', defaultKey: 'sib' },
{ code: 'tub', name: 'Tuba', defaultKey: 'do' },
{ code: 'cba', name: 'Contrebasse', defaultKey: 'sib' },
{ code: 'per', name: 'Percussions', defaultKey: '' },
{ code: 'pia', name: 'Piano', defaultKey: '' },
{ code: 'har', name: 'Harpe', defaultKey: '' }
];
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let scores: Score[] = $state([]);
let loading = $state(true);
let error = $state('');
let userRole = $state('');
let userRole = $derived($auth.user?.role || '');
let isAdmin = $derived(userRole === 'admin');
// Form for new score
let showForm = $state(false);
let newName = $state('');
let newCompositor = $state('');
let newRessource = $state('');
let newPieceCount = $state(1);
let newPieceNames = $state<string[]>(['']);
let saving = $state(false);
// Upload form
let uploadScoreId = $state('');
let uploadPiece = $state('1');
let uploadInstrument = $state('');
let uploadVersion = $state('1');
let uploadFile: File | null = $state(null);
let uploading = $state(false);
let uploadError = $state('');
let uploadSuccess = $state('');
let selectedScorePieceCount = $state(1);
let selectedScorePieces = $state<{id: string; name: string}[]>([]);
let showAdvanced = $state(false);
let deleteConfirmId = $state<string | null>(null);
let uploadKey = $state('');
let uploadClef = $state('');
let uploadVariant = $state('');
let uploadPart = $state('1');
let uploadWatermark = $state(false);
function handlePieceCountChange(count: number) {
newPieceCount = count;
newPieceNames = Array(count).fill('').map((_, i) => newPieceNames[i] || '');
}
// When score is selected, get piece count
async function handleScoreSelect(e: Event) {
const target = e.target as HTMLSelectElement;
uploadScoreId = target.value;
if (uploadScoreId) {
try {
const pieces = await apiService.getPieces(uploadScoreId);
selectedScorePieceCount = pieces.length;
selectedScorePieces = pieces.map((p: any) => ({
id: String(p.id),
name: p.name || `Partie ${p.id}`
}));
if (selectedScorePieceCount === 1) {
uploadPiece = '1';
}
} catch (err) {
console.error(err);
selectedScorePieceCount = 1;
selectedScorePieces = [];
}
}
}
function getExpectedFilename(): string {
if (!uploadInstrument) return '';
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
const instName = inst ? inst.name.toLowerCase().replace(/ /g, '_') : 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`;
}
let expectedFilename = $derived(getExpectedFilename());
onMount(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
const unsubscribe = auth.subscribe((state) => {
userRole = state.user?.role || '';
});
if (userRole !== 'admin') {
unsubscribe();
goto('/scores');
return;
}
await loadScores();
});
@@ -146,7 +61,6 @@
showForm = false;
newName = '';
newCompositor = '';
newRessource = '';
newPieceCount = 1;
newPieceNames = [''];
await loadScores();
@@ -174,48 +88,6 @@
console.error(err);
}
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
uploadFile = target.files[0];
}
}
async function uploadPdf() {
if (!uploadFile || !uploadInstrument || !uploadScoreId) {
uploadError = 'Veuillez remplir tous les champs';
return;
}
uploading = true;
uploadError = '';
uploadSuccess = '';
try {
const result = await apiService.uploadPdf(
uploadScoreId,
uploadFile,
uploadPiece,
uploadInstrument,
uploadVersion,
uploadKey,
uploadClef,
uploadVariant,
uploadPart,
uploadWatermark
);
if (result.success) {
uploadSuccess = 'Fichier uploadé avec succès!';
uploadFile = null;
} else {
uploadError = result.error || 'Erreur';
}
} catch (err) {
uploadError = 'Erreur lors de l\'upload';
console.error(err);
} finally {
uploading = false;
}
}
</script>
<svelte:head>
@@ -263,8 +135,9 @@
<div class="mt-4 p-4 bg-gray-50 rounded-lg">
<form onsubmit={(e) => { e.preventDefault(); createScore(); }} class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Nom</label>
<label for="new-name" class="block text-sm font-medium text-gray-700">Nom</label>
<input
id="new-name"
type="text"
bind:value={newName}
required
@@ -273,8 +146,9 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Compositeur</label>
<label for="new-compositor" class="block text-sm font-medium text-gray-700">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"
@@ -282,8 +156,9 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Nombre de parties</label>
<label for="new-piece-count" class="block text-sm font-medium text-gray-700">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"
@@ -294,17 +169,18 @@
</select>
</div>
{#if newPieceCount > 1}
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Nom des parties</label>
<fieldset class="space-y-2">
<legend class="block text-sm font-medium text-gray-700">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"
placeholder="Partie {i + 1}"
/>
{/each}
</div>
</fieldset>
{/if}
<button
type="submit"
@@ -320,12 +196,12 @@
<!-- List of scores -->
<div class="bg-white rounded-lg shadow overflow-x-auto">
<table class="w-full min-w-[700px]">
<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 whitespace-nowrap">Nom</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">Compositeur</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>
</tr>
</thead>
@@ -333,8 +209,8 @@
{#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 whitespace-nowrap text-sm text-gray-700">{score.name}</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500">{score.compositor || '-'}</td>
<td class="px-3 py-4 text-sm text-gray-700">{score.name}</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
@@ -361,32 +237,15 @@
</div>
{/if}
{#if deleteConfirmId}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onclick={() => deleteConfirmId = null}>
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Confirmer la suppression</h3>
<p class="text-gray-600 mb-6">
Êtes-vous sûr de vouloir supprimer cette partition ? Cette action est irréversible et supprimera également tous les fichiers associés.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => deleteConfirmId = null}
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Annuler
</button>
<button
onclick={async () => {
const id = deleteConfirmId;
deleteConfirmId = null;
if (id) await deleteScore(id);
}}
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
{/if}
<ConfirmDialog
open={!!deleteConfirmId}
title="Confirmer la suppression"
message="Êtes-vous sûr de vouloir supprimer cette partition ? Cette action est irréversible et supprimera également tous les fichiers associés."
onconfirm={async () => {
const id = deleteConfirmId;
deleteConfirmId = null;
if (id) await deleteScore(id);
}}
oncancel={() => (deleteConfirmId = null)}
/>
</div>
+55 -97
View File
@@ -3,42 +3,23 @@
import { apiService } from '$lib/api';
import { auth } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import { INSTRUMENTS, getInstrument } from '$lib/instruments';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const INSTRUMENTS = [
{ code: 'dir', name: 'Direction', defaultKey: '' },
{ code: 'pic', name: 'Piccolo', defaultKey: 'do' },
{ code: 'flu', name: 'Flûte', defaultKey: 'do' },
{ code: 'cla', name: 'Clarinette', defaultKey: 'sib' },
{ code: 'clb', name: 'Clarinette Basse', defaultKey: 'sib' },
{ code: 'sax', name: 'Saxophone Alto', defaultKey: 'mib' },
{ code: 'sat', name: 'Saxophone Ténor', defaultKey: 'sib' },
{ code: 'sab', name: 'Saxophone Baryton', defaultKey: 'mib' },
{ code: 'coa', name: 'Cor Anglais', defaultKey: 'fa' },
{ code: 'htb', name: 'Hautbois', defaultKey: 'do' },
{ code: 'bas', name: 'Basson', defaultKey: 'do' },
{ code: 'cor', name: 'Cor', defaultKey: 'fa' },
{ code: 'trp', name: 'Trompette', defaultKey: 'sib' },
{ code: 'crn', name: 'Cornet', defaultKey: 'sib' },
{ code: 'trb', name: 'Trombone', defaultKey: 'do' },
{ code: 'eup', name: 'Euphonium', defaultKey: 'sib' },
{ code: 'tub', name: 'Tuba', defaultKey: 'do' },
{ code: 'cba', name: 'Contrebasse', defaultKey: 'sib' },
{ code: 'per', name: 'Percussions', defaultKey: '' },
{ code: 'pia', name: 'Piano', defaultKey: '' },
{ code: 'har', name: 'Harpe', defaultKey: '' },
{ code: 'sup', name: 'Parties supplémentaires', defaultKey: '' }
];
let scoreId = $derived($page.params.id || '');
let scoreId = $derived(page.params.id || '');
let scoreName = $state('');
let scoreCompositor = $state('');
let scoreRessource = $state('');
let loading = $state(true);
let error = $state('');
let userRole = $state('');
let userRole = $derived($auth.user?.role || '');
let isAdmin = $derived(userRole === 'admin');
function focusOnMount(node: HTMLElement) {
node.focus();
}
// Edit mode
let editingField = $state<string | null>(null);
let editValue = $state('');
@@ -88,7 +69,7 @@
// Auto-set default key when instrument changes
$effect(() => {
if (uploadInstrument) {
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
const inst = getInstrument(uploadInstrument);
if (inst?.defaultKey) {
uploadKey = inst.defaultKey;
}
@@ -147,7 +128,7 @@
function getExpectedFilename(): string {
if (!uploadInstrument) return '';
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
const inst = getInstrument(uploadInstrument);
const instName = inst ? inst.name.toLowerCase().replace(/ /g, '_') : uploadInstrument;
const key = uploadKey || inst?.defaultKey || '';
const keyStr = key ? `_${key}` : '';
@@ -159,18 +140,11 @@
let expectedFilename = $derived(getExpectedFilename());
onMount(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
const unsubscribe = auth.subscribe((state) => {
userRole = state.user?.role || '';
});
if (userRole !== 'admin') {
unsubscribe();
goto('/scores');
return;
}
await loadScoreInfo();
await handleScoreSelect();
await loadFiles();
@@ -311,12 +285,12 @@
<!-- Name -->
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Nom</label>
<span class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Nom</span>
{#if editingField === 'name'}
<input
type="text"
bind:value={editValue}
autofocus
use:focusOnMount
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('name'); if (e.key === 'Escape') cancelEdit(); }}
class="flex-1 text-xl font-bold text-ohmj-primary border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
/>
@@ -327,9 +301,9 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
{:else}
<span class="flex-1 text-xl font-bold text-ohmj-primary cursor-pointer hover:text-ohmj-secondary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('name', scoreName)} title="Cliquer pour modifier">
<button type="button" class="flex-1 text-left text-xl font-bold text-ohmj-primary cursor-pointer hover:text-ohmj-secondary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('name', scoreName)} title="Cliquer pour modifier">
{scoreName || 'Sans titre'}
</span>
</button>
<button onclick={() => startEdit('name', scoreName)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
</button>
@@ -338,12 +312,12 @@
<!-- Compositor -->
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Compositeur</label>
<span class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Compositeur</span>
{#if editingField === 'compositor'}
<input
type="text"
bind:value={editValue}
autofocus
use:focusOnMount
placeholder="Compositeur"
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('compositor'); if (e.key === 'Escape') cancelEdit(); }}
class="flex-1 text-lg text-gray-700 border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
@@ -355,9 +329,9 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
{:else}
<span class="flex-1 text-lg text-gray-700 cursor-pointer hover:text-ohmj-primary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('compositor', scoreCompositor)} title="Cliquer pour modifier">
<button type="button" class="flex-1 text-left text-lg text-gray-700 cursor-pointer hover:text-ohmj-primary px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('compositor', scoreCompositor)} title="Cliquer pour modifier">
{scoreCompositor || 'Aucun compositeur'}
</span>
</button>
<button onclick={() => startEdit('compositor', scoreCompositor)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
</button>
@@ -366,12 +340,12 @@
<!-- Ressource -->
<div class="flex items-center gap-4 py-3">
<label class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Ressource</label>
<span class="w-32 text-sm font-semibold text-gray-600 flex-shrink-0">Ressource</span>
{#if editingField === 'ressource'}
<input
type="url"
bind:value={editValue}
autofocus
use:focusOnMount
placeholder="URL (YouTube, Google Drive, etc.)"
onkeydown={(e) => { if (e.key === 'Enter') saveEdit('ressource'); if (e.key === 'Escape') cancelEdit(); }}
class="flex-1 text-sm text-blue-600 border-b-2 border-ohmj-primary bg-transparent px-3 py-2 focus:outline-none"
@@ -384,13 +358,13 @@
</button>
{:else}
{#if scoreRessource}
<span class="flex-1 text-sm text-blue-600 cursor-pointer hover:text-blue-800 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
<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}
</span>
</button>
{:else}
<span class="flex-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-50" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
<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">
Aucune ressource
</span>
</button>
{/if}
<button onclick={() => startEdit('ressource', scoreRessource)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
@@ -468,8 +442,9 @@
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Instrument</label>
<label for="up-instrument" class="block text-sm font-medium text-gray-700">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"
@@ -496,8 +471,9 @@
<div class="grid grid-cols-2 md:grid-cols-7 gap-4 p-3 bg-gray-100 rounded">
{#if selectedScorePieceCount > 1}
<div>
<label class="block text-sm font-medium text-gray-700">Pièce</label>
<label for="up-piece" class="block text-sm font-medium text-gray-700">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"
@@ -509,8 +485,9 @@
</div>
{/if}
<div>
<label class="block text-sm font-medium text-gray-700">Version</label>
<label for="up-version" class="block text-sm font-medium text-gray-700">Version</label>
<input
id="up-version"
type="number"
min="1"
bind:value={uploadVersion}
@@ -520,19 +497,19 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Tonalité</label>
<select 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="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">
<option value="">-</option>
<option value="sib">Si♭ (sib)</option>
<option value="mib">Mi♭ (mif)</option>
<option value="fa">Fa (fa)</option>
<option value="do">Do (ut)</option>
<option value="sol">Sol</option>
<option value="sib">Si♭ (B♭)</option>
<option value="mib">Mi♭ (E♭)</option>
<option value="fa">Fa (F)</option>
<option value="do">Do (C/Ut)</option>
<option value="sol">Sol (G)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Clé</label>
<select 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="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">
<option value="">-</option>
<option value="clesol">Sol</option>
<option value="clefa">Fa</option>
@@ -540,8 +517,8 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Partie</label>
<select 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="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">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
@@ -549,8 +526,8 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Variante</label>
<select 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="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">
<option value="">-</option>
<option value="solo">Solo</option>
<option value="default">Default</option>
@@ -569,7 +546,7 @@
{/if}
<div>
<label class="block text-sm font-medium text-gray-700">Fichier PDF</label>
<label for="file-input" class="block text-sm font-medium text-gray-700">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" />
@@ -682,32 +659,13 @@
{/if}
<!-- Delete Confirmation Modal -->
{#if deleteFilePath}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onclick={() => deleteFilePath = null}>
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Confirmer la suppression</h3>
<p class="text-gray-600 mb-6">
Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.
</p>
<p class="text-sm text-gray-500 mb-6 font-mono bg-gray-100 p-2 rounded">
{deleteFilePath}
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => deleteFilePath = null}
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Annuler
</button>
<button
onclick={deleteFile}
disabled={deletingFile}
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors disabled:opacity-50"
>
{deletingFile ? 'Suppression...' : 'Supprimer'}
</button>
</div>
</div>
</div>
{/if}
<ConfirmDialog
open={!!deleteFilePath}
title="Confirmer la suppression"
message="Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
detail={deleteFilePath ?? ''}
busy={deletingFile}
onconfirm={deleteFile}
oncancel={() => (deleteFilePath = null)}
/>
</div>
+24 -47
View File
@@ -1,70 +1,47 @@
<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';
import { page } from '$app/state';
import { apiService, type Score, type Piece, type Instrument } from '$lib/api';
import { getInstrumentIcon, getInstrumentName } from '$lib/instruments';
const INSTRUMENT_ICONS: Record<string, string> = {
dir: '🎼', pic: '/icons/piccolo.png', flu: '/icons/flute.png', cla: '/icons/clarinette.png', clb: '/icons/clarinette_basse.png',
sax: '/icons/sax-alto.png', sab: '/icons/sax-barython.png', sat: '/icons/sax-tenor.png', coa: '/icons/cor-anglais.jpg', cba: '/icons/contrebasse.png',
cor: '/icons/cor.png', trp: '/icons/trumpet.png', trb: '/icons/tronbonne.png', tub: '/icons/tuba.png', htb: '/icons/hautbois.png',
bas: '/icons/basson.png', per: '/icons/percussion.png', crn: '/icons/cornet.png', eup: '/icons/euphonium.png', har: '/icons/harpe.png',
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 isSafeUrl(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
}
function getInstrumentName(code: string): string {
return INSTRUMENT_NAMES[code] || code;
}
let scoreId = '';
let score: Score | null = null;
let pieces: Piece[] = [];
let instruments: any[] = [];
let scoreId = $derived(page.params.id || '');
let score = $state<Score | null>(null);
let pieces = $state<Piece[]>([]);
let instruments = $state<Instrument[]>([]);
let loading = $state(true);
let error = $state('');
let selectedPiece = $state(1);
page.subscribe(($page) => {
scoreId = $page.params.id || '';
// Read piece from URL param or query string
const pieceParam = $page.params.piece || $page.url.searchParams.get('piece');
$effect(() => {
if (!scoreId) return;
const pieceParam = page.url.searchParams.get('piece');
if (pieceParam) {
selectedPiece = parseInt(pieceParam) || 1;
}
loadScore(scoreId);
});
onMount(async () => {
async function loadScore(id: string) {
loading = true;
error = '';
try {
score = await apiService.getScore(scoreId);
pieces = await apiService.getPieces(scoreId);
// Set default piece to 1 if not in URL
if (pieces.length > 0 && !pieces.find(p => p.id === selectedPiece)) {
score = await apiService.getScore(id);
pieces = await apiService.getPieces(id);
// Default to the first piece if the requested one does not exist
if (pieces.length > 0 && !pieces.find((p) => p.id === selectedPiece)) {
selectedPiece = pieces[0].id;
}
// Load instruments for the selected piece
await loadInstrumentsForPiece(selectedPiece);
instruments = await apiService.getInstruments(id, selectedPiece);
} catch (err) {
error = 'Erreur lors du chargement';
console.error(err);
} finally {
loading = false;
}
});
}
async function loadInstrumentsForPiece(pieceId: number) {
loading = true;
@@ -107,7 +84,7 @@
{#if score?.compositor}
<p class="text-gray-600 mt-1 text-lg">{score.compositor}</p>
{/if}
{#if score?.ressource}
{#if score?.ressource && isSafeUrl(score.ressource)}
<a
href={score.ressource}
target="_blank"
@@ -143,7 +120,7 @@
{: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 code = instrument.id || ''}
{@const title = instrument.title || getInstrumentName(code)}
{@const fileCount = instrument.parts?.[0]?.files?.length || 0}
{@const icon = getInstrumentIcon(code)}
@@ -1,43 +1,32 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { apiService, type Score, type Instrument } from '$lib/api';
import { page } from '$app/state';
import { apiService, type Instrument } from '$lib/api';
import { getInstrumentIcon } from '$lib/instruments';
const INSTRUMENT_ICONS: Record<string, string> = {
dir: '🎼', pic: '/icons/piccolo.png', flu: '/icons/flute.png', cla: '/icons/clarinette.png', clb: '/icons/clarinette_basse.png',
sax: '/icons/sax-alto.png', sab: '/icons/sax-barython.png', sat: '/icons/sax-tenor.png', coa: '/icons/cor-anglais.jpg', cba: '/icons/contrebasse.png',
cor: '/icons/cor.png', trp: '/icons/trumpet.png', trb: '/icons/tronbonne.png', tub: '/icons/tuba.png', htb: '/icons/hautbois.png',
bas: '/icons/basson.png', per: '/icons/percussion.png', crn: '/icons/cornet.png', eup: '/icons/euphonium.png', har: '/icons/harpe.png',
pia: '🎹', sup: '📄', par: '📄'
};
function getInstrumentIcon(code: string): string {
return INSTRUMENT_ICONS[code] || '🎵';
}
let scoreId = '';
let pieceId = '';
let instruments: Instrument[] = $state([]);
let scoreId = $derived(page.params.id || '');
let pieceId = $derived(page.params.piece || '');
let instruments = $state<Instrument[]>([]);
let loading = $state(true);
let error = $state('');
page.subscribe(($page) => {
scoreId = $page.params.id || '';
pieceId = $page.params.piece || '';
$effect(() => {
if (!scoreId) return;
loadInstruments(scoreId, pieceId);
});
onMount(async () => {
async function loadInstruments(id: string, piece: string) {
loading = true;
error = '';
try {
const score = await apiService.getScore(scoreId);
// Filter instruments by piece
instruments = (score.instruments || []).filter(i => i.piece === pieceId);
const score = await apiService.getScore(id);
instruments = (score.instruments || []).filter((i) => i.piece === piece);
} catch (err) {
error = 'Erreur lors du chargement';
console.error(err);
} finally {
loading = false;
}
});
}
</script>
<svelte:head>
@@ -1,41 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { page } from '$app/state';
import { apiService, type Part, type PdfFile } from '$lib/api';
import { getInstrumentName } from '$lib/instruments';
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 scoreId = $derived(page.params.id || '');
let pieceId = $derived(page.params.piece || '');
let instrumentCode = $derived(page.params.instrument || '');
let instrumentTitle = $state('');
let parts = $state<Part[]>([]);
let loading = $state(true);
let error = $state('');
let selectedFile = $state('');
@@ -44,27 +17,29 @@
let pieceCount = $state(0);
let pdfViewerUrl = $derived(selectedFile ? apiService.getDownloadUrl(selectedFile) : '');
let pdfFileName = $derived(selectedFile ? selectedFile.split('/').pop() || 'Partition' : 'Partition');
let pdfFileName = $derived(
selectedFile ? selectedFile.split('/').pop() || 'Partition' : 'Partition'
);
page.subscribe(($page) => {
scoreId = $page.params.id || '';
pieceId = $page.params.piece || '';
instrumentCode = $page.params.instrument || '';
$effect(() => {
if (!scoreId) return;
loadInstrument(scoreId, pieceId, instrumentCode);
});
onMount(async () => {
async function loadInstrument(id: string, piece: string, code: string) {
loading = true;
error = '';
try {
// Get pieces info
const pieces = await apiService.getPieces(scoreId);
const pieces = await apiService.getPieces(id);
pieceCount = pieces.length;
const piece = pieces.find(p => p.id.toString() === pieceId);
if (piece) {
pieceName = piece.name;
const found = pieces.find((p) => p.id.toString() === piece);
if (found) {
pieceName = found.name;
}
const score = await apiService.getScore(scoreId);
const score = await apiService.getScore(id);
const instrument = (score.instruments || []).find(
i => i.id === instrumentCode && i.piece === pieceId
(i) => i.id === code && i.piece === piece
);
if (instrument) {
instrumentTitle = instrument.title;
@@ -79,7 +54,7 @@
} finally {
loading = false;
}
});
}
function getFileInfo(file: PdfFile): string {
const info = [];
@@ -1,83 +0,0 @@
<script lang="ts">
const INSTRUMENT_ICONS: Record<string, string> = {
dir: '🎼',
pic: '/icons/piccolo.png',
flu: '/icons/flute.png',
cla: '/icons/clarinette.png',
clb: '/icons/clarinette_basse.png',
sax: '/icons/sax-alto.png',
sab: '/icons/sax-barython.png',
sat: '/icons/sax-tenor.png',
coa: '/icons/cor-anglais.jpg',
cba: '/icons/contrebasse.png',
cor: '/icons/cor.png',
trp: '/icons/trumpet.png',
trb: '/icons/tronbonne.png',
tub: '/icons/tuba.png',
htb: '/icons/hautbois.png',
bas: '/icons/basson.png',
per: '/icons/percussion.png',
crn: '/icons/cornet.png',
eup: '/icons/euphonium.png',
har: '/icons/harpe.png',
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">
{#if inst.icon.startsWith('/')}
<img src={inst.icon} alt={inst.name} class="w-12 h-12 mx-auto object-contain" />
{:else}
{inst.icon}
{/if}
</div>
<p class="font-semibold">{inst.name}</p>
<p class="text-sm text-gray-400">{inst.code}</p>
</div>
{/each}
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+200
View File
@@ -0,0 +1,200 @@
/**
* End-to-end smoke test (dependency-free) driving the system Chromium via CDP.
*
* Prerequisites:
* - API running: cd api && php -c php-upload.ini -S localhost:8000 router.php
* - Front running: cd partitions && npm run dev
*
* Run: npm run test:e2e
*
* Env overrides: API_URL, FRONT_URL, CHROME_PATH, LOGIN_USER, LOGIN_PASS
*/
import { spawn } from 'node:child_process';
const API = process.env.API_URL || 'http://localhost:8000';
const FRONT = process.env.FRONT_URL || 'http://localhost:5173';
const CHROME = process.env.CHROME_PATH || 'chromium';
const USER = process.env.LOGIN_USER || 'admin';
const PASS = process.env.LOGIN_PASS || 'password';
const CDP_PORT = 9300 + Math.floor(Math.random() * 400);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let passed = 0;
let failed = 0;
function check(name, cond) {
if (cond) {
console.log(`✓ ${name}`);
passed++;
} else {
console.log(`✗ ${name}`);
failed++;
}
}
async function waitForCdp() {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json`)).json();
const page = list.find((t) => t.type === 'page');
if (page) return page.webSocketDebuggerUrl;
} catch {
/* retry */
}
await sleep(250);
}
throw new Error('CDP endpoint not available');
}
const chrome = spawn(
CHROME,
[
`--remote-debugging-port=${CDP_PORT}`,
'--headless=new',
'--no-sandbox',
'--disable-gpu',
'--window-size=1280,900',
`--user-data-dir=/tmp/opencode/e2e-profile-${CDP_PORT}`,
'about:blank'
],
{ stdio: 'ignore' }
);
let ws;
try {
// 1. Verify servers are up
const apiLogin = await fetch(`${API}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS })
}).then((r) => r.json());
check('API login succeeds', apiLogin.success === true && !!apiLogin.token);
const frontStatus = await fetch(FRONT).then((r) => r.status).catch(() => 0);
check('Frontend dev server reachable', frontStatus === 200);
// 2. Connect to Chromium
ws = new WebSocket(await waitForCdp());
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = rej;
});
let id = 0;
const pending = new Map();
const apiResponses = [];
const downloadRequests = [];
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) {
pending.get(m.id).res(m.result);
pending.delete(m.id);
} else if (m.method === 'Network.responseReceived' && m.params.response.url.startsWith(API)) {
apiResponses.push({ status: m.params.response.status, url: m.params.response.url });
} else if (m.method === 'Network.requestWillBeSent' && m.params.request.url.includes('/download/')) {
downloadRequests.push({
url: m.params.request.url,
method: m.params.request.method,
headers: m.params.request.headers
});
}
};
const send = (method, params = {}) =>
new Promise((res) => {
const i = ++id;
pending.set(i, { res });
ws.send(JSON.stringify({ id: i, method, params }));
});
await send('Page.enable');
await send('Runtime.enable');
await send('Network.enable');
// 3. Login through the API then inject the session (same as the app does)
await send('Page.navigate', { url: `${FRONT}/` });
await sleep(2500);
await send('Runtime.evaluate', {
expression: `localStorage.setItem('auth', ${JSON.stringify(JSON.stringify({ token: apiLogin.token, user: apiLogin.user }))})`
});
async function visit(path, expectation) {
apiResponses.length = 0;
await send('Page.navigate', { url: FRONT + path });
await sleep(3500);
const body = await send('Runtime.evaluate', {
expression: 'document.body.innerText',
returnByValue: true
});
const text = body.result.value || '';
const bad = apiResponses.filter((r) => r.status >= 400);
check(`${path}: no API error`, bad.length === 0);
check(`${path}: ${expectation.label}`, expectation.test(text));
const pathname = await send('Runtime.evaluate', {
expression: 'window.location.pathname',
returnByValue: true
});
check(`${path}: stays on page`, pathname.result.value === path);
return text;
}
// 4. Public pages
await visit('/scores', { label: 'renders repertoire', test: (t) => t.includes('Répertoire') });
await visit('/admin', { label: 'renders admin', test: (t) => t.includes('Administration') });
// 5. Pick a real score/instrument through the API and open the viewer
const token = apiLogin.token;
const auth = { Authorization: `Bearer ${token}` };
const scores = await fetch(`${API}/scores`, { headers: auth }).then((r) => r.json());
const candidates = scores.scores || [];
check('API returns at least one score', candidates.length > 0);
// Find a score that actually has an instrument with files (skip empty ones)
let chosen = null;
for (const score of candidates.slice(0, 25)) {
const instr = await fetch(`${API}/scores/${score.id}/instruments`, { headers: auth }).then((r) => r.json());
const instrument = (instr.instruments || []).find((i) => i.parts?.[0]?.files?.length);
if (instrument) {
chosen = { scoreId: score.id, instrument };
break;
}
}
check('API returns a playable score/instrument', !!chosen);
if (chosen) {
await visit(`/scores/${chosen.scoreId}`, {
label: 'renders score detail',
test: (t) => t.length > 0
});
const viewerPath = `/scores/${chosen.scoreId}/${chosen.instrument.piece}/${chosen.instrument.id}`;
downloadRequests.length = 0;
await visit(viewerPath, { label: 'renders instrument', test: (t) => t.length > 0 });
check('Viewer fetches the PDF', downloadRequests.some((r) => r.method === 'GET'));
const getDownloads = downloadRequests.filter((r) => r.method === 'GET');
const hasAuthHeader = getDownloads.every((r) =>
Object.keys(r.headers).some((k) => k.toLowerCase() === 'authorization')
);
check(
'PDF download uses Authorization header',
hasAuthHeader && getDownloads.every((r) => !r.url.includes('token='))
);
await visit(`/admin/${chosen.scoreId}`, {
label: 'renders admin detail',
test: (t) => t.includes('Informations de la partition')
});
}
console.log(`\n=== E2E Summary ===\nPassed: ${passed}\nFailed: ${failed}\nTotal: ${passed + failed}`);
if (failed > 0) {
console.log('\n⚠ Some E2E checks failed!');
process.exitCode = 1;
} else {
console.log('\n✓ All E2E checks passed!');
}
} catch (err) {
console.error('E2E error:', err.message || err);
process.exitCode = 1;
} finally {
if (ws) ws.close();
chrome.kill();
}
-3
View File
@@ -5,8 +5,5 @@ export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173
},
define: {
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL || 'http://localhost:8000')
}
});