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.
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
<script lang="ts">
|
||||
export interface BlockedDate {
|
||||
date: string;
|
||||
reason: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onSelect,
|
||||
min = '',
|
||||
blockedDates = [],
|
||||
disabled = false,
|
||||
id = ''
|
||||
}: {
|
||||
value?: string;
|
||||
onSelect: (date: string) => void;
|
||||
min?: string;
|
||||
blockedDates?: BlockedDate[];
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
const DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] as const;
|
||||
|
||||
const MONTHS = [
|
||||
'Janvier',
|
||||
'Février',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Août',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Décembre'
|
||||
] as const;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
weekend: '#9ca3af',
|
||||
holiday: '#ef4444',
|
||||
vacation: '#3b82f6',
|
||||
pedagogical_day: '#f59e0b',
|
||||
rule_hard: '#dc2626',
|
||||
rule_soft: '#f97316'
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
weekend: 'Weekend',
|
||||
holiday: 'Jour férié',
|
||||
vacation: 'Vacances',
|
||||
pedagogical_day: 'J. pédagogique',
|
||||
rule_hard: 'Règle (bloquant)',
|
||||
rule_soft: 'Règle (avertissement)'
|
||||
};
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
let month = $state(new Date().getMonth());
|
||||
let year = $state(new Date().getFullYear());
|
||||
|
||||
// Sync month/year when value changes externally
|
||||
$effect(() => {
|
||||
if (value) {
|
||||
const d = new Date(value + 'T00:00:00');
|
||||
month = d.getMonth();
|
||||
year = d.getFullYear();
|
||||
}
|
||||
});
|
||||
|
||||
let monthLabel = $derived(`${MONTHS[month]} ${year}`);
|
||||
let daysInMonth = $derived(new Date(year, month + 1, 0).getDate());
|
||||
|
||||
let firstDayOffset = $derived.by(() => {
|
||||
const jsDay = new Date(year, month, 1).getDay();
|
||||
return jsDay === 0 ? 6 : jsDay - 1;
|
||||
});
|
||||
|
||||
// Build a Set for O(1) lookup of blocked dates
|
||||
let blockedDateMap = $derived.by(() => {
|
||||
const map = new Map<string, BlockedDate>();
|
||||
for (const bd of blockedDates) {
|
||||
map.set(bd.date, bd);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
interface DayCell {
|
||||
day: number;
|
||||
dateStr: string;
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
isBlocked: boolean;
|
||||
isWarning: boolean;
|
||||
blockedReason: string;
|
||||
blockedType: string;
|
||||
isBeforeMin: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
let calendarGrid = $derived.by(() => {
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const cells: (DayCell | null)[] = [];
|
||||
|
||||
for (let i = 0; i < firstDayOffset; i++) {
|
||||
cells.push(null);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const dayOfWeek = (firstDayOffset + d - 1) % 7;
|
||||
const isWeekend = dayOfWeek >= 5;
|
||||
|
||||
const blocked = blockedDateMap.get(dateStr);
|
||||
const isSoftRule = blocked?.type === 'rule_soft';
|
||||
const isBlocked = isWeekend || (!!blocked && !isSoftRule);
|
||||
const isBeforeMin = min !== '' && dateStr < min;
|
||||
|
||||
cells.push({
|
||||
day: d,
|
||||
dateStr,
|
||||
isToday: dateStr === todayStr,
|
||||
isWeekend,
|
||||
isBlocked,
|
||||
isWarning: isSoftRule,
|
||||
blockedReason: blocked?.reason ?? (isWeekend ? 'Weekend' : ''),
|
||||
blockedType: blocked?.type ?? (isWeekend ? 'weekend' : ''),
|
||||
isBeforeMin,
|
||||
isSelected: dateStr === value
|
||||
});
|
||||
}
|
||||
|
||||
return cells;
|
||||
});
|
||||
|
||||
// Determine which blocked types are visible this month for the legend
|
||||
let visibleBlockedTypes = $derived.by(() => {
|
||||
const types = new Set<string>();
|
||||
for (const cell of calendarGrid) {
|
||||
if ((cell?.isBlocked || cell?.isWarning) && cell.blockedType) {
|
||||
types.add(cell.blockedType);
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
});
|
||||
|
||||
// Navigation bounds: don't go before current month or beyond 3 months ahead
|
||||
let canGoPrev = $derived.by(() => {
|
||||
const now = new Date();
|
||||
return year > now.getFullYear() || (year === now.getFullYear() && month > now.getMonth());
|
||||
});
|
||||
|
||||
let canGoNext = $derived.by(() => {
|
||||
const limit = new Date();
|
||||
limit.setMonth(limit.getMonth() + 3);
|
||||
return year < limit.getFullYear() || (year === limit.getFullYear() && month < limit.getMonth());
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
if (!canGoPrev) return;
|
||||
if (month === 0) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else {
|
||||
month--;
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (!canGoNext) return;
|
||||
if (month === 11) {
|
||||
month = 0;
|
||||
year++;
|
||||
} else {
|
||||
month++;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDate(dateStr: string) {
|
||||
onSelect(dateStr);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function formatDisplayDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.calendar-date-picker')) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => document.removeEventListener('click', handleClickOutside, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="calendar-date-picker" class:disabled onkeydown={handleKeydown}>
|
||||
{#if id}
|
||||
<input
|
||||
type="date"
|
||||
{id}
|
||||
value={value}
|
||||
{min}
|
||||
onchange={(e) => onSelect((e.target as HTMLInputElement).value)}
|
||||
class="sr-only-input"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="picker-trigger"
|
||||
onclick={() => {
|
||||
if (!disabled) isOpen = !isOpen;
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{#if value}
|
||||
<span class="picker-value">{formatDisplayDate(value)}</span>
|
||||
{:else}
|
||||
<span class="picker-placeholder">Choisir une date</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="calendar-dropdown" role="dialog" aria-label="Calendrier">
|
||||
<div class="calendar-header">
|
||||
<button type="button" class="nav-btn" onclick={prevMonth} disabled={!canGoPrev} aria-label="Mois précédent">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="month-label">{monthLabel}</span>
|
||||
<button type="button" class="nav-btn" onclick={nextMonth} disabled={!canGoNext} aria-label="Mois suivant">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid" role="grid" aria-label="Jours du mois">
|
||||
<div class="calendar-row day-names" role="row">
|
||||
{#each DAYS as day}
|
||||
<div class="day-name" role="columnheader">{day}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each { length: Math.ceil(calendarGrid.length / 7) } as _, weekIndex}
|
||||
<div class="calendar-row" role="row">
|
||||
{#each calendarGrid.slice(weekIndex * 7, weekIndex * 7 + 7) as cell}
|
||||
{#if cell === null}
|
||||
<div class="day-cell empty" role="gridcell"></div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="day-cell"
|
||||
class:today={cell.isToday}
|
||||
class:selected={cell.isSelected}
|
||||
class:weekend={cell.isWeekend}
|
||||
class:blocked={cell.isBlocked}
|
||||
class:warning={cell.isWarning}
|
||||
class:before-min={cell.isBeforeMin}
|
||||
disabled={cell.isBlocked || cell.isBeforeMin}
|
||||
onclick={() => selectDate(cell.dateStr)}
|
||||
title={cell.isBlocked || cell.isWarning
|
||||
? cell.blockedReason
|
||||
: cell.isBeforeMin
|
||||
? 'Date antérieure au minimum autorisé'
|
||||
: ''}
|
||||
role="gridcell"
|
||||
aria-selected={cell.isSelected}
|
||||
aria-disabled={cell.isBlocked || cell.isBeforeMin}
|
||||
>
|
||||
{cell.day}
|
||||
{#if (cell.isBlocked || cell.isWarning) && !cell.isWeekend}
|
||||
<span
|
||||
class="blocked-dot"
|
||||
style="background-color: {TYPE_COLORS[cell.blockedType] || '#9ca3af'}"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if visibleBlockedTypes.length > 0}
|
||||
<div class="calendar-legend">
|
||||
{#each visibleBlockedTypes as type}
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background-color: {TYPE_COLORS[type] || '#9ca3af'}"></span>
|
||||
<span class="legend-label">{TYPE_LABELS[type] || type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sr-only-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendar-date-picker {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-date-picker.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.picker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.picker-trigger:hover:not(:disabled) {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.picker-trigger:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.picker-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.calendar-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.calendar-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.day-names {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.day-cell:not(.empty):not(:disabled):hover {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px #3b82f6;
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-cell.selected:hover {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.day-cell.weekend {
|
||||
color: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.day-cell.blocked:not(.weekend) {
|
||||
color: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.day-cell.warning {
|
||||
color: #9a3412;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.day-cell.before-min {
|
||||
color: #e5e7eb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.blocked-dot {
|
||||
position: absolute;
|
||||
bottom: 0.125rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0.25rem;
|
||||
height: 0.25rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.6875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@
|
||||
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(() => {
|
||||
@@ -52,6 +53,15 @@
|
||||
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}.`;
|
||||
@@ -62,14 +72,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = input.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
async function processFiles(fileList: globalThis.FileList) {
|
||||
error = null;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
for (const file of fileList) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
error = validationError;
|
||||
@@ -87,10 +93,37 @@
|
||||
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;
|
||||
|
||||
@@ -114,6 +147,7 @@
|
||||
<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
|
||||
@@ -140,22 +174,38 @@
|
||||
{/if}
|
||||
|
||||
{#if !disabled}
|
||||
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
<!-- 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>
|
||||
Ajouter un fichier
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
<p class="upload-hint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -216,6 +266,16 @@
|
||||
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;
|
||||
@@ -249,25 +309,51 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: inline-flex;
|
||||
.drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
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;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user