From 540f306b732dce901b3f1d615ad3ab3ab8828e8e Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Thu, 15 Oct 2026 18:21:44 -0300 Subject: [PATCH 1/3] docs: plan navigating between folders and creating folders (LUI-179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance file docs/tickets/LUI-177/LUI-179/aceite.md (CA1-CA12). The OpenAPI snapshot gains GET /items/{id} (ItemDetailDto with the trail), the optional type=folder query on GET /items/{id}/children and POST /folders (CreateFolderDto, 201 ItemDto); no table changes. Decision: the depth counts the folders of the path without the root, so a folder directly in "Meus arquivos" is level 1 and the 50th level is the last one allowed. This makes the limit testable with a chain of exactly 50 folders. Decision: the HTTP tests seed trees and pending items straight in the database, because a 50-level chain cannot be built through routes without repeating what CA3 already proves; only CA3 and the browser tests create folders through the real route. Decision: creating a folder takes the per-user advisory lock (pg_advisory_xact_lock on the owner id) before checking depth and writing, so the lock the move ticket (LUI-181) will reuse is registered in the LLD now. Decision: the LLD records the type=folder filter, the rule that the list accepts any active folder of the User, the /pasta/[id] route and the advisory lock (CA12); the subtree-height rule of the move is registered too, as the move ticket depends on it. Files and interfaces the implementation will touch: - apps/api/src/modules/items: folder creation, the trail query (recursive, SQL via $queryRaw, up to 50 levels), the type filter and any-active-folder listing in children; FoldersController (POST /folders) and ItemsController.get. - apps/api/openapi.json: regenerated from the controllers and DTOs. - apps/api/test: items-folders.e2e-spec.ts. - apps/web: route /pasta/[id], breadcrumb with the collapsed middle on mobile, "Nova pasta" dialog and toast (Server Action), folder rows opening on double click, "Enviar" to the open folder, not-found page. - apps/web/e2e: folders.spec.ts and the folder helpers in support/items.ts. - docs/lld.md: sections 3.3, 4.1 and the web route map. Existing tests whose behavior changes: items-root-children.e2e-spec.ts, "responde not_found para uma pasta que não é a raiz, enquanto só ela é aceita", which must now use an id that is not an active folder of the User. Data changes: none. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/openapi.json | 243 +++++++++++++++++++++++++ docs/lld.md | 11 +- docs/tickets/LUI-177/LUI-179/aceite.md | 103 +++++++++++ 3 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 docs/tickets/LUI-177/LUI-179/aceite.md diff --git a/apps/api/openapi.json b/apps/api/openapi.json index a64a798..ca59e22 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -587,6 +587,85 @@ ] } }, + "/v1/items/{id}": { + "get": { + "operationId": "ItemsController_get", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemDetailDto" + } + } + } + }, + "401": { + "description": "unauthenticated", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + } + } + } + } + } + }, + "404": { + "description": "not_found", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + } + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "items" + ] + } + }, "/v1/items/{id}/children": { "get": { "operationId": "ItemsController_listChildren", @@ -617,6 +696,17 @@ "schema": { "type": "string" } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "enum": [ + "folder" + ], + "type": "string" + } } ], "responses": { @@ -786,6 +876,109 @@ ] } }, + "/v1/folders": { + "post": { + "operationId": "FoldersController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFolderDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemDto" + } + } + } + }, + "400": { + "description": "validation_error, max_depth_exceeded", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "validation_error", + "max_depth_exceeded" + ] + } + } + } + } + } + }, + "401": { + "description": "unauthenticated", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + } + } + } + } + } + }, + "404": { + "description": "not_found", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + ], + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + } + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "folders" + ] + } + }, "/v1/uploads": { "post": { "operationId": "UploadsController_create", @@ -1424,6 +1617,39 @@ "nextCursor" ] }, + "ItemTrailEntryDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "ItemDetailDto": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/ItemDto" + }, + "trail": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemTrailEntryDto" + } + } + }, + "required": [ + "item", + "trail" + ] + }, "DownloadUrlDto": { "type": "object", "properties": { @@ -1440,6 +1666,23 @@ "expiresAt" ] }, + "CreateFolderDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "parentId": { + "type": "string" + } + }, + "required": [ + "name", + "parentId" + ] + }, "CreateUploadDto": { "type": "object", "properties": { diff --git a/docs/lld.md b/docs/lld.md index 2d24361..5c27b64 100644 --- a/docs/lld.md +++ b/docs/lld.md @@ -306,7 +306,7 @@ Todo erro sai com `Content-Type: application/problem+json` e este corpo: | Método e rota | Entrada | Saída | | --- | --- | --- | | `GET /items/{id}` | | O item e a trilha de pastas até a raiz | -| `GET /items/{id}/children` | `limit`, `cursor` | Lista de itens ativos da pasta | +| `GET /items/{id}/children` | `limit`, `cursor`, `type` (opcional, `folder`) | Lista de itens ativos da pasta | | `POST /folders` | `name`, `parentId` | 201, a pasta criada | | `PATCH /items/{id}` | `name` e/ou `parentId` | O item atualizado | | `DELETE /items/{id}` | | 204. Envia para a lixeira. | @@ -318,7 +318,8 @@ Todo erro sai com `Content-Type: application/problem+json` e este corpo: O id `root` é aceito como atalho para a pasta raiz do usuário. Na primeira vez que ele é usado, a raiz nasce (seção 4.1). -- **`GET /items/{id}/children` antes das pastas:** até a funcionalidade de pastas, a rota aceita só `root`, e qualquer outro id responde `not_found`. A data que a lista mostra é a de modificação (`updatedAt`). A forma da resposta está no snapshot da OpenAPI. +- **`GET /items/{id}/children`:** aceita `root` ou qualquer pasta ativa do Usuário; outro valor (inexistente, de outro Usuário, arquivo, não-UUID, `pending` ou `deleting`) responde `not_found`. O parâmetro `type=folder` é opcional e filtra para só pastas, para o seletor de "Mover para…"; qualquer outro valor responde `validation_error`. A ordenação, o cursor e o limite não mudam. A data que a lista mostra é a de modificação (`updatedAt`). A forma da resposta está no snapshot da OpenAPI. +- **`GET /items/{id}`:** devolve o Item e a trilha do topo (a Pasta raiz, "Meus arquivos") até ele; o atalho `root` devolve a trilha só com a raiz. Item inexistente, de outro Usuário, de id inválido ou não ativo responde `not_found`. ### 3.4 Upload e download @@ -380,7 +381,9 @@ stateDiagram-v2 - **Consequência para busca e links públicos:** como os descendentes não são marcados, essas consultas precisam conferir se algum ancestral está na lixeira. A conferência é feita com uma consulta recursiva sobre a página de resultados (até 200 itens, até 50 níveis). - **Restaurar** devolve o item à pasta de origem. Se ela não existir mais ou estiver na lixeira, o item volta para a raiz. - **Expurgo de uma pasta** remove todos os descendentes, de baixo para cima. -- **Mover:** a API sobe pelos ancestrais do destino e rejeita o pedido se encontrar o próprio item (`invalid_move`) ou se a profundidade passar de 50 níveis. +- **Profundidade:** conta as pastas do caminho, sem a Pasta raiz: uma pasta direto em "Meus arquivos" está no nível 1, e o 50º é o último que pode existir. Criar uma pasta no 50º nível responde `max_depth_exceeded`. +- **Lock consultivo por Usuário:** a transação que grava mudando a árvore (criar pasta e, no ticket de mover, mover) toma `pg_advisory_xact_lock` sobre o id do dono antes de conferir e gravar, para duas verificações do mesmo Usuário nunca enxergarem a árvore antiga ao mesmo tempo. Só as gravações do mesmo Usuário esperam umas pelas outras, e o lock se solta no fim da transação. +- **Mover:** a API sobe pelos ancestrais do destino e rejeita o pedido se encontrar o próprio item (`invalid_move`). O limite de 50 níveis vale para a árvore inteira: a profundidade do destino mais a altura da subárvore movida (consulta recursiva para baixo, só de pastas) não pode passar de 50 (`max_depth_exceeded`). - **Pasta raiz:** nasce sob demanda, na primeira vez que o usuário a usa (o atalho `root`), com `INSERT ... ON CONFLICT DO NOTHING` seguido da leitura. O índice único da raiz garante uma só, mesmo com chamadas simultâneas. Ela se chama "Meus arquivos", nunca aparece numa listagem e não pode ser renomeada, movida nem excluída. ### 4.2 Cota @@ -667,7 +670,7 @@ Esta seção descreve o que a web apresenta e protege: as rotas e telas, a Sess | `/sessao-encerrada` | Pública, rota que só redireciona | `POST /auth/refresh` | Renova a Sessão ou a encerra | Existe | | `/termos`, `/privacidade` | Pública | | | Existe, provisórias | | `/` | Protegida | `GET /me`, `GET /items/root/children` | Sair (`POST /auth/logout`), pelo menu da conta, "Carregar mais" e "Enviar" (`POST /uploads` e `POST /uploads/{itemId}/complete`, por Server Action). "Baixar", no menu da linha de um arquivo, é um link para `/arquivos/[id]/baixar`. | Existe: "Meus arquivos", a lista da pasta raiz | -| `/pasta/[id]` | Protegida | `GET /items/{id}`, `GET /items/{id}/children` | Criar pasta, renomear, mover, excluir, enviar arquivos, compartilhar | A detalhar | +| `/pasta/[id]` | Protegida | `GET /items/{id}`, `GET /items/{id}/children`, `POST /folders` | Abrir uma pasta por duplo clique, breadcrumb, "Carregar mais", "Nova pasta" (Server Action) e "Enviar" para a pasta aberta. Id de arquivo, inexistente, de outro Usuário, não-UUID ou de Item não ativo mostra a página de não encontrado. Renomear, mover, excluir e compartilhar ficam para os tickets seguintes | Existe (renomear, mover e excluir a detalhar) | | `/lixeira` | Protegida | `GET /trash` | Restaurar, excluir definitivamente, esvaziar | A detalhar | | `/busca?q=` | Protegida | `GET /search` | | A detalhar | | `/planos` | Protegida | `GET /plans`, `GET /billing/subscription` | `POST /billing/checkout`, `POST /billing/subscription/cancel` | A detalhar | diff --git a/docs/tickets/LUI-177/LUI-179/aceite.md b/docs/tickets/LUI-177/LUI-179/aceite.md new file mode 100644 index 0000000..0a16dc4 --- /dev/null +++ b/docs/tickets/LUI-177/LUI-179/aceite.md @@ -0,0 +1,103 @@ +# Aceite do LUI-179 + +Ticket de navegar entre pastas e criar pastas. Os CA1 a CA6 são provados por testes HTTP da API (um arquivo de ponta a ponta, com o PostgreSQL de testes real), os CA7 a CA11, por testes no Playwright, e o CA12, pela leitura do `docs/lld.md` no diff do PR. + +Os testes HTTP preparam pastas e arquivos direto no banco (`itemRoutes(app).seed`), porque a árvore de que precisam, inclusive a de 50 níveis do CA5, não se monta por rotas sem repetir o que o CA3 já prova. Quem cria pasta pela rota real é o teste do CA3 e os do navegador. Um item `pending` entra no CA1 e no CA2 por essa mesma preparação, já que a rota que o cria é a do upload. + +A profundidade conta as pastas do caminho, sem a Pasta raiz: uma pasta direto em "Meus arquivos" está no nível 1, e o 50º nível é a última pasta que pode existir. O CA5 monta uma cadeia de 50 pastas e confere os dois lados do limite. + +Os testes no navegador seguem o estilo de `drive.spec.ts`: um Usuário verificado, a árvore preparada no banco por auxiliares de `e2e/support/items.ts` (que ganha o de pastas) e as telas lidas pelos papéis e nomes que o usuário vê. O CA10 confere o celular com a janela em 390 px, e a aparência de cada tela, a conferência com o Figma do LUI-178, que é da validação visual do `docs/design-system.md`, e não deste arquivo. + +## LUI-179-CA1 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com uma cadeia de três pastas dentro da raiz, quando ele pede `GET /items/{id}` da terceira pasta, então recebe o Item e a trilha com "Meus arquivos" e as três pastas, do topo até ela, e o atalho `root` devolve a trilha só com a raiz. Dado o mesmo pedido com um id inexistente, com o id de uma pasta de outro Usuário, com o de um arquivo, com um texto que não é UUID e com o de um Item `pending`, então cada um responde `not_found`. + +**Teste previsto:** testes de ponta a ponta da API, `items-folders.e2e-spec.ts`, "responde a trilha do topo até a pasta" e "responde not_found à trilha de um id que não é de uma pasta ativa do Usuário". + +## LUI-179-CA2 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com uma pasta que tem pastas, arquivos e um Item `pending` dentro, quando ele pede `GET /items/{pasta}/children`, então recebe só os Itens ativos, pastas primeiro e depois por nome, e com `limit` menor que o total a resposta traz `nextCursor` e a página seguinte continua sem repetir nem pular. Com `type=folder`, só as pastas voltam, também paginadas. Um `type` que não é `folder` responde `validation_error`, e uma pasta inexistente, de outro Usuário ou que é arquivo responde `not_found`. + +**Teste previsto:** testes de ponta a ponta da API, `items-folders.e2e-spec.ts`, "lista os filhos de uma pasta, paginada e na ordem do contrato", "filtra só as pastas com type=folder" e "responde not_found à lista de uma pasta que não é do Usuário". + +## LUI-179-CA3 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com uma pasta chamada "Fotos" na raiz, quando ele cria, por `POST /folders`, a pasta " Fotos " na raiz e outra na pasta, então cada resposta é 201 com a pasta criada, de nome "Fotos" sem os espaços das pontas, e a lista da pasta de destino a traz, com o nome igual ao do irmão aceito. + +**Teste previsto:** testes de ponta a ponta da API, `items-folders.e2e-spec.ts`, "cria a pasta sem os espaços das pontas e aceita nome igual". + +## LUI-179-CA4 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com a raiz, quando ele cria uma pasta com o nome vazio, só de espaços, com `/`, com 256 caracteres ou sem o campo, então cada pedido responde `validation_error`, e a lista da raiz continua sem pasta nova. Um nome com 255 caracteres, e outro cujo comprimento só passa de 255 por causa dos espaços das pontas, são aceitos. + +**Teste previsto:** testes de ponta a ponta da API, `items-folders.e2e-spec.ts`, "recusa o nome inválido com validation_error" e "mede os 255 caracteres depois de descartar os espaços das pontas". + +## LUI-179-CA5 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário, quando ele cria uma pasta num destino que não existe, que não é UUID, que é arquivo, que é de outro Usuário ou que é um Item `pending`, então cada pedido responde `not_found`. Dada uma cadeia de 50 pastas, quando ele cria uma dentro da de nível 49, então responde 201 (a pasta nasce no nível 50), e dentro da de nível 50 responde `max_depth_exceeded`, sem criar nada. + +**Teste previsto:** testes de ponta a ponta da API, `items-folders.e2e-spec.ts`, "responde not_found a um destino que não é uma pasta ativa do Usuário" e "cria até o 50º nível e recusa o 51º com max_depth_exceeded". + +## LUI-179-CA6 + +**Marca:** teste HTTP + +**Cenário:** Dado um Usuário com a conta em modo somente leitura (a cota usada acima do espaço do plano), quando ele cria uma pasta, então a resposta é 201, e `GET /me` continua com o mesmo `usedBytes` e `readOnly` verdadeiro. + +**Teste previsto:** testes de ponta a ponta da API, `items-folders.e2e-spec.ts`, "cria a pasta com a conta somente leitura, sem mexer na Cota". + +## LUI-179-CA7 + +**Marca:** teste no Playwright + +**Cenário:** Dado um Usuário com a pasta "Projetos", que tem a subpasta "2026" com um arquivo, quando ele abre "Projetos" e depois "2026" por duplo clique, então o endereço de cada uma é `/pasta/`, o breadcrumb mostra "Meus arquivos", "Projetos" e "2026" (a última sem link) e a lista traz o conteúdo da pasta aberta. Quando ele clica em "Projetos" no breadcrumb, volta a ela, e o botão "voltar" do navegador leva à pasta anterior. Recarregar a página numa pasta a mantém aberta. + +**Teste previsto:** testes no navegador, `folders.spec.ts`, "o Usuário abre pastas por duplo clique e volta pelo breadcrumb". + +## LUI-179-CA8 + +**Marca:** teste no Playwright + +**Cenário:** Dado um Usuário dentro de uma pasta, quando ele aperta "Nova pasta", digita um nome com espaços nas pontas e confirma, então o diálogo fecha, o endereço continua o da pasta, a nova pasta aparece na lista com o nome sem os espaços e um aviso de confirmação é mostrado. Quando ele confirma um nome só de espaços, o diálogo continua aberto com o erro em português junto do campo e nenhuma pasta é criada. Quando a árvore já está no 50º nível, o erro de profundidade aparece em português no diálogo. + +**Teste previsto:** testes no navegador, `folders.spec.ts`, "Nova pasta cria a pasta na pasta atual, avisa e fica nela" e "Nova pasta mostra o erro em português quando o nome é inválido ou a árvore está no limite". + +## LUI-179-CA9 + +**Marca:** teste no Playwright + +**Cenário:** Dado um Usuário, quando ele abre o endereço `/pasta/` de uma pasta que não existe, de uma de outro Usuário, de um arquivo e de um texto que não é UUID, então cada um mostra a página de "não encontrado" da casca do Drive, com o caminho de volta a "Meus arquivos", e a Sessão continua aberta. + +**Teste previsto:** testes no navegador, `folders.spec.ts`, "o endereço de uma pasta que não é do Usuário mostra a página de não encontrado". + +## LUI-179-CA10 + +**Marca:** teste no Playwright + +**Cenário:** Dado um Usuário com uma pasta de 60 Itens, quando ele a abre, então a lista mostra 50 e, ao apertar "Carregar mais", os outros 10 são acrescentados e o botão some. Dada uma janela de 390 px e uma pasta no 5º nível, quando ele a abre, então o breadcrumb mostra "Meus arquivos", o "…" e a pasta atual; o "…" abre um menu com as pastas do meio, e escolher uma delas leva a ela. + +**Teste previsto:** testes no navegador, `folders.spec.ts`, "Carregar mais acrescenta a página seguinte de uma pasta longa" e "no celular o breadcrumb longo colapsa os trechos do meio num menu". + +## LUI-179-CA11 + +**Marca:** teste no Playwright + +**Cenário:** Dado um Usuário dentro de uma pasta, quando ele escolhe um arquivo pelo botão "Enviar", então o envio conclui e o arquivo aparece na lista dessa pasta e não na de "Meus arquivos". Quando ele envia outro arquivo estando na raiz, o arquivo aparece na raiz. + +**Teste previsto:** testes no navegador, `folders.spec.ts`, "os arquivos enviados com o botão Enviar vão para a pasta aberta". + +## LUI-179-CA12 + +**Marca:** documento + +**Cenário:** Dado o `docs/lld.md`, quando se leem a seção de itens da API, a de estados e regras do Item e o mapa de rotas da web, então o filtro `type=folder` da lista de filhos, a regra de que a lista aceita qualquer pasta ativa do Usuário, a rota `/pasta/[id]` com a situação "Existe" e o lock consultivo por Usuário que serializa as gravações que mudam a árvore estão registrados. From d1f54ace04e00c7206e6e8b11e9adabe81e25b91 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Thu, 15 Oct 2026 18:45:23 -0300 Subject: [PATCH 2/3] feat: navigate between folders and create folders (LUI-179) GET /items/{id} returns the item and its trail, the children list accepts any active folder and the type=folder filter, and POST /folders creates a folder under a per-user advisory lock and the 50-level limit. The web gets the /pasta/[id] route, the breadcrumb, "Nova pasta" with a toast, and the upload button sends files to the open folder. Co-Authored-By: Claude Sonnet 5.5 --- .../validation}/file-name.decorator.ts | 0 .../modules/items/dto/create-folder.dto.ts | 20 + .../src/modules/items/dto/item-detail.dto.ts | 14 + .../items/dto/list-children-query.dto.ts | 6 + .../src/modules/items/entities/item.entity.ts | 34 ++ .../items/errors/max-depth-exceeded.error.ts | 11 + .../src/modules/items/folders.controller.ts | 28 ++ .../api/src/modules/items/items.controller.ts | 13 + apps/api/src/modules/items/items.module.ts | 3 +- .../api/src/modules/items/items.repository.ts | 84 +++- apps/api/src/modules/items/items.service.ts | 86 +++- .../modules/uploads/dto/create-upload.dto.ts | 2 +- apps/api/test/items-folders.e2e-spec.ts | 408 ++++++++++++++++++ apps/api/test/items-root-children.e2e-spec.ts | 12 +- apps/api/test/support/items.ts | 43 +- apps/web/app/(drive)/actions.ts | 71 ++- apps/web/app/(drive)/layout.tsx | 69 +-- apps/web/app/(drive)/page.tsx | 49 +-- apps/web/app/(drive)/pasta/[id]/not-found.tsx | 16 + apps/web/app/(drive)/pasta/[id]/page.tsx | 25 ++ apps/web/app/vitrine/drive/page.tsx | 38 ++ apps/web/components/files/file-row.tsx | 28 +- apps/web/components/files/folder-contents.tsx | 48 +++ apps/web/components/files/folder-view.tsx | 46 ++ .../components/files/new-folder-button.tsx | 91 ++++ apps/web/components/files/openable-row.tsx | 37 ++ apps/web/components/navigation/breadcrumb.tsx | 74 ++++ apps/web/components/ui/button-secondary.tsx | 17 + apps/web/components/ui/dialog.tsx | 52 +++ apps/web/components/ui/menu.tsx | 30 +- apps/web/components/ui/toast.tsx | 68 +++ apps/web/components/uploads/upload-button.tsx | 8 +- apps/web/e2e/folders.spec.ts | 219 ++++++++++ apps/web/e2e/support/items.ts | 113 +++-- apps/web/lib/api/error-messages.ts | 2 + apps/web/lib/api/items.ts | 37 +- apps/web/lib/api/types.ts | 20 + apps/web/lib/dal/items.ts | 40 +- apps/web/lib/drive.ts | 5 + docs/lld.md | 2 +- 40 files changed, 1793 insertions(+), 176 deletions(-) rename apps/api/src/{modules/uploads/dto => common/validation}/file-name.decorator.ts (100%) create mode 100644 apps/api/src/modules/items/dto/create-folder.dto.ts create mode 100644 apps/api/src/modules/items/dto/item-detail.dto.ts create mode 100644 apps/api/src/modules/items/errors/max-depth-exceeded.error.ts create mode 100644 apps/api/src/modules/items/folders.controller.ts create mode 100644 apps/api/test/items-folders.e2e-spec.ts create mode 100644 apps/web/app/(drive)/pasta/[id]/not-found.tsx create mode 100644 apps/web/app/(drive)/pasta/[id]/page.tsx create mode 100644 apps/web/components/files/folder-contents.tsx create mode 100644 apps/web/components/files/folder-view.tsx create mode 100644 apps/web/components/files/new-folder-button.tsx create mode 100644 apps/web/components/files/openable-row.tsx create mode 100644 apps/web/components/navigation/breadcrumb.tsx create mode 100644 apps/web/components/ui/button-secondary.tsx create mode 100644 apps/web/components/ui/dialog.tsx create mode 100644 apps/web/components/ui/toast.tsx create mode 100644 apps/web/e2e/folders.spec.ts diff --git a/apps/api/src/modules/uploads/dto/file-name.decorator.ts b/apps/api/src/common/validation/file-name.decorator.ts similarity index 100% rename from apps/api/src/modules/uploads/dto/file-name.decorator.ts rename to apps/api/src/common/validation/file-name.decorator.ts diff --git a/apps/api/src/modules/items/dto/create-folder.dto.ts b/apps/api/src/modules/items/dto/create-folder.dto.ts new file mode 100644 index 0000000..a74368d --- /dev/null +++ b/apps/api/src/modules/items/dto/create-folder.dto.ts @@ -0,0 +1,20 @@ +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; +import { IsFileName } from '../../../common/validation/file-name.decorator.js'; + +/** A criação de uma pasta (seção 3.3 do docs/lld.md). */ +export class CreateFolderDto { + /** Sem os espaços das pontas: o tamanho vale depois de descartá-los. */ + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + @MinLength(1) + @MaxLength(255) + @IsFileName() + name: string; + + /** `root` ou o id de uma pasta ativa do Usuário. */ + @IsString() + @IsNotEmpty() + parentId: string; +} diff --git a/apps/api/src/modules/items/dto/item-detail.dto.ts b/apps/api/src/modules/items/dto/item-detail.dto.ts new file mode 100644 index 0000000..bc06d4d --- /dev/null +++ b/apps/api/src/modules/items/dto/item-detail.dto.ts @@ -0,0 +1,14 @@ +import type { ItemDetail, ItemTrailEntry } from '../entities/item.entity.js'; +import { ItemDto } from './item.dto.js'; + +/** Um trecho do caminho do topo até a pasta. */ +export class ItemTrailEntryDto implements ItemTrailEntry { + id: string; + name: string; +} + +/** Uma pasta e o caminho do topo, a Pasta raiz, até ela. */ +export class ItemDetailDto implements ItemDetail { + item: ItemDto; + trail: ItemTrailEntryDto[]; +} diff --git a/apps/api/src/modules/items/dto/list-children-query.dto.ts b/apps/api/src/modules/items/dto/list-children-query.dto.ts index db5a098..712ca82 100644 --- a/apps/api/src/modules/items/dto/list-children-query.dto.ts +++ b/apps/api/src/modules/items/dto/list-children-query.dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { + IsIn, IsInt, IsNotEmpty, IsOptional, @@ -25,4 +26,9 @@ export class ListChildrenQueryDto { @IsString() @IsNotEmpty() cursor?: string; + + /** Só `folder`: devolve apenas as pastas. Ausente, devolve pastas e arquivos. */ + @IsOptional() + @IsIn(['folder']) + type?: 'folder'; } diff --git a/apps/api/src/modules/items/entities/item.entity.ts b/apps/api/src/modules/items/entities/item.entity.ts index 41fa2f1..f981600 100644 --- a/apps/api/src/modules/items/entities/item.entity.ts +++ b/apps/api/src/modules/items/entities/item.entity.ts @@ -27,6 +27,40 @@ export interface ItemPage { */ export type ItemPosition = Pick; +/** Um trecho do caminho até uma pasta. */ +export interface ItemTrailEntry { + id: string; + name: string; +} + +/** Um trecho do caminho, com a data que a lista mostra. */ +export interface FolderPathEntry extends ItemTrailEntry { + updatedAt: Date; +} + +/** Uma pasta e o caminho do topo, a Pasta raiz, até ela. */ +export interface ItemDetail { + item: Item; + trail: ItemTrailEntry[]; +} + +/** A pasta que a criação grava, já com o id e o destino resolvidos. */ +export interface NewFolder { + id: string; + ownerId: string; + parentId: string; + name: string; +} + +/** O que a criação de uma pasta resolve: criada, sem destino ou passando do limite. */ +export type CreateFolderResult = + | { status: 'created'; folder: Item } + | { status: 'parent_not_found' } + | { status: 'too_deep' }; + +/** Quantas pastas o caminho de um Item pode ter, sem contar a Pasta raiz. */ +export const MAX_FOLDER_DEPTH = 50; + /** Um arquivo de um Usuário que ainda está sendo enviado (`pending`) ou já foi concluído (`active`). */ export interface FileUpload extends Item { status: 'pending' | 'active'; diff --git a/apps/api/src/modules/items/errors/max-depth-exceeded.error.ts b/apps/api/src/modules/items/errors/max-depth-exceeded.error.ts new file mode 100644 index 0000000..94828d5 --- /dev/null +++ b/apps/api/src/modules/items/errors/max-depth-exceeded.error.ts @@ -0,0 +1,11 @@ +import { DomainError } from '../../../common/errors/domain-error.js'; +import { MAX_FOLDER_DEPTH } from '../entities/item.entity.js'; + +/** A pasta passaria do 50º nível de profundidade. */ +export class MaxDepthExceededError extends DomainError { + readonly code = 'max_depth_exceeded'; + + constructor() { + super(`As pastas podem ter no máximo ${MAX_FOLDER_DEPTH} níveis.`); + } +} diff --git a/apps/api/src/modules/items/folders.controller.ts b/apps/api/src/modules/items/folders.controller.ts new file mode 100644 index 0000000..d6342af --- /dev/null +++ b/apps/api/src/modules/items/folders.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '../../common/auth/auth.guard.js'; +import { CurrentUserId } from '../../common/auth/current-user-id.decorator.js'; +import { ApiProblem } from '../../common/openapi/api-problem.decorator.js'; +import { CreateFolderDto } from './dto/create-folder.dto.js'; +import { ItemDto } from './dto/item.dto.js'; +import { ItemsService } from './items.service.js'; + +@ApiTags('folders') +@ApiBearerAuth() +@Controller('folders') +@UseGuards(AuthGuard) +export class FoldersController { + constructor(private readonly items: ItemsService) {} + + @Post() + @ApiCreatedResponse({ type: ItemDto }) + @ApiProblem(400, 'validation_error', 'max_depth_exceeded') + @ApiProblem(401, 'unauthenticated') + @ApiProblem(404, 'not_found') + create( + @Body() body: CreateFolderDto, + @CurrentUserId() userId: string, + ): Promise { + return this.items.createFolder(userId, body); + } +} diff --git a/apps/api/src/modules/items/items.controller.ts b/apps/api/src/modules/items/items.controller.ts index e77280d..c782e89 100644 --- a/apps/api/src/modules/items/items.controller.ts +++ b/apps/api/src/modules/items/items.controller.ts @@ -4,6 +4,7 @@ import { AuthGuard } from '../../common/auth/auth.guard.js'; import { CurrentUserId } from '../../common/auth/current-user-id.decorator.js'; import { ApiProblem } from '../../common/openapi/api-problem.decorator.js'; import { DownloadUrlDto } from './dto/download-url.dto.js'; +import { ItemDetailDto } from './dto/item-detail.dto.js'; import { ItemListDto } from './dto/item-list.dto.js'; import { DEFAULT_PAGE_SIZE, @@ -18,6 +19,17 @@ import { ItemsService } from './items.service.js'; export class ItemsController { constructor(private readonly items: ItemsService) {} + @Get(':id') + @ApiOkResponse({ type: ItemDetailDto }) + @ApiProblem(401, 'unauthenticated') + @ApiProblem(404, 'not_found') + get( + @Param('id') id: string, + @CurrentUserId() userId: string, + ): Promise { + return this.items.getFolder(userId, id); + } + @Get(':id/children') @ApiOkResponse({ type: ItemListDto }) @ApiProblem(400, 'validation_error') @@ -31,6 +43,7 @@ export class ItemsController { return this.items.listChildren(userId, id, { limit: query.limit ?? DEFAULT_PAGE_SIZE, cursor: query.cursor, + foldersOnly: query.type === 'folder', }); } diff --git a/apps/api/src/modules/items/items.module.ts b/apps/api/src/modules/items/items.module.ts index ada9d0d..4bdcab5 100644 --- a/apps/api/src/modules/items/items.module.ts +++ b/apps/api/src/modules/items/items.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AccessTokensModule } from '../../common/auth/access-tokens.module.js'; import { StorageModule } from '../../infra/storage/storage.module.js'; +import { FoldersController } from './folders.controller.js'; import { ItemsController } from './items.controller.js'; import { ItemsRepository } from './items.repository.js'; import { ItemsService } from './items.service.js'; @@ -8,7 +9,7 @@ import { ItemsService } from './items.service.js'; /** Dono dos Itens e da Pasta raiz. */ @Module({ imports: [AccessTokensModule, StorageModule], - controllers: [ItemsController], + controllers: [ItemsController, FoldersController], providers: [ItemsService, ItemsRepository], exports: [ItemsService], }) diff --git a/apps/api/src/modules/items/items.repository.ts b/apps/api/src/modules/items/items.repository.ts index c86a03e..f4229d7 100644 --- a/apps/api/src/modules/items/items.repository.ts +++ b/apps/api/src/modules/items/items.repository.ts @@ -4,8 +4,12 @@ import { PrismaService } from '../../infra/database/prisma.service.js'; import { PendingUpload, ResumableUpload, + CreateFolderResult, FileUpload, + FolderPathEntry, Item, + MAX_FOLDER_DEPTH, + NewFolder, ItemPosition, ItemType, NewPendingFile, @@ -31,6 +35,19 @@ const PENDING_UPLOAD_FIELDS = { uploadSessionUri: true, } satisfies Prisma.ItemSelect; +type ItemRow = Prisma.ItemGetPayload<{ select: typeof ITEM_FIELDS }>; + +/** A linha do banco como Item: o tipo estreitado e o tamanho como `number`. */ +function toItem( + row: T, +): Omit & { type: ItemType; sizeBytes: number } { + return { + ...row, + type: row.type as ItemType, + sizeBytes: Number(row.sizeBytes), + }; +} + function toPendingUpload( row: Prisma.ItemGetPayload<{ select: typeof PENDING_UPLOAD_FIELDS }>, ): PendingUpload { @@ -41,6 +58,9 @@ function toPendingUpload( }; } +/** Até onde a consulta do caminho sobe: alguns níveis além do limite, para nunca cortar um caminho válido. */ +const PATH_QUERY_MAX_LEVEL = MAX_FOLDER_DEPTH + 10; + @Injectable() export class ItemsRepository { constructor(private readonly prisma: PrismaService) {} @@ -80,11 +100,13 @@ export class ItemsRepository { parentId: string, after: ItemPosition | undefined, limit: number, + foldersOnly = false, ): Promise { const rows = await this.prisma.item.findMany({ where: { parentId, status: 'active', + ...(foldersOnly && { type: 'folder' }), ...(after && { OR: [ { type: { lt: after.type } }, @@ -97,11 +119,7 @@ export class ItemsRepository { take: limit, select: ITEM_FIELDS, }); - return rows.map((row) => ({ - ...row, - type: row.type as ItemType, - sizeBytes: Number(row.sizeBytes), - })); + return rows.map(toItem); } /** Se o Usuário tem uma pasta ativa com esse id. */ @@ -113,6 +131,58 @@ export class ItemsRepository { return folder !== null; } + /** + * O caminho do topo até a pasta ativa do Usuário, pela consulta recursiva que + * sobe pelos pais: do primeiro, a Pasta raiz, ao último, a própria pasta. + * Vazio se ela não existe, é de outro Usuário, é arquivo ou não está ativa. A + * subida para alguns níveis além do limite de profundidade. + */ + async folderPath( + ownerId: string, + id: string, + db: Prisma.TransactionClient = this.prisma, + ): Promise { + return db.$queryRaw` + WITH RECURSIVE path AS ( + SELECT id, name, parent_id, updated_at, 1 AS level + FROM items + WHERE id = ${id}::uuid AND owner_id = ${ownerId}::uuid + AND type = 'folder' AND status = 'active' + UNION ALL + SELECT i.id, i.name, i.parent_id, i.updated_at, p.level + 1 + FROM items i + JOIN path p ON i.id = p.parent_id + WHERE i.owner_id = ${ownerId}::uuid AND p.level < ${PATH_QUERY_MAX_LEVEL} + ) + SELECT id, name, updated_at AS "updatedAt" FROM path ORDER BY level DESC`; + } + + /** + * Cria a pasta ativa dentro de `folder.parentId`, na mesma transação que a + * confere. O lock consultivo do Usuário, tomado antes, faz as gravações que + * mudam a árvore dele esperarem umas pelas outras: duas criações nunca passam + * juntas do limite de profundidade (seção 4.1 do docs/lld.md). O lock se solta + * no fim da transação. + */ + createFolderWithinDepthLimit(folder: NewFolder): Promise { + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`items_tree:${folder.ownerId}`}, 0))`; + const path = await this.folderPath(folder.ownerId, folder.parentId, tx); + if (path.length === 0) { + return { status: 'parent_not_found' }; + } + // O caminho do destino traz a raiz, que não conta como nível. + if (path.length > MAX_FOLDER_DEPTH) { + return { status: 'too_deep' }; + } + const row = await tx.item.create({ + data: { ...folder, type: 'folder', status: 'active' }, + select: ITEM_FIELDS, + }); + return { status: 'created', folder: toItem(row) }; + }); + } + /** Cria o Item `pending` de um arquivo, invisível nas listagens. */ async createPendingFile( file: NewPendingFile, @@ -155,11 +225,9 @@ export class ItemsRepository { return null; } return { - ...row, - type: 'file', + ...toItem(row), status: row.status as FileUpload['status'], objectKey: row.objectKey, - sizeBytes: Number(row.sizeBytes), }; } diff --git a/apps/api/src/modules/items/items.service.ts b/apps/api/src/modules/items/items.service.ts index 919e3cc..a4e31d5 100644 --- a/apps/api/src/modules/items/items.service.ts +++ b/apps/api/src/modules/items/items.service.ts @@ -8,6 +8,8 @@ import { } from '../../infra/storage/file-storage.js'; import { DownloadUrl, + Item, + ItemDetail, PendingUpload, ResumableUpload, FileUpload, @@ -15,6 +17,7 @@ import { NewPendingFile, } from './entities/item.entity.js'; import { ItemNotFoundError } from './errors/item-not-found.error.js'; +import { MaxDepthExceededError } from './errors/max-depth-exceeded.error.js'; import { decodeItemCursor, encodeItemCursor } from './item-cursor.js'; import { ItemsRepository } from './items.repository.js'; @@ -35,27 +38,25 @@ export class ItemsService { ) {} /** - * Uma página dos filhos ativos da pasta, pastas primeiro e depois por nome. - * Até a funcionalidade de pastas, só a Pasta raiz é aceita, pelo atalho - * `root`, e ela nasce aqui no primeiro uso. Um `cursor` ilegível é entrada - * inválida. + * Uma página dos filhos ativos da pasta, pastas primeiro e depois por nome: + * a Pasta raiz, pelo atalho `root` (que nasce aqui no primeiro uso), ou uma + * pasta ativa do Usuário. Com `foldersOnly`, só as pastas. Um `cursor` + * ilegível é entrada inválida. */ async listChildren( ownerId: string, folderId: string, - page: { limit: number; cursor?: string }, + page: { limit: number; cursor?: string; foldersOnly?: boolean }, ): Promise { - if (folderId !== ROOT_ALIAS) { - throw new ItemNotFoundError(); - } const after = page.cursor === undefined ? undefined : decodeItemCursor(page.cursor); - const rootId = await this.items.ensureRoot(ownerId, uuidv7); + const parentId = await this.resolveDestinationFolder(ownerId, folderId); // Um Item a mais que a página diz se há outra depois dela. const rows = await this.items.listActiveChildren( - rootId, + parentId, after, page.limit + 1, + page.foldersOnly, ); const items = rows.slice(0, page.limit); const last = items.at(-1); @@ -66,6 +67,71 @@ export class ItemsService { }; } + /** + * A pasta ativa do Usuário (ou a Pasta raiz, pelo atalho `root`) e o caminho + * do topo até ela. Qualquer outro id é `not_found`. + */ + async getFolder(ownerId: string, id: string): Promise { + const path = await this.items.folderPath( + ownerId, + await this.folderIdOf(ownerId, id), + ); + const folder = path.at(-1); + if (!folder) { + throw new ItemNotFoundError(); + } + return { + item: { + id: folder.id, + type: 'folder', + name: folder.name, + sizeBytes: 0, + mimeType: null, + updatedAt: folder.updatedAt, + }, + trail: path.map(({ id: entryId, name }) => ({ id: entryId, name })), + }; + } + + /** + * Cria uma pasta ativa em `parentId` (`root` ou uma pasta ativa do Usuário). + * A pasta nasce no nível do destino mais um: passar de 50 é + * `max_depth_exceeded`. + */ + async createFolder( + ownerId: string, + input: { name: string; parentId: string }, + ): Promise { + const result = await this.items.createFolderWithinDepthLimit({ + id: uuidv7(), + ownerId, + parentId: await this.folderIdOf(ownerId, input.parentId), + name: input.name, + }); + switch (result.status) { + case 'created': + return result.folder; + case 'too_deep': + throw new MaxDepthExceededError(); + case 'parent_not_found': + throw new ItemNotFoundError(); + } + } + + /** + * O id da pasta pedida: o da Pasta raiz, pelo atalho `root`, ou o próprio id. + * Um texto que não é UUID é `not_found`. Se a pasta existe, quem lê confere. + */ + private async folderIdOf(ownerId: string, id: string): Promise { + if (id === ROOT_ALIAS) { + return this.items.ensureRoot(ownerId, uuidv7); + } + if (!isUUID(id)) { + throw new ItemNotFoundError(); + } + return id; + } + /** * O id da pasta de destino de um envio: a Pasta raiz, pelo atalho `root`, ou * uma pasta ativa do Usuário. Qualquer outro valor, inclusive um texto que diff --git a/apps/api/src/modules/uploads/dto/create-upload.dto.ts b/apps/api/src/modules/uploads/dto/create-upload.dto.ts index 8c3b860..4bd91ff 100644 --- a/apps/api/src/modules/uploads/dto/create-upload.dto.ts +++ b/apps/api/src/modules/uploads/dto/create-upload.dto.ts @@ -7,7 +7,7 @@ import { Min, MinLength, } from 'class-validator'; -import { IsFileName } from './file-name.decorator.js'; +import { IsFileName } from '../../../common/validation/file-name.decorator.js'; /** O início do envio de um arquivo (seção 3.4 do docs/lld.md). */ export class CreateUploadDto { diff --git a/apps/api/test/items-folders.e2e-spec.ts b/apps/api/test/items-folders.e2e-spec.ts new file mode 100644 index 0000000..1172940 --- /dev/null +++ b/apps/api/test/items-folders.e2e-spec.ts @@ -0,0 +1,408 @@ +import { INestApplication } from '@nestjs/common'; +import { App } from 'supertest/types.js'; +import { v7 as uuidv7 } from 'uuid'; +import { authRoutes } from './support/auth-routes.js'; +import { createTestApp } from './support/create-test-app.js'; +import { itemRoutes } from './support/items.js'; +import { uniqueEmail } from './support/mailpit.js'; +import { + expectProblem, + expectValidationError, + NOT_FOUND, +} from './support/problem.js'; +import { uploadRoutes } from './support/uploads.js'; + +interface ListBody { + items: { id: string; type: string; name: string }[]; + nextCursor: string | null; +} + +describe('Pastas', () => { + let app: INestApplication; + let auth: ReturnType; + let items: ReturnType; + let uploads: ReturnType; + + beforeAll(async () => { + app = await createTestApp(); + auth = authRoutes(app); + items = itemRoutes(app); + uploads = uploadRoutes(app); + }); + + afterAll(async () => { + await app.close(); + }); + + /** Um Usuário verificado, com Sessão, e o id dele e o da Pasta raiz. */ + const signedInUser = async () => { + const { accessToken } = await auth.openSession(uniqueEmail()); + const { body } = await auth.me(accessToken).expect(200); + const userId = (body as { id: string }).id; + return { + accessToken, + userId, + rootId: await items.rootId(accessToken, userId), + }; + }; + + /** Uma cadeia de `length` pastas dentro de `parentId`, a primeira no nível 1 se for a raiz. Devolve os ids, do topo ao fim. */ + const folderChain = async ( + userId: string, + parentId: string, + length: number, + ) => { + const ids: string[] = []; + let current = parentId; + for (let level = 1; level <= length; level++) { + [current] = await items.seed(userId, current, [ + { name: `nivel-${level}`, type: 'folder' }, + ]); + ids.push(current); + } + return ids; + }; + + describe('GET /items/{id}', () => { + it( + 'responde a trilha do topo até a pasta', + { tags: ['@LUI-179-CA1'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const [first, second, third] = await folderChain(userId, rootId, 3); + + const response = await items.get(third, accessToken).expect(200); + const rootResponse = await items.get('root', accessToken).expect(200); + + expect(response.body).toEqual({ + item: { + id: third, + type: 'folder', + name: 'nivel-3', + sizeBytes: 0, + mimeType: null, + updatedAt: expect.any(String), + }, + trail: [ + { id: rootId, name: 'Meus arquivos' }, + { id: first, name: 'nivel-1' }, + { id: second, name: 'nivel-2' }, + { id: third, name: 'nivel-3' }, + ], + }); + expect(rootResponse.body.trail).toEqual([ + { id: rootId, name: 'Meus arquivos' }, + ]); + }, + ); + + it( + 'responde not_found à trilha de um id que não é de uma pasta ativa do Usuário', + { tags: ['@LUI-179-CA1'] }, + async () => { + const owner = await signedInUser(); + const other = await signedInUser(); + const [othersFolder] = await items.seed(other.userId, other.rootId, [ + { name: 'de-outro', type: 'folder' }, + ]); + const [file, pending] = await items.seed(owner.userId, owner.rootId, [ + { name: 'arquivo.txt' }, + { name: 'pendente', type: 'folder', status: 'pending' }, + ]); + + const responses = await Promise.all( + [uuidv7(), othersFolder, file, 'nao-e-uuid', pending].map((id) => + items.get(id, owner.accessToken), + ), + ); + + for (const response of responses) { + expect(response.status).toBe(404); + expectProblem(response, NOT_FOUND); + } + }, + ); + }); + + describe('GET /items/{id}/children', () => { + it( + 'lista os filhos de uma pasta, paginada e na ordem do contrato', + { tags: ['@LUI-179-CA2'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const [folder] = await items.seed(userId, rootId, [ + { name: 'pasta', type: 'folder' }, + ]); + const [, , , , file] = await items.seed(userId, folder, [ + { name: 'z-pasta', type: 'folder' }, + { name: 'b-pasta', type: 'folder' }, + { name: 'c.txt' }, + { name: 'pendente.txt', status: 'pending' }, + { name: 'a.txt' }, + ]); + + const first = await items + .listChildren(folder, accessToken, { limit: 2 }) + .expect(200); + const second = await items + .listChildren(folder, accessToken, { + limit: 2, + cursor: (first.body as ListBody).nextCursor ?? '', + }) + .expect(200); + + expect((first.body as ListBody).items.map((i) => i.name)).toEqual([ + 'b-pasta', + 'z-pasta', + ]); + expect((first.body as ListBody).nextCursor).toEqual(expect.any(String)); + expect((second.body as ListBody).items.map((i) => i.name)).toEqual([ + 'a.txt', + 'c.txt', + ]); + expect((second.body as ListBody).items[0].id).toBe(file); + expect((second.body as ListBody).nextCursor).toBeNull(); + }, + ); + + it( + 'filtra só as pastas com type=folder', + { tags: ['@LUI-179-CA2'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + await items.seed(userId, rootId, [ + { name: 'a', type: 'folder' }, + { name: 'b', type: 'folder' }, + { name: 'c', type: 'folder' }, + { name: 'arquivo.txt' }, + ]); + + const first = await items + .listChildren('root', accessToken, { type: 'folder', limit: 2 }) + .expect(200); + const second = await items + .listChildren('root', accessToken, { + type: 'folder', + limit: 2, + cursor: (first.body as ListBody).nextCursor ?? '', + }) + .expect(200); + const invalid = await items.listChildren('root', accessToken, { + type: 'file', + }); + + expect((first.body as ListBody).items.map((i) => i.name)).toEqual([ + 'a', + 'b', + ]); + expect((second.body as ListBody).items.map((i) => i.name)).toEqual([ + 'c', + ]); + expect((second.body as ListBody).nextCursor).toBeNull(); + expectValidationError(invalid, 'type'); + }, + ); + + it( + 'responde not_found à lista de uma pasta que não é do Usuário', + { tags: ['@LUI-179-CA2'] }, + async () => { + const owner = await signedInUser(); + const other = await signedInUser(); + const [othersFolder] = await items.seed(other.userId, other.rootId, [ + { name: 'de-outro', type: 'folder' }, + ]); + const [file] = await items.seed(owner.userId, owner.rootId, [ + { name: 'arquivo.txt' }, + ]); + + const responses = await Promise.all( + [uuidv7(), othersFolder, file].map((id) => + items.listChildren(id, owner.accessToken), + ), + ); + + for (const response of responses) { + expect(response.status).toBe(404); + expectProblem(response, NOT_FOUND); + } + }, + ); + }); + + describe('POST /folders', () => { + it( + 'cria a pasta sem os espaços das pontas e aceita nome igual', + { tags: ['@LUI-179-CA3'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const [parent] = await items.seed(userId, rootId, [ + { name: 'Fotos', type: 'folder' }, + ]); + + const inRoot = await items + .createFolder(accessToken, { name: ' Fotos ', parentId: 'root' }) + .expect(201); + const inFolder = await items + .createFolder(accessToken, { name: ' Fotos ', parentId: parent }) + .expect(201); + const rootList = await items.listChildren('root', accessToken); + const folderList = await items.listChildren(parent, accessToken); + + expect(inRoot.body).toEqual({ + id: expect.any(String), + type: 'folder', + name: 'Fotos', + sizeBytes: 0, + mimeType: null, + updatedAt: expect.any(String), + }); + expect((rootList.body as ListBody).items.map((i) => i.name)).toEqual([ + 'Fotos', + 'Fotos', + ]); + expect((folderList.body as ListBody).items.map((i) => i.id)).toEqual([ + (inFolder.body as { id: string }).id, + ]); + }, + ); + + it( + 'recusa o nome inválido com validation_error', + { tags: ['@LUI-179-CA4'] }, + async () => { + const { accessToken } = await signedInUser(); + const inputs: object[] = [ + { name: '', parentId: 'root' }, + { name: ' ', parentId: 'root' }, + { name: 'a/b', parentId: 'root' }, + { name: 'a'.repeat(256), parentId: 'root' }, + { parentId: 'root' }, + ]; + + for (const input of inputs) { + const response = await items.createFolder(accessToken, input); + expectValidationError(response, 'name'); + } + const list = await items.listChildren('root', accessToken); + + expect((list.body as ListBody).items).toEqual([]); + }, + ); + + it( + 'mede os 255 caracteres depois de descartar os espaços das pontas', + { tags: ['@LUI-179-CA4'] }, + async () => { + const { accessToken } = await signedInUser(); + const name = 'a'.repeat(255); + + const exact = await items.createFolder(accessToken, { + name, + parentId: 'root', + }); + const padded = await items.createFolder(accessToken, { + name: ` ${name} `, + parentId: 'root', + }); + + expect(exact.status).toBe(201); + expect(padded.status).toBe(201); + expect(padded.body.name).toBe(name); + }, + ); + + it( + 'responde not_found a um destino que não é uma pasta ativa do Usuário', + { tags: ['@LUI-179-CA5'] }, + async () => { + const owner = await signedInUser(); + const other = await signedInUser(); + const [othersFolder] = await items.seed(other.userId, other.rootId, [ + { name: 'de-outro', type: 'folder' }, + ]); + const [file, pending] = await items.seed(owner.userId, owner.rootId, [ + { name: 'arquivo.txt' }, + { name: 'pendente', type: 'folder', status: 'pending' }, + ]); + + for (const parentId of [ + uuidv7(), + 'nao-e-uuid', + file, + othersFolder, + pending, + ]) { + const response = await items.createFolder(owner.accessToken, { + name: 'Nova', + parentId, + }); + expect(response.status).toBe(404); + expectProblem(response, NOT_FOUND); + } + }, + ); + + it( + 'cria até o 50º nível e recusa o 51º com max_depth_exceeded', + { tags: ['@LUI-179-CA5'] }, + async () => { + const { accessToken, userId, rootId } = await signedInUser(); + const chain = await folderChain(userId, rootId, 50); + + const atLimit = await items.createFolder(accessToken, { + name: 'ultima', + parentId: chain[48], + }); + const beyond = await items.createFolder(accessToken, { + name: 'alem', + parentId: chain[49], + }); + const list = await items.listChildren(chain[49], accessToken); + + expect(atLimit.status).toBe(201); + expectProblem(beyond, { + title: 'Bad Request', + status: 400, + code: 'max_depth_exceeded', + detail: expect.any(String), + }); + expect((list.body as ListBody).items).toEqual([]); + }, + ); + + it( + 'cria a pasta com a conta somente leitura, sem mexer na Cota', + { tags: ['@LUI-179-CA6'] }, + async () => { + const { accessToken, userId } = await signedInUser(); + await uploads.leaveFreeBytes(userId, -1024); + const usedBefore = await uploads.usedBytes(userId); + const refusedUpload = await uploads.create(accessToken, { + name: 'arquivo.txt', + sizeBytes: 1, + parentId: 'root', + }); + + const response = await items.createFolder(accessToken, { + name: 'Somente leitura', + parentId: 'root', + }); + + expect(refusedUpload.status).toBe(413); + expect(refusedUpload.body.code).toBe('quota_exceeded'); + expect(response.status).toBe(201); + expect(await uploads.usedBytes(userId)).toBe(usedBefore); + }, + ); + }); + + it('responde unauthenticated sem token de acesso', async () => { + const response = await items.createFolder(undefined, { + name: 'Nova', + parentId: 'root', + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/api/test/items-root-children.e2e-spec.ts b/apps/api/test/items-root-children.e2e-spec.ts index e463646..e99e404 100644 --- a/apps/api/test/items-root-children.e2e-spec.ts +++ b/apps/api/test/items-root-children.e2e-spec.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { App } from 'supertest/types.js'; +import { v7 as uuidv7 } from 'uuid'; import { authRoutes } from './support/auth-routes.js'; import { createTestApp } from './support/create-test-app.js'; import { itemRoutes } from './support/items.js'; @@ -224,16 +225,11 @@ describe('Lista da Pasta raiz', () => { }, ); - it('responde not_found para uma pasta que não é a raiz, enquanto só ela é aceita', async () => { - const { accessToken, userId } = await signedInUser(); - const [folderId] = await items.seed( - userId, - await items.rootId(accessToken, userId), - [{ name: 'pasta', type: 'folder' }], - ); + it('responde not_found para um id que não é de uma pasta ativa do Usuário', async () => { + const { accessToken } = await signedInUser(); const response = await items - .listChildren(folderId, accessToken) + .listChildren(uuidv7(), accessToken) .expect(404); expectProblem(response, { diff --git a/apps/api/test/support/items.ts b/apps/api/test/support/items.ts index 668c440..12a186e 100644 --- a/apps/api/test/support/items.ts +++ b/apps/api/test/support/items.ts @@ -14,6 +14,13 @@ export interface SeedItem { updatedAt?: Date; } +/** Põe o token de acesso no cabeçalho quando ele é passado. */ +function withAuth(call: request.Test, accessToken?: string): request.Test { + return accessToken + ? call.set('Authorization', `Bearer ${accessToken}`) + : call; +} + /** As chamadas da lista de Itens, contra uma aplicação de teste. */ export function itemRoutes(app: INestApplication) { /** `GET /items/{id}/children`, com o token de acesso no cabeçalho quando ele é passado. */ @@ -22,22 +29,34 @@ export function itemRoutes(app: INestApplication) { accessToken?: string, query: Record = {}, ) => { - const call = request(app.getHttpServer()) - .get(`/v1/items/${id}/children`) - .query(query); - return accessToken - ? call.set('Authorization', `Bearer ${accessToken}`) - : call; + return withAuth( + request(app.getHttpServer()).get(`/v1/items/${id}/children`).query(query), + accessToken, + ); }; /** `GET /items/{id}/download-url`, com o token de acesso no cabeçalho quando ele é passado. */ const downloadUrl = (id: string, accessToken?: string) => { - const call = request(app.getHttpServer()).get( - `/v1/items/${id}/download-url`, + return withAuth( + request(app.getHttpServer()).get(`/v1/items/${id}/download-url`), + accessToken, + ); + }; + + /** `GET /items/{id}`, com o token de acesso no cabeçalho quando ele é passado. */ + const get = (id: string, accessToken?: string) => { + return withAuth( + request(app.getHttpServer()).get(`/v1/items/${id}`), + accessToken, + ); + }; + + /** `POST /folders`, com o token de acesso no cabeçalho quando ele é passado. */ + const createFolder = (accessToken: string | undefined, input: object) => { + return withAuth( + request(app.getHttpServer()).post('/v1/folders').send(input), + accessToken, ); - return accessToken - ? call.set('Authorization', `Bearer ${accessToken}`) - : call; }; /** @@ -88,5 +107,5 @@ export function itemRoutes(app: INestApplication) { return rows.map((row) => row.id); }; - return { listChildren, downloadUrl, rootId, seed }; + return { listChildren, downloadUrl, get, createFolder, rootId, seed }; } diff --git a/apps/web/app/(drive)/actions.ts b/apps/web/app/(drive)/actions.ts index 7636d94..99afa8a 100644 --- a/apps/web/app/(drive)/actions.ts +++ b/apps/web/app/(drive)/actions.ts @@ -14,7 +14,7 @@ import type { StartUploadInput, StartUploadResult, } from "@/lib/uploads/types"; -import { getRootItems } from "@/lib/dal/items"; +import { createFolder as makeFolder, getFolderItems } from "@/lib/dal/items"; import { discardUpload, finishUpload, @@ -46,16 +46,23 @@ export async function logout(): Promise { /** O cursor é opaco e vem do navegador: a ação só confere que ele é um texto. */ const cursorSchema = z.string().min(1).max(2048); +/** O id de um Item, ou `root` como destino. A API confere o resto. */ +const itemIdSchema = z.string().min(1).max(64); + /** - * A página seguinte da Pasta raiz, já no formato das linhas da lista. A Sessão é conferida - * de novo, pela própria listagem na API. + * A página seguinte de uma pasta (`root` ou o id dela), já no formato das linhas da lista. A + * Sessão é conferida de novo, pela própria listagem na API. */ -export async function loadMoreRootItems(cursor: string): Promise { - const parsed = cursorSchema.safeParse(cursor); - if (!parsed.success) { +export async function loadMoreFolderItems( + folderId: string, + cursor: string, +): Promise { + const parsedCursor = cursorSchema.safeParse(cursor); + const parsedFolder = itemIdSchema.safeParse(folderId); + if (!parsedCursor.success || !parsedFolder.success) { return { ok: false, message: errorMessage("validation_error") }; } - const result = await getRootItems(parsed.data); + const result = await getFolderItems(parsedFolder.data, parsedCursor.data); if (!result.ok) { return { ok: false, message: errorMessage(result.error.code) }; } @@ -66,8 +73,54 @@ export async function loadMoreRootItems(cursor: string): Promise }; } -/** O id de um Item, ou `root` como destino. A API confere o resto. */ -const itemIdSchema = z.string().min(1).max(64); +/** O que o "Nova pasta" devolve ao diálogo: a pasta criada ou o erro, em português, do campo. */ +export type CreateFolderResult = + | { ok: true; name: string } + | { ok: false; message: string }; + +/** + * O nome de uma pasta: sem os espaços das pontas, de 1 a 255 caracteres. As outras regras (`/`, + * caracteres de controle, "." e "..") são da API, e o `validation_error` dela vira a mensagem + * de `INVALID_FOLDER_NAME`. + */ +const folderNameSchema = z + .string() + .trim() + .min(1, "Informe um nome para a pasta.") + .max(255, "O nome pode ter até 255 caracteres."); + +const INVALID_FOLDER_NAME = + "O nome não pode ter “/” nem caracteres de controle, nem ser “.” ou “..”."; + +/** + * Cria uma pasta dentro de `parentId`. É uma Server Action, um endpoint público: o nome é + * conferido aqui, e a Sessão, pela própria chamada à API. + */ +export async function createFolder( + parentId: string, + name: string, +): Promise { + const parsedName = folderNameSchema.safeParse(name); + const parsedParent = itemIdSchema.safeParse(parentId); + if (!parsedName.success) { + return { ok: false, message: parsedName.error.issues[0].message }; + } + if (!parsedParent.success) { + return { ok: false, message: errorMessage("validation_error") }; + } + const result = await makeFolder({ + name: parsedName.data, + parentId: parsedParent.data, + }); + if (!result.ok) { + const message = + result.error.code === "validation_error" + ? INVALID_FOLDER_NAME + : errorMessage(result.error.code); + return { ok: false, message }; + } + return { ok: true, name: result.data.name }; +} /** A Server Action é um endpoint público: o que vem do navegador é conferido antes de ir à API. */ const startUploadSchema = z.object({ diff --git a/apps/web/app/(drive)/layout.tsx b/apps/web/app/(drive)/layout.tsx index 8c89058..3b66b91 100644 --- a/apps/web/app/(drive)/layout.tsx +++ b/apps/web/app/(drive)/layout.tsx @@ -4,6 +4,7 @@ import { AppBar } from "@/components/navigation/app-bar"; import { Sidebar } from "@/components/navigation/sidebar"; import { SidebarDrawer } from "@/components/navigation/sidebar-drawer"; import { getCurrentUser } from "@/lib/dal/user"; +import { ToastProvider } from "@/components/ui/toast"; import { UploadButton } from "@/components/uploads/upload-button"; import { UploadPanel } from "@/components/uploads/upload-panel"; import { UploadProvider } from "@/components/uploads/upload-provider"; @@ -37,34 +38,46 @@ function AccountMenuPlaceholder() { */ export default function DriveLayout({ children }: LayoutProps<"/">) { return ( - -
- - - - } - upload={} - account={ - }> - - - } - /> -
- - {children} + + +
+ `, para a casca continuar pré-renderizada. + navigation={ + + + + + + } + upload={ + + + + } + account={ + }> + + + } + /> +
+ + {children} +
-
- - + + + ); } diff --git a/apps/web/app/(drive)/page.tsx b/apps/web/app/(drive)/page.tsx index b961986..173fe76 100644 --- a/apps/web/app/(drive)/page.tsx +++ b/apps/web/app/(drive)/page.tsx @@ -1,59 +1,18 @@ -import { CloudUpload } from "lucide-react"; import type { Metadata } from "next"; import { Suspense } from "react"; -import { EmptyState } from "@/components/files/empty-state"; import { FileListSkeleton } from "@/components/files/file-list"; -import { Dropzone } from "@/components/files/dropzone"; -import { FileTable } from "@/components/files/file-table"; +import { FolderView } from "@/components/files/folder-view"; import { ROOT_FOLDER_LABEL } from "@/lib/drive"; -import { getRootItems } from "@/lib/dal/items"; -import { loadMoreRootItems } from "./actions"; export const metadata: Metadata = { title: ROOT_FOLDER_LABEL }; -async function RootFolderContents() { - const result = await getRootItems(); - if (!result.ok) { - // A pessoa não é deslogada por uma falha da API: a página mostra o erro. - throw new Error( - `Não foi possível listar a Pasta raiz na API (${result.error.status} ${result.error.code}).`, - ); - } - const { items, nextCursor } = result.data; - - if (items.length === 0) { - return ( - } - title="Nenhum arquivo ainda" - > - Arraste arquivos para cá ou clique em Enviar. - - ); - } - - return ( - - ); -} - /** "Meus arquivos": a lista da Pasta raiz (docs/lld.md, seção 7.1). */ export default function MyFilesPage() { return (
-
-

{ROOT_FOLDER_LABEL}

-
- - }> - - - + }> + +
); } diff --git a/apps/web/app/(drive)/pasta/[id]/not-found.tsx b/apps/web/app/(drive)/pasta/[id]/not-found.tsx new file mode 100644 index 0000000..1500e41 --- /dev/null +++ b/apps/web/app/(drive)/pasta/[id]/not-found.tsx @@ -0,0 +1,16 @@ +import { FolderX } from "lucide-react"; +import { EmptyState } from "@/components/files/empty-state"; +import { TextLink } from "@/components/ui/text-link"; +import { ROOT_FOLDER_LABEL } from "@/lib/drive"; + +/** O endereço de uma pasta que não é do Usuário: a página de "não encontrado" da casca do Drive. */ +export default function FolderNotFound() { + return ( +
+ } title="Pasta não encontrada"> + Ela não existe ou você não tem acesso a ela.{" "} + Voltar para {ROOT_FOLDER_LABEL} + +
+ ); +} diff --git a/apps/web/app/(drive)/pasta/[id]/page.tsx b/apps/web/app/(drive)/pasta/[id]/page.tsx new file mode 100644 index 0000000..8ef807b --- /dev/null +++ b/apps/web/app/(drive)/pasta/[id]/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { FileListSkeleton } from "@/components/files/file-list"; +import { FolderView } from "@/components/files/folder-view"; + +export const metadata: Metadata = { title: "Pasta" }; + +/** O id lido dos parâmetros. O `root` não é um endereço de pasta: a Pasta raiz é a página inicial. */ +async function OpenFolder({ params }: PageProps<"/pasta/[id]">) { + const { id } = await params; + if (id === "root") notFound(); + return ; +} + +/** Uma pasta de "Meus arquivos" (docs/lld.md, seção 7.1). */ +export default function FolderPage(props: PageProps<"/pasta/[id]">) { + return ( +
+ }> + + +
+ ); +} diff --git a/apps/web/app/vitrine/drive/page.tsx b/apps/web/app/vitrine/drive/page.tsx index aff2ebf..12e1a56 100644 --- a/apps/web/app/vitrine/drive/page.tsx +++ b/apps/web/app/vitrine/drive/page.tsx @@ -14,6 +14,9 @@ import { UploadItem } from "@/components/uploads/upload-item"; import { UploadPanel } from "@/components/uploads/upload-panel"; import { UploadProvider } from "@/components/uploads/upload-provider"; import { Dropzone } from "@/components/files/dropzone"; +import { Breadcrumb } from "@/components/navigation/breadcrumb"; +import { NewFolderButton } from "@/components/files/new-folder-button"; +import { ToastProvider } from "@/components/ui/toast"; import { Banner } from "@/components/ui/banner"; import { FOLDER_DROPPED_MESSAGE } from "@/lib/uploads/panel"; import type { @@ -27,6 +30,14 @@ import type { Item } from "@/lib/api/types"; /** Os arquivos das telas do Figma, para comparar a lista com o desenho. */ const ITEMS: Item[] = [ + { + id: "0199e2a4-7c1b-7d2e-9f3a-4b5c6d7e8f90", + type: "folder", + name: "Projetos", + mimeType: null, + sizeBytes: 0, + updatedAt: "2026-10-14T15:00:00.000Z", + }, file("Contrato de locação.pdf", "application/pdf", 2_516_582, "2026-10-12"), file("Férias na praia.jpg", "image/jpeg", 5_033_165, "2026-10-10"), file("Apresentação final.mp4", "video/mp4", 1_288_490_189, "2026-10-08"), @@ -50,6 +61,20 @@ function file( }; } +/** O "Nova pasta" da vitrine só confirma, sem chamar a API. */ +async function createFolderNoop(): Promise<{ ok: true; name: string }> { + "use server"; + return { ok: true, name: "Nova pasta" }; +} + +const TRAIL = [ + { id: "root", name: ROOT_FOLDER_LABEL }, + { id: "1", name: "Projetos" }, + { id: "2", name: "2026" }, + { id: "3", name: "Contratos" }, + { id: "4", name: "Assinados" }, +]; + async function noop() { "use server"; } @@ -185,6 +210,7 @@ export default function VitrineDrivePage() { cancelUpload={cancelUploadNoop} resumeUpload={resumeUploadGone} > +

Drive

@@ -217,6 +243,17 @@ export default function VitrineDrivePage() {
+
+
+
+ +
+
+ +
+
+
+
    {UPLOAD_ENTRIES.map((entry) => ( @@ -264,6 +301,7 @@ export default function VitrineDrivePage() {
+
); } diff --git a/apps/web/components/files/file-row.tsx b/apps/web/components/files/file-row.tsx index 1f8b126..914309e 100644 --- a/apps/web/components/files/file-row.tsx +++ b/apps/web/components/files/file-row.tsx @@ -10,8 +10,13 @@ import { type LucideIcon, } from "lucide-react"; import type { FileKind, FileRowData } from "@/components/files/format"; +import { OpenableRow } from "@/components/files/openable-row"; import { buttonIconClassName } from "@/components/ui/button-icon"; import { Menu, MenuLink } from "@/components/ui/menu"; +import { folderPath } from "@/lib/drive"; + +const ROW_CLASS_NAME = + "flex h-12 items-center gap-3 border-b border-hairline bg-canvas px-4"; /** O ícone de cada tipo, que é como os tipos se distinguem. */ export const FILE_KIND_ICONS: Record = { @@ -28,16 +33,14 @@ export const FILE_KIND_ICONS: Record = { * O `{component.file-row}`: ícone do tipo, nome, tipo, tamanho e data. As colunas saem da * direita para a esquerda conforme a largura diminui, e o tamanho e a data vão para uma * segunda linha, sob o nome. Uma linha de arquivo tem o menu "Mais ações", só com "Baixar"; a de - * pasta não tem, porque ainda não há ação de pasta. A seleção entra com a funcionalidade dela. + * pasta não tem, porque ainda não há ação de pasta, e abre a pasta com dois cliques ou com Enter. + * A seleção entra com a funcionalidade dela. */ export function FileRow({ row }: { row: FileRowData }) { const Icon = FILE_KIND_ICONS[row.kind]; - return ( -
+ const cells = ( + <>
@@ -81,6 +84,19 @@ export function FileRow({ row }: { row: FileRowData }) { )}
+ + ); + + if (row.kind === "folder") { + return ( + + {cells} + + ); + } + return ( +
+ {cells}
); } diff --git a/apps/web/components/files/folder-contents.tsx b/apps/web/components/files/folder-contents.tsx new file mode 100644 index 0000000..9dcd30f --- /dev/null +++ b/apps/web/components/files/folder-contents.tsx @@ -0,0 +1,48 @@ +import { CloudUpload } from "lucide-react"; +import { EmptyState } from "@/components/files/empty-state"; +import { FileTable } from "@/components/files/file-table"; +import type { ApiResult, ItemPage } from "@/lib/api/types"; +import { loadMoreFolderItems } from "@/app/(drive)/actions"; + +/** + * A lista de uma pasta: a primeira página, ou o estado vazio. A leitura já foi disparada por quem + * monta a página, em paralelo com a do caminho, e esta peça só a espera. Uma falha da API não + * desloga a pessoa: a página mostra o erro. + */ +export async function FolderContents({ + folderId, + label, + page, +}: { + folderId: string; + label: string; + page: Promise>; +}) { + const result = await page; + if (!result.ok) { + throw new Error( + `Não foi possível listar a pasta ${folderId} na API (${result.error.status} ${result.error.code}).`, + ); + } + const { items, nextCursor } = result.data; + + if (items.length === 0) { + return ( + } + title="Nenhum arquivo ainda" + > + Arraste arquivos para cá ou clique em Enviar. + + ); + } + + return ( + + ); +} diff --git a/apps/web/components/files/folder-view.tsx b/apps/web/components/files/folder-view.tsx new file mode 100644 index 0000000..fd7b26d --- /dev/null +++ b/apps/web/components/files/folder-view.tsx @@ -0,0 +1,46 @@ +import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { createFolder } from "@/app/(drive)/actions"; +import { Dropzone } from "@/components/files/dropzone"; +import { FileListSkeleton } from "@/components/files/file-list"; +import { FolderContents } from "@/components/files/folder-contents"; +import { NewFolderButton } from "@/components/files/new-folder-button"; +import { Breadcrumb } from "@/components/navigation/breadcrumb"; +import { getFolder, getFolderItems } from "@/lib/dal/items"; + +/** + * Uma pasta aberta (`root` para "Meus arquivos", ou o id dela): o breadcrumb, a barra de + * ferramentas com "Nova pasta" e a lista, que é a zona de soltar. A leitura da lista começa junto + * com a do caminho, e o caminho não a segura. Uma pasta que não é do Usuário mostra a página de + * não encontrado; outra falha da API derruba a página, sem deslogar a pessoa. + */ +export async function FolderView({ id }: { id: string }) { + const page = getFolderItems(id); + // Se o caminho falhar antes de a lista ser esperada, a lista não fica com rejeição sem + // tratamento. Quem a espera, o `FolderContents`, continua recebendo o erro. + page.catch(() => {}); + const result = await getFolder(id); + if (!result.ok) { + if (result.error.code === "not_found") notFound(); + throw new Error( + `Não foi possível ler a pasta ${id} na API (${result.error.status} ${result.error.code}).`, + ); + } + const { item, trail } = result.data; + + return ( + <> +
+ +
+
+ +
+ + }> + + + + + ); +} diff --git a/apps/web/components/files/new-folder-button.tsx b/apps/web/components/files/new-folder-button.tsx new file mode 100644 index 0000000..c9091ed --- /dev/null +++ b/apps/web/components/files/new-folder-button.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { FolderPlus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useActionState, useState } from "react"; +import type { CreateFolderResult } from "@/app/(drive)/actions"; +import { ButtonPrimary } from "@/components/ui/button-primary"; +import { ButtonSecondary } from "@/components/ui/button-secondary"; +import { ButtonUtility } from "@/components/ui/button-utility"; +import { Dialog } from "@/components/ui/dialog"; +import { TextField } from "@/components/ui/text-field"; +import { useToast } from "@/components/ui/toast"; + +type NewFolderButtonProps = { + /** `root` ou o id da pasta aberta, onde a nova pasta nasce. */ + parentId: string; + /** A Server Action que cria a pasta. */ + create: (parentId: string, name: string) => Promise; +}; + +/** O "Nova pasta" da barra de ferramentas: abre o diálogo com o campo de nome. */ +export function NewFolderButton({ parentId, create }: NewFolderButtonProps) { + const [open, setOpen] = useState(false); + return ( + <> + } onClick={() => setOpen(true)}> + Nova pasta + + {/* Montado só enquanto aberto: cada abertura recomeça com o campo vazio e sem erro. */} + {open && ( + setOpen(false)} + /> + )} + + ); +} + +/** O erro do último envio e o nome que foi enviado: o React limpa o formulário depois da ação, e o nome digitado volta. */ +type FormState = { error: string | null; name: string }; + +/** + * O diálogo com o campo de nome. O erro de nome inválido ou de profundidade aparece abaixo do + * campo, em português, sem fechar o diálogo. Criada a pasta, o diálogo fecha, a lista é atualizada + * e um aviso confirma. A pessoa continua na pasta em que estava. + */ +function NewFolderDialog({ + parentId, + create, + onClose, +}: NewFolderButtonProps & { onClose: () => void }) { + const router = useRouter(); + const toast = useToast(); + + const [state, submit, pending] = useActionState( + async (_previous, formData) => { + const raw = formData.get("name"); + const name = typeof raw === "string" ? raw : ""; + const result = await create(parentId, name); + if (!result.ok) return { error: result.message, name }; + toast.show(`Pasta “${result.name}” criada.`); + router.refresh(); + onClose(); + return { error: null, name: "" }; + }, + { error: null, name: "" }, + ); + + return ( + +
+ +
+ Cancelar + + Criar + +
+ +
+ ); +} diff --git a/apps/web/components/files/openable-row.tsx b/apps/web/components/files/openable-row.tsx new file mode 100644 index 0000000..9dafdf9 --- /dev/null +++ b/apps/web/components/files/openable-row.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import type { ReactNode } from "react"; + +type OpenableRowProps = { + /** Para onde a linha leva: a pasta que ela representa. */ + href: string; + className: string; + /** As células da linha, já renderizadas no servidor. */ + children: ReactNode; +}; + +/** + * A linha de uma pasta, que a abre com dois cliques (docs/design-system.md, `file-row`) ou com + * Enter, para quem usa o teclado. A navegação é a do app, e não um recarregamento, para os + * envios em andamento seguirem. + */ +export function OpenableRow({ href, className, children }: OpenableRowProps) { + const router = useRouter(); + return ( +
router.push(href)} + onKeyDown={(event) => { + // Enter num botão de dentro da linha é do botão, e não abre a pasta. + if (event.key === "Enter" && event.target === event.currentTarget) { + router.push(href); + } + }} + className={`${className} focus-ring cursor-default select-none hover:bg-surface-hover`} + > + {children} +
+ ); +} diff --git a/apps/web/components/navigation/breadcrumb.tsx b/apps/web/components/navigation/breadcrumb.tsx new file mode 100644 index 0000000..40d1f8e --- /dev/null +++ b/apps/web/components/navigation/breadcrumb.tsx @@ -0,0 +1,74 @@ +import { ChevronRight, Ellipsis, Folder } from "lucide-react"; +import Link from "next/link"; +import { buttonIconClassName } from "@/components/ui/button-icon"; +import { Menu, MenuLink } from "@/components/ui/menu"; +import type { ItemTrailEntry } from "@/lib/api/types"; +import { folderPath } from "@/lib/drive"; + +const SEGMENT_CLASS_NAME = + "focus-ring truncate rounded-sm text-ui text-ink-muted hover:text-ink hover:underline"; + +function Separator() { + return ; +} + +/** + * O `{component.breadcrumb}`: o caminho da pasta atual, no topo da área de conteúdo. Cada trecho + * é um link, e o último, a pasta aberta, é o título da página. A partir de `sm` mostra o caminho + * inteiro; abaixo disso, com mais de dois trechos, os do meio colapsam em "…", que abre um menu + * com essas pastas. O primeiro trecho é a Pasta raiz, que é a página inicial. + */ +export function Breadcrumb({ trail }: { trail: ItemTrailEntry[] }) { + const hrefs = trail.map((entry, index) => (index === 0 ? "/" : folderPath(entry.id))); + const current = trail[trail.length - 1]; + const collapsible = trail.length > 2; + + return ( + + ); +} diff --git a/apps/web/components/ui/button-secondary.tsx b/apps/web/components/ui/button-secondary.tsx new file mode 100644 index 0000000..2b4e948 --- /dev/null +++ b/apps/web/components/ui/button-secondary.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from "react"; + +type ButtonSecondaryProps = Omit, "className">; + +/** + * O `{component.button-secondary}`: a segunda ação, ao lado do primário ("Cancelar"). Mesmo + * tamanho e forma do `button-primary`, com a borda e o texto em `{colors.primary}`. + */ +export function ButtonSecondary({ type = "button", ...props }: ButtonSecondaryProps) { + return ( +