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:
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
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
|
||||
}: {
|
||||
existingFiles?: UploadedFile[];
|
||||
onUpload: (file: File) => Promise<UploadedFile>;
|
||||
onDelete: (fileId: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let files = $state<UploadedFile[]>(existingFiles);
|
||||
let pendingFiles = $state<{ name: string; size: number }[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
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 validateFile(file: File): string | null {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
return `Type de fichier non accepté : ${file.type}. Types autorisés : PDF, JPEG, PNG.`;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return `Le fichier dépasse la taille maximale de 10 Mo (${formatFileSize(file.size)}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = input.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
error = null;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
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-size">{formatFileSize(file.fileSize)}</span>
|
||||
{#if !disabled}
|
||||
<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}
|
||||
<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" />
|
||||
</svg>
|
||||
Ajouter un fichier
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onchange={handleFileSelect}
|
||||
class="file-input-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<p class="upload-hint">PDF, JPEG ou PNG — 10 Mo max par fichier</p>
|
||||
{/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-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;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: inline-flex;
|
||||
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;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Editor as EditorType } from '@tiptap/core';
|
||||
|
||||
let {
|
||||
content = '',
|
||||
onUpdate,
|
||||
placeholder = 'Saisissez votre texte...',
|
||||
disabled = false
|
||||
}: {
|
||||
content?: string;
|
||||
onUpdate: (html: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let editorElement: HTMLDivElement;
|
||||
let editor: EditorType | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
initEditor();
|
||||
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
async function initEditor() {
|
||||
const [{ Editor }, { default: StarterKit }, { default: Link }] = await Promise.all([
|
||||
import('@tiptap/core'),
|
||||
import('@tiptap/starter-kit'),
|
||||
import('@tiptap/extension-link')
|
||||
]);
|
||||
|
||||
editor = new Editor({
|
||||
element: editorElement,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
code: false
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank'
|
||||
}
|
||||
})
|
||||
],
|
||||
content,
|
||||
editable: !disabled,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
const html = e.isEmpty ? '' : e.getHTML();
|
||||
onUpdate(html);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-text-content',
|
||||
'aria-label': placeholder,
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (editor && disabled !== undefined) {
|
||||
editor.setEditable(!disabled);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleBold() {
|
||||
editor?.chain().focus().toggleBold().run();
|
||||
}
|
||||
|
||||
function toggleItalic() {
|
||||
editor?.chain().focus().toggleItalic().run();
|
||||
}
|
||||
|
||||
function toggleBulletList() {
|
||||
editor?.chain().focus().toggleBulletList().run();
|
||||
}
|
||||
|
||||
function toggleOrderedList() {
|
||||
editor?.chain().focus().toggleOrderedList().run();
|
||||
}
|
||||
|
||||
function toggleLink() {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = window.prompt('URL du lien :');
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rich-text-editor" class:disabled>
|
||||
{#if editor && !disabled}
|
||||
<div class="toolbar" role="toolbar" aria-label="Formatage du texte">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('bold')}
|
||||
onclick={toggleBold}
|
||||
title="Gras"
|
||||
aria-label="Gras"
|
||||
aria-pressed={editor.isActive('bold')}
|
||||
>
|
||||
<strong>G</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('italic')}
|
||||
onclick={toggleItalic}
|
||||
title="Italique"
|
||||
aria-label="Italique"
|
||||
aria-pressed={editor.isActive('italic')}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<span class="toolbar-separator" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('bulletList')}
|
||||
onclick={toggleBulletList}
|
||||
title="Liste à puces"
|
||||
aria-label="Liste à puces"
|
||||
aria-pressed={editor.isActive('bulletList')}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 000 2h10a1 1 0 100-2H7zm0 5a1 1 0 000 2h10a1 1 0 100-2H7zm0 5a1 1 0 000 2h10a1 1 0 100-2H7zM3 9a1 1 0 100 2 1 1 0 000-2zm0 5a1 1 0 100 2 1 1 0 000-2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('orderedList')}
|
||||
onclick={toggleOrderedList}
|
||||
title="Liste numérotée"
|
||||
aria-label="Liste numérotée"
|
||||
aria-pressed={editor.isActive('orderedList')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" aria-hidden="true">
|
||||
<line x1="10" y1="6" x2="21" y2="6" />
|
||||
<line x1="10" y1="12" x2="21" y2="12" />
|
||||
<line x1="10" y1="18" x2="21" y2="18" />
|
||||
<text x="4" y="7.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">1</text>
|
||||
<text x="4" y="13.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">2</text>
|
||||
<text x="4" y="19.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">3</text>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="toolbar-separator" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('link')}
|
||||
onclick={toggleLink}
|
||||
title="Lien"
|
||||
aria-label="Lien"
|
||||
aria-pressed={editor.isActive('link')}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div bind:this={editorElement} class="editor-container"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rich-text-editor {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.rich-text-editor:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.rich-text-editor.disabled {
|
||||
opacity: 0.6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: #d1d5db;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content) {
|
||||
padding: 0.75rem;
|
||||
min-height: 8rem;
|
||||
outline: none;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content a) {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content .is-empty::before) {
|
||||
content: attr(data-placeholder);
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
float: left;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -93,7 +93,7 @@
|
||||
{#if detail.description}
|
||||
<section class="detail-description">
|
||||
<h3>Description</h3>
|
||||
<p>{detail.description}</p>
|
||||
<div class="description-content">{@html detail.description}</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -183,11 +183,34 @@
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.detail-description p {
|
||||
margin: 0;
|
||||
.detail-description :global(.description-content) {
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content a) {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.download-error {
|
||||
|
||||
Reference in New Issue
Block a user