[WIP] Skeleton of the admin part.
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// Increase upload limits for this script
|
||||||
|
ini_set('upload_max_filesize', '64M');
|
||||||
|
ini_set('post_max_size', '64M');
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||||
|
|
||||||
@@ -31,6 +35,12 @@ $method = $_SERVER['REQUEST_METHOD'];
|
|||||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
$path = trim($uri, '/');
|
$path = trim($uri, '/');
|
||||||
|
|
||||||
|
// Remove 'api/' prefix if present
|
||||||
|
$path = preg_replace('#^api/#', '', $path);
|
||||||
|
|
||||||
|
// Debug: log the path
|
||||||
|
// file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " PATH: $path METHOD: $method\n", FILE_APPEND);
|
||||||
|
|
||||||
// GET /download/:path - Download PDF (BEFORE auth check)
|
// GET /download/:path - Download PDF (BEFORE auth check)
|
||||||
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||||
$filePath = urldecode($matches[1]);
|
$filePath = urldecode($matches[1]);
|
||||||
@@ -68,7 +78,7 @@ if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Route matching
|
// Route matching
|
||||||
if ($path === 'login' && $method === 'POST') {
|
if (($path === 'login' || $path === 'api/login') && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$username = $input['username'] ?? '';
|
$username = $input['username'] ?? '';
|
||||||
$password = $input['password'] ?? '';
|
$password = $input['password'] ?? '';
|
||||||
@@ -162,7 +172,27 @@ if ($path === 'admin/scores' && $method === 'POST') {
|
|||||||
$name = $input['name'] ?? '';
|
$name = $input['name'] ?? '';
|
||||||
$compositor = $input['compositor'] ?? '';
|
$compositor = $input['compositor'] ?? '';
|
||||||
|
|
||||||
if (empty($id) || empty($name)) {
|
// Auto-generate ID if not provided
|
||||||
|
if (empty($id)) {
|
||||||
|
$scores = $scanner->listScores();
|
||||||
|
$maxId = 0;
|
||||||
|
foreach ($scores as $s) {
|
||||||
|
$num = intval($s['id']);
|
||||||
|
if ($num > $maxId) $maxId = $maxId;
|
||||||
|
}
|
||||||
|
// Find highest numeric ID
|
||||||
|
foreach ($scores as $s) {
|
||||||
|
$num = intval($s['id']);
|
||||||
|
if ($num > $maxId) $maxId = $num;
|
||||||
|
}
|
||||||
|
$id = strval($maxId + 1);
|
||||||
|
// Pad with zeros to 3 digits if needed
|
||||||
|
if (strlen($id) < 3) {
|
||||||
|
$id = str_pad($id, 3, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($name)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'ID and name required']);
|
echo json_encode(['error' => 'ID and name required']);
|
||||||
exit;
|
exit;
|
||||||
@@ -227,9 +257,12 @@ if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method ===
|
|||||||
$piece = $_POST['piece'] ?? '1';
|
$piece = $_POST['piece'] ?? '1';
|
||||||
$instrument = $_POST['instrument'] ?? '';
|
$instrument = $_POST['instrument'] ?? '';
|
||||||
$version = $_POST['version'] ?? '1';
|
$version = $_POST['version'] ?? '1';
|
||||||
$filename = $_POST['filename'] ?? '';
|
$key = $_POST['key'] ?? '';
|
||||||
|
$clef = $_POST['clef'] ?? '';
|
||||||
|
$variant = $_POST['variant'] ?? '';
|
||||||
|
$part = $_POST['part'] ?? '1';
|
||||||
|
|
||||||
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $filename);
|
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $key, $clef, $variant, $part);
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
echo json_encode(['success' => true, 'path' => $result['path']]);
|
echo json_encode(['success' => true, 'path' => $result['path']]);
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ class ScoreScanner {
|
|||||||
rmdir($dir);
|
rmdir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $filename): array {
|
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $key = '', string $clef = '', string $variant = '', string $part = '1'): array {
|
||||||
$scoreDir = $this->scoresPath . $scoreId;
|
$scoreDir = $this->scoresPath . $scoreId;
|
||||||
|
|
||||||
if (!is_dir($scoreDir)) {
|
if (!is_dir($scoreDir)) {
|
||||||
@@ -484,18 +484,49 @@ class ScoreScanner {
|
|||||||
// Create directory structure: scoreId/piece/instrument/version
|
// Create directory structure: scoreId/piece/instrument/version
|
||||||
$targetDir = $scoreDir . '/' . $piece . '/' . $instrument . '/' . $version;
|
$targetDir = $scoreDir . '/' . $piece . '/' . $instrument . '/' . $version;
|
||||||
|
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
if (!mkdir($targetDir, 0755, true)) {
|
if (!mkdir($targetDir, 0755, true)) {
|
||||||
return ['success' => false, 'error' => 'Failed to create directory'];
|
return ['success' => false, 'error' => 'Failed to create directory'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine filename
|
|
||||||
if (empty($filename)) {
|
|
||||||
$filename = $file['name'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map instrument code to name
|
||||||
|
$instrumentNames = [
|
||||||
|
'dir' => 'direction', 'pic' => 'piccolo', 'flu' => 'flute', 'cla' => 'clarinette',
|
||||||
|
'clb' => 'clarinette_basse', 'sax' => 'saxophone_alto', 'sat' => 'saxophone_tenor',
|
||||||
|
'sab' => 'saxophone_baryton', 'coa' => 'cor_anglais', 'htb' => 'hautbois',
|
||||||
|
'bas' => 'basson', 'cor' => 'cor', 'trp' => 'trompette', 'crn' => 'cornet',
|
||||||
|
'trb' => 'trombone', 'eup' => 'euphonium', 'tub' => 'tuba', 'cba' => 'contrebasse',
|
||||||
|
'per' => 'percussion', 'pia' => 'piano', 'har' => 'harpe'
|
||||||
|
];
|
||||||
|
|
||||||
|
$instName = $instrumentNames[$instrument] ?? $instrument;
|
||||||
|
|
||||||
|
// Build filename: instrument_variant_key_clef_part.pdf
|
||||||
|
$filenameParts = [$instName];
|
||||||
|
if (!empty($variant)) {
|
||||||
|
$filenameParts[] = $variant;
|
||||||
|
}
|
||||||
|
if (!empty($key)) {
|
||||||
|
$filenameParts[] = $key;
|
||||||
|
}
|
||||||
|
if (!empty($clef)) {
|
||||||
|
$filenameParts[] = $clef;
|
||||||
|
}
|
||||||
|
$filenameParts[] = $part;
|
||||||
|
|
||||||
|
$filename = implode('_', $filenameParts) . '.pdf';
|
||||||
|
|
||||||
$targetPath = $targetDir . '/' . $filename;
|
$targetPath = $targetDir . '/' . $filename;
|
||||||
|
|
||||||
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
|
// Use copy for CLI testing, move_uploaded_file for real uploads
|
||||||
|
if (is_uploaded_file($file['tmp_name'])) {
|
||||||
|
$result = move_uploaded_file($file['tmp_name'], $targetPath);
|
||||||
|
} else {
|
||||||
|
$result = copy($file['tmp_name'], $targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
api/php-upload.ini
Normal file
2
api/php-upload.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
upload_max_filesize = 64M
|
||||||
|
post_max_size = 64M
|
||||||
@@ -112,5 +112,39 @@ export const apiService = {
|
|||||||
token = state.token || '';
|
token = state.token || '';
|
||||||
})();
|
})();
|
||||||
return `${API_BASE_URL}/download/${path}?token=${token}`;
|
return `${API_BASE_URL}/download/${path}?token=${token}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createScore(name: string, compositor: string): Promise<{ success: boolean; score?: any; error?: string }> {
|
||||||
|
const response = await api.post('/admin/scores', { name, compositor });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateScore(id: string, name: string, compositor: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const response = await api.put(`/admin/scores/${id}`, { name, compositor });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteScore(id: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const response = await api.delete(`/admin/scores/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadPdf(scoreId: string, file: File, piece: string, instrument: string, version: string, key?: string, clef?: string, variant?: string, part?: string): Promise<{ success: boolean; path?: string; error?: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('piece', piece);
|
||||||
|
formData.append('instrument', instrument);
|
||||||
|
formData.append('version', version);
|
||||||
|
if (key) formData.append('key', key);
|
||||||
|
if (clef) formData.append('clef', clef);
|
||||||
|
if (variant) formData.append('variant', variant);
|
||||||
|
if (part) formData.append('part', part);
|
||||||
|
|
||||||
|
const response = await api.post(`/admin/scores/${scoreId}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
|
|
||||||
let isAuthenticated = false;
|
let isAuthenticated = false;
|
||||||
let currentPath = '/';
|
let currentPath = '/';
|
||||||
|
let userRole = '';
|
||||||
|
|
||||||
auth.subscribe((state) => {
|
auth.subscribe((state) => {
|
||||||
isAuthenticated = !!state.token;
|
isAuthenticated = !!state.token;
|
||||||
|
userRole = state.user?.role || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
page.subscribe(($page) => {
|
page.subscribe(($page) => {
|
||||||
@@ -48,6 +50,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="flex items-center gap-4">
|
<nav class="flex items-center gap-4">
|
||||||
|
{#if userRole === 'admin'}
|
||||||
|
<a href="/admin" class="text-white hover:text-ohmj-secondary text-sm">
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={logout}
|
onclick={logout}
|
||||||
class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm transition-colors"
|
class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm transition-colors"
|
||||||
|
|||||||
474
partitions/src/routes/admin/+page.svelte
Normal file
474
partitions/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
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: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let scores: Score[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let userRole = $state('');
|
||||||
|
let isAdmin = $derived(userRole === 'admin');
|
||||||
|
|
||||||
|
// Form for new score
|
||||||
|
let showForm = $state(false);
|
||||||
|
let newName = $state('');
|
||||||
|
let newCompositor = $state('');
|
||||||
|
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 showAdvanced = $state(false);
|
||||||
|
let uploadKey = $state('');
|
||||||
|
let uploadClef = $state('');
|
||||||
|
let uploadVariant = $state('');
|
||||||
|
let uploadPart = $state('1');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
if (selectedScorePieceCount === 1) {
|
||||||
|
uploadPiece = '1';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
selectedScorePieceCount = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadScores() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const allScores = await apiService.getScores();
|
||||||
|
// Sort by ID descending (newest first)
|
||||||
|
scores = allScores.sort((a, b) => parseInt(b.id) - parseInt(a.id));
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors du chargement';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createScore() {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const result = await apiService.createScore(newName, newCompositor);
|
||||||
|
if (result.success) {
|
||||||
|
showForm = false;
|
||||||
|
newName = '';
|
||||||
|
newCompositor = '';
|
||||||
|
await loadScores();
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Erreur';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors de la création';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteScore(id: string) {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cette partition?')) return;
|
||||||
|
try {
|
||||||
|
const result = await apiService.deleteScore(id);
|
||||||
|
if (result.success) {
|
||||||
|
await loadScores();
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Erreur';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Erreur lors de la suppression';
|
||||||
|
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
|
||||||
|
);
|
||||||
|
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>
|
||||||
|
<title>Admin - OHMJ</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="mb-6 flex justify-between items-center">
|
||||||
|
<h1 class="text-3xl font-bold text-ohmj-primary">Administration</h1>
|
||||||
|
<a href="/scores" class="text-ohmj-primary hover:underline">← Retour aux partitions</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isAdmin}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||||
|
Accès refusé. Vous devez être administrateur.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mb-6 border-b">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 border-b-2 border-ohmj-primary text-ohmj-primary font-semibold"
|
||||||
|
>
|
||||||
|
Partitions
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Create new score -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<button
|
||||||
|
onclick={() => showForm = !showForm}
|
||||||
|
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{showForm ? 'Annuler' : '+ Nouvelle partition'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showForm}
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newName}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
|
||||||
|
placeholder="Nom du morceau"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Compositeur</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
placeholder="Compositeur (optionnel)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Création...' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload PDF -->
|
||||||
|
<div class="mb-8 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700 mb-4">Uploader un PDF</h2>
|
||||||
|
<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">Score</label>
|
||||||
|
<select
|
||||||
|
value={uploadScoreId}
|
||||||
|
onchange={handleScoreSelect}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{#each scores as score}
|
||||||
|
<option value={score.id}>{score.id} - {score.name.substring(0, 20)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Instrument</label>
|
||||||
|
<select
|
||||||
|
bind:value={uploadInstrument}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{#each INSTRUMENTS as inst}
|
||||||
|
<option value={inst.code}>{inst.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showAdvanced = !showAdvanced;
|
||||||
|
if (showAdvanced && !uploadKey) {
|
||||||
|
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
|
||||||
|
if (inst?.defaultKey) {
|
||||||
|
uploadKey = inst.defaultKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="mt-6 text-sm text-ohmj-primary hover:underline"
|
||||||
|
>
|
||||||
|
{showAdvanced ? '▼ Masquer' : '▶ Options avancées'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAdvanced}
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={uploadPiece}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Version</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={uploadVersion}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="sib">Si♭ (sib)</option>
|
||||||
|
<option value="mib">Mi♭ (mib)</option>
|
||||||
|
<option value="fa">Fa (fa)</option>
|
||||||
|
<option value="do">Do (ut)</option>
|
||||||
|
<option value="sol">Sol</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">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="clesol">Sol</option>
|
||||||
|
<option value="clefa">Fa</option>
|
||||||
|
<option value="cleut">Ut</option>
|
||||||
|
</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">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</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">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="solo">Solo</option>
|
||||||
|
<option value="default">Default (substitut)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => showAdvanced = false}
|
||||||
|
class="mt-1 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Masquer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 rounded border border-blue-200">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>Nom attendu:</strong> <code class="bg-white px-2 py-1 rounded">{expectedFilename || 'Sélectionnez un instrument'}</code>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-blue-600 mt-1">
|
||||||
|
Le fichier sera renommé automatiquement lors de l'upload
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Fichier PDF</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if uploadError}
|
||||||
|
<p class="text-red-600 text-sm">{uploadError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if uploadSuccess}
|
||||||
|
<p class="text-green-600 text-sm">{uploadSuccess}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={uploading}
|
||||||
|
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? 'Upload...' : 'Uploader'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List of scores -->
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Compositeur</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase w-32">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
{#each scores as score}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{score.id}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{score.name}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{score.compositor || '-'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<a
|
||||||
|
href="/scores/{score.id}"
|
||||||
|
class="text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Voir
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={() => deleteScore(score.id)}
|
||||||
|
class="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user