diff --git a/.gitignore b/.gitignore index 68bc17f..6c4d8e0 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,9 @@ venv.bak/ # Rope project settings .ropeproject +# NodeModules +node_modules/ + # mkdocs documentation /site diff --git a/backend/app/api/v1/labels.py b/backend/app/api/v1/labels.py new file mode 100644 index 0000000..2366a7a --- /dev/null +++ b/backend/app/api/v1/labels.py @@ -0,0 +1,237 @@ +""" +Router tag (Label). + +Endpoint: + - GET /labels – elenca i tag del tenant + - POST /labels – crea un nuovo tag (admin) + - PATCH /labels/{id} – modifica un tag (admin) + - DELETE /labels/{id} – elimina un tag (admin) + + - GET /messages/{id}/labels – tag di un messaggio + - PUT /messages/{id}/labels – imposta i tag di un messaggio (sostituisce) + - POST /messages/{id}/labels/add – aggiunge tag a un messaggio + - POST /messages/{id}/labels/remove – rimuove tag da un messaggio + + - POST /messages/bulk-labels – aggiunge/rimuove tag in blocco + +Permessi: + - GET /labels: tutti gli utenti autenticati + - POST/PATCH/DELETE: solo admin + - Operazioni su messaggi: utenti con accesso alla casella del messaggio +""" + +import uuid + +from fastapi import APIRouter, status +from sqlalchemy import select + +from app.core.exceptions import ForbiddenError, NotFoundError +from app.dependencies import AdminUser, CurrentUser, DB +from app.models.message import Message +from app.schemas.label import ( + LabelCreate, + LabelResponse, + LabelUpdate, + MessageBulkLabelRequest, + MessageBulkLabelResponse, + MessageLabelAddRequest, + MessageLabelRemoveRequest, + MessageLabelSetRequest, +) +from app.services.label_service import LabelService + +router = APIRouter(tags=["Labels"]) + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +async def _check_message_access( + message_id: uuid.UUID, + current_user, + db, +) -> Message: + """Verifica che il messaggio esista e l'utente vi abbia accesso.""" + result = await db.execute( + select(Message).where( + Message.id == message_id, + Message.tenant_id == current_user.tenant_id, + ) + ) + message = result.scalar_one_or_none() + if not message: + raise NotFoundError(f"Messaggio {message_id} non trovato") + + if not current_user.is_admin: + from app.services.permission_service import PermissionService + perm_svc = PermissionService(db) + if not await perm_svc.check_can_read(current_user, message.mailbox_id): + raise ForbiddenError("Accesso al messaggio non autorizzato") + + return message + + +# ─── CRUD Label ─────────────────────────────────────────────────────────────── + +@router.get("/labels", response_model=list[LabelResponse]) +async def list_labels( + current_user: CurrentUser, + db: DB, +) -> list[LabelResponse]: + """Elenca tutti i tag del tenant corrente.""" + svc = LabelService(db) + labels = await svc.list_labels(current_user.tenant_id) + return [LabelResponse.model_validate(l) for l in labels] + + +@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED) +async def create_label( + data: LabelCreate, + current_user: AdminUser, + db: DB, +) -> LabelResponse: + """Crea un nuovo tag (solo admin).""" + svc = LabelService(db) + label = await svc.create_label(current_user.tenant_id, data) + return LabelResponse.model_validate(label) + + +@router.patch("/labels/{label_id}", response_model=LabelResponse) +async def update_label( + label_id: uuid.UUID, + data: LabelUpdate, + current_user: AdminUser, + db: DB, +) -> LabelResponse: + """Modifica un tag esistente (solo admin).""" + svc = LabelService(db) + label = await svc.update_label(current_user.tenant_id, label_id, data) + return LabelResponse.model_validate(label) + + +@router.delete("/labels/{label_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_label( + label_id: uuid.UUID, + current_user: AdminUser, + db: DB, +) -> None: + """Elimina un tag (solo admin). Viene rimosso automaticamente da tutti i messaggi.""" + svc = LabelService(db) + await svc.delete_label(current_user.tenant_id, label_id) + + +# ─── Tag su singolo messaggio ───────────────────────────────────────────────── + +@router.get("/messages/{message_id}/labels", response_model=list[LabelResponse]) +async def get_message_labels( + message_id: uuid.UUID, + current_user: CurrentUser, + db: DB, +) -> list[LabelResponse]: + """Elenca i tag assegnati a un messaggio.""" + await _check_message_access(message_id, current_user, db) + svc = LabelService(db) + labels = await svc.get_message_labels(message_id, current_user.tenant_id) + return [LabelResponse.model_validate(l) for l in labels] + + +@router.put("/messages/{message_id}/labels", response_model=list[LabelResponse]) +async def set_message_labels( + message_id: uuid.UUID, + data: MessageLabelSetRequest, + current_user: CurrentUser, + db: DB, +) -> list[LabelResponse]: + """ + Sostituisce tutti i tag di un messaggio. + Passare una lista vuota per rimuovere tutti i tag. + """ + await _check_message_access(message_id, current_user, db) + svc = LabelService(db) + labels = await svc.set_message_labels( + message_id, current_user.tenant_id, data.label_ids + ) + return [LabelResponse.model_validate(l) for l in labels] + + +@router.post("/messages/{message_id}/labels/add", response_model=list[LabelResponse]) +async def add_message_labels( + message_id: uuid.UUID, + data: MessageLabelAddRequest, + current_user: CurrentUser, + db: DB, +) -> list[LabelResponse]: + """Aggiunge tag a un messaggio senza rimuovere quelli esistenti.""" + await _check_message_access(message_id, current_user, db) + svc = LabelService(db) + labels = await svc.add_message_labels( + message_id, current_user.tenant_id, data.label_ids + ) + return [LabelResponse.model_validate(l) for l in labels] + + +@router.post("/messages/{message_id}/labels/remove", response_model=list[LabelResponse]) +async def remove_message_labels( + message_id: uuid.UUID, + data: MessageLabelRemoveRequest, + current_user: CurrentUser, + db: DB, +) -> list[LabelResponse]: + """Rimuove specifici tag da un messaggio.""" + await _check_message_access(message_id, current_user, db) + svc = LabelService(db) + labels = await svc.remove_message_labels( + message_id, current_user.tenant_id, data.label_ids + ) + return [LabelResponse.model_validate(l) for l in labels] + + +# ─── Bulk labels ────────────────────────────────────────────────────────────── + +@router.post("/messages/bulk-labels", response_model=MessageBulkLabelResponse) +async def bulk_labels( + data: MessageBulkLabelRequest, + current_user: CurrentUser, + db: DB, +) -> MessageBulkLabelResponse: + """ + Aggiunge o rimuove tag da più messaggi in blocco. + + - action="add": aggiunge i tag ai messaggi indicati + - action="remove": rimuove i tag dai messaggi indicati + + I messaggi non accessibili all'utente vengono silenziosamente ignorati. + """ + if not data.message_ids or not data.label_ids: + return MessageBulkLabelResponse(updated=0) + + # Per utenti non-admin filtra per caselle accessibili + message_ids = [str(mid) for mid in data.message_ids] + if not current_user.is_admin: + from app.services.permission_service import PermissionService + perm_svc = PermissionService(db) + visible = await perm_svc.get_visible_mailboxes(current_user) + visible_set = set(visible) if visible else set() + + # Filtra i messaggi per caselle visibili + result = await db.execute( + select(Message.id).where( + Message.id.in_(data.message_ids), + Message.tenant_id == current_user.tenant_id, + Message.mailbox_id.in_(visible_set), + ) + ) + filtered_ids = list(result.scalars().all()) + else: + filtered_ids = data.message_ids + + svc = LabelService(db) + if data.action == "add": + updated = await svc.bulk_add_labels( + filtered_ids, current_user.tenant_id, data.label_ids + ) + else: + updated = await svc.bulk_remove_labels( + filtered_ids, current_user.tenant_id, data.label_ids + ) + + return MessageBulkLabelResponse(updated=updated) diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index f1d5687..8b157a6 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -23,11 +23,13 @@ from fastapi import APIRouter, Query, status from fastapi.responses import StreamingResponse from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.config import get_settings from app.core.exceptions import ForbiddenError, NotFoundError from app.database import get_db from app.dependencies import CurrentUser, DB +from app.models.label import Label from app.models.message import Attachment, Message from app.schemas.message import ( AttachmentResponse, @@ -107,12 +109,20 @@ async def _resolve_message( current_user, db: AsyncSession, ) -> Message: - """Carica il messaggio e verifica i permessi di accesso.""" + """Carica il messaggio e verifica i permessi di accesso. + + L'accesso è consentito se: + 1. L'utente è admin del tenant, oppure + 2. L'utente ha un permesso diretto can_read sulla casella, oppure + 3. L'utente è assegnato a una Virtual Box attiva che include la casella. + """ result = await db.execute( - select(Message).where( + select(Message) + .where( Message.id == message_id, Message.tenant_id == current_user.tenant_id, ) + .options(selectinload(Message.labels)) ) message = result.scalar_one_or_none() if not message: @@ -121,8 +131,39 @@ async def _resolve_message( if not current_user.is_admin: from app.services.permission_service import PermissionService perm_svc = PermissionService(db) - if not await perm_svc.check_can_read(current_user, message.mailbox_id): - raise ForbiddenError("Accesso al messaggio non autorizzato") + has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id) + + if not has_direct_access: + # Verifica accesso tramite Virtual Box: + # l'utente deve essere assegnato a una VBox attiva + # che include la casella del messaggio. + from app.models.virtual_box import ( + VirtualBox, + VirtualBoxAssignment, + virtual_box_mailboxes, + ) + vbox_result = await db.execute( + select(VirtualBox.id) + .join( + VirtualBoxAssignment, + VirtualBox.id == VirtualBoxAssignment.virtual_box_id, + ) + .join( + virtual_box_mailboxes, + VirtualBox.id == virtual_box_mailboxes.c.virtual_box_id, + ) + .where( + VirtualBoxAssignment.user_id == current_user.id, + virtual_box_mailboxes.c.mailbox_id == message.mailbox_id, + VirtualBox.tenant_id == current_user.tenant_id, + VirtualBox.is_active == True, + ) + .limit(1) + ) + has_vbox_access = vbox_result.scalar_one_or_none() is not None + + if not has_vbox_access: + raise ForbiddenError("Accesso al messaggio non autorizzato") return message @@ -160,7 +201,6 @@ async def list_messages( # ── Filtro Virtual Box ──────────────────────────────────────────────────── vbox_rules: list = [] if vbox_id is not None: - from sqlalchemy.orm import selectinload from app.models.virtual_box import VirtualBox, VirtualBoxAssignment vbox_result = await db.execute( @@ -258,7 +298,8 @@ async def list_messages( # Ordinamento e paginazione q = ( - q.order_by( + q.options(selectinload(Message.labels)) + .order_by( Message.received_at.desc().nullslast(), Message.created_at.desc(), ) diff --git a/backend/app/api/v1/send.py b/backend/app/api/v1/send.py index b5233d8..e69ec6e 100644 --- a/backend/app/api/v1/send.py +++ b/backend/app/api/v1/send.py @@ -1,20 +1,22 @@ """ -Router API – Invio PEC (Fase 4). +Router API – Invio PEC. Endpoint: - POST /send – invia una nuova PEC (crea Message + SendJob, accoda job) - GET /send/jobs – lista job di invio del tenant (paginata) - GET /send/jobs/{id} – dettaglio di un singolo job - DELETE /send/jobs/{id} – annulla job se ancora pending/retrying + POST /send – invia una PEC via JSON (body_text, senza allegati) + POST /send/multipart – invia una PEC con allegati (multipart/form-data) + GET /send/jobs – lista job di invio del tenant (paginata) + GET /send/jobs/{id} – dettaglio di un singolo job + DELETE /send/jobs/{id} – annulla job se ancora pending/retrying """ +import json import uuid from typing import Annotated -from fastapi import APIRouter, Query, status +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status from app.core.exceptions import ForbiddenError -from app.dependencies import AdminUser, CurrentUser, DB +from app.dependencies import CurrentUser, DB from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest from app.services.permission_service import PermissionService from app.services.send_service import SendService @@ -32,18 +34,16 @@ def _job_response(job) -> SendJobResponse: return SendJobResponse.model_validate(job) -# ─── Endpoints ──────────────────────────────────────────────────────────────── - +# ─── POST /send (JSON – retrocompatibile) ──────────────────────────────────── @router.post( "", response_model=SendJobResponse, status_code=status.HTTP_201_CREATED, - summary="Invia una PEC", + summary="Invia una PEC (JSON, senza allegati)", description=( "Crea un messaggio PEC in uscita e accoda il job di invio SMTP. " - "Il job viene eseguito in background con retry automatico. " - "Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)." + "Per inviare allegati utilizzare `POST /send/multipart`." ), ) async def create_send_job( @@ -54,11 +54,87 @@ async def create_send_job( svc = _svc(db) job = await svc.create_send_job(current_user=current_user, data=data) await db.commit() - # Refresh per ottenere tutti i valori default dal DB await db.refresh(job) return _job_response(job) +# ─── POST /send/multipart (Form + files) ───────────────────────────────────── + +@router.post( + "/multipart", + response_model=SendJobResponse, + status_code=status.HTTP_201_CREATED, + summary="Invia una PEC con allegati (multipart/form-data)", + description=( + "Accetta un form multipart con:\n" + "- **data**: JSON string con i campi della PEC (stessa struttura di `POST /send`, " + " più il campo opzionale `body_html`)\n" + "- **attachments**: zero o più file allegati (max 20 MB ciascuno)\n\n" + "Carica gli allegati su MinIO e crea i record `Attachment` nel DB prima di accodare il job." + ), +) +async def create_send_job_multipart( + current_user: CurrentUser, + db: DB, + data: str = Form( + ..., + description="JSON con i dati della PEC (campi SendPecRequest + body_html opzionale)", + ), + attachments: list[UploadFile] = File( + default=[], + description="File allegati (0 o più, max 20 MB ciascuno)", + ), +) -> SendJobResponse: + # ── Parse del JSON ──────────────────────────────────────────────────────── + try: + raw = json.loads(data) + pec_data = SendPecRequest.model_validate(raw) + # Estrai body_html (non presente nel modello base) + body_html: str | None = raw.get("body_html") or None + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Dati PEC non validi: {exc}", + ) + + # ── Leggi file allegati ─────────────────────────────────────────────────── + MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB + files_data: list[dict] = [] + + for upload in attachments: + content = await upload.read() + if not content: + continue # Salta file vuoti + if len(content) > MAX_FILE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Il file '{upload.filename}' supera il limite di 20 MB", + ) + files_data.append( + { + "filename": upload.filename or "allegato", + "content": content, + "content_type": upload.content_type or "application/octet-stream", + } + ) + + # ── Sovrascrive body_html se fornito ────────────────────────────────────── + if body_html: + pec_data.body_html = body_html # type: ignore[attr-defined] + + svc = _svc(db) + job = await svc.create_send_job( + current_user=current_user, + data=pec_data, + attachments=files_data if files_data else None, + ) + await db.commit() + await db.refresh(job) + return _job_response(job) + + +# ─── GET /send/jobs ──────────────────────────────────────────────────────────── + @router.get( "/jobs", response_model=SendJobListResponse, @@ -76,15 +152,8 @@ async def list_send_jobs( description="Filtra per stato: pending | sending | sent | failed | retrying", ), ) -> SendJobListResponse: - """ - Elenca i job di invio del tenant. - - Gli admin vedono tutti i job; gli operatori vedono solo i job - delle caselle su cui hanno permesso can_read. - """ svc = _svc(db) - # Filtro opzionale per casella: verifica accesso se non admin if mailbox_id and not current_user.is_admin: perm_svc = PermissionService(db) if not await perm_svc.check_can_read(current_user, mailbox_id): @@ -105,6 +174,8 @@ async def list_send_jobs( ) +# ─── GET /send/jobs/{id} ─────────────────────────────────────────────────────── + @router.get( "/jobs/{job_id}", response_model=SendJobResponse, @@ -115,11 +186,9 @@ async def get_send_job( current_user: CurrentUser, db: DB, ) -> SendJobResponse: - """Recupera lo stato di un singolo job di invio.""" svc = _svc(db) job = await svc.get_send_job(job_id, current_user.tenant_id) - # Verifica accesso alla casella se non admin if not current_user.is_admin: perm_svc = PermissionService(db) if not await perm_svc.check_can_read(current_user, job.mailbox_id): @@ -128,6 +197,8 @@ async def get_send_job( return _job_response(job) +# ─── DELETE /send/jobs/{id} ──────────────────────────────────────────────────── + @router.delete( "/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, @@ -138,16 +209,9 @@ async def cancel_send_job( current_user: CurrentUser, db: DB, ) -> None: - """ - Annulla un job di invio se è ancora in stato **pending** o **retrying**. - - Non è possibile annullare un invio già partito (stato sending) o - completato (sent). - """ svc = _svc(db) - - # Verifica che l'utente possa agire su questo job job = await svc.get_send_job(job_id, current_user.tenant_id) + if not current_user.is_admin: perm_svc = PermissionService(db) if not await perm_svc.check_can_send(current_user, job.mailbox_id): diff --git a/backend/app/main.py b/backend/app/main.py index 8c373cc..d887c26 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from slowapi.util import get_remote_address -from app.api.v1 import auth, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws +from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws from app.config import get_settings from app.core.logging import get_logger, setup_logging from app.database import engine @@ -93,6 +93,7 @@ app.include_router(send.router, prefix=API_PREFIX) app.include_router(ws.router, prefix=API_PREFIX) app.include_router(virtual_boxes.router, prefix=API_PREFIX) app.include_router(notifications.router, prefix=API_PREFIX) +app.include_router(labels.router, prefix=API_PREFIX) # ─── Health check ───────────────────────────────────────────────────────────── diff --git a/backend/app/models/message.py b/backend/app/models/message.py index b8be47e..3af1989 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -108,6 +108,11 @@ class Message(Base): children: Mapped[list["Message"]] = relationship( "Message", foreign_keys=[parent_message_id] ) + labels: Mapped[list["Label"]] = relationship( # type: ignore[name-defined] + "Label", + secondary="message_labels", + lazy="select", + ) __table_args__ = ( Index("idx_messages_tenant", "tenant_id"), diff --git a/backend/app/schemas/label.py b/backend/app/schemas/label.py new file mode 100644 index 0000000..f8d0f3a --- /dev/null +++ b/backend/app/schemas/label.py @@ -0,0 +1,55 @@ +""" +Schemi Pydantic per Label (tag) e operazioni correlate. +""" + +import uuid +from typing import Optional + +from pydantic import BaseModel, Field + + +class LabelCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + + +class LabelUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + + +class LabelResponse(BaseModel): + id: uuid.UUID + tenant_id: uuid.UUID + name: str + color: Optional[str] = None + + model_config = {"from_attributes": True} + + +# ─── Richieste per assegnazione tag a messaggi ──────────────────────────────── + +class MessageLabelSetRequest(BaseModel): + """Sostituisce tutti i tag di un messaggio con quelli indicati.""" + label_ids: list[uuid.UUID] + + +class MessageLabelAddRequest(BaseModel): + """Aggiunge tag a un messaggio senza rimuovere quelli esistenti.""" + label_ids: list[uuid.UUID] + + +class MessageLabelRemoveRequest(BaseModel): + """Rimuove specifici tag da un messaggio.""" + label_ids: list[uuid.UUID] + + +class MessageBulkLabelRequest(BaseModel): + """Aggiunge o rimuove tag da più messaggi in blocco.""" + message_ids: list[uuid.UUID] + label_ids: list[uuid.UUID] + action: str = Field(..., pattern=r'^(add|remove)$') + + +class MessageBulkLabelResponse(BaseModel): + updated: int diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index d5e288c..a3a37bc 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -8,6 +8,8 @@ from typing import Optional from pydantic import BaseModel, model_validator +from app.schemas.label import LabelResponse + class AttachmentResponse(BaseModel): id: uuid.UUID @@ -50,6 +52,7 @@ class MessageResponse(BaseModel): raw_eml_path: Optional[str] = None created_at: datetime updated_at: datetime + labels: list[LabelResponse] = [] @model_validator(mode="before") @classmethod diff --git a/backend/app/services/label_service.py b/backend/app/services/label_service.py new file mode 100644 index 0000000..62779e8 --- /dev/null +++ b/backend/app/services/label_service.py @@ -0,0 +1,271 @@ +""" +Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi. +""" + +import uuid + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ConflictError, NotFoundError +from app.models.label import Label, MessageLabel +from app.models.message import Message +from app.schemas.label import LabelCreate, LabelUpdate + + +class LabelService: + def __init__(self, db: AsyncSession): + self.db = db + + # ─── CRUD Label ─────────────────────────────────────────────────────────── + + async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]: + result = await self.db.execute( + select(Label) + .where(Label.tenant_id == tenant_id) + .order_by(Label.name) + ) + return list(result.scalars().all()) + + async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label: + result = await self.db.execute( + select(Label).where( + Label.id == label_id, + Label.tenant_id == tenant_id, + ) + ) + label = result.scalar_one_or_none() + if not label: + raise NotFoundError(f"Tag {label_id} non trovato") + return label + + async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label: + # Verifica unicità + existing = await self.db.execute( + select(Label).where( + Label.tenant_id == tenant_id, + Label.name == data.name, + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"Tag '{data.name}' già esistente") + + label = Label( + tenant_id=tenant_id, + name=data.name, + color=data.color, + ) + self.db.add(label) + await self.db.commit() + await self.db.refresh(label) + return label + + async def update_label( + self, tenant_id: uuid.UUID, label_id: uuid.UUID, data: LabelUpdate + ) -> Label: + label = await self.get_label(tenant_id, label_id) + + if data.name is not None: + # Verifica unicità del nuovo nome + existing = await self.db.execute( + select(Label).where( + Label.tenant_id == tenant_id, + Label.name == data.name, + Label.id != label_id, + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"Tag '{data.name}' già esistente") + label.name = data.name + + if data.color is not None: + label.color = data.color + + await self.db.commit() + await self.db.refresh(label) + return label + + async def delete_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> None: + label = await self.get_label(tenant_id, label_id) + await self.db.delete(label) + await self.db.commit() + + # ─── Assegnazione tag a singolo messaggio ───────────────────────────────── + + async def get_message_labels( + self, message_id: uuid.UUID, tenant_id: uuid.UUID + ) -> list[Label]: + result = await self.db.execute( + select(Label) + .join(MessageLabel, Label.id == MessageLabel.label_id) + .where( + MessageLabel.message_id == message_id, + Label.tenant_id == tenant_id, + ) + .order_by(Label.name) + ) + return list(result.scalars().all()) + + async def set_message_labels( + self, + message_id: uuid.UUID, + tenant_id: uuid.UUID, + label_ids: list[uuid.UUID], + ) -> list[Label]: + """Sostituisce tutti i tag di un messaggio con quelli indicati.""" + # Verifica che i label appartengano al tenant + valid_ids: set[uuid.UUID] = set() + if label_ids: + result = await self.db.execute( + select(Label).where( + Label.id.in_(label_ids), + Label.tenant_id == tenant_id, + ) + ) + valid_ids = {lbl.id for lbl in result.scalars().all()} + + # Rimuovi tutti i tag esistenti dal messaggio + await self.db.execute( + delete(MessageLabel).where(MessageLabel.message_id == message_id) + ) + + # Aggiungi i nuovi tag validi + for lbl_id in valid_ids: + self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id)) + + await self.db.commit() + return await self.get_message_labels(message_id, tenant_id) + + async def add_message_labels( + self, + message_id: uuid.UUID, + tenant_id: uuid.UUID, + label_ids: list[uuid.UUID], + ) -> list[Label]: + """Aggiunge tag a un messaggio senza rimuovere quelli esistenti.""" + if not label_ids: + return await self.get_message_labels(message_id, tenant_id) + + # Verifica appartenenza al tenant + result = await self.db.execute( + select(Label).where( + Label.id.in_(label_ids), + Label.tenant_id == tenant_id, + ) + ) + valid_labels = list(result.scalars().all()) + + # Carica tag esistenti per evitare duplicati + existing_result = await self.db.execute( + select(MessageLabel.label_id).where( + MessageLabel.message_id == message_id + ) + ) + existing_ids = set(existing_result.scalars().all()) + + for lbl in valid_labels: + if lbl.id not in existing_ids: + self.db.add(MessageLabel(message_id=message_id, label_id=lbl.id)) + + await self.db.commit() + return await self.get_message_labels(message_id, tenant_id) + + async def remove_message_labels( + self, + message_id: uuid.UUID, + tenant_id: uuid.UUID, + label_ids: list[uuid.UUID], + ) -> list[Label]: + """Rimuove specifici tag da un messaggio.""" + if label_ids: + await self.db.execute( + delete(MessageLabel).where( + MessageLabel.message_id == message_id, + MessageLabel.label_id.in_(label_ids), + ) + ) + await self.db.commit() + return await self.get_message_labels(message_id, tenant_id) + + # ─── Azioni bulk ────────────────────────────────────────────────────────── + + async def bulk_add_labels( + self, + message_ids: list[uuid.UUID], + tenant_id: uuid.UUID, + label_ids: list[uuid.UUID], + ) -> int: + """Aggiunge tag a più messaggi in blocco.""" + if not label_ids or not message_ids: + return 0 + + # Verifica label del tenant + lbl_result = await self.db.execute( + select(Label).where( + Label.id.in_(label_ids), + Label.tenant_id == tenant_id, + ) + ) + valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()] + + # Verifica messaggi del tenant + msg_result = await self.db.execute( + select(Message.id).where( + Message.id.in_(message_ids), + Message.tenant_id == tenant_id, + ) + ) + valid_message_ids = list(msg_result.scalars().all()) + + if not valid_label_ids or not valid_message_ids: + return 0 + + # Carica coppie esistenti per evitare duplicati + existing_result = await self.db.execute( + select(MessageLabel).where( + MessageLabel.message_id.in_(valid_message_ids), + MessageLabel.label_id.in_(valid_label_ids), + ) + ) + existing_pairs = { + (ml.message_id, ml.label_id) for ml in existing_result.scalars().all() + } + + for msg_id in valid_message_ids: + for lbl_id in valid_label_ids: + if (msg_id, lbl_id) not in existing_pairs: + self.db.add(MessageLabel(message_id=msg_id, label_id=lbl_id)) + + await self.db.commit() + return len(valid_message_ids) + + async def bulk_remove_labels( + self, + message_ids: list[uuid.UUID], + tenant_id: uuid.UUID, + label_ids: list[uuid.UUID], + ) -> int: + """Rimuove tag da più messaggi in blocco.""" + if not label_ids or not message_ids: + return 0 + + # Verifica messaggi del tenant + msg_result = await self.db.execute( + select(Message.id).where( + Message.id.in_(message_ids), + Message.tenant_id == tenant_id, + ) + ) + valid_message_ids = list(msg_result.scalars().all()) + + if not valid_message_ids: + return 0 + + await self.db.execute( + delete(MessageLabel).where( + MessageLabel.message_id.in_(valid_message_ids), + MessageLabel.label_id.in_(label_ids), + ) + ) + await self.db.commit() + return len(valid_message_ids) diff --git a/backend/app/services/send_service.py b/backend/app/services/send_service.py index 6be65b8..6945700 100644 --- a/backend/app/services/send_service.py +++ b/backend/app/services/send_service.py @@ -1,18 +1,20 @@ """ -SendService – logica di business per l'invio PEC (Fase 4). +SendService – logica di business per l'invio PEC. Responsabilità: 1. Valida permessi (check_can_send) sulla casella selezionata 2. Crea il record Message (direction=outbound, state=queued) - 3. Crea il record SendJob (status=pending) - 4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq - 5. Ritorna SendJobResponse + 3. [Opzionale] Carica gli allegati su MinIO e crea i record Attachment + 4. Crea il record SendJob (status=pending) + 5. Enqueue il job arq 'send_pec' tramite il pool Redis/arq + 6. Ritorna SendJob Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura del raw EML su MinIO. """ import asyncio +import hashlib import uuid from datetime import datetime, timezone @@ -21,13 +23,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ForbiddenError, NotFoundError from app.models.mailbox import Mailbox -from app.models.message import Message, SendJob +from app.models.message import Attachment, Message, SendJob from app.models.user import User from app.schemas.send import SendJobResponse, SendPecRequest from app.services.permission_service import PermissionService # ─── Pool arq (singleton lazy) ──────────────────────────────────────────────── -# Usato per fare enqueue dei job send_pec senza avviare un worker nel backend. _arq_pool = None _arq_pool_lock = asyncio.Lock() @@ -68,6 +69,40 @@ async def close_arq_pool() -> None: _arq_pool = None +# ─── Helper: HTML → testo semplice ──────────────────────────────────────────── + +def _html_to_plain(html: str) -> str: + """ + Converte HTML in testo semplice usando html.parser della stdlib. + Usato per generare la parte text/plain quando viene fornito solo body_html. + """ + from html.parser import HTMLParser + + BLOCK_TAGS = { + "p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", + "blockquote", "pre", "hr", "tr", + } + + class _Extractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self._parts.append(data) + + def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001 + if tag.lower() in BLOCK_TAGS: + self._parts.append("\n") + + def text(self) -> str: + return "".join(self._parts).strip() + + extractor = _Extractor() + extractor.feed(html) + return extractor.text() + + # ─── SendService ────────────────────────────────────────────────────────────── class SendService: @@ -80,20 +115,22 @@ class SendService: self, current_user: User, data: SendPecRequest, + attachments: list[dict] | None = None, ) -> SendJob: """ - Crea Message + SendJob e accoda il job di invio. + Crea Message + (Attachment*) + SendJob e accoda il job di invio. Args: current_user: utente autenticato che richiede l'invio data: dati della PEC da inviare + attachments: lista opzionale di dict {filename, content: bytes, content_type} Returns: SendJob appena creato Raises: - NotFoundError: casella non trovata o non appartenente al tenant - ForbiddenError: utente senza can_send sulla casella + NotFoundError: casella non trovata o non appartenente al tenant + ForbiddenError: utente senza can_send sulla casella """ # ── Verifica casella ────────────────────────────────────────────────── mailbox = await self.db.get(Mailbox, data.mailbox_id) @@ -105,7 +142,6 @@ class SendService: raise NotFoundError("casella PEC mittente") if mailbox.status != "active": - from app.core.exceptions import ForbiddenError raise ForbiddenError( f"La casella è in stato '{mailbox.status}' e non può inviare" ) @@ -118,8 +154,18 @@ class SendService: "Non hai il permesso di inviare da questa casella" ) + # ── Ricava body_text ────────────────────────────────────────────────── + body_text = data.body_text or "" + body_html: str | None = getattr(data, "body_html", None) or data.body_html # type: ignore[attr-defined] + + # Se il body_text è vuoto ma abbiamo HTML, genera il plain text + if not body_text and body_html: + body_text = _html_to_plain(body_html) + # ── Crea il messaggio outbound ──────────────────────────────────────── now = datetime.now(tz=timezone.utc) + has_files = bool(attachments) + message = Message( tenant_id=current_user.tenant_id, mailbox_id=data.mailbox_id, @@ -130,9 +176,9 @@ class SendService: from_address=mailbox.email_address, to_addresses=[str(a) for a in data.to_addresses], cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [], - body_text=data.body_text or "", - body_html=data.body_html, - has_attachments=False, # allegati in Fase 5 + body_text=body_text, + body_html=body_html, + has_attachments=has_files, sent_at=None, received_at=None, ) @@ -144,7 +190,16 @@ class SendService: message.parent_message_id = data.reply_to_message_id self.db.add(message) - await self.db.flush() + await self.db.flush() # Ottieni message.id + + # ── Carica allegati su MinIO e crea record Attachment ───────────────── + if has_files: + await self._upload_attachments( + message=message, + attachments=attachments, # type: ignore[arg-type] + tenant_id=current_user.tenant_id, + mailbox_id=data.mailbox_id, + ) # ── Crea il SendJob ─────────────────────────────────────────────────── job = SendJob( @@ -171,11 +226,70 @@ class SendService: f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. " "Il job resterà in stato 'pending' per pickup manuale." ) - # Non alziamo eccezione: il job è nel DB e verrà processato - # dal cron di polling se disponibile return job + # ── Upload allegati ──────────────────────────────────────────────────────── + + async def _upload_attachments( + self, + message: Message, + attachments: list[dict], + tenant_id: uuid.UUID, + mailbox_id: uuid.UUID, + ) -> None: + """ + Carica ogni allegato su MinIO e crea il record Attachment nel DB. + + Gestisce gli errori di upload singolarmente: un allegato che non + riesce a caricarsi non blocca gli altri. + """ + from app.core.logging import get_logger + from app.storage.minio_client import upload_attachment as minio_upload + + logger = get_logger(__name__) + + for att in attachments: + filename: str = att.get("filename") or "allegato" + content: bytes = att.get("content") or b"" + content_type: str = att.get("content_type") or "application/octet-stream" + + if not content: + continue + + try: + storage_path = await minio_upload( + tenant_id=str(tenant_id), + mailbox_id=str(mailbox_id), + message_id=str(message.id), + filename=filename, + content=content, + content_type=content_type, + ) + checksum = hashlib.sha256(content).hexdigest() + + attachment_record = Attachment( + tenant_id=tenant_id, + message_id=message.id, + filename=filename, + content_type=content_type, + size_bytes=len(content), + storage_path=storage_path, + checksum_sha256=checksum, + ) + self.db.add(attachment_record) + logger.debug( + f"[send_service] Allegato caricato: {storage_path} " + f"({len(content)} bytes)" + ) + except Exception as upload_err: + logger.error( + f"[send_service] Errore upload allegato '{filename}': {upload_err}" + ) + # Non sollevare: l'invio continuerà senza questo allegato + + await self.db.flush() + # ── Lista job di invio ──────────────────────────────────────────────────── async def list_send_jobs( @@ -226,10 +340,7 @@ class SendService: job_id: uuid.UUID, tenant_id: uuid.UUID, ) -> SendJob: - """ - Annulla un job di invio se è ancora in stato 'pending'. - Non cancella il messaggio associato. - """ + """Annulla un job di invio se è ancora in stato 'pending' o 'retrying'.""" job = await self.get_send_job(job_id, tenant_id) if job.status not in ("pending", "retrying"): @@ -240,7 +351,6 @@ class SendService: job.status = "failed" job.last_error = "Annullato dall'utente" - # Aggiorna anche il messaggio if job.message_id: msg = await self.db.get(Message, job.message_id) if msg and msg.state in ("queued", "draft"): diff --git a/backend/app/services/virtual_box_service.py b/backend/app/services/virtual_box_service.py index 0a314c2..d1f8751 100644 --- a/backend/app/services/virtual_box_service.py +++ b/backend/app/services/virtual_box_service.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError from app.models.mailbox import Mailbox from app.models.user import User -from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule +from app.models.virtual_box import VirtualBox, VirtualBoxAssignment, VirtualBoxRule, virtual_box_mailboxes from app.schemas.virtual_box import ( AssignedUserResponse, VirtualBoxCreate, @@ -65,10 +65,15 @@ class VirtualBoxService: ) self.db.add(rule) - # Associa le caselle reali + # Associa le caselle reali (INSERT diretto sulla tabella di associazione + # per evitare MissingGreenlet con SQLAlchemy async) if data.mailbox_ids: - mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id) - vbox.mailboxes = mailboxes + valid_mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id) + if valid_mailboxes: + await self.db.execute( + virtual_box_mailboxes.insert(), + [{"virtual_box_id": vbox.id, "mailbox_id": mb.id} for mb in valid_mailboxes], + ) await self.db.flush() return await self._load_full(vbox.id) @@ -136,10 +141,19 @@ class VirtualBoxService: if data.is_active is not None: vbox.is_active = data.is_active - # Aggiorna le caselle associate se fornito + # Aggiorna le caselle associate se fornito (INSERT/DELETE diretti) if data.mailbox_ids is not None: - mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id) - vbox.mailboxes = mailboxes + await self.db.execute( + virtual_box_mailboxes.delete().where( + virtual_box_mailboxes.c.virtual_box_id == vbox_id + ) + ) + valid_mailboxes = await self._load_mailboxes(data.mailbox_ids, tenant_id) + if valid_mailboxes: + await self.db.execute( + virtual_box_mailboxes.insert(), + [{"virtual_box_id": vbox_id, "mailbox_id": mb.id} for mb in valid_mailboxes], + ) await self.db.flush() return await self._load_full(vbox_id) @@ -196,8 +210,18 @@ class VirtualBoxService: if not vbox or vbox.tenant_id != tenant_id: raise NotFoundError("Virtual Box") - mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id) - vbox.mailboxes = mailboxes + # Sostituzione completa con INSERT/DELETE diretti + await self.db.execute( + virtual_box_mailboxes.delete().where( + virtual_box_mailboxes.c.virtual_box_id == vbox_id + ) + ) + valid_mailboxes = await self._load_mailboxes(mailbox_ids, tenant_id) + if valid_mailboxes: + await self.db.execute( + virtual_box_mailboxes.insert(), + [{"virtual_box_id": vbox_id, "mailbox_id": mb.id} for mb in valid_mailboxes], + ) await self.db.flush() return await self._load_full(vbox_id) diff --git a/backend/app/storage/__init__.py b/backend/app/storage/__init__.py new file mode 100644 index 0000000..be094ce --- /dev/null +++ b/backend/app/storage/__init__.py @@ -0,0 +1 @@ +# Storage utilities per il backend (MinIO/S3) diff --git a/backend/app/storage/minio_client.py b/backend/app/storage/minio_client.py new file mode 100644 index 0000000..088596c --- /dev/null +++ b/backend/app/storage/minio_client.py @@ -0,0 +1,89 @@ +""" +Client MinIO per il backend – upload allegati outbound. + +Percorso allegati outbound: + tenants/{tenant_id}/mailboxes/{mailbox_id}/attachments/{message_id}/{filename} +""" + +import io +import logging +import re +from functools import lru_cache + +from miniopy_async import Minio + +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=1) +def get_minio_client() -> Minio: + """Restituisce l'istanza singleton del client MinIO.""" + settings = get_settings() + return Minio( + endpoint=settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_use_ssl, + ) + + +async def upload_attachment( + tenant_id: str, + mailbox_id: str, + message_id: str, + filename: str, + content: bytes, + content_type: str = "application/octet-stream", +) -> str: + """ + Carica un allegato outbound su MinIO. + + Args: + tenant_id: UUID del tenant (stringa) + mailbox_id: UUID della casella mittente + message_id: UUID del messaggio associato + filename: Nome file originale + content: Byte del file + content_type: MIME type + + Returns: + Percorso oggetto su MinIO (senza nome bucket) + """ + settings = get_settings() + client = get_minio_client() + bucket = settings.minio_bucket + + safe_filename = _sanitize_filename(filename) + object_path = ( + f"tenants/{tenant_id}/mailboxes/{mailbox_id}" + f"/attachments/{message_id}/{safe_filename}" + ) + + data_stream = io.BytesIO(content) + await client.put_object( + bucket_name=bucket, + object_name=object_path, + data=data_stream, + length=len(content), + content_type=content_type, + ) + logger.debug( + f"Allegato outbound caricato: {object_path} " + f"({len(content)} bytes, {content_type})" + ) + return object_path + + +def _sanitize_filename(filename: str) -> str: + """Sanitizza il nome file per uso sicuro come path MinIO.""" + safe = filename.replace("/", "_").replace("\\", "_").replace("\x00", "") + safe = re.sub(r"[^\w.\-() ]", "_", safe, flags=re.UNICODE) + if len(safe) > 200: + parts = safe.rsplit(".", 1) + if len(parts) == 2: + safe = parts[0][:196] + "." + parts[1] + else: + safe = safe[:200] + return safe or "attachment" diff --git a/frontend/node_modules/.package-lock.json b/frontend/node_modules/.package-lock.json index 9cfba31..8a09e67 100644 --- a/frontend/node_modules/.package-lock.json +++ b/frontend/node_modules/.package-lock.json @@ -2150,6 +2150,12 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -2180,6 +2186,20 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@tanstack/query-core": { "version": "5.91.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.0.tgz", @@ -2233,6 +2253,505 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz", + "integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.4.tgz", + "integrity": "sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.4.tgz", + "integrity": "sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.4.tgz", + "integrity": "sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.4.tgz", + "integrity": "sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.4.tgz", + "integrity": "sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.4.tgz", + "integrity": "sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-color": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.20.4.tgz", + "integrity": "sha512-+OT9wWEJnqoWmzfqPYt0oWm8LZcH+D44Z3jA2TNzBj4tLGQ2YPxN2SyS12AlRi7MuguVT7utFy7qDXrfir8eUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.4.tgz", + "integrity": "sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.4.tgz", + "integrity": "sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.4.tgz", + "integrity": "sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-font-family": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-font-family/-/extension-font-family-3.20.4.tgz", + "integrity": "sha512-u5HjpNVBK7N9glR4Sz/HyVvTTAprEiion0oyyBWPBlgZvLrJta0zNvhfwG9ZUoubvqou3fBRbZwVosfonN2fAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.4.tgz", + "integrity": "sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.4.tgz", + "integrity": "sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.4.tgz", + "integrity": "sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.4.tgz", + "integrity": "sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz", + "integrity": "sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.4.tgz", + "integrity": "sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz", + "integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.4.tgz", + "integrity": "sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.4.tgz", + "integrity": "sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.4.tgz", + "integrity": "sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.4.tgz", + "integrity": "sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.4.tgz", + "integrity": "sha512-GB0KWtqm83YHG8cnqBLijvUBm+xvLfQHDfFRRH2fb3EzH3eIsM9jKRC31ADT27RSV1zVpHMFGcP3/pWpdrN1Lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.4.tgz", + "integrity": "sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.4.tgz", + "integrity": "sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.20.4.tgz", + "integrity": "sha512-6ZuRyClIyCimXu+S5LQ54DueEsYg5VOVOmubOVbG+WAjM9svn9Z8gv2sNDah2yEqXrX06B02zYcSyMiD7CHbfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.4.tgz", + "integrity": "sha512-PvW0Ja7ahWpo4bRuR8YCCVv4PH8lXjzhzlBAa4bMbsumOg+GbhX8Su7fwqd+IIPrHqfPXz9HTBMApSfzP6/08A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.4.tgz", + "integrity": "sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.4.tgz", + "integrity": "sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz", + "integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.4.tgz", + "integrity": "sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.20.4", + "@tiptap/extension-floating-menu": "^3.20.4" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.4.tgz", + "integrity": "sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/extension-blockquote": "^3.20.4", + "@tiptap/extension-bold": "^3.20.4", + "@tiptap/extension-bullet-list": "^3.20.4", + "@tiptap/extension-code": "^3.20.4", + "@tiptap/extension-code-block": "^3.20.4", + "@tiptap/extension-document": "^3.20.4", + "@tiptap/extension-dropcursor": "^3.20.4", + "@tiptap/extension-gapcursor": "^3.20.4", + "@tiptap/extension-hard-break": "^3.20.4", + "@tiptap/extension-heading": "^3.20.4", + "@tiptap/extension-horizontal-rule": "^3.20.4", + "@tiptap/extension-italic": "^3.20.4", + "@tiptap/extension-link": "^3.20.4", + "@tiptap/extension-list": "^3.20.4", + "@tiptap/extension-list-item": "^3.20.4", + "@tiptap/extension-list-keymap": "^3.20.4", + "@tiptap/extension-ordered-list": "^3.20.4", + "@tiptap/extension-paragraph": "^3.20.4", + "@tiptap/extension-strike": "^3.20.4", + "@tiptap/extension-text": "^3.20.4", + "@tiptap/extension-underline": "^3.20.4", + "@tiptap/extensions": "^3.20.4", + "@tiptap/pm": "^3.20.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2285,6 +2804,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -2299,14 +2840,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2317,12 +2856,17 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2485,6 +3029,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -2803,6 +3353,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2910,6 +3466,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3011,6 +3579,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3031,6 +3611,15 @@ "node": ">=12.0.0" } }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3396,6 +3985,21 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3444,6 +4048,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3453,6 +4074,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3573,6 +4200,12 @@ "node": ">= 6" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3832,12 +4465,216 @@ "dev": true, "license": "MIT" }, + "node_modules/prosemirror-changeset": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.7", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", + "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -4137,6 +4974,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4450,6 +5293,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4696,6 +5545,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2c4dbbb..1fcd11e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,16 @@ "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.56.2", "@tanstack/react-query-devtools": "^5.56.2", + "@tiptap/extension-color": "^3.20.4", + "@tiptap/extension-font-family": "^3.20.4", + "@tiptap/extension-link": "^3.20.4", + "@tiptap/extension-placeholder": "^3.20.4", + "@tiptap/extension-text-align": "^3.20.4", + "@tiptap/extension-text-style": "^3.20.4", + "@tiptap/extension-underline": "^3.20.4", + "@tiptap/pm": "^3.20.4", + "@tiptap/react": "^3.20.4", + "@tiptap/starter-kit": "^3.20.4", "axios": "^1.7.9", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -2569,6 +2579,12 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -2988,6 +3004,505 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz", + "integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.4.tgz", + "integrity": "sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.4.tgz", + "integrity": "sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.4.tgz", + "integrity": "sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.4.tgz", + "integrity": "sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.4.tgz", + "integrity": "sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.4.tgz", + "integrity": "sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-color": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.20.4.tgz", + "integrity": "sha512-+OT9wWEJnqoWmzfqPYt0oWm8LZcH+D44Z3jA2TNzBj4tLGQ2YPxN2SyS12AlRi7MuguVT7utFy7qDXrfir8eUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.4.tgz", + "integrity": "sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.4.tgz", + "integrity": "sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.4.tgz", + "integrity": "sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-font-family": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-font-family/-/extension-font-family-3.20.4.tgz", + "integrity": "sha512-u5HjpNVBK7N9glR4Sz/HyVvTTAprEiion0oyyBWPBlgZvLrJta0zNvhfwG9ZUoubvqou3fBRbZwVosfonN2fAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.4.tgz", + "integrity": "sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.4.tgz", + "integrity": "sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.4.tgz", + "integrity": "sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.4.tgz", + "integrity": "sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz", + "integrity": "sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.4.tgz", + "integrity": "sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz", + "integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.4.tgz", + "integrity": "sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.4.tgz", + "integrity": "sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.4.tgz", + "integrity": "sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.4.tgz", + "integrity": "sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.4.tgz", + "integrity": "sha512-GB0KWtqm83YHG8cnqBLijvUBm+xvLfQHDfFRRH2fb3EzH3eIsM9jKRC31ADT27RSV1zVpHMFGcP3/pWpdrN1Lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.4.tgz", + "integrity": "sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.4.tgz", + "integrity": "sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.20.4.tgz", + "integrity": "sha512-6ZuRyClIyCimXu+S5LQ54DueEsYg5VOVOmubOVbG+WAjM9svn9Z8gv2sNDah2yEqXrX06B02zYcSyMiD7CHbfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.4.tgz", + "integrity": "sha512-PvW0Ja7ahWpo4bRuR8YCCVv4PH8lXjzhzlBAa4bMbsumOg+GbhX8Su7fwqd+IIPrHqfPXz9HTBMApSfzP6/08A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.4.tgz", + "integrity": "sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.4.tgz", + "integrity": "sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz", + "integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.4.tgz", + "integrity": "sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.20.4", + "@tiptap/extension-floating-menu": "^3.20.4" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.4.tgz", + "integrity": "sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/extension-blockquote": "^3.20.4", + "@tiptap/extension-bold": "^3.20.4", + "@tiptap/extension-bullet-list": "^3.20.4", + "@tiptap/extension-code": "^3.20.4", + "@tiptap/extension-code-block": "^3.20.4", + "@tiptap/extension-document": "^3.20.4", + "@tiptap/extension-dropcursor": "^3.20.4", + "@tiptap/extension-gapcursor": "^3.20.4", + "@tiptap/extension-hard-break": "^3.20.4", + "@tiptap/extension-heading": "^3.20.4", + "@tiptap/extension-horizontal-rule": "^3.20.4", + "@tiptap/extension-italic": "^3.20.4", + "@tiptap/extension-link": "^3.20.4", + "@tiptap/extension-list": "^3.20.4", + "@tiptap/extension-list-item": "^3.20.4", + "@tiptap/extension-list-keymap": "^3.20.4", + "@tiptap/extension-ordered-list": "^3.20.4", + "@tiptap/extension-paragraph": "^3.20.4", + "@tiptap/extension-strike": "^3.20.4", + "@tiptap/extension-text": "^3.20.4", + "@tiptap/extension-underline": "^3.20.4", + "@tiptap/extensions": "^3.20.4", + "@tiptap/pm": "^3.20.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3040,6 +3555,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -3054,14 +3591,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3072,12 +3607,17 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3240,6 +3780,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3558,6 +4104,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3665,6 +4217,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3766,6 +4330,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3786,6 +4362,15 @@ "node": ">=12.0.0" } }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4166,6 +4751,21 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4214,6 +4814,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4223,6 +4840,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4343,6 +4966,12 @@ "node": ">= 6" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4602,12 +5231,216 @@ "dev": true, "license": "MIT" }, + "node_modules/prosemirror-changeset": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.7", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", + "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -4907,6 +5740,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5220,6 +6059,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5481,6 +6326,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 90bc7c7..ceb1d26 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,31 @@ "test:e2e": "playwright test" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.3", + "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query-devtools": "^5.56.2", + "@tiptap/extension-color": "^3.20.4", + "@tiptap/extension-font-family": "^3.20.4", + "@tiptap/extension-link": "^3.20.4", + "@tiptap/extension-placeholder": "^3.20.4", + "@tiptap/extension-text-align": "^3.20.4", + "@tiptap/extension-text-style": "^3.20.4", + "@tiptap/extension-underline": "^3.20.4", + "@tiptap/pm": "^3.20.4", + "@tiptap/react": "^3.20.4", + "@tiptap/starter-kit": "^3.20.4", "axios": "^1.7.9", + "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", "lucide-react": "^0.441.0", @@ -24,21 +48,7 @@ "react-hot-toast": "^2.4.1", "react-router-dom": "^6.26.2", "tailwind-merge": "^2.5.2", - "zustand": "^5.0.0", - "@tanstack/react-query": "^5.56.2", - "@tanstack/react-query-devtools": "^5.56.2", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-tooltip": "^1.1.3", - "@radix-ui/react-avatar": "^1.1.1", - "@radix-ui/react-scroll-area": "^1.2.0", - "class-variance-authority": "^0.7.0" + "zustand": "^5.0.0" }, "devDependencies": { "@playwright/test": "^1.47.2", diff --git a/frontend/src/api/labels.api.ts b/frontend/src/api/labels.api.ts new file mode 100644 index 0000000..855a110 --- /dev/null +++ b/frontend/src/api/labels.api.ts @@ -0,0 +1,59 @@ +import apiClient from './client' +import type { + LabelCreate, + LabelResponse, + LabelUpdate, + MessageBulkLabelRequest, + MessageBulkLabelResponse, + MessageLabelAddRequest, + MessageLabelRemoveRequest, + MessageLabelSetRequest, +} from '@/types/api.types' + +export const labelsApi = { + // ─── CRUD Tag ───────────────────────────────────────────────────────────── + + list: () => + apiClient.get('/labels').then((r) => r.data), + + create: (data: LabelCreate) => + apiClient.post('/labels', data).then((r) => r.data), + + update: (id: string, data: LabelUpdate) => + apiClient.patch(`/labels/${id}`, data).then((r) => r.data), + + delete: (id: string) => + apiClient.delete(`/labels/${id}`).then((r) => r.data), + + // ─── Tag su singolo messaggio ───────────────────────────────────────────── + + getMessageLabels: (messageId: string) => + apiClient + .get(`/messages/${messageId}/labels`) + .then((r) => r.data), + + /** Sostituisce tutti i tag di un messaggio. */ + setMessageLabels: (messageId: string, data: MessageLabelSetRequest) => + apiClient + .put(`/messages/${messageId}/labels`, data) + .then((r) => r.data), + + /** Aggiunge tag a un messaggio senza rimuovere quelli esistenti. */ + addMessageLabels: (messageId: string, data: MessageLabelAddRequest) => + apiClient + .post(`/messages/${messageId}/labels/add`, data) + .then((r) => r.data), + + /** Rimuove specifici tag da un messaggio. */ + removeMessageLabels: (messageId: string, data: MessageLabelRemoveRequest) => + apiClient + .post(`/messages/${messageId}/labels/remove`, data) + .then((r) => r.data), + + // ─── Bulk ───────────────────────────────────────────────────────────────── + + bulkLabels: (data: MessageBulkLabelRequest) => + apiClient + .post('/messages/bulk-labels', data) + .then((r) => r.data), +} diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index da978e7..0750c8f 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -69,8 +69,25 @@ export const messagesApi = { getAttachments: (id: string) => apiClient.get(`/messages/${id}/attachments`).then((r) => r.data), - getAttachmentUrl: (messageId: string, attachmentId: string) => - `/api/v1/messages/${messageId}/attachments/${attachmentId}/download`, + /** + * Scarica un allegato autenticato e lo salva localmente. + * Utilizza apiClient (con Bearer token) per evitare il 401 che si ottiene + * navigando direttamente verso l'URL con un . + */ + downloadAttachment: async (messageId: string, attachmentId: string, filename: string): Promise => { + const response = await apiClient.get( + `/messages/${messageId}/attachments/${attachmentId}/download`, + { responseType: 'blob' }, + ) + const blobUrl = window.URL.createObjectURL(new Blob([response.data])) + const anchor = document.createElement('a') + anchor.href = blobUrl + anchor.setAttribute('download', filename) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(blobUrl) + }, getReceipts: (id: string) => apiClient.get(`/messages/${id}/receipts`).then((r) => r.data), diff --git a/frontend/src/api/send.api.ts b/frontend/src/api/send.api.ts index 5b51948..b5da7d9 100644 --- a/frontend/src/api/send.api.ts +++ b/frontend/src/api/send.api.ts @@ -12,10 +12,33 @@ export interface SendJobFilters { status?: string } +/** Payload esteso per l'invio multipart (include body_html) */ +export interface SendPecMultipartRequest extends SendPecRequest { + body_html?: string +} + export const sendApi = { + /** Invio PEC semplice (JSON, senza allegati) – retrocompatibile */ send: (data: SendPecRequest) => apiClient.post('/send', data).then((r) => r.data), + /** + * Invio PEC con allegati tramite multipart/form-data. + * + * Il campo `data` viene serializzato come JSON string; + * i file vengono appesi come `attachments[]`. + */ + sendMultipart: (data: SendPecMultipartRequest, files: File[] = []) => { + const formData = new FormData() + formData.append('data', JSON.stringify(data)) + files.forEach((file) => formData.append('attachments', file)) + return apiClient + .post('/send/multipart', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + .then((r) => r.data) + }, + listJobs: (filters: SendJobFilters = {}) => apiClient.get('/send/jobs', { params: filters }).then((r) => r.data), diff --git a/frontend/src/components/RichTextEditor/RichTextEditor.tsx b/frontend/src/components/RichTextEditor/RichTextEditor.tsx new file mode 100644 index 0000000..9a60988 --- /dev/null +++ b/frontend/src/components/RichTextEditor/RichTextEditor.tsx @@ -0,0 +1,517 @@ +/** + * RichTextEditor – editor di testo ricco in stile Roundcube. + * + * Usa TipTap (ProseMirror) con estensioni per: + * - Formattazione testo: grassetto, corsivo, sottolineato, barrato + * - Colore testo (palette + picker custom) + * - Titoli H1/H2/H3 + * - Elenchi puntati, numerati, citazioni + * - Allineamento testo + * - Collegamento ipertestuale + * - Undo/Redo + * - Rimuovi formattazione + */ + +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import UnderlineExt from '@tiptap/extension-underline' +import TextAlign from '@tiptap/extension-text-align' +import Link from '@tiptap/extension-link' +import Placeholder from '@tiptap/extension-placeholder' +import { TextStyle } from '@tiptap/extension-text-style' +import Color from '@tiptap/extension-color' +import { useEffect, useCallback, useState, useRef } from 'react' +import { + Bold, + Italic, + Underline, + Strikethrough, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, + List, + ListOrdered, + Quote, + Link as LinkIcon, + Link2Off, + Undo2, + Redo2, + Baseline, + Eraser, +} from 'lucide-react' + +// ─── Tipi ──────────────────────────────────────────────────────────────────── + +interface RichTextEditorProps { + value: string + onChange: (html: string) => void + placeholder?: string + minHeight?: string +} + +// ─── Palette colori ─────────────────────────────────────────────────────────── + +const COLOR_PALETTE = [ + // Grigi + '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', + // Vivaci + '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff', + // Chiari + '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc', + // Standard + '#ea4335', '#fbbc04', '#34a853', '#4285f4', '#9334e6', '#c2185b', '#e65100', '#00838f', +] + +// ─── Sub-componenti toolbar ─────────────────────────────────────────────────── + +interface ToolbarButtonProps { + onClick: () => void + isActive?: boolean + disabled?: boolean + title: string + children: React.ReactNode +} + +function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) { + return ( + + ) +} + +function ToolbarSeparator() { + return
+} + +// ─── Color Picker ───────────────────────────────────────────────────────────── + +interface ColorPickerProps { + currentColor?: string + onSelect: (color: string) => void + onClose: () => void +} + +function ColorPicker({ currentColor, onSelect, onClose }: ColorPickerProps) { + const ref = useRef(null) + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose() + } + } + // Delay to avoid closing immediately on the button click that opened us + const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50) + return () => { + clearTimeout(timer) + document.removeEventListener('mousedown', handler) + } + }, [onClose]) + + return ( +
+
Colore testo
+
+ {COLOR_PALETTE.map((color) => ( +
+
+ + onSelect(e.target.value)} + /> + +
+
+ ) +} + +// ─── Link Dialog ────────────────────────────────────────────────────────────── + +interface LinkDialogProps { + currentUrl?: string + onConfirm: (url: string) => void + onRemove: () => void + onClose: () => void +} + +function LinkDialog({ currentUrl, onConfirm, onRemove, onClose }: LinkDialogProps) { + const [url, setUrl] = useState(currentUrl || 'https://') + const ref = useRef(null) + const inputRef = useRef(null) + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 50) + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose() + } + setTimeout(() => document.addEventListener('mousedown', handler), 50) + return () => { + clearTimeout(timer) + document.removeEventListener('mousedown', handler) + } + }, [onClose]) + + const confirm = useCallback(() => { + if (url && url !== 'https://') { + onConfirm(url.startsWith('http') ? url : `https://${url}`) + } + onClose() + }, [url, onConfirm, onClose]) + + return ( +
+
+ {currentUrl ? 'Modifica collegamento' : 'Inserisci collegamento'} +
+
+ setUrl(e.target.value)} + placeholder="https://esempio.it" + className="flex-1 text-sm border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring bg-background" + onKeyDown={(e) => { + if (e.key === 'Enter') confirm() + if (e.key === 'Escape') onClose() + }} + /> + + {currentUrl && ( + + )} +
+
+ ) +} + +// ─── Toolbar principale ─────────────────────────────────────────────────────── + +interface ToolbarProps { + editor: ReturnType +} + +function Toolbar({ editor }: ToolbarProps) { + const [showColorPicker, setShowColorPicker] = useState(false) + const [showLinkDialog, setShowLinkDialog] = useState(false) + const colorRef = useRef(null) + const linkRef = useRef(null) + + if (!editor) return null + + const currentColor = editor.getAttributes('textStyle').color as string | undefined + const currentLinkUrl = editor.getAttributes('link').href as string | undefined + + return ( +
+ + {/* Undo / Redo */} + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title="Annulla (Ctrl+Z)" + > + + + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title="Ripeti (Ctrl+Y)" + > + + + + + + {/* Formattazione di base */} + editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title="Grassetto (Ctrl+B)" + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title="Corsivo (Ctrl+I)" + > + + + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive('underline')} + title="Sottolineato (Ctrl+U)" + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + title="Barrato" + > + + + + + + {/* Colore testo */} +
+ setShowColorPicker((v) => !v)} + isActive={showColorPicker} + title="Colore testo" + > + + + + + + {showColorPicker && ( + { + if (color) { + editor.chain().focus().setColor(color).run() + } else { + editor.chain().focus().unsetColor().run() + } + }} + onClose={() => setShowColorPicker(false)} + /> + )} +
+ + {/* Rimuovi formattazione */} + editor.chain().focus().clearNodes().unsetAllMarks().run()} + title="Rimuovi formattazione" + > + + + + + + {/* Titoli */} + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive('heading', { level: 1 })} + title="Titolo 1" + > + H1 + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive('heading', { level: 2 })} + title="Titolo 2" + > + H2 + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive('heading', { level: 3 })} + title="Titolo 3" + > + H3 + + + + + {/* Elenchi */} + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title="Elenco puntato" + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title="Elenco numerato" + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title="Citazione" + > + + + + + + {/* Allineamento */} + editor.chain().focus().setTextAlign('left').run()} + isActive={editor.isActive({ textAlign: 'left' })} + title="Allinea a sinistra" + > + + + editor.chain().focus().setTextAlign('center').run()} + isActive={editor.isActive({ textAlign: 'center' })} + title="Centra" + > + + + editor.chain().focus().setTextAlign('right').run()} + isActive={editor.isActive({ textAlign: 'right' })} + title="Allinea a destra" + > + + + editor.chain().focus().setTextAlign('justify').run()} + isActive={editor.isActive({ textAlign: 'justify' })} + title="Giustifica" + > + + + + + + {/* Link */} +
+ setShowLinkDialog((v) => !v)} + isActive={editor.isActive('link') || showLinkDialog} + title={editor.isActive('link') ? 'Modifica collegamento' : 'Inserisci collegamento'} + > + + + {showLinkDialog && ( + + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + } + onRemove={() => editor.chain().focus().unsetLink().run()} + onClose={() => setShowLinkDialog(false)} + /> + )} +
+ +
+ ) +} + +// ─── Componente principale ──────────────────────────────────────────────────── + +export function RichTextEditor({ + value, + onChange, + placeholder = 'Scrivi il testo della PEC...', + minHeight = '260px', +}: RichTextEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + }), + UnderlineExt, + TextStyle, + Color, + TextAlign.configure({ + types: ['heading', 'paragraph'], + defaultAlignment: 'left', + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: 'rte-link', + rel: 'noopener noreferrer', + }, + }), + Placeholder.configure({ placeholder }), + ], + content: value, + onUpdate: ({ editor }) => { + onChange(editor.getHTML()) + }, + editorProps: { + attributes: { + class: 'rte-content focus:outline-none', + }, + }, + }) + + // Sync da valore esterno (es. modifica replyTo dopo il mount) + useEffect(() => { + if (editor && !editor.isDestroyed && value !== editor.getHTML()) { + editor.commands.setContent(value, { emitUpdate: false }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/TagManager/TagBadge.tsx b/frontend/src/components/TagManager/TagBadge.tsx new file mode 100644 index 0000000..f2f32b2 --- /dev/null +++ b/frontend/src/components/TagManager/TagBadge.tsx @@ -0,0 +1,115 @@ +/** + * TagBadge – badge colorato per un singolo tag/label. + * + * Mostra il nome del tag con il colore di sfondo configurato. + * Se `onRemove` è fornito, mostra un pulsante × per rimuovere il tag. + */ +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { LabelResponse } from '@/types/api.types' + +// Colore di default quando il tag non ha colore configurato +const DEFAULT_COLOR = '#6b7280' + +/** + * Calcola il colore del testo (bianco o nero) in base alla luminosità + * del colore di sfondo per garantire un contrasto leggibile. + */ +function getTextColor(hexColor: string): string { + const hex = hexColor.replace('#', '') + const r = parseInt(hex.substring(0, 2), 16) + const g = parseInt(hex.substring(2, 4), 16) + const b = parseInt(hex.substring(4, 6), 16) + // Formula luminosità relativa (WCAG) + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return luminance > 0.5 ? '#1f2937' : '#ffffff' +} + +interface TagBadgeProps { + label: LabelResponse + onRemove?: () => void + size?: 'sm' | 'md' + className?: string +} + +export function TagBadge({ label, onRemove, size = 'sm', className }: TagBadgeProps) { + const bgColor = label.color || DEFAULT_COLOR + const textColor = getTextColor(bgColor) + + return ( + + {label.name} + {onRemove && ( + + )} + + ) +} + +/** + * TagBadgeList – mostra una lista compatta di tag badge. + * Se ci sono più tag del limite `maxVisible`, mostra un badge "+N". + */ +interface TagBadgeListProps { + labels: LabelResponse[] + onRemove?: (labelId: string) => void + maxVisible?: number + size?: 'sm' | 'md' + className?: string +} + +export function TagBadgeList({ + labels, + onRemove, + maxVisible = 3, + size = 'sm', + className, +}: TagBadgeListProps) { + if (!labels || labels.length === 0) return null + + const visible = labels.slice(0, maxVisible) + const overflow = labels.length - maxVisible + + return ( +
+ {visible.map((label) => ( + onRemove(label.id) : undefined} + size={size} + /> + ))} + {overflow > 0 && ( + l.name) + .join(', ')} + > + +{overflow} + + )} +
+ ) +} diff --git a/frontend/src/components/TagManager/TagSelector.tsx b/frontend/src/components/TagManager/TagSelector.tsx new file mode 100644 index 0000000..6298a2c --- /dev/null +++ b/frontend/src/components/TagManager/TagSelector.tsx @@ -0,0 +1,392 @@ +/** + * TagSelector – pannello di selezione/creazione tag per messaggi. + * + * Modalità d'uso: + * - single: apre un Dialog per impostare i tag di un singolo messaggio + * - bulk: apre un Dialog per aggiungere o rimuovere tag da più messaggi + * + * Funzionalità: + * - Lista tutti i tag del tenant con checkbox + * - Ricerca/filtro tag + * - Crea nuovi tag con nome e colore (solo per admin) + * - Mostra anteprima del badge colorato + */ +import { useState, useMemo } from 'react' +import { Plus, Search, Tag, Check, Trash2 } from 'lucide-react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/Dialog' +import { TagBadge } from './TagBadge' +import { labelsApi } from '@/api/labels.api' +import { getErrorMessage } from '@/api/client' +import { useAuth } from '@/hooks/useAuth' +import { cn } from '@/lib/utils' +import type { LabelResponse } from '@/types/api.types' + +// ─── Tavolozza colori predefiniti ───────────────────────────────────────────── + +const PRESET_COLORS = [ + '#ef4444', // rosso + '#f97316', // arancione + '#eab308', // giallo + '#22c55e', // verde + '#14b8a6', // teal + '#3b82f6', // blu + '#8b5cf6', // viola + '#ec4899', // rosa + '#6b7280', // grigio + '#78716c', // marrone +] + +// ─── Tipi ────────────────────────────────────────────────────────────────────── + +interface TagSelectorSingleProps { + mode: 'single' + open: boolean + onClose: () => void + /** ID dei tag attualmente assegnati al messaggio */ + currentLabelIds: string[] + /** Chiamata quando l'utente conferma la selezione */ + onApply: (labelIds: string[]) => void + isApplying?: boolean +} + +interface TagSelectorBulkProps { + mode: 'bulk' + open: boolean + onClose: () => void + /** Numero di messaggi selezionati (per info nell'UI) */ + messageCount: number + /** Chiamata con i label selezionati e l'azione (add/remove) */ + onApply: (labelIds: string[], action: 'add' | 'remove') => void + isApplying?: boolean +} + +type TagSelectorProps = TagSelectorSingleProps | TagSelectorBulkProps + +// ─── Componente principale ──────────────────────────────────────────────────── + +export function TagSelector(props: TagSelectorProps) { + const { open, onClose, onApply, isApplying } = props + const { user } = useAuth() + const isAdmin = user?.role === 'admin' || user?.role === 'super_admin' + const queryClient = useQueryClient() + + // Stato selezione (per single: IDs scelti; per bulk: IDs da applicare) + const [selectedIds, setSelectedIds] = useState>( + props.mode === 'single' ? new Set(props.currentLabelIds) : new Set(), + ) + const [searchQuery, setSearchQuery] = useState('') + const [showCreate, setShowCreate] = useState(false) + const [newName, setNewName] = useState('') + const [newColor, setNewColor] = useState(PRESET_COLORS[5]) // blu default + + // Reset dello stato all'apertura + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setSearchQuery('') + setShowCreate(false) + setNewName('') + setNewColor(PRESET_COLORS[5]) + if (props.mode === 'bulk') { + setSelectedIds(new Set()) + } + onClose() + } else if (props.mode === 'single') { + setSelectedIds(new Set(props.currentLabelIds)) + } + } + + // Carica lista tag del tenant + const { data: allLabels = [], isLoading } = useQuery({ + queryKey: ['labels'], + queryFn: labelsApi.list, + staleTime: 5 * 60 * 1000, + enabled: open, + }) + + // Tag filtrati per ricerca + const filteredLabels = useMemo(() => { + if (!searchQuery.trim()) return allLabels + const q = searchQuery.toLowerCase() + return allLabels.filter((l) => l.name.toLowerCase().includes(q)) + }, [allLabels, searchQuery]) + + // Crea nuovo tag + const createMutation = useMutation({ + mutationFn: () => labelsApi.create({ name: newName.trim(), color: newColor }), + onSuccess: (newLabel) => { + queryClient.invalidateQueries({ queryKey: ['labels'] }) + setSelectedIds((prev) => new Set([...prev, newLabel.id])) + setNewName('') + setShowCreate(false) + toast.success(`Tag "${newLabel.name}" creato`) + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Elimina tag + const deleteMutation = useMutation({ + mutationFn: (id: string) => labelsApi.delete(id), + onSuccess: (_, deletedId) => { + queryClient.invalidateQueries({ queryKey: ['labels'] }) + setSelectedIds((prev) => { + const next = new Set(prev) + next.delete(deletedId) + return next + }) + toast.success('Tag eliminato') + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + const toggleLabel = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const handleApplySingle = () => { + if (props.mode === 'single') { + onApply(Array.from(selectedIds)) + } + } + + const handleApplyBulk = (action: 'add' | 'remove') => { + if (props.mode === 'bulk') { + onApply(Array.from(selectedIds), action) + } + } + + const canConfirm = selectedIds.size > 0 + + return ( + + + + + + {props.mode === 'single' ? 'Gestisci tag' : `Assegna tag a ${props.mode === 'bulk' ? props.messageCount : ''} messaggi`} + + + +
+ {/* Ricerca tag */} +
+ + setSearchQuery(e.target.value)} + autoFocus + /> +
+ + {/* Lista tag */} +
+ {isLoading ? ( +
+
+
+ ) : filteredLabels.length === 0 ? ( +

+ {searchQuery ? 'Nessun tag trovato' : 'Nessun tag disponibile'} +

+ ) : ( + filteredLabels.map((label) => ( + toggleLabel(label.id)} + onDelete={isAdmin ? () => deleteMutation.mutate(label.id) : undefined} + isDeleting={deleteMutation.isPending && deleteMutation.variables === label.id} + /> + )) + )} +
+ + {/* Crea nuovo tag (admin only) */} + {isAdmin && ( +
+ + + {showCreate && ( +
+ setNewName(e.target.value)} + className="h-8 text-sm" + maxLength={100} + onKeyDown={(e) => { + if (e.key === 'Enter' && newName.trim()) createMutation.mutate() + }} + /> + + {/* Selettore colore */} +
+

Colore

+
+ {PRESET_COLORS.map((color) => ( +
+
+ + {/* Anteprima badge */} + {newName.trim() && ( +
+ Anteprima: + +
+ )} + + +
+ )} +
+ )} +
+ + {/* Footer azioni */} + + + + {props.mode === 'single' ? ( + + ) : ( +
+ + +
+ )} +
+ +
+ ) +} + +// ─── Riga singolo label nella lista ────────────────────────────────────────── + +interface LabelRowProps { + label: LabelResponse + isSelected: boolean + onToggle: () => void + onDelete?: () => void + isDeleting?: boolean +} + +function LabelRow({ label, isSelected, onToggle, onDelete, isDeleting }: LabelRowProps) { + return ( +
+ {/* Checkbox */} +
+ {isSelected && } +
+ + {/* Badge con colore */} + + + {/* Spacer */} +
+ + {/* Pulsante elimina (solo admin, visibile su hover) */} + {onDelete && ( + + )} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index dba94c4..fb088ba 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -82,6 +82,82 @@ animation: spin 1s linear infinite; } +/* ─── Rich Text Editor (TipTap / ProseMirror) ─────────────────────────────── */ + +.rte-content { + outline: none; + min-height: inherit; + line-height: 1.6; + font-size: 0.875rem; +} + +/* Placeholder */ +.rte-content p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: hsl(var(--muted-foreground)); + float: left; + height: 0; + pointer-events: none; +} + +/* Tipografia */ +.rte-content h1 { font-size: 1.6rem; font-weight: 700; margin: 0.6rem 0 0.3rem; line-height: 1.3; } +.rte-content h2 { font-size: 1.3rem; font-weight: 600; margin: 0.5rem 0 0.25rem; line-height: 1.3; } +.rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.4rem 0 0.2rem; line-height: 1.3; } +.rte-content p { margin: 0.2rem 0; } +.rte-content p + p { margin-top: 0.4rem; } + +/* Elenchi */ +.rte-content ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.3rem 0; } +.rte-content ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.3rem 0; } +.rte-content li { margin: 0.15rem 0; } + +/* Citazione */ +.rte-content blockquote { + border-left: 3px solid hsl(var(--border)); + padding-left: 0.85rem; + color: hsl(var(--muted-foreground)); + margin: 0.4rem 0; + font-style: italic; +} + +/* Link */ +.rte-content a.rte-link, +.rte-content a { + color: hsl(var(--primary)); + text-decoration: underline; + cursor: pointer; +} +.rte-content a:hover { opacity: 0.8; } + +/* Codice */ +.rte-content code { + background: hsl(var(--muted)); + padding: 0.1rem 0.3rem; + border-radius: 0.25rem; + font-family: ui-monospace, monospace; + font-size: 0.8em; +} +.rte-content pre { + background: hsl(222 47% 11%); + color: hsl(210 40% 96%); + padding: 0.75rem 1rem; + border-radius: 0.375rem; + overflow-x: auto; + margin: 0.4rem 0; +} +.rte-content pre code { background: none; color: inherit; padding: 0; } + +/* Separatore orizzontale */ +.rte-content hr { + border: none; + border-top: 1px solid hsl(var(--border)); + margin: 0.6rem 0; +} + +/* Selezione testo */ +.rte-content .ProseMirror-selectednode { outline: 2px solid hsl(var(--primary)); } + /* Badge PEC stati */ .pec-badge-draft { @apply bg-gray-100 text-gray-700 border-gray-200; } .pec-badge-queued { @apply bg-yellow-100 text-yellow-700 border-yellow-200; } diff --git a/frontend/src/pages/Compose/ComposePage.tsx b/frontend/src/pages/Compose/ComposePage.tsx index e15a5f5..e88872e 100644 --- a/frontend/src/pages/Compose/ComposePage.tsx +++ b/frontend/src/pages/Compose/ComposePage.tsx @@ -1,12 +1,13 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useForm, useFieldArray } from 'react-hook-form' -import { Send, X, Plus, ArrowLeft, AlertCircle } from 'lucide-react' +import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload } from 'lucide-react' import { useQuery, useMutation } from '@tanstack/react-query' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' +import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' import { sendApi } from '@/api/send.api' import { mailboxesApi } from '@/api/mailboxes.api' import { getErrorMessage } from '@/api/client' @@ -17,7 +18,22 @@ interface ComposeFormValues { to_addresses: { value: string }[] cc_addresses: { value: string }[] subject: string - body_text: string +} + +const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20 MB + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** Estrae testo semplice dall'HTML del rich editor per la parte text/plain dell'email */ +function htmlToText(html: string): string { + if (!html || html === '

') return '' + const div = document.createElement('div') + div.innerHTML = html + return div.textContent || div.innerText || '' } export function ComposePage() { @@ -25,6 +41,27 @@ export function ComposePage() { const location = useLocation() const replyTo = location.state?.replyTo as MessageResponse | undefined const [showCc, setShowCc] = useState(false) + const fileInputRef = useRef(null) + + // Corpo HTML (gestito da TipTap) + const [bodyHtml, setBodyHtml] = useState(() => { + if (!replyTo) return '' + const date = new Date( + replyTo.received_at || replyTo.created_at + ).toLocaleDateString('it-IT') + return [ + '

', + '

', + '
', + `

In risposta al messaggio del ${date}

`, + `

Da: ${replyTo.from_address || ''}

`, + `

A: ${replyTo.to_addresses?.join(', ') || ''}

`, + `

Oggetto: ${replyTo.subject || ''}

`, + ].join('') + }) + + // Allegati + const [attachments, setAttachments] = useState([]) const { register, @@ -34,12 +71,11 @@ export function ComposePage() { } = useForm({ defaultValues: { mailbox_id: replyTo?.mailbox_id || '', - to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }], + to_addresses: replyTo + ? [{ value: replyTo.from_address || '' }] + : [{ value: '' }], cc_addresses: [], subject: replyTo ? `Re: ${replyTo.subject || ''}` : '', - body_text: replyTo - ? `\n\n---\nIn risposta al messaggio del ${new Date(replyTo.received_at || replyTo.created_at).toLocaleDateString('it-IT')}\nDa: ${replyTo.from_address || ''}\nA: ${replyTo.to_addresses?.join(', ') || ''}\nOggetto: ${replyTo.subject || ''}` - : '', }, }) @@ -62,7 +98,10 @@ export function ComposePage() { }) const sendMutation = useMutation({ - mutationFn: sendApi.send, + mutationFn: (args: { + data: Parameters[0] + files: File[] + }) => sendApi.sendMultipart(args.data, args.files), onSuccess: (job) => { toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`) navigate('/sent') @@ -72,8 +111,29 @@ export function ComposePage() { }, }) - const onSubmit = async (data: ComposeFormValues) => { - const toAddresses = data.to_addresses + // ── Gestione allegati ────────────────────────────────────────────────────── + + const handleFileAdd = (files: FileList | null) => { + if (!files) return + const valid: File[] = [] + for (const file of Array.from(files)) { + if (file.size > MAX_FILE_SIZE) { + toast.error(`"${file.name}" supera il limite di 20 MB`) + } else { + valid.push(file) + } + } + if (valid.length) setAttachments((prev) => [...prev, ...valid]) + } + + const removeAttachment = (idx: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== idx)) + } + + // ── Submit ───────────────────────────────────────────────────────────────── + + const onSubmit = async (formData: ComposeFormValues) => { + const toAddresses = formData.to_addresses .map((t) => t.value.trim()) .filter((v) => v.length > 0) @@ -83,14 +143,18 @@ export function ComposePage() { } await sendMutation.mutateAsync({ - mailbox_id: data.mailbox_id, - to_addresses: toAddresses, - cc_addresses: data.cc_addresses - .map((c) => c.value.trim()) - .filter((v) => v.length > 0), - subject: data.subject, - body_text: data.body_text, - reply_to_message_id: replyTo?.id, + data: { + mailbox_id: formData.mailbox_id, + to_addresses: toAddresses, + cc_addresses: formData.cc_addresses + .map((c) => c.value.trim()) + .filter((v) => v.length > 0), + subject: formData.subject, + body_text: htmlToText(bodyHtml), + body_html: bodyHtml, + reply_to_message_id: replyTo?.id, + }, + files: attachments, }) } @@ -99,28 +163,29 @@ export function ComposePage() { return (
{/* Header */} -
-
- -
-

- {replyTo ? 'Rispondi a PEC' : 'Nuova PEC'} -

- {replyTo && ( -

- In risposta a: {replyTo.subject} -

- )} -
+
+ +
+

+ {replyTo ? 'Rispondi a PEC' : 'Nuova PEC'} +

+ {replyTo && ( +

+ In risposta a: {replyTo.subject} +

+ )}
{/* Form */}
-
- {/* Avviso */} + + {/* Avviso informativo */}

@@ -130,7 +195,7 @@ export function ComposePage() {

{/* Casella mittente */} -
+
{mailboxesLoading ? (
@@ -141,8 +206,10 @@ export function ComposePage() { ) : ( - {/* Corpo */} -
- -