Nexus — Fase 3 7 episódios

Sequência da Fase 3 do Nexus — hierarquia e organização Notion-like. Notas viram árvore (parent/child) com sidebar à esquerda mostrando a estrutura, drag & drop pra mover, blocos extras no editor (checklist, citação, divisor), attachments (imagem anexada por upload/paste/drop) e markdown import/export. Fase fechada — 6/6 sub-entregas.

01 - Visão GeralVISÃO GERAL

Panorama da Fase 3 fechada — 3 decisões iniciais + 6 frentes entregues

0:00 / 0:00

Resumo

A Fase 3 é sobre hierarquia. A Fase 2 entregou busca e tags pra achar nota; mas a lista continua plana. Pra organização vertical (pasta dentro de pasta dentro de pasta — estilo Notion) a gente precisa de árvore. A Fase 3 fechou as 6 sub-entregas planejadas.

Decisões iniciais (antes de codar): JSON do TipTap em pages.content (não tabela blocks — TipTap já produz JSON ProseMirror, salvar é 1 UPDATE, reordenar é grátis); imagens locais no servidor próprio (não R2/S3 — coerente com §3.8 do plano, dado pessoal sob controle); drag & drop com svelte-dnd-action (HTML5 nativo machuca pra árvore com mover-entre-pais).

6 entregas em sequência: 3.1 backend (V10 com parent_id + position, /tree, /move com validação de ciclo) → 3.2 sidebar frontend (PageTree recursivo, store global, layout colapsável) → 3.3 drag & drop (svelte-dnd-action com dndzone aninhada por nível) → 3.4 blocos extras (checklist, citação, linha horizontal) → 3.5 attachments (V11 + UUID público + paste/drop/upload) → 3.6 markdown (export hand-rolled + import via marked, tudo no frontend).

parent_id CASCADEposition gap-friendlyJSONB TipTapstore global runessvelte-dnd-actionTaskListUUID públicomarked
CÓDIGO COMPLETO
02 - Hierarquia backendparent_id

V10 (parent_id + position), entidade Note self-reference, /tree, /move com validação de ciclo

0:00 / 0:00

Resumo

Nota vira nó de árvore. Migration V10 adiciona parent_id BIGINT NULL REFERENCES notes(id) ON DELETE CASCADE (NULL = raiz; deletar pai apaga subárvore inteira) e position INT NOT NULL DEFAULT 0 (ordem entre irmãos). Dois índices: idx_notes_parent_id pra buscar filhos, e composto (user_id, parent_id, position) pra render da sidebar. Notas existentes ficam com parent_id NULL e position 0 — todas viram raízes automaticamente, migração transparente.

Entidade Note ganha Long parentId e int position. Sem @ManyToOne — Long puro, mais simples: a sidebar não navega parent → children via JPA, monta a árvore no client a partir da lista flat. NoteRequest ganha parentId opcional; NoteResponse expõe parentId e position. Novo DTO NoteMoveRequest ({parentId, position}).

Service: create valida que o pai existe e pertence ao user (sem isso, dava pra anexar nota ao pai de outro user — vazamento de hierarquia) e aloca position = MAX(position dos irmãos) + 1 (gap-friendly, sem repacking). move(id, MoveRequest) valida ownership do pai novo + chama ensureNoCycle que sobe a cadeia de ancestrais a partir do candidato; se em algum ponto encontrar o id da nota que tá movendo, rejeita com 400. Profundidade típica é baixa (segundo cérebro raramente passa de 5-10 níveis); se virar gargalo, troca por recursive CTE depois.

Endpoints novos: GET /notes/tree retorna lista flat de todas as notas (sem filtro), ordenada por (parentId, position) — frontend agrupa por parentId e ordena os filhos por position. PATCH /notes/{id}/move recebe {parentId, position} e aplica via NoteService.move. PUT /notes/{id} NÃO mexe em hierarquia — isso é responsabilidade só do move.

Testes: 47 verdes (era 42, +5 da Fase 3.1). 4 novos no NoteControllerTest (tree, move 200/400/404), 1 novo no NexusIntegrationTest (cria árvore 3 níveis, move neto pra raiz, tenta ciclo, tenta self-move, tenta pai 404). Backend ainda compatível com frontend antigo: POST sem parentId = nota raiz, GET retorna campos extras que o cliente velho ignora.

Flyway V10parent_id CASCADEposition gap-friendly/notes/treePATCH /moveensureNoCycle
CÓDIGO COMPLETO
03 - Sidebar frontendSvelteKit

PageTree recursivo, store global tree.svelte.ts, layout com sidebar 17rem colapsável, ?parent= no /notes/new

0:00 / 0:00

Resumo

Sidebar à esquerda mostra árvore de páginas. Estado global em lib/tree.svelte.ts: classe TreeStore com runes ($state em notes, loading, error) + métodos refresh() e clear(). Páginas chamam tree.refresh() depois de criar/editar/deletar pra manter sidebar em sincronia, sem prop drilling. +layout.svelte dispara refresh ao logar e clear no logout.

