[FIX] Uge session of debugging and code coverage.
This commit is contained in:
@@ -1 +0,0 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Generated
+406
-230
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 !== '/'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user