768 lines
29 KiB
Svelte
768 lines
29 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { apiService } from '$lib/api';
|
|
import { auth } from '$lib/stores/auth';
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
import { INSTRUMENTS, getInstrument, getDefaultKey, getInstrumentFile } from '$lib/instruments';
|
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
import BackLink from '$lib/components/BackLink.svelte';
|
|
import Alert from '$lib/components/Alert.svelte';
|
|
|
|
let scoreId = $derived(page.params.id || '');
|
|
let scoreName = $state('');
|
|
let scoreCompositor = $state('');
|
|
let scoreRessource = $state('');
|
|
let loading = $state(true);
|
|
let error = $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('');
|
|
let saving = $state(false);
|
|
|
|
// Upload form
|
|
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 uploadKey = $state('');
|
|
let uploadClef = $state('');
|
|
let uploadVariant = $state('');
|
|
let uploadPart = $state('1');
|
|
let uploadWatermark = $state(false);
|
|
|
|
// Combined parts (`sup`/`par`): list of instruments with their key/clef/part
|
|
type CombinedPart = { instrument: string; key: string; clef: string; part: string };
|
|
const KEYS = ['sib', 'mib', 'fa', 'do', 'ut', 'sol', 're', 'mi', 'si'];
|
|
const CLEFS = ['clesol', 'clefa', 'cleut'];
|
|
let combinedParts = $state<CombinedPart[]>([
|
|
{ instrument: '', key: '', clef: '', part: '1' }
|
|
]);
|
|
let uploadCombined = $derived(
|
|
combinedParts
|
|
.filter((p) => p.instrument)
|
|
.map((p) =>
|
|
[getInstrumentFile(p.instrument), p.key, p.clef, p.part]
|
|
.filter((v) => v !== '')
|
|
.join('-')
|
|
)
|
|
.join('+')
|
|
);
|
|
let uploadWatermarkPosition = $state<'left' | 'right'>('left');
|
|
|
|
// Edit pieces
|
|
let editingPieces = $state(false);
|
|
let piecesEdit = $state<{id: string; name: string}[]>([]);
|
|
let savingPieces = $state(false);
|
|
|
|
async function savePieces() {
|
|
savingPieces = true;
|
|
try {
|
|
const result = await apiService.updatePieces(scoreId, piecesEdit);
|
|
if (result.success) {
|
|
selectedScorePieces = [...piecesEdit];
|
|
editingPieces = false;
|
|
} else {
|
|
alert('Erreur: ' + result.error);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Erreur lors de la sauvegarde');
|
|
} finally {
|
|
savingPieces = false;
|
|
}
|
|
}
|
|
|
|
// Auto-set default key when instrument changes
|
|
$effect(() => {
|
|
if (uploadInstrument) {
|
|
const inst = getInstrument(uploadInstrument);
|
|
if (inst?.defaultKey) {
|
|
uploadKey = inst.defaultKey;
|
|
}
|
|
}
|
|
});
|
|
|
|
// File tree
|
|
let files: any[] = $state([]);
|
|
|
|
// Delete modal
|
|
let deleteFilePath = $state<string | null>(null);
|
|
let deletingFile = $state(false);
|
|
|
|
async function loadScoreInfo() {
|
|
try {
|
|
const score = await apiService.getScore(scoreId);
|
|
scoreName = score.name;
|
|
scoreCompositor = score.compositor || '';
|
|
scoreRessource = score.ressource || '';
|
|
} catch (err) {
|
|
console.error(err);
|
|
error = 'Partition non trouvée';
|
|
}
|
|
}
|
|
|
|
async function loadFiles() {
|
|
try {
|
|
const result = await apiService.getScoreFiles(scoreId);
|
|
if (result.success) {
|
|
files = result.files;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
async function handleScoreSelect() {
|
|
if (scoreId) {
|
|
try {
|
|
const pieces = await apiService.getPieces(scoreId);
|
|
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 '';
|
|
// sup/par: the file is named after the combined instruments (see NAMING.md)
|
|
if (uploadInstrument === 'sup' || uploadInstrument === 'par') {
|
|
const combined = uploadCombined.trim().toLowerCase();
|
|
return combined ? `${combined}.pdf` : '';
|
|
}
|
|
const inst = getInstrument(uploadInstrument);
|
|
const base = inst?.file || uploadInstrument;
|
|
const key = uploadKey || inst?.defaultKey || '';
|
|
const parts = [base, key, uploadVariant, uploadClef].filter((v) => v !== '');
|
|
parts.push(uploadPart);
|
|
return `${parts.join('-')}.pdf`;
|
|
}
|
|
|
|
let expectedFilename = $derived(getExpectedFilename());
|
|
|
|
onMount(async () => {
|
|
if (userRole !== 'admin') {
|
|
goto('/scores');
|
|
return;
|
|
}
|
|
|
|
await loadScoreInfo();
|
|
await handleScoreSelect();
|
|
await loadFiles();
|
|
loading = false;
|
|
});
|
|
|
|
function fileExists(tree: any[], targetPath: string): boolean {
|
|
const parts = targetPath.split('/');
|
|
let current = tree;
|
|
|
|
for (const part of parts) {
|
|
const found = current.find((item: any) => item.name === part);
|
|
if (!found) return false;
|
|
if (found.type === 'file') {
|
|
return true; // Found the file
|
|
}
|
|
current = found.children || [];
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function uploadPdf() {
|
|
if (!uploadFile || !uploadInstrument) {
|
|
uploadError = 'Veuillez remplir tous les champs';
|
|
return;
|
|
}
|
|
|
|
uploadError = '';
|
|
uploadSuccess = '';
|
|
|
|
// Check if file already exists
|
|
const targetPath = `${uploadPiece}/${uploadInstrument}/${uploadVersion}/${expectedFilename}`;
|
|
if (fileExists(files, targetPath)) {
|
|
uploadError = `Le fichier existe déjà. Supprimez-le d'abord pour le remplacer.`;
|
|
return;
|
|
}
|
|
|
|
uploading = true;
|
|
try {
|
|
const result = await apiService.uploadPdf(
|
|
scoreId,
|
|
uploadFile,
|
|
uploadPiece,
|
|
uploadInstrument,
|
|
uploadVersion,
|
|
uploadKey,
|
|
uploadClef,
|
|
uploadVariant,
|
|
uploadPart,
|
|
uploadWatermark,
|
|
uploadWatermarkPosition,
|
|
uploadCombined
|
|
);
|
|
if (result.success) {
|
|
uploadSuccess = 'Fichier uploadé avec succès';
|
|
uploadFile = null;
|
|
(document.getElementById('file-input') as HTMLInputElement).value = '';
|
|
await loadFiles();
|
|
} else {
|
|
uploadError = result.error || 'Erreur';
|
|
}
|
|
} catch (err) {
|
|
uploadError = 'Erreur lors de l\'upload';
|
|
console.error(err);
|
|
} finally {
|
|
uploading = false;
|
|
}
|
|
}
|
|
|
|
async function deleteFile() {
|
|
if (!deleteFilePath) return;
|
|
deletingFile = true;
|
|
try {
|
|
const result = await apiService.deleteScoreFile(scoreId, deleteFilePath);
|
|
if (result.success) {
|
|
await loadFiles();
|
|
} else {
|
|
error = result.error || 'Erreur lors de la suppression';
|
|
}
|
|
} catch (err) {
|
|
error = 'Erreur lors de la suppression';
|
|
console.error(err);
|
|
} finally {
|
|
deleteFilePath = null;
|
|
deletingFile = false;
|
|
}
|
|
}
|
|
|
|
function startEdit(field: string, value: string) {
|
|
editingField = field;
|
|
editValue = value;
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editingField = null;
|
|
editValue = '';
|
|
}
|
|
|
|
async function saveEdit(field: string) {
|
|
saving = true;
|
|
try {
|
|
if (field === 'name') {
|
|
scoreName = editValue;
|
|
} else if (field === 'compositor') {
|
|
scoreCompositor = editValue;
|
|
} else if (field === 'ressource') {
|
|
scoreRessource = editValue;
|
|
}
|
|
const result = await apiService.updateScore(scoreId, scoreName, scoreCompositor, scoreRessource);
|
|
if (!result.success) {
|
|
error = result.error || 'Erreur lors de la mise à jour';
|
|
await loadScoreInfo();
|
|
}
|
|
} catch (err) {
|
|
error = 'Erreur lors de la mise à jour';
|
|
console.error(err);
|
|
await loadScoreInfo();
|
|
} finally {
|
|
editingField = null;
|
|
editValue = '';
|
|
saving = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="container mx-auto px-4 py-8">
|
|
<div class="mb-6">
|
|
<BackLink href="/admin" label="Retour à l'admin" class="mb-4" />
|
|
|
|
<!-- Info Card -->
|
|
<div class="card mb-6 overflow-hidden">
|
|
<div class="flex items-center gap-3 border-b border-gray-200 bg-gray-50 px-6 py-4">
|
|
<span class="badge bg-ohmj-secondary/20 font-semibold text-ohmj-primary">№ {scoreId}</span>
|
|
<h2 class="text-lg font-semibold text-ohmj-primary">Informations de la partition</h2>
|
|
</div>
|
|
<div class="p-4">
|
|
|
|
<!-- Name -->
|
|
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
|
|
<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}
|
|
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"
|
|
/>
|
|
<button onclick={() => saveEdit('name')} disabled={saving} class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" title="Enregistrer">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
|
</button>
|
|
<button onclick={() => cancelEdit()} disabled={saving} class="text-red-600 hover:text-red-800 p-2 rounded-full hover:bg-red-50" title="Annuler">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
</button>
|
|
{:else}
|
|
<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'}
|
|
</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>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Compositor -->
|
|
<div class="flex items-center gap-4 py-3 border-b border-gray-100">
|
|
<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}
|
|
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"
|
|
/>
|
|
<button onclick={() => saveEdit('compositor')} disabled={saving} class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" title="Enregistrer">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
|
</button>
|
|
<button onclick={() => cancelEdit()} disabled={saving} class="text-red-600 hover:text-red-800 p-2 rounded-full hover:bg-red-50" title="Annuler">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
</button>
|
|
{:else}
|
|
<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'}
|
|
</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>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Ressource -->
|
|
<div class="flex items-center gap-4 py-3">
|
|
<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}
|
|
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"
|
|
/>
|
|
<button onclick={() => saveEdit('ressource')} disabled={saving} class="text-green-600 hover:text-green-800 p-2 rounded-full hover:bg-green-50" title="Enregistrer">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
|
</button>
|
|
<button onclick={() => cancelEdit()} disabled={saving} class="text-red-600 hover:text-red-800 p-2 rounded-full hover:bg-red-50" title="Annuler">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
</button>
|
|
{:else}
|
|
{#if scoreRessource}
|
|
<button type="button" class="flex flex-1 items-center gap-1.5 px-3 py-2 text-left text-sm text-ohmj-primary hover:underline" onclick={() => startEdit('ressource', scoreRessource)} title="Cliquer pour modifier">
|
|
<svg class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5M10 14 20 4m0 0h-5m5 0v5" />
|
|
</svg>
|
|
{scoreRessource}
|
|
</button>
|
|
{:else}
|
|
<button type="button" class="flex-1 rounded-lg px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 hover:text-gray-600" onclick={() => startEdit('ressource', '')} title="Cliquer pour ajouter">
|
|
Aucune ressource
|
|
</button>
|
|
{/if}
|
|
<button onclick={() => startEdit('ressource', scoreRessource)} class="text-gray-400 hover:text-ohmj-primary p-2 rounded-full hover:bg-gray-100" title="Modifier">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Parties -->
|
|
<div class="card mb-6 overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold text-ohmj-primary">Parties ({selectedScorePieceCount})</h2>
|
|
{#if !editingPieces}
|
|
<button onclick={() => { piecesEdit = [...selectedScorePieces]; editingPieces = true; }} class="btn btn-ghost">
|
|
Modifier
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<div class="p-4">
|
|
{#if editingPieces}
|
|
<div class="space-y-3">
|
|
{#each piecesEdit as piece, i}
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm text-gray-500 w-8">#{i + 1}</span>
|
|
<input
|
|
type="text"
|
|
bind:value={piece.name}
|
|
class="input flex-1"
|
|
placeholder="Nom de la partie"
|
|
/>
|
|
<button onclick={() => piecesEdit = piecesEdit.filter((_, idx) => idx !== i)} class="text-red-500 hover:text-red-700 p-2" title="Supprimer">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
<button onclick={() => piecesEdit = [...piecesEdit, {id: String(piecesEdit.length + 1), name: ''}]} class="btn btn-ghost">
|
|
+ Ajouter une partie
|
|
</button>
|
|
<div class="flex gap-2 pt-3 border-t">
|
|
<button onclick={() => { piecesEdit = [...selectedScorePieces]; editingPieces = false; }} class="btn btn-ghost">Annuler</button>
|
|
<button onclick={savePieces} disabled={savingPieces} class="btn btn-primary">
|
|
{savingPieces ? 'Enregistrement...' : 'Enregistrer'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each selectedScorePieces as piece}
|
|
<span class="px-3 py-1 bg-gray-100 rounded-full text-sm">{piece.name || `Partie ${piece.id}`}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if error}
|
|
<Alert message={error} class="mb-4" />
|
|
{/if}
|
|
|
|
{#if !isAdmin}
|
|
<Alert message="Accès refusé. Vous devez être administrateur." />
|
|
{:else}
|
|
<!-- Upload PDF -->
|
|
<div class="card mb-8 overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|
<h2 class="text-lg font-semibold text-ohmj-primary">Ajouter une partition</h2>
|
|
</div>
|
|
<div class="p-6">
|
|
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label for="up-instrument" class="label">Instrument</label>
|
|
<select
|
|
id="up-instrument"
|
|
bind:value={uploadInstrument}
|
|
required
|
|
class="input mt-1"
|
|
>
|
|
<option value="">Sélectionner...</option>
|
|
{#each INSTRUMENTS as inst}
|
|
<option value={inst.code}>{inst.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{#if uploadInstrument === 'sup' || uploadInstrument === 'par'}
|
|
<div>
|
|
<span class="label">Parties combinées</span>
|
|
<div class="mt-1 space-y-2">
|
|
{#each combinedParts as p, i}
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<select
|
|
bind:value={p.instrument}
|
|
onchange={() => (p.key = getDefaultKey(p.instrument))}
|
|
class="input w-44"
|
|
aria-label="Instrument {i + 1}"
|
|
>
|
|
<option value="">Instrument…</option>
|
|
{#each INSTRUMENTS.filter((x) => x.file) as inst}
|
|
<option value={inst.code}>{inst.name}</option>
|
|
{/each}
|
|
</select>
|
|
<select bind:value={p.key} class="input w-28" aria-label="Tonalité {i + 1}">
|
|
<option value="">-</option>
|
|
{#each KEYS as k}<option value={k}>{k}</option>{/each}
|
|
</select>
|
|
<select bind:value={p.clef} class="input w-28" aria-label="Clé {i + 1}">
|
|
<option value="">-</option>
|
|
{#each CLEFS as c}<option value={c}>{c}</option>{/each}
|
|
</select>
|
|
<input
|
|
bind:value={p.part}
|
|
class="input w-20"
|
|
placeholder="Partie"
|
|
aria-label="Partie {i + 1}"
|
|
/>
|
|
{#if combinedParts.length > 1}
|
|
<button
|
|
type="button"
|
|
onclick={() => (combinedParts = combinedParts.filter((_, k) => k !== i))}
|
|
class="rounded-full p-1.5 text-red-500 hover:bg-red-50 hover:text-red-700"
|
|
title="Retirer"
|
|
aria-label="Retirer l'instrument {i + 1}"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => (combinedParts = [...combinedParts, { instrument: '', key: '', clef: '', part: '1' }])}
|
|
class="btn btn-ghost mt-2 px-2 py-1"
|
|
>
|
|
+ Ajouter un instrument
|
|
</button>
|
|
<p class="mt-1 text-xs text-gray-400">
|
|
Chaque instrument avec sa tonalité/clé/partie, reliés par « + » dans le nom
|
|
(ex. <code>contrebasse-sib-clefa-1+baryton-sib-clefa-1</code>).
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onclick={() => { showAdvanced = !showAdvanced; if (showAdvanced && !uploadKey) { uploadKey = INSTRUMENTS.find(i => i.code === uploadInstrument)?.defaultKey || ''; } }}
|
|
class="btn btn-ghost"
|
|
>
|
|
<svg class="h-4 w-4 transition-transform {showAdvanced ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
{showAdvanced ? 'Masquer les options' : 'Options avancées'}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showAdvanced}
|
|
<div class="grid grid-cols-2 gap-4 rounded-md border border-gray-200 bg-gray-50 p-3 md:grid-cols-7">
|
|
{#if selectedScorePieceCount > 1}
|
|
<div>
|
|
<label for="up-piece" class="label">Pièce</label>
|
|
<select
|
|
id="up-piece"
|
|
bind:value={uploadPiece}
|
|
required
|
|
class="input mt-1"
|
|
>
|
|
{#each selectedScorePieces as piece}
|
|
<option value={piece.id}>{piece.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<label for="up-version" class="label">Version</label>
|
|
<input
|
|
id="up-version"
|
|
type="number"
|
|
min="1"
|
|
bind:value={uploadVersion}
|
|
required
|
|
class="input mt-1"
|
|
placeholder="1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label for="up-key" class="label">Tonalité</label>
|
|
<select id="up-key" bind:value={uploadKey} class="input mt-1">
|
|
<option value="">-</option>
|
|
<option value="sib">Si♭ (B♭)</option>
|
|
<option value="mib">Mi♭ (E♭)</option>
|
|
<option value="fa">Fa (F)</option>
|
|
<option value="do">Do (C/Ut)</option>
|
|
<option value="sol">Sol (G)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="up-clef" class="label">Clé</label>
|
|
<select id="up-clef" bind:value={uploadClef} class="input mt-1">
|
|
<option value="">-</option>
|
|
<option value="clesol">Sol</option>
|
|
<option value="clefa">Fa</option>
|
|
<option value="cleut">Ut</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="up-part" class="label">Partie</label>
|
|
<select id="up-part" bind:value={uploadPart} class="input mt-1">
|
|
<option value="1">1</option>
|
|
<option value="2">2</option>
|
|
<option value="3">3</option>
|
|
<option value="4">4</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="up-variant" class="label">Variante</label>
|
|
<select id="up-variant" bind:value={uploadVariant} class="input mt-1">
|
|
<option value="">-</option>
|
|
<option value="solo">Solo</option>
|
|
<option value="default">Default</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-end">
|
|
<button type="button" onclick={() => showAdvanced = false} class="btn btn-ghost">
|
|
Masquer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div>
|
|
<label for="file-input" class="label">Fichier PDF</label>
|
|
<div class="flex items-center gap-3 mt-1">
|
|
<label class="relative inline-flex items-center cursor-pointer whitespace-nowrap">
|
|
<input type="checkbox" bind:checked={uploadWatermark} class="sr-only peer" />
|
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ohmj-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-ohmj-primary"></div>
|
|
<span class="ml-2 text-sm text-gray-700">Ajouter le numéro</span>
|
|
</label>
|
|
{#if uploadWatermark}
|
|
<select
|
|
bind:value={uploadWatermarkPosition}
|
|
class="input w-auto"
|
|
>
|
|
<option value="left">Gauche</option>
|
|
<option value="right">Droite</option>
|
|
</select>
|
|
{/if}
|
|
</div>
|
|
<input
|
|
id="file-input"
|
|
type="file"
|
|
accept="application/pdf"
|
|
onchange={(e) => uploadFile = (e.target as HTMLInputElement).files?.[0] || null}
|
|
required
|
|
class="mt-1 block w-full text-sm"
|
|
/>
|
|
</div>
|
|
{#if expectedFilename}
|
|
<p class="text-sm text-gray-500">Nom attendu: <code class="bg-gray-100 px-1 rounded">{expectedFilename}</code></p>
|
|
{/if}
|
|
{#if uploadError}
|
|
<Alert message={uploadError} />
|
|
{/if}
|
|
{#if uploadSuccess}
|
|
<p class="rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
|
{uploadSuccess}
|
|
</p>
|
|
{/if}
|
|
<button
|
|
type="submit"
|
|
disabled={uploading}
|
|
class="btn btn-primary"
|
|
>
|
|
{uploading ? 'Upload...' : 'Uploader'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File Tree -->
|
|
<div class="card overflow-hidden">
|
|
<div class="border-b bg-gray-50 p-4">
|
|
<h2 class="text-lg font-semibold text-ohmj-primary">Fichiers</h2>
|
|
</div>
|
|
<div class="p-4">
|
|
{#if files.length === 0}
|
|
<p class="py-8 text-center text-gray-500">Aucun fichier uploadé</p>
|
|
{:else}
|
|
<div class="space-y-3 text-sm">
|
|
{#each files as piece}
|
|
<div>
|
|
<div class="flex items-center gap-1.5 font-medium text-gray-700">
|
|
<svg class="h-4 w-4 text-ohmj-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
</svg>
|
|
{piece.name}/
|
|
</div>
|
|
{#if piece.children}
|
|
<div class="ml-2 mt-1.5 space-y-2 border-l border-gray-200 pl-3">
|
|
{#each piece.children as instrument}
|
|
<div>
|
|
<div class="flex items-center gap-1.5 text-gray-600">
|
|
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
</svg>
|
|
{instrument.name}/
|
|
</div>
|
|
{#if instrument.children}
|
|
<div class="ml-2 mt-1.5 space-y-2 border-l border-gray-200 pl-3">
|
|
{#each instrument.children as version}
|
|
<div>
|
|
<div class="flex items-center gap-1.5 text-gray-500">
|
|
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
</svg>
|
|
{version.name}/
|
|
</div>
|
|
{#if version.children}
|
|
<ul class="ml-2 mt-1 space-y-0.5 border-l border-gray-200 pl-3">
|
|
{#each version.children as file}
|
|
<li class="flex items-center gap-1.5">
|
|
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 2v6h6" />
|
|
</svg>
|
|
<span class="truncate font-mono text-gray-600">{file.name}</span>
|
|
<button
|
|
type="button"
|
|
onclick={() => (deleteFilePath = file.path)}
|
|
class="ml-1 rounded-full p-1 text-red-500 hover:bg-red-50 hover:text-red-700"
|
|
title="Supprimer"
|
|
aria-label="Supprimer"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.997-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<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>
|