feat: Permettre à l'enseignant de rédiger avec un éditeur riche et joindre des fichiers
Les enseignants avaient besoin de consignes plus claires pour les élèves : le champ description en texte brut ne permettait ni mise en forme ni partage de documents. Cette limitation obligeait à décrire verbalement les ressources au lieu de les joindre directement. L'éditeur WYSIWYG (TipTap) remplace le textarea avec gras, italique, listes et liens. Le contenu HTML est sanitisé côté backend via symfony/html-sanitizer pour prévenir les injections XSS. Les pièces jointes (PDF, JPEG, PNG, max 10 Mo) sont uploadées via une API dédiée avec validation MIME côté domaine et protection path-traversal sur le téléchargement. Les descriptions en texte brut existantes restent lisibles sans migration de données.
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
|
||||
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
@@ -28,6 +30,13 @@
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface HomeworkAttachmentFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface RuleWarning {
|
||||
ruleType: string;
|
||||
message: string;
|
||||
@@ -75,6 +84,10 @@
|
||||
let newDescription = $state('');
|
||||
let newDueDate = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let newPendingFiles = $state<File[]>([]);
|
||||
|
||||
// Attachments
|
||||
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
|
||||
|
||||
// Edit modal
|
||||
let showEditModal = $state(false);
|
||||
@@ -321,6 +334,7 @@
|
||||
newTitle = '';
|
||||
newDescription = '';
|
||||
newDueDate = '';
|
||||
newPendingFiles = [];
|
||||
ruleConformMinDate = '';
|
||||
dueDateError = null;
|
||||
}
|
||||
@@ -382,6 +396,22 @@
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// Upload pending files if any
|
||||
if (newPendingFiles.length > 0) {
|
||||
const created = await response.json().catch(() => null);
|
||||
const homeworkId = created?.id;
|
||||
if (homeworkId) {
|
||||
for (const file of newPendingFiles) {
|
||||
try {
|
||||
await uploadAttachment(homeworkId, file);
|
||||
} catch {
|
||||
// Best effort — file upload failure doesn't block creation
|
||||
}
|
||||
}
|
||||
}
|
||||
newPendingFiles = [];
|
||||
}
|
||||
|
||||
closeCreateModal();
|
||||
showRuleWarningModal = false;
|
||||
ruleWarnings = [];
|
||||
@@ -525,18 +555,63 @@
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
async function uploadAttachment(homeworkId: string, file: File): Promise<HomeworkAttachmentFile> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const formData = new window.FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail ?? 'Erreur lors de l\'envoi du fichier.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<HomeworkAttachmentFile>;
|
||||
}
|
||||
|
||||
async function deleteAttachment(homeworkId: string, attachmentId: string): Promise<void> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments/${attachmentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la suppression du fichier.');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAttachments(homeworkId: string): Promise<HomeworkAttachmentFile[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments`);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
return (data as HomeworkAttachmentFile[]) ?? [];
|
||||
}
|
||||
|
||||
// --- Edit ---
|
||||
function openEditModal(hw: Homework) {
|
||||
async function openEditModal(hw: Homework) {
|
||||
editHomework = hw;
|
||||
editTitle = hw.title;
|
||||
editDescription = hw.description ?? '';
|
||||
editDueDate = hw.dueDate;
|
||||
editAttachments = [];
|
||||
showEditModal = true;
|
||||
|
||||
// Charger les pièces jointes existantes en arrière-plan
|
||||
editAttachments = await fetchAttachments(hw.id);
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
showEditModal = false;
|
||||
editHomework = null;
|
||||
editAttachments = [];
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
@@ -811,7 +886,7 @@
|
||||
</div>
|
||||
|
||||
{#if hw.description}
|
||||
<p class="homework-description">{hw.description}</p>
|
||||
<div class="homework-description">{@html hw.description}</div>
|
||||
{/if}
|
||||
|
||||
{#if hw.status === 'published'}
|
||||
@@ -901,13 +976,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hw-description">Description</label>
|
||||
<textarea
|
||||
id="hw-description"
|
||||
bind:value={newDescription}
|
||||
<label>Description</label>
|
||||
<RichTextEditor
|
||||
content={newDescription}
|
||||
onUpdate={(html) => (newDescription = html)}
|
||||
placeholder="Consignes, pages à lire, liens utiles..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -931,6 +1006,27 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Pièces jointes</label>
|
||||
<FileUpload
|
||||
existingFiles={[]}
|
||||
onUpload={async (file) => {
|
||||
newPendingFiles = [...newPendingFiles, file];
|
||||
const pendingId = `pending-${file.name}-${file.size}`;
|
||||
return { id: pendingId, filename: file.name, fileSize: file.size, mimeType: file.type };
|
||||
}}
|
||||
onDelete={async (fileId) => {
|
||||
newPendingFiles = newPendingFiles.filter(
|
||||
(f) => `pending-${f.name}-${f.size}` !== fileId
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{#if newPendingFiles.length > 0}
|
||||
<small class="form-hint">Les fichiers seront envoyés après la création du devoir.</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
|
||||
Annuler
|
||||
@@ -997,13 +1093,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
bind:value={editDescription}
|
||||
<label>Description</label>
|
||||
<RichTextEditor
|
||||
content={editDescription}
|
||||
onUpdate={(html) => (editDescription = html)}
|
||||
placeholder="Consignes, pages à lire, liens utiles..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -1011,6 +1107,18 @@
|
||||
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
|
||||
</div>
|
||||
|
||||
{#if editHomework}
|
||||
<div class="form-group">
|
||||
<label>Pièces jointes</label>
|
||||
<FileUpload
|
||||
existingFiles={editAttachments}
|
||||
onUpload={(file) => uploadAttachment(editHomework!.id, file)}
|
||||
onDelete={(attachmentId) => deleteAttachment(editHomework!.id, attachmentId)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}>
|
||||
Annuler
|
||||
@@ -1553,7 +1661,16 @@
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.homework-description :global(ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.homework-description :global(ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.homework-actions {
|
||||
|
||||
Reference in New Issue
Block a user