PageTree.svelte: header "PÁGINAS" + "+" pra criar raiz, lista das raízes filtrada da store. Constrói uma árvore aninhada local em $state a partir do flat (groupBy parentId + sort por position) — isso é necessário pra biblioteca de drag mutar arrays nativos por zona (ver episódio 04).

PageTreeNode.svelte (recursivo, importa a si mesmo): caret "▾/▸" pra expand/collapse (escondido se não tem filho), label clicável que navega pra /notes/{id}, ações no hover ("+" pra criar filho, "⋯" abre menu com Excluir). activeId recebido por prop destaca a nota aberta em laranja. Indentação por depth * 0.9rem.

Layout: header full-width com botão ☰ no canto que toggla a sidebar. Sidebar de 17rem com overflow-y: auto, position: sticky pra rolar junto com a página. Main vira flex sem max-width quando sidebar está aberta (caso contrário, mantém os 720px centralizados originais).

Criar filho: dois fluxos. Direto da sidebar — clica "+" num nó → api.createNote({title:"Sem título", content:"", parentId}), refresh, navega pro detalhe (UX Notion-like, criação instantânea). Pelo formulário — /notes/new?parent=ID; o +page.svelte derivada o parentId da URL e passa pro create. Se URL não tiver ?parent=, cria como raiz (mesmo comportamento de antes). Detalhe: ao salvar, refresh pega rename do título; ao excluir, refresh + redirect pra home (CASCADE no backend pega filhos).

Compatibilidade: tipos Note ganham parentId e position; NoteRequest ganha parentId?; novo NoteMoveRequest. api.ts ganha treeNotes() e moveNote().

PageTree recursivo$state runesstore globalsidebar sticky?parent= URL
CÓDIGO COMPLETO
04 - Drag and Dropsvelte-dnd-action

dndzone aninhada por nível, consider/finalize, padrão "só zona destino persiste", refresh como sincronização

0:00 / 0:00

Resumo

Stack: svelte-dnd-action (~10kb). HTML5 nativo machuca em árvore com mover-entre-pais — distinguir "antes/dentro/depois", bloquear ciclo, animação. Lib resolve isso pronto. Cada nível da árvore vira uma dndzone: filhos da raiz são uma zona, filhos de cada nó expandido são outras zonas. Quando o usuário arrasta nó da zona A pra B, ambas recebem evento — só a zona DESTINO (a que contém o item ao final) chama o backend, evita 2 chamadas.

Eventos: consider dispara durante o arrasto (a cada movimento), atualiza array bound pra animação funcionar; finalize dispara quando o usuário solta. No finalize, pega e.detail.info.id (id do item movido) e findIndex no novo array. Se idx === -1, item saiu desta zona — outra zona vai persistir, então não faz nada. Se info.trigger === TRIGGERS.DRAG_STOPPED, é cancelamento (ESC) — também não chama o backend.

Fluxo de persistência: applyMove(noteId, parentId, position) chama api.moveNote + tree.refresh(). Se backend rejeita (ciclo, ownership), mostra alert + refresh mesmo assim — refresh sincroniza nested local com a verdade do servidor, descartando a alteração visual feita no consider. Servidor é a fonte da verdade; cliente só animaa.

Limitação v1: pra dropar dentro de nó colapsado, expandir primeiro (zona dos filhos não existe se colapsado). Notion expande automaticamente no hover-during-drag — polish pra próxima versão. Validação de ciclo só no servidor por ora — cliente aceita arrasto inválido, vê alerta e refresha (piscadinha visual aceitável).

svelte-dnd-actiondndzone aninhadaconsider/finalizesó destino persisteDRAG_STOPPED ESCservidor = verdade
CÓDIGO COMPLETO
05 - Blocos extrasTipTap

TaskList nested + Blockquote + HorizontalRule, escritório não muda (RichTextExtractor já era genérico)

0:00 / 0:00

Resumo

Editor da Fase 2 só tinha B/I/H1/H2/listas/code. Faltava 3 blocos pra segundo cérebro: checklist (caixinha pra marcar tarefa), citação (barrinha esquerda), linha horizontal (separador).

Instalação: npm i @tiptap/extension-task-list @tiptap/extension-task-item. TaskItem.configure({ nested: true }) permite Tab pra aninhar checklist. Blockquote e HorizontalRule já vinham no StarterKit — só faltava expor na toolbar (descoberta tardia: parte do "novo" era só ler doc da lib).

Toolbar ganha 3 botões (☐ checklist, ❝ blockquote, ── divisor) + 3 cases no exec(): toggleTaskList(), toggleBlockquote(), setHorizontalRule(). Convenção TipTap: toggleX liga/desliga bloco no cursor; setX só insere (HR não tem "estado", é nó separado).

CSS por bloco: blockquote ganha border-left: 3px solid #ddd + cor cinza; HR vira linha cinza com margem; checklist é mais delicada — TipTap renderiza cada item como li[data-type="taskList"] com label + div. Quando user marca, data-checked="true" entra no DOM. Seletor de atributo aplica linha sobre o texto via CSS — sem JS extra.

