Files
Classeo/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte
Mathias STRASSER 713e408773
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
2026-04-10 15:24:27 +02:00

373 lines
8.2 KiB
Svelte

<script lang="ts">
const DEFAULT_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
const DEFAULT_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png';
const DEFAULT_HINT = 'PDF, JPEG ou PNG — 10 Mo max par fichier';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
interface UploadedFile {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}
let {
existingFiles = [],
onUpload,
onDelete,
disabled = false,
acceptedTypes = DEFAULT_ACCEPTED_TYPES,
acceptAttr = DEFAULT_ACCEPT_ATTR,
hint = DEFAULT_HINT,
showDelete = true
}: {
existingFiles?: UploadedFile[];
onUpload: (file: File) => Promise<UploadedFile>;
onDelete: (fileId: string) => Promise<void>;
disabled?: boolean;
acceptedTypes?: string[];
acceptAttr?: string;
hint?: string;
showDelete?: boolean;
} = $props();
let files = $state<UploadedFile[]>(existingFiles);
let pendingFiles = $state<{ name: string; size: number }[]>([]);
let error = $state<string | null>(null);
let isDragging = $state(false);
let fileInput: HTMLInputElement;
$effect(() => {
files = existingFiles;
});
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
function getFileIcon(mimeType: string): string {
if (mimeType === 'application/pdf') return '📄';
if (mimeType.startsWith('image/')) return '🖼️';
return '📎';
}
function formatFileType(mimeType: string): string {
if (mimeType === 'application/pdf') return 'PDF';
if (mimeType === 'image/jpeg') return 'JPEG';
if (mimeType === 'image/png') return 'PNG';
if (mimeType.includes('wordprocessingml')) return 'DOCX';
const parts = mimeType.split('/');
return parts[1]?.toUpperCase() ?? mimeType;
}
function validateFile(file: File): string | null {
if (!acceptedTypes.includes(file.type)) {
return `Type de fichier non accepté : ${file.type}.`;
}
if (file.size > MAX_FILE_SIZE) {
return `Le fichier dépasse la taille maximale de 10 Mo (${formatFileSize(file.size)}).`;
}
return null;
}
async function processFiles(fileList: globalThis.FileList) {
error = null;
for (const file of fileList) {
const validationError = validateFile(file);
if (validationError) {
error = validationError;
continue;
}
pendingFiles = [...pendingFiles, { name: file.name, size: file.size }];
try {
const uploaded = await onUpload(file);
files = [...files, uploaded];
} catch {
error = `Erreur lors de l'envoi de "${file.name}".`;
} finally {
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
}
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;
if (!selectedFiles || selectedFiles.length === 0) return;
await processFiles(selectedFiles);
input.value = '';
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragging = true;
}
function handleDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
const related = event.relatedTarget as globalThis.Node | null;
if (related && target.contains(related)) return;
isDragging = false;
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
isDragging = false;
const droppedFiles = event.dataTransfer?.files;
if (!droppedFiles || droppedFiles.length === 0) return;
await processFiles(droppedFiles);
}
async function handleDelete(fileId: string) {
error = null;
try {
await onDelete(fileId);
files = files.filter((f) => f.id !== fileId);
} catch {
error = 'Erreur lors de la suppression du fichier.';
}
}
</script>
<div class="file-upload" class:disabled>
{#if error}
<p class="upload-error" role="alert">{error}</p>
{/if}
{#if files.length > 0 || pendingFiles.length > 0}
<ul class="file-list">
{#each files as file}
<li class="file-item">
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
<span class="file-name">{file.filename}</span>
<span class="file-type">{formatFileType(file.mimeType)}</span>
<span class="file-size">{formatFileSize(file.fileSize)}</span>
{#if !disabled && showDelete}
<button
type="button"
class="file-remove"
onclick={() => handleDelete(file.id)}
title="Supprimer {file.filename}"
aria-label="Supprimer {file.filename}"
>
</button>
{/if}
</li>
{/each}
{#each pendingFiles as pending}
<li class="file-item file-pending">
<span class="file-icon"></span>
<span class="file-name">{pending.name}</span>
<span class="file-size">{formatFileSize(pending.size)}</span>
<span class="file-uploading">Envoi...</span>
</li>
{/each}
</ul>
{/if}
{#if !disabled}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="drop-zone"
class:drop-zone-active={isDragging}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p class="drop-zone-text">
Glissez-déposez vos fichiers ici ou
<button type="button" class="drop-zone-browse" onclick={(e) => { e.stopPropagation(); fileInput.click(); }}>
parcourir
</button>
</p>
<p class="upload-hint">{hint}</p>
</div>
<input
bind:this={fileInput}
type="file"
accept={acceptAttr}
onchange={handleFileSelect}
multiple
class="file-input-hidden"
aria-hidden="true"
tabindex="-1"
/>
{/if}
</div>
<style>
.file-upload {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-upload.disabled {
opacity: 0.6;
}
.upload-error {
margin: 0;
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.file-pending {
opacity: 0.6;
}
.file-icon {
flex-shrink: 0;
}
.file-name {
flex: 1;
color: #374151;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-type {
color: #6b7280;
font-size: 0.6875rem;
font-weight: 500;
padding: 0.0625rem 0.375rem;
background: #f3f4f6;
border-radius: 0.25rem;
flex-shrink: 0;
}
.file-size {
color: #9ca3af;
font-size: 0.75rem;
flex-shrink: 0;
}
.file-uploading {
color: #3b82f6;
font-size: 0.75rem;
flex-shrink: 0;
}
.file-remove {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: 50%;
background: #e5e7eb;
color: #6b7280;
font-size: 0.625rem;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s;
}
.file-remove:hover {
background: #fecaca;
color: #dc2626;
}
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
background: #fafafa;
color: #6b7280;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s;
}
.drop-zone:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.drop-zone-active {
border-color: #3b82f6;
background: #dbeafe;
}
.drop-zone-text {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
.drop-zone-browse {
display: inline;
padding: 0;
border: none;
background: none;
color: #2563eb;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.drop-zone-browse:hover {
color: #1d4ed8;
}
.file-input-hidden {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
}
.upload-hint {
margin: 0;
font-size: 0.75rem;
color: #9ca3af;
}
</style>