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:
@@ -28,6 +28,7 @@
|
|||||||
"symfony/dotenv": "^8.0",
|
"symfony/dotenv": "^8.0",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "^8.0",
|
"symfony/framework-bundle": "^8.0",
|
||||||
|
"symfony/html-sanitizer": "8.0.*",
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/lock": "8.0.*",
|
"symfony/lock": "8.0.*",
|
||||||
"symfony/mailer": "8.0.*",
|
"symfony/mailer": "8.0.*",
|
||||||
|
|||||||
256
backend/composer.lock
generated
256
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "cf5f7c77977031afccfa7da74ed52205",
|
"content-hash": "92b9472c96a59c314d96372c4094f185",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@@ -1785,6 +1785,188 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-10-17T11:30:53+00:00"
|
"time": "2025-10-17T11:30:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/uri",
|
||||||
|
"version": "7.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/uri.git",
|
||||||
|
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
|
||||||
|
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"league/uri-interfaces": "^7.8.1",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/http-factory": "^1"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"league/uri-schemes": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-bcmath": "to improve IPV4 host parsing",
|
||||||
|
"ext-dom": "to convert the URI into an HTML anchor tag",
|
||||||
|
"ext-fileinfo": "to create Data URI from file contennts",
|
||||||
|
"ext-gmp": "to improve IPV4 host parsing",
|
||||||
|
"ext-intl": "to handle IDN host with the best performance",
|
||||||
|
"ext-uri": "to use the PHP native URI class",
|
||||||
|
"jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
|
||||||
|
"league/uri-components": "to provide additional tools to manipulate URI objects components",
|
||||||
|
"league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
|
||||||
|
"php-64bit": "to improve IPV4 host parsing",
|
||||||
|
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
|
||||||
|
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "7.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Uri\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ignace Nyamagana Butera",
|
||||||
|
"email": "nyamsprod@gmail.com",
|
||||||
|
"homepage": "https://nyamsprod.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "URI manipulation library",
|
||||||
|
"homepage": "https://uri.thephpleague.com",
|
||||||
|
"keywords": [
|
||||||
|
"URN",
|
||||||
|
"data-uri",
|
||||||
|
"file-uri",
|
||||||
|
"ftp",
|
||||||
|
"hostname",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"middleware",
|
||||||
|
"parse_str",
|
||||||
|
"parse_url",
|
||||||
|
"psr-7",
|
||||||
|
"query-string",
|
||||||
|
"querystring",
|
||||||
|
"rfc2141",
|
||||||
|
"rfc3986",
|
||||||
|
"rfc3987",
|
||||||
|
"rfc6570",
|
||||||
|
"rfc8141",
|
||||||
|
"uri",
|
||||||
|
"uri-template",
|
||||||
|
"url",
|
||||||
|
"ws"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://uri.thephpleague.com",
|
||||||
|
"forum": "https://thephpleague.slack.com",
|
||||||
|
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||||
|
"source": "https://github.com/thephpleague/uri/tree/7.8.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/sponsors/nyamsprod",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-15T20:22:25+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "league/uri-interfaces",
|
||||||
|
"version": "7.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/uri-interfaces.git",
|
||||||
|
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
|
||||||
|
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-filter": "*",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/http-message": "^1.1 || ^2.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-bcmath": "to improve IPV4 host parsing",
|
||||||
|
"ext-gmp": "to improve IPV4 host parsing",
|
||||||
|
"ext-intl": "to handle IDN host with the best performance",
|
||||||
|
"php-64bit": "to improve IPV4 host parsing",
|
||||||
|
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
|
||||||
|
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "7.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Uri\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ignace Nyamagana Butera",
|
||||||
|
"email": "nyamsprod@gmail.com",
|
||||||
|
"homepage": "https://nyamsprod.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
|
||||||
|
"homepage": "https://uri.thephpleague.com",
|
||||||
|
"keywords": [
|
||||||
|
"data-uri",
|
||||||
|
"file-uri",
|
||||||
|
"ftp",
|
||||||
|
"hostname",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"parse_str",
|
||||||
|
"parse_url",
|
||||||
|
"psr-7",
|
||||||
|
"query-string",
|
||||||
|
"querystring",
|
||||||
|
"rfc3986",
|
||||||
|
"rfc3987",
|
||||||
|
"rfc6570",
|
||||||
|
"uri",
|
||||||
|
"url",
|
||||||
|
"ws"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://uri.thephpleague.com",
|
||||||
|
"forum": "https://thephpleague.slack.com",
|
||||||
|
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||||
|
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/sponsors/nyamsprod",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-08T20:05:35+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "lexik/jwt-authentication-bundle",
|
"name": "lexik/jwt-authentication-bundle",
|
||||||
"version": "v3.2.0",
|
"version": "v3.2.0",
|
||||||
@@ -4841,6 +5023,78 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-27T09:06:10+00:00"
|
"time": "2026-01-27T09:06:10+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/html-sanitizer",
|
||||||
|
"version": "v8.0.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/html-sanitizer.git",
|
||||||
|
"reference": "555b37caeee3d07af33471e02377d5ff561f8ac2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2",
|
||||||
|
"reference": "555b37caeee3d07af33471e02377d5ff561f8ac2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"league/uri": "^6.5|^7.0",
|
||||||
|
"php": ">=8.4"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\HtmlSanitizer\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Titouan Galopin",
|
||||||
|
"email": "galopintitouan@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"Purifier",
|
||||||
|
"html",
|
||||||
|
"sanitizer"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-06T13:17:40+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client",
|
"name": "symfony/http-client",
|
||||||
"version": "v8.0.5",
|
"version": "v8.0.5",
|
||||||
|
|||||||
13
backend/config/packages/html_sanitizer.yaml
Normal file
13
backend/config/packages/html_sanitizer.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
framework:
|
||||||
|
html_sanitizer:
|
||||||
|
sanitizers:
|
||||||
|
homework_sanitizer:
|
||||||
|
allow_elements:
|
||||||
|
p: []
|
||||||
|
br: []
|
||||||
|
strong: []
|
||||||
|
em: []
|
||||||
|
ul: []
|
||||||
|
ol: []
|
||||||
|
li: []
|
||||||
|
a: ['href', 'target', 'rel']
|
||||||
@@ -222,6 +222,13 @@ services:
|
|||||||
App\Scolarite\Domain\Service\HomeworkDuplicator:
|
App\Scolarite\Domain\Service\HomeworkDuplicator:
|
||||||
autowire: true
|
autowire: true
|
||||||
|
|
||||||
|
App\Scolarite\Application\Port\HtmlSanitizer:
|
||||||
|
alias: App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer
|
||||||
|
|
||||||
|
App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer:
|
||||||
|
arguments:
|
||||||
|
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
|
||||||
|
|
||||||
App\Scolarite\Application\Port\FileStorage:
|
App\Scolarite\Application\Port\FileStorage:
|
||||||
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
|
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Administration\Domain\Model\User\UserId;
|
|||||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||||
|
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||||
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
||||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
@@ -29,6 +30,7 @@ final readonly class CreateHomeworkHandler
|
|||||||
private CurrentCalendarProvider $calendarProvider,
|
private CurrentCalendarProvider $calendarProvider,
|
||||||
private DueDateValidator $dueDateValidator,
|
private DueDateValidator $dueDateValidator,
|
||||||
private HomeworkRulesChecker $rulesChecker,
|
private HomeworkRulesChecker $rulesChecker,
|
||||||
|
private HtmlSanitizer $htmlSanitizer,
|
||||||
private Clock $clock,
|
private Clock $clock,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -63,13 +65,17 @@ final readonly class CreateHomeworkHandler
|
|||||||
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
|
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$description = $command->description !== null
|
||||||
|
? $this->htmlSanitizer->sanitize($command->description)
|
||||||
|
: null;
|
||||||
|
|
||||||
$homework = Homework::creer(
|
$homework = Homework::creer(
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
classId: $classId,
|
classId: $classId,
|
||||||
subjectId: $subjectId,
|
subjectId: $subjectId,
|
||||||
teacherId: $teacherId,
|
teacherId: $teacherId,
|
||||||
title: $command->title,
|
title: $command->title,
|
||||||
description: $command->description,
|
description: $description,
|
||||||
dueDate: $dueDate,
|
dueDate: $dueDate,
|
||||||
now: $now,
|
now: $now,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Scolarite\Application\Command\UpdateHomework;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||||
|
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||||
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
|
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
|
||||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
@@ -23,6 +24,7 @@ final readonly class UpdateHomeworkHandler
|
|||||||
private HomeworkRepository $homeworkRepository,
|
private HomeworkRepository $homeworkRepository,
|
||||||
private CurrentCalendarProvider $calendarProvider,
|
private CurrentCalendarProvider $calendarProvider,
|
||||||
private DueDateValidator $dueDateValidator,
|
private DueDateValidator $dueDateValidator,
|
||||||
|
private HtmlSanitizer $htmlSanitizer,
|
||||||
private Clock $clock,
|
private Clock $clock,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -44,9 +46,13 @@ final readonly class UpdateHomeworkHandler
|
|||||||
$dueDate = new DateTimeImmutable($command->dueDate);
|
$dueDate = new DateTimeImmutable($command->dueDate);
|
||||||
$this->dueDateValidator->valider($dueDate, $now, $calendar);
|
$this->dueDateValidator->valider($dueDate, $now, $calendar);
|
||||||
|
|
||||||
|
$description = $command->description !== null
|
||||||
|
? $this->htmlSanitizer->sanitize($command->description)
|
||||||
|
: null;
|
||||||
|
|
||||||
$homework->modifier(
|
$homework->modifier(
|
||||||
title: $command->title,
|
title: $command->title,
|
||||||
description: $command->description,
|
description: $description,
|
||||||
dueDate: $dueDate,
|
dueDate: $dueDate,
|
||||||
now: $now,
|
now: $now,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ final readonly class UploadHomeworkAttachmentHandler
|
|||||||
$this->homeworkRepository->get($homeworkId, $tenantId);
|
$this->homeworkRepository->get($homeworkId, $tenantId);
|
||||||
|
|
||||||
$attachmentId = HomeworkAttachmentId::generate();
|
$attachmentId = HomeworkAttachmentId::generate();
|
||||||
$storagePath = sprintf('homework/%s/%s/%s', $command->tenantId, $command->homeworkId, $command->filename);
|
$storagePath = sprintf('homework/%s/%s/%s/%s', $command->tenantId, $command->homeworkId, (string) $attachmentId, $command->filename);
|
||||||
|
|
||||||
$content = file_get_contents($command->tempFilePath);
|
$content = file_get_contents($command->tempFilePath);
|
||||||
|
|
||||||
|
|||||||
10
backend/src/Scolarite/Application/Port/HtmlSanitizer.php
Normal file
10
backend/src/Scolarite/Application/Port/HtmlSanitizer.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Port;
|
||||||
|
|
||||||
|
interface HtmlSanitizer
|
||||||
|
{
|
||||||
|
public function sanitize(string $html): string;
|
||||||
|
}
|
||||||
@@ -18,4 +18,6 @@ interface HomeworkAttachmentRepository
|
|||||||
public function hasAttachments(HomeworkId ...$homeworkIds): array;
|
public function hasAttachments(HomeworkId ...$homeworkIds): array;
|
||||||
|
|
||||||
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
|
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
|
||||||
|
|
||||||
|
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||||
|
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentCommand;
|
||||||
|
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
|
||||||
|
use App\Scolarite\Application\Port\FileStorage;
|
||||||
|
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
use function realpath;
|
||||||
|
use function str_starts_with;
|
||||||
|
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
final readonly class HomeworkAttachmentController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private HomeworkRepository $homeworkRepository,
|
||||||
|
private HomeworkAttachmentRepository $attachmentRepository,
|
||||||
|
private UploadHomeworkAttachmentHandler $uploadHandler,
|
||||||
|
private FileStorage $fileStorage,
|
||||||
|
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||||
|
private string $storageDir,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_list', methods: ['GET'])]
|
||||||
|
public function list(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
|
|
||||||
|
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||||
|
|
||||||
|
if ($homework === null) {
|
||||||
|
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $homework->teacherId !== $user->userId()) {
|
||||||
|
throw new AccessDeniedHttpException('Accès non autorisé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||||
|
|
||||||
|
return new JsonResponse(array_map(
|
||||||
|
static fn (HomeworkAttachment $a): array => [
|
||||||
|
'id' => (string) $a->id,
|
||||||
|
'filename' => $a->filename,
|
||||||
|
'fileSize' => $a->fileSize,
|
||||||
|
'mimeType' => $a->mimeType,
|
||||||
|
],
|
||||||
|
$attachments,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_upload', methods: ['POST'])]
|
||||||
|
public function upload(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
|
|
||||||
|
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||||
|
|
||||||
|
if ($homework === null) {
|
||||||
|
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $homework->teacherId !== $user->userId()) {
|
||||||
|
throw new AccessDeniedHttpException('Seul le propriétaire peut ajouter des pièces jointes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
|
||||||
|
if ($file === null) {
|
||||||
|
throw new BadRequestHttpException('Aucun fichier envoyé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
|
||||||
|
$originalName = $file->getClientOriginalName();
|
||||||
|
$mimeType = $file->getMimeType() ?? $file->getClientMimeType() ?? '';
|
||||||
|
$fileSize = $file->getSize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attachment = ($this->uploadHandler)(new UploadHomeworkAttachmentCommand(
|
||||||
|
tenantId: $user->tenantId(),
|
||||||
|
homeworkId: $id,
|
||||||
|
filename: $originalName,
|
||||||
|
mimeType: $mimeType,
|
||||||
|
fileSize: (int) $fileSize,
|
||||||
|
tempFilePath: $file->getPathname(),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->attachmentRepository->save($homework->id, $attachment);
|
||||||
|
} catch (PieceJointeInvalideException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => (string) $attachment->id,
|
||||||
|
'filename' => $attachment->filename,
|
||||||
|
'fileSize' => $attachment->fileSize,
|
||||||
|
'mimeType' => $attachment->mimeType,
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])]
|
||||||
|
public function download(string $id, string $attachmentId): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
|
|
||||||
|
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||||
|
|
||||||
|
if ($homework === null) {
|
||||||
|
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $homework->teacherId !== $user->userId()) {
|
||||||
|
throw new AccessDeniedHttpException('Accès non autorisé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
|
$fullPath = $this->storageDir . '/' . $attachment->filePath;
|
||||||
|
$realPath = realpath($fullPath);
|
||||||
|
$realStorageDir = realpath($this->storageDir);
|
||||||
|
|
||||||
|
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
|
||||||
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($realPath);
|
||||||
|
$response->setContentDisposition(
|
||||||
|
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||||
|
$attachment->filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_delete', methods: ['DELETE'])]
|
||||||
|
public function delete(string $id, string $attachmentId): Response
|
||||||
|
{
|
||||||
|
$user = $this->getSecurityUser();
|
||||||
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
|
|
||||||
|
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||||
|
|
||||||
|
if ($homework === null) {
|
||||||
|
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $homework->teacherId !== $user->userId()) {
|
||||||
|
throw new AccessDeniedHttpException('Seul le propriétaire peut supprimer des pièces jointes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
|
$this->fileStorage->delete($attachment->filePath);
|
||||||
|
$this->attachmentRepository->delete($homework->id, $attachment);
|
||||||
|
|
||||||
|
return new Response(status: Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSecurityUser(): SecurityUser
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if (!$user instanceof SecurityUser) {
|
||||||
|
throw new AccessDeniedHttpException('Authentification requise.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ final readonly class ParentHomeworkController
|
|||||||
private GetChildrenHomeworkDetailHandler $detailHandler,
|
private GetChildrenHomeworkDetailHandler $detailHandler,
|
||||||
private HomeworkRepository $homeworkRepository,
|
private HomeworkRepository $homeworkRepository,
|
||||||
private HomeworkAttachmentRepository $attachmentRepository,
|
private HomeworkAttachmentRepository $attachmentRepository,
|
||||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||||
private string $uploadsDir,
|
private string $uploadsDir,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,8 @@ final readonly class ParentHomeworkController
|
|||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if ((string) $attachment->id === $attachmentId) {
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
$realPath = realpath($attachment->filePath);
|
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
||||||
|
$realPath = realpath($fullPath);
|
||||||
$realUploadsDir = realpath($this->uploadsDir);
|
$realUploadsDir = realpath($this->uploadsDir);
|
||||||
|
|
||||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final readonly class StudentHomeworkController
|
|||||||
private HomeworkAttachmentRepository $attachmentRepository,
|
private HomeworkAttachmentRepository $attachmentRepository,
|
||||||
private ScheduleDisplayReader $displayReader,
|
private ScheduleDisplayReader $displayReader,
|
||||||
private StudentClassReader $studentClassReader,
|
private StudentClassReader $studentClassReader,
|
||||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||||
private string $uploadsDir,
|
private string $uploadsDir,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,8 @@ final readonly class StudentHomeworkController
|
|||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if ((string) $attachment->id === $attachmentId) {
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
$realPath = realpath($attachment->filePath);
|
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
||||||
|
$realPath = realpath($fullPath);
|
||||||
$realUploadsDir = realpath($this->uploadsDir);
|
$realUploadsDir = realpath($this->uploadsDir);
|
||||||
|
|
||||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM homework_attachments WHERE id = :id AND homework_id = :homework_id',
|
||||||
|
[
|
||||||
|
'id' => (string) $attachment->id,
|
||||||
|
'homework_id' => (string) $homeworkId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $row */
|
/** @param array<string, mixed> $row */
|
||||||
private function hydrate(array $row): HomeworkAttachment
|
private function hydrate(array $row): HomeworkAttachment
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
|||||||
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||||
|
|
||||||
use function array_fill_keys;
|
use function array_fill_keys;
|
||||||
|
use function array_filter;
|
||||||
use function array_map;
|
use function array_map;
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
use Override;
|
use Override;
|
||||||
|
|
||||||
@@ -42,4 +44,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe
|
|||||||
{
|
{
|
||||||
$this->byHomeworkId[(string) $homeworkId][] = $attachment;
|
$this->byHomeworkId[(string) $homeworkId][] = $attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
|
||||||
|
{
|
||||||
|
$key = (string) $homeworkId;
|
||||||
|
|
||||||
|
if (!isset($this->byHomeworkId[$key])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->byHomeworkId[$key] = array_values(array_filter(
|
||||||
|
$this->byHomeworkId[$key],
|
||||||
|
static fn (HomeworkAttachment $a): bool => (string) $a->id !== (string) $attachment->id,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
|
||||||
|
|
||||||
|
final readonly class HomeworkHtmlSanitizer implements HtmlSanitizer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HtmlSanitizerInterface $homeworkSanitizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return $this->homeworkSanitizer->sanitize($html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
|||||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||||
|
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||||
use App\Scolarite\Application\Port\RuleWarning;
|
use App\Scolarite\Application\Port\RuleWarning;
|
||||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||||
@@ -110,6 +111,46 @@ final class CreateHomeworkHandlerTest extends TestCase
|
|||||||
self::assertNull($homework->description);
|
self::assertNull($homework->description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSanitizesHtmlDescription(): void
|
||||||
|
{
|
||||||
|
$sanitizer = new class implements HtmlSanitizer {
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
|
||||||
|
$command = $this->createCommand(description: '<p>Texte <strong>gras</strong></p><script>alert("xss")</script>');
|
||||||
|
|
||||||
|
$homework = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('<p>Texte <strong>gras</strong></p>alert("xss")', $homework->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDoesNotSanitizeNullDescription(): void
|
||||||
|
{
|
||||||
|
$sanitizer = new class implements HtmlSanitizer {
|
||||||
|
public bool $called = false;
|
||||||
|
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
$this->called = true;
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
|
||||||
|
$command = $this->createCommand(description: null);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
self::assertFalse($sanitizer->called);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
|
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
|
||||||
{
|
{
|
||||||
@@ -269,12 +310,67 @@ final class CreateHomeworkHandlerTest extends TestCase
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$htmlSanitizer = new class implements HtmlSanitizer {
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return new CreateHomeworkHandler(
|
return new CreateHomeworkHandler(
|
||||||
$this->homeworkRepository,
|
$this->homeworkRepository,
|
||||||
$affectationChecker,
|
$affectationChecker,
|
||||||
$calendarProvider,
|
$calendarProvider,
|
||||||
new DueDateValidator(),
|
new DueDateValidator(),
|
||||||
$rulesChecker,
|
$rulesChecker,
|
||||||
|
$htmlSanitizer,
|
||||||
|
$this->clock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createHandlerWithSanitizer(bool $affecte, HtmlSanitizer $htmlSanitizer, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler
|
||||||
|
{
|
||||||
|
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
|
||||||
|
public function __construct(private readonly bool $affecte)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
|
||||||
|
{
|
||||||
|
return $this->affecte;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||||
|
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||||
|
{
|
||||||
|
return SchoolCalendar::reconstitute(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||||
|
zone: null,
|
||||||
|
entries: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker {
|
||||||
|
public function __construct(private readonly HomeworkRulesCheckResult $result)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
|
||||||
|
{
|
||||||
|
return $this->result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CreateHomeworkHandler(
|
||||||
|
$this->homeworkRepository,
|
||||||
|
$affectationChecker,
|
||||||
|
$calendarProvider,
|
||||||
|
new DueDateValidator(),
|
||||||
|
$rulesChecker,
|
||||||
|
$htmlSanitizer,
|
||||||
$this->clock,
|
$this->clock,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Administration\Domain\Model\User\UserId;
|
|||||||
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand;
|
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand;
|
||||||
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler;
|
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler;
|
||||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||||
|
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||||
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
|
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
|
||||||
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
|
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
|
||||||
@@ -172,6 +173,60 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
|||||||
self::assertSame('Exercices sans description', $homework->title);
|
self::assertSame('Exercices sans description', $homework->title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSanitizesHtmlDescription(): void
|
||||||
|
{
|
||||||
|
$sanitizer = new class implements HtmlSanitizer {
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$handler = $this->createHandlerWithSanitizer($sanitizer);
|
||||||
|
$command = new UpdateHomeworkCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
homeworkId: (string) $this->existingHomeworkId,
|
||||||
|
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||||
|
title: 'Test sanitize',
|
||||||
|
description: '<p>Texte</p><script>alert("xss")</script>',
|
||||||
|
dueDate: '2026-04-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
$homework = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame('<p>Texte</p>alert("xss")', $homework->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDoesNotSanitizeNullDescription(): void
|
||||||
|
{
|
||||||
|
$sanitizer = new class implements HtmlSanitizer {
|
||||||
|
public bool $called = false;
|
||||||
|
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
$this->called = true;
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$handler = $this->createHandlerWithSanitizer($sanitizer);
|
||||||
|
$command = new UpdateHomeworkCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
homeworkId: (string) $this->existingHomeworkId,
|
||||||
|
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||||
|
title: 'Test null desc',
|
||||||
|
description: null,
|
||||||
|
dueDate: '2026-04-20',
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
self::assertFalse($sanitizer->called);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function itThrowsWhenNotOwner(): void
|
public function itThrowsWhenNotOwner(): void
|
||||||
{
|
{
|
||||||
@@ -206,7 +261,7 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
|||||||
$this->homeworkRepository->save($homework);
|
$this->homeworkRepository->save($homework);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createHandler(): UpdateHomeworkHandler
|
private function createHandlerWithSanitizer(HtmlSanitizer $htmlSanitizer): UpdateHomeworkHandler
|
||||||
{
|
{
|
||||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||||
@@ -224,6 +279,37 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
|||||||
$this->homeworkRepository,
|
$this->homeworkRepository,
|
||||||
$calendarProvider,
|
$calendarProvider,
|
||||||
new DueDateValidator(),
|
new DueDateValidator(),
|
||||||
|
$htmlSanitizer,
|
||||||
|
$this->clock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createHandler(): UpdateHomeworkHandler
|
||||||
|
{
|
||||||
|
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||||
|
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||||
|
{
|
||||||
|
return SchoolCalendar::reconstitute(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||||
|
zone: null,
|
||||||
|
entries: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$htmlSanitizer = new class implements HtmlSanitizer {
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new UpdateHomeworkHandler(
|
||||||
|
$this->homeworkRepository,
|
||||||
|
$calendarProvider,
|
||||||
|
new DueDateValidator(),
|
||||||
|
$htmlSanitizer,
|
||||||
$this->clock,
|
$this->clock,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||||
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||||
|
|
||||||
|
final class HomeworkHtmlSanitizerTest extends TestCase
|
||||||
|
{
|
||||||
|
private HomeworkHtmlSanitizer $sanitizer;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$config = (new HtmlSanitizerConfig())
|
||||||
|
->allowElement('p')
|
||||||
|
->allowElement('br')
|
||||||
|
->allowElement('strong')
|
||||||
|
->allowElement('em')
|
||||||
|
->allowElement('ul')
|
||||||
|
->allowElement('ol')
|
||||||
|
->allowElement('li')
|
||||||
|
->allowElement('a', ['href', 'target', 'rel']);
|
||||||
|
|
||||||
|
$this->sanitizer = new HomeworkHtmlSanitizer(new HtmlSanitizer($config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsBoldText(): void
|
||||||
|
{
|
||||||
|
$html = '<p>Texte <strong>en gras</strong></p>';
|
||||||
|
|
||||||
|
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsItalicText(): void
|
||||||
|
{
|
||||||
|
$html = '<p>Texte <em>en italique</em></p>';
|
||||||
|
|
||||||
|
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsUnorderedLists(): void
|
||||||
|
{
|
||||||
|
$html = '<ul><li>Élément 1</li><li>Élément 2</li></ul>';
|
||||||
|
|
||||||
|
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsOrderedLists(): void
|
||||||
|
{
|
||||||
|
$html = '<ol><li>Premier</li><li>Deuxième</li></ol>';
|
||||||
|
|
||||||
|
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsLinksWithSafeAttributes(): void
|
||||||
|
{
|
||||||
|
$html = '<p><a href="https://example.com" target="_blank" rel="noopener noreferrer">Lien</a></p>';
|
||||||
|
|
||||||
|
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStripsScriptTags(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('<p>Texte</p><script>alert("xss")</script>');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('<script>', $result);
|
||||||
|
self::assertStringNotContainsString('alert', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStripsEventHandlers(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('<p onclick="alert(1)">Texte</p>');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('onclick', $result);
|
||||||
|
self::assertStringContainsString('Texte', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStripsStyleTags(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('<style>body{display:none}</style><p>Texte</p>');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('<style>', $result);
|
||||||
|
self::assertStringContainsString('Texte', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStripsIframeTags(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('<p>Texte</p><iframe src="evil.com"></iframe>');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('<iframe>', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStripsImgTags(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('<p>Texte</p><img src="x" onerror="alert(1)">');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('<img', $result);
|
||||||
|
self::assertStringNotContainsString('onerror', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itHandlesEmptyString(): void
|
||||||
|
{
|
||||||
|
self::assertSame('', $this->sanitizer->sanitize(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itHandlesPlainText(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('Texte simple sans HTML');
|
||||||
|
|
||||||
|
self::assertSame('Texte simple sans HTML', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStripsJavascriptLinks(): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize('<a href="javascript:alert(1)">Clic</a>');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('javascript:', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('richContentProvider')]
|
||||||
|
public function itPreservesRichContent(string $input, string $expectedSubstring): void
|
||||||
|
{
|
||||||
|
$result = $this->sanitizer->sanitize($input);
|
||||||
|
|
||||||
|
self::assertStringContainsString($expectedSubstring, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return iterable<string, array{string, string}> */
|
||||||
|
public static function richContentProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'gras' => ['<strong>gras</strong>', '<strong>gras</strong>'];
|
||||||
|
yield 'italique' => ['<em>italique</em>', '<em>italique</em>'];
|
||||||
|
yield 'liste à puces' => ['<ul><li>item</li></ul>', '<li>item</li>'];
|
||||||
|
yield 'liste numérotée' => ['<ol><li>item</li></ol>', '<ol>'];
|
||||||
|
yield 'paragraphe' => ['<p>texte</p>', '<p>texte</p>'];
|
||||||
|
yield 'lien' => ['<a href="https://x.com">lien</a>', 'href="https://x.com"'];
|
||||||
|
}
|
||||||
|
}
|
||||||
467
frontend/e2e/homework-richtext-attachments.spec.ts
Normal file
467
frontend/e2e/homework-richtext-attachments.spec.ts
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
|
||||||
|
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||||
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
function runSql(sql: string) {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Cache pool may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||||
|
const output = execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||||
|
`require "/app/vendor/autoload.php"; ` +
|
||||||
|
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||||
|
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||||
|
`' 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
).trim();
|
||||||
|
const [schoolId, academicYearId] = output.split('\n');
|
||||||
|
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextWeekday(daysFromNow: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + daysFromNow);
|
||||||
|
const day = date.getDay();
|
||||||
|
if (day === 0) date.setDate(date.getDate() + 1);
|
||||||
|
if (day === 6) date.setDate(date.getDate() + 2);
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTeacherAssignments() {
|
||||||
|
const { academicYearId } = resolveDeterministicIds();
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||||
|
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||||
|
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHomework(page: import('@playwright/test').Page, title: string) {
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill(title);
|
||||||
|
|
||||||
|
// Type in WYSIWYG editor
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Consignes du devoir');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(title)).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTempPdf(): string {
|
||||||
|
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
||||||
|
mkdirSync(tmpDir, { recursive: true });
|
||||||
|
const filePath = join(tmpDir, 'test-attachment.pdf');
|
||||||
|
const pdfContent = `%PDF-1.4
|
||||||
|
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||||
|
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||||
|
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
190
|
||||||
|
%%EOF`;
|
||||||
|
writeFileSync(filePath, pdfContent);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTempTxt(): string {
|
||||||
|
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
||||||
|
mkdirSync(tmpDir, { recursive: true });
|
||||||
|
const filePath = join(tmpDir, 'test-invalid.txt');
|
||||||
|
writeFileSync(filePath, 'This is a plain text file that should be rejected.');
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupTempFiles() {
|
||||||
|
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
||||||
|
for (const name of ['test-attachment.pdf', 'test-invalid.txt']) {
|
||||||
|
try {
|
||||||
|
unlinkSync(join(tmpDir, name));
|
||||||
|
} catch {
|
||||||
|
// May not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Ensure teacher user exists (same as homework.spec.ts)
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
seedTeacherAssignments();
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(() => {
|
||||||
|
cleanupTempFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T4.1 : WYSIWYG Editor
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('WYSIWYG Editor', () => {
|
||||||
|
test('create form shows rich text editor with toolbar', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Rich text editor with toolbar should be visible
|
||||||
|
const editor = page.locator('.rich-text-editor');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.toolbar')).toBeVisible();
|
||||||
|
|
||||||
|
// Toolbar buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create homework with rich text description', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir texte riche');
|
||||||
|
|
||||||
|
// Type in rich text editor
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Consignes importantes');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bold formatting works in editor', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir gras test');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Normal ');
|
||||||
|
|
||||||
|
// Apply bold via keyboard shortcut (more reliable than toolbar click)
|
||||||
|
await page.keyboard.press('Control+b');
|
||||||
|
await page.keyboard.type('en gras');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify bold is rendered in the description
|
||||||
|
const description = page.locator('.homework-description');
|
||||||
|
await expect(description.locator('strong')).toContainText('en gras');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T4.2 : Upload attachment
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Attachments', () => {
|
||||||
|
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
// Create homework
|
||||||
|
await createHomework(page, 'Devoir avec PJ');
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const pdfPath = createTempPdf();
|
||||||
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
|
||||||
|
// File appears in list
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// T4.3 : Delete attachment
|
||||||
|
test('can delete an uploaded attachment', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir suppr PJ');
|
||||||
|
|
||||||
|
// Open edit modal and upload
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const pdfPath = createTempPdf();
|
||||||
|
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Delete the attachment
|
||||||
|
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T5.9.1 : Invalid file type rejection (P1)
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Invalid File Type Rejection', () => {
|
||||||
|
test('rejects a .txt file with an error message', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir rejet fichier');
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Try to upload a .txt file
|
||||||
|
const txtPath = createTempTxt();
|
||||||
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
|
await fileInput.setInputFiles(txtPath);
|
||||||
|
|
||||||
|
// Error message should appear
|
||||||
|
const errorAlert = page.locator('[role="alert"]');
|
||||||
|
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
||||||
|
|
||||||
|
// The .txt file should NOT appear in the file list
|
||||||
|
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T5.9.2 : Attachment persistence after save (P1)
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Attachment Persistence', () => {
|
||||||
|
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir persistance PJ');
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Upload a PDF
|
||||||
|
const pdfPath = createTempPdf();
|
||||||
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Save the changes
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Reopen the edit modal
|
||||||
|
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||||
|
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The attachment should still be there
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T5.9.3 : File size display after upload (P2)
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('File Size Display', () => {
|
||||||
|
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir taille fichier');
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Upload a PDF
|
||||||
|
const pdfPath = createTempPdf();
|
||||||
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
|
||||||
|
const fileSize = page.locator('.file-size');
|
||||||
|
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// T4.4 : Backward compatibility
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Backward Compatibility', () => {
|
||||||
|
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||||
|
// Create homework with plain text description via SQL
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||||
|
`FROM users u, school_classes c, subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||||
|
`LIMIT 1`
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
// Plain text description displays correctly
|
||||||
|
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||||
|
`FROM users u, school_classes c, subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||||
|
`LIMIT 1`
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// WYSIWYG editor contains the old text
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -217,7 +217,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
|||||||
await page.locator('#hw-title').fill('Exercices chapitre 5');
|
await page.locator('#hw-title').fill('Exercices chapitre 5');
|
||||||
|
|
||||||
// Fill description
|
// Fill description
|
||||||
await page.locator('#hw-description').fill('Pages 42-45, exercices 1 à 10');
|
await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Pages 42-45, exercices 1 à 10');
|
||||||
|
|
||||||
// Set due date (next weekday, at least 2 days from now)
|
// Set due date (next weekday, at least 2 days from now)
|
||||||
await page.locator('#hw-due-date').fill(getNextWeekday(3));
|
await page.locator('#hw-due-date').fill(getNextWeekday(3));
|
||||||
@@ -389,7 +389,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
|||||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
await page.locator('#hw-title').fill('Devoir date passée');
|
await page.locator('#hw-title').fill('Devoir date passée');
|
||||||
await page.locator('#hw-description').fill('Test validation');
|
await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Test validation');
|
||||||
|
|
||||||
// Set a past date — fill() works with Svelte 5 bind:value
|
// Set a past date — fill() works with Svelte 5 bind:value
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
@@ -686,7 +686,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
|||||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
await page.locator('#hw-title').fill('Titre original');
|
await page.locator('#hw-title').fill('Titre original');
|
||||||
await page.locator('#hw-description').fill('Description inchangée');
|
await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Description inchangée');
|
||||||
await page.locator('#hw-due-date').fill(dueDate);
|
await page.locator('#hw-due-date').fill(dueDate);
|
||||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
@@ -698,7 +698,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
|||||||
await expect(editDialog).toBeVisible({ timeout: 10000 });
|
await expect(editDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Verify pre-filled values
|
// Verify pre-filled values
|
||||||
await expect(page.locator('#edit-description')).toHaveValue('Description inchangée');
|
await expect(page.locator('.modal .rich-text-content')).toContainText('Description inchangée');
|
||||||
await expect(page.locator('#edit-due-date')).toHaveValue(dueDate);
|
await expect(page.locator('#edit-due-date')).toHaveValue(dueDate);
|
||||||
|
|
||||||
// Change only the title
|
// Change only the title
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"svelte-eslint-parser": "^1.0.0",
|
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
@@ -42,6 +41,7 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||||
"svelte": "^5.15.0",
|
"svelte": "^5.15.0",
|
||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
|
"svelte-eslint-parser": "^1.0.0",
|
||||||
"tailwindcss": "^3.4.16",
|
"tailwindcss": "^3.4.16",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
@@ -51,6 +51,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/sveltekit": "^8.50.0",
|
"@sentry/sveltekit": "^8.50.0",
|
||||||
"@tanstack/svelte-query": "^5.66.0",
|
"@tanstack/svelte-query": "^5.66.0",
|
||||||
|
"@tiptap/core": "^3.20.4",
|
||||||
|
"@tiptap/extension-link": "^3.20.4",
|
||||||
|
"@tiptap/pm": "^3.20.4",
|
||||||
|
"@tiptap/starter-kit": "^3.20.4",
|
||||||
"@vite-pwa/sveltekit": "^0.6.8",
|
"@vite-pwa/sveltekit": "^0.6.8",
|
||||||
"web-vitals": "^4.2.0",
|
"web-vitals": "^4.2.0",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
|||||||
535
frontend/pnpm-lock.yaml
generated
535
frontend/pnpm-lock.yaml
generated
@@ -14,6 +14,18 @@ importers:
|
|||||||
'@tanstack/svelte-query':
|
'@tanstack/svelte-query':
|
||||||
specifier: ^5.66.0
|
specifier: ^5.66.0
|
||||||
version: 5.90.2(svelte@5.49.1)
|
version: 5.90.2(svelte@5.49.1)
|
||||||
|
'@tiptap/core':
|
||||||
|
specifier: ^3.20.4
|
||||||
|
version: 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/extension-link':
|
||||||
|
specifier: ^3.20.4
|
||||||
|
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm':
|
||||||
|
specifier: ^3.20.4
|
||||||
|
version: 3.20.4
|
||||||
|
'@tiptap/starter-kit':
|
||||||
|
specifier: ^3.20.4
|
||||||
|
version: 3.20.4
|
||||||
'@vite-pwa/sveltekit':
|
'@vite-pwa/sveltekit':
|
||||||
specifier: ^0.6.8
|
specifier: ^0.6.8
|
||||||
version: 0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))
|
version: 0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))
|
||||||
@@ -1315,6 +1327,9 @@ packages:
|
|||||||
'@prisma/instrumentation@5.22.0':
|
'@prisma/instrumentation@5.22.0':
|
||||||
resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==}
|
resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==}
|
||||||
|
|
||||||
|
'@remirror/core-constants@3.0.0':
|
||||||
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
'@rollup/plugin-babel@5.3.1':
|
'@rollup/plugin-babel@5.3.1':
|
||||||
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1741,6 +1756,132 @@ packages:
|
|||||||
vitest:
|
vitest:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tiptap/core@3.20.4':
|
||||||
|
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/pm': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-blockquote@3.20.4':
|
||||||
|
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-bold@3.20.4':
|
||||||
|
resolution: {integrity: sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-bullet-list@3.20.4':
|
||||||
|
resolution: {integrity: sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-code-block@3.20.4':
|
||||||
|
resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
'@tiptap/pm': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-code@3.20.4':
|
||||||
|
resolution: {integrity: sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-document@3.20.4':
|
||||||
|
resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-dropcursor@3.20.4':
|
||||||
|
resolution: {integrity: sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extensions': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-gapcursor@3.20.4':
|
||||||
|
resolution: {integrity: sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extensions': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-hard-break@3.20.4':
|
||||||
|
resolution: {integrity: sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-heading@3.20.4':
|
||||||
|
resolution: {integrity: sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-horizontal-rule@3.20.4':
|
||||||
|
resolution: {integrity: sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
'@tiptap/pm': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-italic@3.20.4':
|
||||||
|
resolution: {integrity: sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-link@3.20.4':
|
||||||
|
resolution: {integrity: sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
'@tiptap/pm': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-list-item@3.20.4':
|
||||||
|
resolution: {integrity: sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-list-keymap@3.20.4':
|
||||||
|
resolution: {integrity: sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-list@3.20.4':
|
||||||
|
resolution: {integrity: sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
'@tiptap/pm': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-ordered-list@3.20.4':
|
||||||
|
resolution: {integrity: sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@3.20.4':
|
||||||
|
resolution: {integrity: sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-strike@3.20.4':
|
||||||
|
resolution: {integrity: sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-text@3.20.4':
|
||||||
|
resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-underline@3.20.4':
|
||||||
|
resolution: {integrity: sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extensions@3.20.4':
|
||||||
|
resolution: {integrity: sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.20.4
|
||||||
|
'@tiptap/pm': ^3.20.4
|
||||||
|
|
||||||
|
'@tiptap/pm@3.20.4':
|
||||||
|
resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==}
|
||||||
|
|
||||||
|
'@tiptap/starter-kit@3.20.4':
|
||||||
|
resolution: {integrity: sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==}
|
||||||
|
|
||||||
'@types/aria-query@5.0.4':
|
'@types/aria-query@5.0.4':
|
||||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||||
|
|
||||||
@@ -1759,6 +1900,15 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/linkify-it@5.0.0':
|
||||||
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
|
'@types/markdown-it@14.1.2':
|
||||||
|
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||||
|
|
||||||
|
'@types/mdurl@2.0.0':
|
||||||
|
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||||
|
|
||||||
'@types/mysql@2.15.26':
|
'@types/mysql@2.15.26':
|
||||||
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
|
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
|
||||||
|
|
||||||
@@ -2137,6 +2287,9 @@ packages:
|
|||||||
core-js-compat@3.48.0:
|
core-js-compat@3.48.0:
|
||||||
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
|
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
|
||||||
|
|
||||||
|
crelt@1.0.6:
|
||||||
|
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2246,6 +2399,10 @@ packages:
|
|||||||
emoji-regex@9.2.2:
|
emoji-regex@9.2.2:
|
||||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
|
|
||||||
|
entities@4.5.0:
|
||||||
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
entities@6.0.1:
|
entities@6.0.1:
|
||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -2864,6 +3021,12 @@ packages:
|
|||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
|
linkify-it@5.0.0:
|
||||||
|
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||||
|
|
||||||
|
linkifyjs@4.3.2:
|
||||||
|
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||||
|
|
||||||
locate-character@3.0.0:
|
locate-character@3.0.0:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||||
|
|
||||||
@@ -2924,6 +3087,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
markdown-it@14.1.1:
|
||||||
|
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2931,6 +3098,9 @@ packages:
|
|||||||
mdn-data@2.12.2:
|
mdn-data@2.12.2:
|
||||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||||
|
|
||||||
|
mdurl@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||||
|
|
||||||
merge2@1.4.1:
|
merge2@1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3038,6 +3208,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
orderedmap@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3317,9 +3490,71 @@ packages:
|
|||||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
prosemirror-changeset@2.4.0:
|
||||||
|
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
|
||||||
|
|
||||||
|
prosemirror-collab@1.3.1:
|
||||||
|
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
|
||||||
|
|
||||||
|
prosemirror-commands@1.7.1:
|
||||||
|
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||||
|
|
||||||
|
prosemirror-dropcursor@1.8.2:
|
||||||
|
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||||
|
|
||||||
|
prosemirror-gapcursor@1.4.1:
|
||||||
|
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||||
|
|
||||||
|
prosemirror-history@1.5.0:
|
||||||
|
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||||
|
|
||||||
|
prosemirror-inputrules@1.5.1:
|
||||||
|
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||||
|
|
||||||
|
prosemirror-keymap@1.2.3:
|
||||||
|
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||||
|
|
||||||
|
prosemirror-markdown@1.13.4:
|
||||||
|
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
|
||||||
|
|
||||||
|
prosemirror-menu@1.3.0:
|
||||||
|
resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
|
||||||
|
|
||||||
|
prosemirror-model@1.25.4:
|
||||||
|
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
|
||||||
|
|
||||||
|
prosemirror-schema-basic@1.2.4:
|
||||||
|
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
|
||||||
|
|
||||||
|
prosemirror-schema-list@1.5.1:
|
||||||
|
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||||
|
|
||||||
|
prosemirror-state@1.4.4:
|
||||||
|
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||||
|
|
||||||
|
prosemirror-tables@1.8.5:
|
||||||
|
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||||
|
|
||||||
|
prosemirror-trailing-node@3.0.0:
|
||||||
|
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
|
||||||
|
peerDependencies:
|
||||||
|
prosemirror-model: ^1.22.1
|
||||||
|
prosemirror-state: ^1.4.2
|
||||||
|
prosemirror-view: ^1.33.8
|
||||||
|
|
||||||
|
prosemirror-transform@1.11.0:
|
||||||
|
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
|
||||||
|
|
||||||
|
prosemirror-view@1.41.7:
|
||||||
|
resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==}
|
||||||
|
|
||||||
proxy-from-env@1.1.0:
|
proxy-from-env@1.1.0:
|
||||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
|
punycode.js@2.3.1:
|
||||||
|
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3405,6 +3640,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rope-sequence@1.3.4:
|
||||||
|
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
@@ -3748,6 +3986,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uc.micro@2.1.0:
|
||||||
|
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3919,6 +4160,9 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8:
|
||||||
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5368,6 +5612,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.6)(rollup@2.79.2)':
|
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.6)(rollup@2.79.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.6
|
'@babel/core': 7.28.6
|
||||||
@@ -5815,6 +6061,152 @@ snapshots:
|
|||||||
vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)
|
vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)
|
||||||
vitest: 2.1.9(@types/node@22.19.7)(jsdom@27.4.0)(terser@5.46.0)
|
vitest: 2.1.9(@types/node@22.19.7)(jsdom@27.4.0)(terser@5.46.0)
|
||||||
|
|
||||||
|
'@tiptap/core@3.20.4(@tiptap/pm@3.20.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-blockquote@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-bold@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-bullet-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-code@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-dropcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-gapcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-hard-break@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-heading@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-horizontal-rule@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-italic@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-link@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
linkifyjs: 4.3.2
|
||||||
|
|
||||||
|
'@tiptap/extension-list-item@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-list-keymap@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
|
||||||
|
'@tiptap/extension-ordered-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-strike@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extension-underline@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
|
||||||
|
'@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
|
||||||
|
'@tiptap/pm@3.20.4':
|
||||||
|
dependencies:
|
||||||
|
prosemirror-changeset: 2.4.0
|
||||||
|
prosemirror-collab: 1.3.1
|
||||||
|
prosemirror-commands: 1.7.1
|
||||||
|
prosemirror-dropcursor: 1.8.2
|
||||||
|
prosemirror-gapcursor: 1.4.1
|
||||||
|
prosemirror-history: 1.5.0
|
||||||
|
prosemirror-inputrules: 1.5.1
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-markdown: 1.13.4
|
||||||
|
prosemirror-menu: 1.3.0
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-schema-basic: 1.2.4
|
||||||
|
prosemirror-schema-list: 1.5.1
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-tables: 1.8.5
|
||||||
|
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
|
||||||
|
'@tiptap/starter-kit@3.20.4':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/extension-blockquote': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-bold': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-bullet-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-code': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/extension-document': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-dropcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-gapcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-hard-break': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-heading': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-horizontal-rule': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/extension-italic': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-link': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/extension-list-item': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-list-keymap': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-ordered-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-paragraph': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-strike': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-text': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extension-underline': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||||
|
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||||
|
'@tiptap/pm': 3.20.4
|
||||||
|
|
||||||
'@types/aria-query@5.0.4': {}
|
'@types/aria-query@5.0.4': {}
|
||||||
|
|
||||||
'@types/connect@3.4.36':
|
'@types/connect@3.4.36':
|
||||||
@@ -5829,6 +6221,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
|
'@types/markdown-it@14.1.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/linkify-it': 5.0.0
|
||||||
|
'@types/mdurl': 2.0.0
|
||||||
|
|
||||||
|
'@types/mdurl@2.0.0': {}
|
||||||
|
|
||||||
'@types/mysql@2.15.26':
|
'@types/mysql@2.15.26':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
@@ -6257,6 +6658,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
|
|
||||||
|
crelt@1.0.6: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -6356,6 +6759,8 @@ snapshots:
|
|||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
|
entities@4.5.0: {}
|
||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
@@ -7107,6 +7512,12 @@ snapshots:
|
|||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
|
linkify-it@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
uc.micro: 2.1.0
|
||||||
|
|
||||||
|
linkifyjs@4.3.2: {}
|
||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
@@ -7165,10 +7576,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
||||||
|
markdown-it@14.1.1:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
entities: 4.5.0
|
||||||
|
linkify-it: 5.0.0
|
||||||
|
mdurl: 2.0.0
|
||||||
|
punycode.js: 2.3.1
|
||||||
|
uc.micro: 2.1.0
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mdn-data@2.12.2: {}
|
mdn-data@2.12.2: {}
|
||||||
|
|
||||||
|
mdurl@2.0.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
@@ -7256,6 +7678,8 @@ snapshots:
|
|||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
|
orderedmap@2.1.1: {}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
@@ -7431,8 +7855,113 @@ snapshots:
|
|||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
|
|
||||||
|
prosemirror-changeset@2.4.0:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
|
||||||
|
prosemirror-collab@1.3.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
|
||||||
|
prosemirror-commands@1.7.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
|
||||||
|
prosemirror-dropcursor@1.8.2:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
|
||||||
|
prosemirror-gapcursor@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
|
||||||
|
prosemirror-history@1.5.0:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
rope-sequence: 1.3.4
|
||||||
|
|
||||||
|
prosemirror-inputrules@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
|
||||||
|
prosemirror-keymap@1.2.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
w3c-keyname: 2.2.8
|
||||||
|
|
||||||
|
prosemirror-markdown@1.13.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/markdown-it': 14.1.2
|
||||||
|
markdown-it: 14.1.1
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
|
||||||
|
prosemirror-menu@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
crelt: 1.0.6
|
||||||
|
prosemirror-commands: 1.7.1
|
||||||
|
prosemirror-history: 1.5.0
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
|
||||||
|
prosemirror-model@1.25.4:
|
||||||
|
dependencies:
|
||||||
|
orderedmap: 2.1.1
|
||||||
|
|
||||||
|
prosemirror-schema-basic@1.2.4:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
|
||||||
|
prosemirror-schema-list@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
|
||||||
|
prosemirror-state@1.4.4:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
|
||||||
|
prosemirror-tables@1.8.5:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
|
||||||
|
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7):
|
||||||
|
dependencies:
|
||||||
|
'@remirror/core-constants': 3.0.0
|
||||||
|
escape-string-regexp: 4.0.0
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-view: 1.41.7
|
||||||
|
|
||||||
|
prosemirror-transform@1.11.0:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
|
||||||
|
prosemirror-view@1.41.7:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.4
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.11.0
|
||||||
|
|
||||||
proxy-from-env@1.1.0: {}
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
|
punycode.js@2.3.1: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
@@ -7557,6 +8086,8 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.57.0
|
'@rollup/rollup-win32-x64-msvc': 4.57.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
rope-sequence@1.3.4: {}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
@@ -7994,6 +8525,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
uc.micro@2.1.0: {}
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -8134,6 +8667,8 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|||||||
@@ -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}
|
{#if detail.description}
|
||||||
<section class="detail-description">
|
<section class="detail-description">
|
||||||
<h3>Description</h3>
|
<h3>Description</h3>
|
||||||
<p>{detail.description}</p>
|
<div class="description-content">{@html detail.description}</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -183,11 +183,34 @@
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-description p {
|
.detail-description :global(.description-content) {
|
||||||
margin: 0;
|
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
line-height: 1.6;
|
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 {
|
.download-error {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||||
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
||||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.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 SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
@@ -28,6 +30,13 @@
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HomeworkAttachmentFile {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RuleWarning {
|
interface RuleWarning {
|
||||||
ruleType: string;
|
ruleType: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -75,6 +84,10 @@
|
|||||||
let newDescription = $state('');
|
let newDescription = $state('');
|
||||||
let newDueDate = $state('');
|
let newDueDate = $state('');
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
|
let newPendingFiles = $state<File[]>([]);
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
|
||||||
|
|
||||||
// Edit modal
|
// Edit modal
|
||||||
let showEditModal = $state(false);
|
let showEditModal = $state(false);
|
||||||
@@ -321,6 +334,7 @@
|
|||||||
newTitle = '';
|
newTitle = '';
|
||||||
newDescription = '';
|
newDescription = '';
|
||||||
newDueDate = '';
|
newDueDate = '';
|
||||||
|
newPendingFiles = [];
|
||||||
ruleConformMinDate = '';
|
ruleConformMinDate = '';
|
||||||
dueDateError = null;
|
dueDateError = null;
|
||||||
}
|
}
|
||||||
@@ -382,6 +396,22 @@
|
|||||||
throw new Error(msg);
|
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();
|
closeCreateModal();
|
||||||
showRuleWarningModal = false;
|
showRuleWarningModal = false;
|
||||||
ruleWarnings = [];
|
ruleWarnings = [];
|
||||||
@@ -525,18 +555,63 @@
|
|||||||
showCreateModal = true;
|
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 ---
|
// --- Edit ---
|
||||||
function openEditModal(hw: Homework) {
|
async function openEditModal(hw: Homework) {
|
||||||
editHomework = hw;
|
editHomework = hw;
|
||||||
editTitle = hw.title;
|
editTitle = hw.title;
|
||||||
editDescription = hw.description ?? '';
|
editDescription = hw.description ?? '';
|
||||||
editDueDate = hw.dueDate;
|
editDueDate = hw.dueDate;
|
||||||
|
editAttachments = [];
|
||||||
showEditModal = true;
|
showEditModal = true;
|
||||||
|
|
||||||
|
// Charger les pièces jointes existantes en arrière-plan
|
||||||
|
editAttachments = await fetchAttachments(hw.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeEditModal() {
|
function closeEditModal() {
|
||||||
showEditModal = false;
|
showEditModal = false;
|
||||||
editHomework = null;
|
editHomework = null;
|
||||||
|
editAttachments = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
@@ -811,7 +886,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hw.description}
|
{#if hw.description}
|
||||||
<p class="homework-description">{hw.description}</p>
|
<div class="homework-description">{@html hw.description}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hw.status === 'published'}
|
{#if hw.status === 'published'}
|
||||||
@@ -901,13 +976,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="hw-description">Description</label>
|
<label>Description</label>
|
||||||
<textarea
|
<RichTextEditor
|
||||||
id="hw-description"
|
content={newDescription}
|
||||||
bind:value={newDescription}
|
onUpdate={(html) => (newDescription = html)}
|
||||||
placeholder="Consignes, pages à lire, liens utiles..."
|
placeholder="Consignes, pages à lire, liens utiles..."
|
||||||
rows="4"
|
disabled={isSubmitting}
|
||||||
></textarea>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -931,6 +1006,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
|
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
|
||||||
Annuler
|
Annuler
|
||||||
@@ -997,13 +1093,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-description">Description</label>
|
<label>Description</label>
|
||||||
<textarea
|
<RichTextEditor
|
||||||
id="edit-description"
|
content={editDescription}
|
||||||
bind:value={editDescription}
|
onUpdate={(html) => (editDescription = html)}
|
||||||
placeholder="Consignes, pages à lire, liens utiles..."
|
placeholder="Consignes, pages à lire, liens utiles..."
|
||||||
rows="4"
|
disabled={isUpdating}
|
||||||
></textarea>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -1011,6 +1107,18 @@
|
|||||||
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
|
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
|
||||||
</div>
|
</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">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}>
|
<button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}>
|
||||||
Annuler
|
Annuler
|
||||||
@@ -1553,7 +1661,16 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
line-height: 1.5;
|
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 {
|
.homework-actions {
|
||||||
|
|||||||
Reference in New Issue
Block a user