36 KiB
Task Documents Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Allow users to attach documents to tasks with drag & drop / file selection, preview images and PDFs in a fullscreen modal, and download any file.
Architecture: New TaskDocument entity with API Platform CRUD (multipart POST via custom Processor, download via custom Provider). Doctrine EntityListener for file cleanup on delete/cascade. Frontend: 3 components (upload zone, document list, preview modal) integrated into TaskModal.vue.
Tech Stack: PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, Nuxt 4, Vue 3, TypeScript, Tailwind CSS
Spec: docs/superpowers/specs/2026-03-15-task-documents-design.md
Chunk 1: Backend — Entity, Migration, Config
Task 1: PHP/Nginx upload limits
Files:
-
Modify:
docker/php/config/php.ini -
Modify:
docker/nginx/conf.d/lesstime.conf -
Step 1: Add upload limits to php.ini
Append to docker/php/config/php.ini:
[Upload]
upload_max_filesize = 50M
post_max_size = 55M
- Step 2: Add client_max_body_size to Nginx
Add client_max_body_size 55m; inside the server block of docker/nginx/conf.d/lesstime.conf, after index index.html;:
client_max_body_size 55m;
- Step 3: Restart containers to apply config
docker restart php-lesstime-fpm nginx-lesstime
- Step 4: Commit
git add docker/php/config/php.ini docker/nginx/conf.d/lesstime.conf
git commit -m "feat(config) : set upload limits to 50MB for task documents"
Task 1b: Docker volume for uploads persistence
Files:
-
Modify:
docker-compose.yml -
Step 1: Add named volume for uploads
In docker-compose.yml, add a named volume uploads_data and mount it in the php service:
Under php.volumes, add:
- uploads_data:/var/www/html/var/uploads
Under top-level volumes, add:
uploads_data:
- Step 2: Restart containers
docker compose down && docker compose up -d
- Step 3: Commit
git add docker-compose.yml
git commit -m "feat(docker) : add named volume for document uploads persistence"
Task 2: TaskDocument entity
Files:
-
Create:
src/Entity/TaskDocument.php -
Step 1: Create the TaskDocument entity
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use App\State\TaskDocumentProcessor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
new Post(
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([\App\EventListener\TaskDocumentListener::class])]
class TaskDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $fileName = null;
#[ORM\Column(length: 100)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $mimeType = null;
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
private ?int $size = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['task_document:read', 'task:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task_document:read', 'task:read'])]
private ?User $uploadedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getTask(): ?Task
{
return $this->task;
}
public function setTask(?Task $task): static
{
$this->task = $task;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
}
public function setOriginalName(string $originalName): static
{
$this->originalName = $originalName;
return $this;
}
public function getFileName(): ?string
{
return $this->fileName;
}
public function setFileName(string $fileName): static
{
$this->fileName = $fileName;
return $this;
}
public function getMimeType(): ?string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): static
{
$this->mimeType = $mimeType;
return $this;
}
public function getSize(): ?int
{
return $this->size;
}
public function setSize(int $size): static
{
$this->size = $size;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUploadedBy(): ?User
{
return $this->uploadedBy;
}
public function setUploadedBy(?User $uploadedBy): static
{
$this->uploadedBy = $uploadedBy;
return $this;
}
}
- Step 2: Add
documentsrelation to Task entity
In src/Entity/Task.php, add the documents OneToMany collection:
- Add import:
use Doctrine\Common\Collections\Collection;(already present) - Add property after
$archived:
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])]
#[Groups(['task:read'])]
private Collection $documents;
-
In constructor, add:
$this->documents = new ArrayCollection(); -
Add getter:
/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
- Step 3: Commit
git add src/Entity/TaskDocument.php src/Entity/Task.php
git commit -m "feat : add TaskDocument entity with Task relation"
Task 3: Generate and run migration
Files:
-
Create:
migrations/VersionXXX.php(auto-generated) -
Step 1: Generate migration
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff
- Step 2: Review the generated migration
Read the file and verify it creates task_document table with correct columns, indexes, and foreign keys.
- Step 3: Run migration
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
- Step 4: Commit
git add migrations/
git commit -m "feat : add task_document migration"
Task 4: TaskDocumentListener (file cleanup on delete)
Files:
-
Create:
src/EventListener/TaskDocumentListener.php -
Step 1: Create the listener
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\TaskDocument;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Psr\Log\LoggerInterface;
class TaskDocumentListener
{
public function __construct(
private readonly string $uploadDir,
private readonly LoggerInterface $logger,
) {}
public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void
{
$filePath = $this->uploadDir . '/' . $document->getFileName();
if (file_exists($filePath)) {
if (!unlink($filePath)) {
$this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]);
}
} else {
$this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]);
}
}
}
- Step 2: Register the service with uploadDir parameter
Create config/services_task_document.yaml or add to config/services.yaml:
# In config/services.yaml, add:
parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
services:
App\EventListener\TaskDocumentListener:
arguments:
$uploadDir: '%task_document_upload_dir%'
If config/services.yaml already has parameters: section, merge into it. If not, add the parameter block.
- Step 3: Create upload directory
mkdir -p var/uploads/documents
- Step 4: Commit
git add src/EventListener/TaskDocumentListener.php config/services.yaml
git commit -m "feat : add TaskDocumentListener for file cleanup on delete"
Task 5: TaskDocumentProcessor (upload handler)
Files:
-
Create:
src/State/TaskDocumentProcessor.php -
Step 1: Create the processor
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\TaskDocument;
use App\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
/**
* @implements ProcessorInterface<TaskDocument, TaskDocument>
*/
final readonly class TaskDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private string $uploadDir,
) {}
/**
* @param TaskDocument $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$taskIri = $request->request->get('task');
if (null === $taskIri || '' === $taskIri) {
throw new BadRequestHttpException('Task IRI is required.');
}
// Extract task ID from IRI (e.g., "/api/tasks/42" -> 42)
$taskId = (int) basename((string) $taskIri);
$task = $this->entityManager->getRepository(Task::class)->find($taskId);
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
// Capture file metadata BEFORE move() — move invalidates the temp file
$originalName = $file->getClientOriginalName();
$extension = $file->getClientOriginalExtension() ?: 'bin';
$mimeType = $file->getClientMimeType() ?? 'application/octet-stream';
$fileSize = $file->getSize();
$uuid = Uuid::v4()->toRfc4122();
$fileName = $uuid . '.' . $extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
$document->setSize($fileSize);
$document->setCreatedAt(new \DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return $document;
}
}
- Step 2: Register uploadDir injection
In config/services.yaml, add:
App\State\TaskDocumentProcessor:
arguments:
$uploadDir: '%task_document_upload_dir%'
- Step 3: Commit
git add src/State/TaskDocumentProcessor.php config/services.yaml
git commit -m "feat : add TaskDocumentProcessor for multipart file upload"
Task 6: TaskDocumentDownloadController (file download)
Files:
- Create:
src/Controller/TaskDocumentDownloadController.php - Modify:
config/routes.yaml(or createconfig/routes/task_document.yaml)
Why a controller instead of a Provider: API Platform providers return resource objects that get serialized. A file download needs to return a BinaryFileResponse directly, which bypasses API Platform's serialization pipeline. A Symfony controller is the correct approach for binary file serving.
- Step 1: Create the download controller
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TaskDocumentDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $uploadDir,
) {}
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function __invoke(int $id): BinaryFileResponse
{
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
if (null === $document) {
throw new NotFoundHttpException('Document not found.');
}
$filePath = $this->uploadDir . '/' . $document->getFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('File not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images and PDFs, attachment for everything else
$disposition = str_starts_with($mimeType, 'image/') || $mimeType === 'application/pdf'
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
$response->setContentDisposition($disposition, $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);
return $response;
}
}
- Step 2: Register uploadDir injection
In config/services.yaml, add:
App\Controller\TaskDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
- Step 3: Commit
git add src/Controller/TaskDocumentDownloadController.php config/services.yaml
git commit -m "feat : add TaskDocumentDownloadController for file download"
Task 7: Verify backend API
- Step 1: Clear cache and verify no errors
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
- Step 2: Test upload via curl
curl -X POST http://localhost:8082/api/task_documents \
-H "Cookie: BEARER=<jwt_token>" \
-F "file=@/path/to/test-file.png" \
-F "task=/api/tasks/1"
Verify response returns JSON with document metadata.
- Step 3: Test download
curl -I http://localhost:8082/api/task_documents/1/download \
-H "Cookie: BEARER=<jwt_token>"
Verify Content-Type and Content-Disposition headers.
- Step 4: Test delete
curl -X DELETE http://localhost:8082/api/task_documents/1 \
-H "Cookie: BEARER=<jwt_token>"
Verify document removed from DB and file deleted from disk.
Chunk 2: Frontend — Service, DTO, Components
Task 8: TypeScript DTO and service
Files:
-
Create:
frontend/services/dto/task-document.ts -
Create:
frontend/services/task-documents.ts -
Step 1: Create DTO
import type { UserData } from './user-data'
export type TaskDocument = {
'@id'?: string
id: number
task: string
originalName: string
fileName: string
mimeType: string
size: number
createdAt: string
uploadedBy: UserData | null
}
- Step 2: Add
documentsto Task DTO
In frontend/services/dto/task.ts, add import and field:
import type { TaskDocument } from './task-document'
Add to Task type:
documents: TaskDocument[]
- Step 3: Create task-documents service
import type { TaskDocument } from './dto/task-document'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
import { $fetch } from 'ofetch'
export function useTaskDocumentService() {
const api = useApi()
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
async function getByTask(taskId: number): Promise<TaskDocument[]> {
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
task: `/api/tasks/${taskId}`,
})
return extractHydraMembers(data)
}
async function upload(taskId: number, file: File, onProgress?: (percent: number) => void): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('task', `/api/tasks/${taskId}`)
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_documents/${id}`, {}, {
toastSuccessKey: 'taskDocuments.deleted',
})
}
function getDownloadUrl(id: number): string {
return `${baseURL}/task_documents/${id}/download`
}
return { getByTask, upload, remove, getDownloadUrl }
}
- Step 4: Commit
git add frontend/services/dto/task-document.ts frontend/services/task-documents.ts frontend/services/dto/task.ts
git commit -m "feat(frontend) : add TaskDocument DTO and service"
Task 9: i18n translations
Files:
-
Modify:
frontend/i18n/locales/fr.json -
Step 1: Add translation keys
Add after the "tasks" block in fr.json:
"taskDocuments": {
"title": "Documents",
"dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner",
"uploaded": "Document ajouté avec succès.",
"deleted": "Document supprimé avec succès.",
"uploadError": "Erreur lors de l'upload du document.",
"confirmDeleteTitle": "Supprimer le document",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
"download": "Télécharger",
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
}
- Step 2: Commit
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add task documents i18n translations"
Task 10: TaskDocumentUpload component
Files:
-
Create:
frontend/components/task/TaskDocumentUpload.vue -
Step 1: Create the upload component
<template>
<div
class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
:class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
multiple
class="hidden"
@change="handleFileSelect"
/>
<div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
<Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
<p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
</div>
<!-- Upload progress -->
<div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
<div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
<div
class="h-full rounded-full transition-all"
:class="upload.error ? 'bg-red-500' : 'bg-blue-500'"
:style="{ width: `${upload.progress}%` }"
/>
</div>
</div>
<Icon
v-if="upload.error"
name="heroicons:exclamation-circle"
class="h-5 w-5 shrink-0 text-red-500"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
taskId: number
}>()
const emit = defineEmits<{
uploaded: []
}>()
const { upload: uploadFile } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false)
type UploadState = {
name: string
progress: number
error: boolean
}
const uploads = ref<UploadState[]>([])
function handleDrop(event: DragEvent) {
isDragging.value = false
const files = event.dataTransfer?.files
if (files?.length) {
processFiles(Array.from(files))
}
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files?.length) {
processFiles(Array.from(input.files))
input.value = ''
}
}
async function processFiles(files: File[]) {
const maxSize = 50 * 1024 * 1024
for (const file of files) {
if (file.size > maxSize) {
toast.error({
title: 'Erreur',
message: t('taskDocuments.maxSizeError'),
})
continue
}
const state: UploadState = reactive({
name: file.name,
progress: 0,
error: false,
})
uploads.value.push(state)
try {
await uploadFile(props.taskId, file)
state.progress = 100
toast.success({
title: 'Succès',
message: t('taskDocuments.uploaded'),
})
} catch {
state.error = true
state.progress = 100
toast.error({
title: 'Erreur',
message: t('taskDocuments.uploadError'),
})
}
}
// Clean up completed uploads after a delay
setTimeout(() => {
uploads.value = uploads.value.filter(u => u.error)
}, 2000)
emit('uploaded')
}
</script>
- Step 2: Commit
git add frontend/components/task/TaskDocumentUpload.vue
git commit -m "feat(frontend) : add TaskDocumentUpload drag & drop component"
Task 11: TaskDocumentList component
Files:
-
Create:
frontend/components/task/TaskDocumentList.vue -
Step 1: Create the document list component
<template>
<div v-if="documents.length" class="mt-3">
<p class="mb-2 text-sm font-medium text-neutral-700">
{{ $t('taskDocuments.title') }} ({{ documents.length }})
</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div
v-for="doc in documents"
:key="doc.id"
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
@click="$emit('preview', doc)"
>
<!-- Thumbnail or icon -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
<img
v-if="isImage(doc.mimeType)"
:src="getDownloadUrl(doc.id)"
:alt="doc.originalName"
class="h-10 w-10 object-cover"
/>
<Icon
v-else
:name="getIconForMime(doc.mimeType)"
class="h-6 w-6 text-neutral-400"
/>
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
</div>
<!-- Delete button -->
<button
v-if="isAdmin"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)"
>
<Icon name="heroicons:x-mark" class="h-4 w-4" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
defineProps<{
documents: TaskDocument[]
isAdmin: boolean
}>()
defineEmits<{
preview: [doc: TaskDocument]
delete: [doc: TaskDocument]
}>()
const { getDownloadUrl } = useTaskDocumentService()
function isImage(mimeType: string): boolean {
return mimeType.startsWith('image/')
}
function getIconForMime(mimeType: string): string {
if (mimeType === 'application/pdf') return 'heroicons:document-text'
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
return 'heroicons:paper-clip'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
</script>
- Step 2: Commit
git add frontend/components/task/TaskDocumentList.vue
git commit -m "feat(frontend) : add TaskDocumentList with thumbnails and icons"
Task 12: TaskDocumentPreview modal
Files:
-
Create:
frontend/components/task/TaskDocumentPreview.vue -
Step 1: Create the preview modal
<template>
<Teleport to="body">
<Transition name="fade" appear>
<div
v-if="document"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
@click.self="$emit('close')"
@keydown.escape="$emit('close')"
@keydown.left="$emit('prev')"
@keydown.right="$emit('next')"
tabindex="0"
ref="overlayRef"
>
<!-- Close button -->
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('close')"
>
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
<!-- Navigation arrows -->
<button
v-if="hasPrev"
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('prev')"
>
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
</button>
<button
v-if="hasNext"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('next')"
>
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
</button>
<!-- Content -->
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
<!-- Image preview -->
<img
v-if="isImage"
:src="downloadUrl"
:alt="document.originalName"
class="max-h-[85vh] max-w-[90vw] object-contain"
/>
<!-- PDF preview -->
<iframe
v-else-if="isPdf"
:src="downloadUrl"
class="h-[85vh] w-[80vw] rounded-lg bg-white"
/>
<!-- Generic file -->
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
<a
:href="downloadUrl"
download
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('taskDocuments.download') }}
</a>
</div>
<!-- File name footer -->
<p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
const props = defineProps<{
document: TaskDocument | null
hasPrev: boolean
hasNext: boolean
}>()
defineEmits<{
close: []
prev: []
next: []
}>()
const overlayRef = ref<HTMLElement | null>(null)
const { getDownloadUrl } = useTaskDocumentService()
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
// Focus overlay for keyboard events
watch(() => props.document, (doc) => {
if (doc) {
nextTick(() => overlayRef.value?.focus())
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
- Step 2: Commit
git add frontend/components/task/TaskDocumentPreview.vue
git commit -m "feat(frontend) : add TaskDocumentPreview fullscreen modal"
Chunk 3: Frontend — Integration into TaskModal
Task 13: Integrate documents into TaskModal
Files:
-
Modify:
frontend/components/task/TaskModal.vue -
Step 1: Add document state and methods to script
In <script setup> of TaskModal.vue, add:
import type { TaskDocument } from '~/services/dto/task-document'
const { remove: removeDocument } = useTaskDocumentService()
Add reactive state:
const previewDoc = ref<TaskDocument | null>(null)
Add computed for documents from task:
const documents = computed(() => props.task?.documents ?? [])
Add helper for admin check:
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
Add methods:
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
previewDoc.value = doc
}
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = documents.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < documents.value.length - 1) {
previewDoc.value = documents.value[previewIndex.value + 1]
}
}
async function handleDeleteDocument(doc: TaskDocument) {
if (!confirm(t('taskDocuments.confirmDeleteMessage'))) return
await removeDocument(doc.id)
emit('saved')
}
function handleDocumentUploaded() {
emit('saved')
}
- Step 2: Add components in template
In the template, after the Description <div class="mt-5">...</div> block (line ~122) and before <TaskGitSection:
<!-- Documents -->
<TaskDocumentUpload
v-if="isEditing && task && isAdmin"
:task-id="task.id"
@uploaded="handleDocumentUploaded"
/>
<TaskDocumentList
v-if="isEditing && task"
:documents="documents"
:is-admin="isAdmin"
@preview="openPreview"
@delete="handleDeleteDocument"
/>
<!-- Document preview modal -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < documents.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
- Step 3: Verify the modal renders correctly
Run make dev-nuxt and open a task modal. Check:
-
Upload zone appears under description (only for admin)
-
Existing documents show in list
-
Clicking a document opens preview modal
-
Navigation arrows work
-
Delete button appears for admin
-
Step 4: Commit
git add frontend/components/task/TaskModal.vue
git commit -m "feat(frontend) : integrate documents into TaskModal"
Task 14: Final verification
- Step 1: Full flow test
- Log in as admin (
admin/admin) - Open a task modal
- Drag a file onto the upload zone → document appears in list
- Click the document → preview modal opens
- Upload a PDF → preview shows embedded PDF
- Upload a non-image/non-PDF → preview shows download button
- Navigate between documents with arrows
- Close preview with Escape
- Delete a document → confirm → document removed
- Delete the task → verify files are cleaned up from disk
- Step 2: Commit all remaining changes (if any)
git add -A
git commit -m "feat : task documents upload and preview"