Por que o backend não mudou: RichTextExtractor da Fase 2 é genérico — percorre o JSON ProseMirror coletando qualquer campo "text" via recursão em "content". Não sabe quais tipos de nó existem; coleta texto de qualquer um. Checklist no JSON é só um nó taskList com filhos taskItem com paragraph/text dentro — extractor desce e pega. Princípio: estrutura desenhada pra extensão, mesmo sem saber a extensão, paga dividendos depois.

TaskList nestedBlockquoteHorizontalRuledata-checkedRichTextExtractor genéricoextensibilidade
CÓDIGO COMPLETO
06 - AttachmentsUUID público

V11 + mesa user-scoped + /attachments/{uuid} público (sem auth) + paste/drop/upload no TipTap

0:00 / 0:00

Resumo

Imagem na nota precisa de dois lugares: arquivo no disco do servidor, metadado no arquivo morto. Migration V11 cria mesa attachments com user_id, uuid, filename, mime, size_bytes. Sem note_id — link entre attachment e nota vive no JSON ProseMirror via <img src>; cleanup de órfão fica pra job futuro. application.yml ganha multipart 10MB e nexus.uploads.dir (default ~/.nexus/uploads/).

UUID público sem auth: GET /attachments/{uuid} liberado no SecurityConfig via requestMatchers(HttpMethod.GET, "/attachments/*").permitAll(). Por quê? Porque a tag <img src> NÃO manda JWT no header — pra funcionar como imagem normal, GET tem que ser público. Como evitar acesso indesejado? UUID v4 é inadivinhável; URL só vaza via JSON da nota, que é auth-protegida. POST/DELETE seguem auth normal.

AttachmentService: upload valida (não-vazio, ≤10MB, mime image/*), gera UUID v4, salva no disco como {uuid}.{ext} (extensão extraída do filename original). Filename original sanitizado (separadores e controles → underscore) — vai pro DB e pro response, mas NUNCA toca o filesystem (storage real é UUID, imune a path traversal). loadByUuid busca + valida arquivo existe + devolve Path pro controller streamar com FileSystemResource. delete confere ownership, apaga disco + DB; se disco resistir (lock/permissão), apaga só o DB e segue.

Frontend (TipTapEditor): 3 entradas pra inserir imagem. Botão 🖼 na toolbar abre file picker. Paste (Ctrl+V): handlePaste percorre clipboardData.items, se algum é file image/* faz upload. Drop: handleDrop mesmo padrão pra dataTransfer.files. Todos chamam uploadAndInsert(file) que faz fetch com FormData (sem Content-Type manual — browser gera o boundary multipart) e usa editor.chain().setImage({src, alt}) pra inserir o nó image no JSON.

Flyway V11UUID v4 públicomultipartFileSystemResourcehandlePaste/DropsetImage TipTap
CÓDIGO COMPLETO
07 - Markdownmarked

Export hand-rolled (TipTap JSON → MD) + import via marked (MD → tokens → ProseMirror), tudo no frontend

0:00 / 0:00

Resumo

JSON ProseMirror é bom pra TipTap, ruim pra portabilidade. Markdown é lingua franca de notas (Notion exporta MD, Obsidian é MD, GitHub README é MD). A Fase 3.6 entrega import + export — tudo no frontend, sem mudança no backend (frontend já tem o JSON em mãos via GET /notes/{id}; converter no client evita endpoint duplicado e permite iterar sem deploy).

Stack: marked (~50kb, GFM nativo) pra parsear MD em tokens estruturados; prosemirror-markdown instalado mas não usado (schema diferente do TipTap, sai mais barato escrever serializer próprio).

Export — noteJsonToMarkdown(json): walker recursivo. renderBlock(node, depth) faz dispatch por node.type — paragraph, heading (H1-H6 via '#'.repeat(level)), bulletList/orderedList, taskList (vira - [ ]/- [x] GFM), blockquote, codeBlock, horizontalRule, image. renderInline aplica marks: bold (**), italic (*), code (`), strike (~~), link ([]()). Listas e quote chamam renderBlock recursivo com depth+1 pra indentar.

Import — markdownToNoteJson(md): marked.lexer(md, {gfm: true}) → array de tokens. tokenToNode faz dispatch por tok.type: paragraph, heading, list (detecta checklist via items.every(i => i.task) — se todos têm flag task, vira taskList; senão bulletList/orderedList), blockquote, code, hr. inlineTokens processa strong/em/codespan/del/link/image em recursão, empilhando marks no nó text.

Botões na UI: 📥 MD no detalhe da nota usa Blob + URL.createObjectURL + âncora invisível pra disparar download (filename = título sanitizado). 📤 Importar MD na home tem file picker; lê texto, extrai H1 do começo como título (se houver), converte corpo, cria nota via POST /notes, refresh da árvore, navega pro detalhe.

Limitações conhecidas: round-trip não é 100% perfeito (marks aninhadas podem reordenar). Imagens (attachments) viram ![alt](url) apontando pro UUID — só funcionam com backend acessível; pra round-trip completo entre máquinas, precisaria embutir como base64 ou empacotar em zip. Fica pra futuro.

marked GFMwalker recursivosó frontendBlob downloadH1 = títulochecklist GFM
CÓDIGO COMPLETO