feat: layout mobile, abas múltiplas e categorias em árvore
- Responsivo ≤760px: sidebar vira gaveta com backdrop, painel de versões em tela cheia, toolbar/menu rolam sem rótulos, alvos de toque, dvh + safe-area, fonte do editor ≥16px (anti-zoom iOS) - Abas: várias notas abertas com estado sujo por aba, ✕ com confirmação, clique do meio, Alt+W / Alt+←/→ / Alt+1..9; abas e rascunhos sobrevivem à sessão via localStorage - Categorias: modelo vira árvore (categories.json) com notas em múltiplas categorias; explorador no sidebar com expandir/renomear/mover/excluir (sem ciclos, GC de vínculos) e seletor multi-marca no editor; filtro por pasta recursivo por padrão; migração automática do campo único legado - API: POST/PUT/DELETE /api/categories; notas aceitam categories: [ids] - Testes: store (árvore, ciclo, migração, GC) e api (CRUD de categorias)
This commit is contained in:
1 parent
9bc5de4c56
commit
7e89207b70
10 files changed
+1737
-489
No files matched your search
+97
-13
@@ -61,13 +61,13 @@ func decode(t *testing.T, resp *http.Response, v any) {
|
||||
}
|
||||
|
||||
type noteView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Hash string `json:"hash"`
|
||||
Content string `json:"content"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Categories []string `json:"categories"`
|
||||
Tags []string `json:"tags"`
|
||||
Hash string `json:"hash"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func TestAuthRequiredAndLoginFlow(t *testing.T) {
|
||||
@@ -130,15 +130,26 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
|
||||
return do(t, srv, method, path, body, testToken, "")
|
||||
}
|
||||
|
||||
// Criar.
|
||||
resp := call("POST", "/api/notes",
|
||||
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
|
||||
// Criar uma categoria raiz e uma filha para vincular à nota.
|
||||
resp := call("POST", "/api/categories", `{"name":"devops","parent":""}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create category: %s", resp.Status)
|
||||
}
|
||||
var cat struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
decode(t, resp, &cat)
|
||||
|
||||
// Criar nota em múltiplas categorias.
|
||||
resp = call("POST", "/api/notes",
|
||||
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %s", resp.Status)
|
||||
}
|
||||
var created noteView
|
||||
decode(t, resp, &created)
|
||||
if created.ID == "" || len(created.Tags) != 2 {
|
||||
if created.ID == "" || len(created.Tags) != 2 || len(created.Categories) != 1 {
|
||||
t.Fatalf("payload criado: %+v", created)
|
||||
}
|
||||
id := created.ID
|
||||
@@ -161,7 +172,7 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
|
||||
|
||||
// Atualizar: nova revisão.
|
||||
resp = call("PUT", "/api/notes/"+id,
|
||||
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
|
||||
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
|
||||
var updated noteView
|
||||
decode(t, resp, &updated)
|
||||
if updated.Hash == created.Hash {
|
||||
@@ -231,7 +242,9 @@ func TestRenderMarkdownIsSafe(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/render",
|
||||
`{"content":"**negrito** <script>alert(1)</script>\nlinha1\nlinha2","format":"md"}`, testToken, "")
|
||||
var out struct{ HTML string `json:"html"` }
|
||||
var out struct {
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
decode(t, resp, &out)
|
||||
if !strings.Contains(out.HTML, "<strong>negrito</strong>") {
|
||||
t.Errorf("markdown não renderizado: %q", out.HTML)
|
||||
@@ -251,6 +264,77 @@ func TestRenderMarkdownIsSafe(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestCategoryCRUDViaAPI(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
call := func(method, path, body string) *http.Response {
|
||||
return do(t, srv, method, path, body, testToken, "")
|
||||
}
|
||||
|
||||
resp := call("POST", "/api/categories", `{"name":"infra","parent":""}`)
|
||||
var infra struct{ ID string }
|
||||
decode(t, resp, &infra)
|
||||
resp = call("POST", "/api/categories", `{"name":"linux","parent":"`+infra.ID+`"}`)
|
||||
var linux struct{ ID string }
|
||||
decode(t, resp, &linux)
|
||||
|
||||
// duplicata entre irmãos → 400
|
||||
resp = call("POST", "/api/categories", `{"name":"Infra","parent":""}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("duplicata: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// nota na subcategoria
|
||||
resp = call("POST", "/api/notes", `{"title":"Kernel","format":"txt","categories":["`+linux.ID+`"],"content":"x","author":"","message":""}`)
|
||||
var note noteView
|
||||
decode(t, resp, ¬e)
|
||||
|
||||
// filtro recursivo pela raiz (padrão) → 1 nota; não-recursivo → 0
|
||||
resp = call("GET", "/api/notes?category="+infra.ID, "")
|
||||
var rec []noteView
|
||||
decode(t, resp, &rec)
|
||||
if len(rec) != 1 {
|
||||
t.Fatalf("recursivo: %+v", rec)
|
||||
}
|
||||
resp = call("GET", "/api/notes?category="+infra.ID+"&recursive=0", "")
|
||||
var nonrec []noteView
|
||||
decode(t, resp, &nonrec)
|
||||
if len(nonrec) != 0 {
|
||||
t.Fatalf("não-recursivo: %+v", nonrec)
|
||||
}
|
||||
|
||||
// ciclo: mover infra para dentro de linux → 400
|
||||
resp = call("PUT", "/api/categories/"+infra.ID, `{"name":"","parent":"`+linux.ID+`"}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("ciclo deveria dar 400, obtive %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// excluir raiz remove subtree e limpa a nota
|
||||
resp = call("DELETE", "/api/categories/"+infra.ID, "")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %s", resp.Status)
|
||||
}
|
||||
resp = call("GET", "/api/notes/"+note.ID, "")
|
||||
decode(t, resp, ¬e)
|
||||
if len(note.Categories) != 0 {
|
||||
t.Errorf("nota deveria ficar órfã: %+v", note.Categories)
|
||||
}
|
||||
resp = call("GET", "/api/categories", "")
|
||||
var cats []map[string]any
|
||||
decode(t, resp, &cats)
|
||||
if len(cats) != 0 {
|
||||
t.Errorf("árvore deveria estar vazia: %+v", cats)
|
||||
}
|
||||
|
||||
// categoria inexistente em nota → 400
|
||||
resp = call("POST", "/api/notes", `{"title":"x","format":"txt","categories":["zzzzzzzzzzzz"],"content":"", "author":"","message":""}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("categoria fantasma: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestInvalidPayloads(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type categoryInput struct {
|
||||
Name string `json:"name"`
|
||||
Parent *string `json:"parent"` // ausente = não move; "" = raiz
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
var in categoryInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
parent := ""
|
||||
if in.Parent != nil {
|
||||
parent = *in.Parent
|
||||
}
|
||||
c, err := s.st.CreateCategory(in.Name, parent)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
var in categoryInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
c, err := s.st.UpdateCategory(r.PathValue("id"), in.Name, in.Parent)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.st.DeleteCategory(r.PathValue("id")); err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
+18
-16
@@ -22,20 +22,20 @@ func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// noteInput é o corpo aceito por POST/PUT de notas.
|
||||
type noteInput struct {
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Category string `json:"category"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Categories []string `json:"categories"` // IDs da árvore de categorias
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (in noteInput) fields() (string, string, string, string, string, string) {
|
||||
func (in noteInput) fields() (string, string, []string, string, string, string) {
|
||||
f := in.Format
|
||||
if f == "" {
|
||||
f = store.FormatTxt
|
||||
}
|
||||
return in.Title, f, in.Category, in.Content, in.Author, in.Message
|
||||
return in.Title, f, in.Categories, in.Content, in.Author, in.Message
|
||||
}
|
||||
|
||||
// noteView é a representação completa (meta + conteúdo) de uma nota.
|
||||
@@ -46,10 +46,12 @@ type noteView struct {
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
recursive := q.Get("recursive") != "0"
|
||||
notes, err := s.st.List(store.Filter{
|
||||
Category: q.Get("category"),
|
||||
Tag: q.Get("tag"),
|
||||
Query: q.Get("q"),
|
||||
Category: q.Get("category"),
|
||||
Recursive: recursive,
|
||||
Tag: q.Get("tag"),
|
||||
Query: q.Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
@@ -67,8 +69,8 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
title, format, category, content, author, message := in.fields()
|
||||
n, err := s.st.Create(title, format, category, content, author, message)
|
||||
title, format, categories, content, author, message := in.fields()
|
||||
n, err := s.st.Create(title, format, categories, content, author, message)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
@@ -91,8 +93,8 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
title, format, category, content, author, message := in.fields()
|
||||
n, err := s.st.Update(r.PathValue("id"), title, format, category, content, author, message)
|
||||
title, format, categories, content, author, message := in.fields()
|
||||
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
@@ -159,7 +161,7 @@ func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if cats == nil {
|
||||
cats = []store.CatCount{}
|
||||
cats = []store.CategoryView{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cats)
|
||||
}
|
||||
@@ -66,6 +66,9 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /api/tags", s.auth(s.handleTags))
|
||||
mux.HandleFunc("GET /api/categories", s.auth(s.handleCategories))
|
||||
mux.HandleFunc("POST /api/categories", s.auth(s.handleCreateCategory))
|
||||
mux.HandleFunc("PUT /api/categories/{id}", s.auth(s.handleUpdateCategory))
|
||||
mux.HandleFunc("DELETE /api/categories/{id}", s.auth(s.handleDeleteCategory))
|
||||
mux.HandleFunc("POST /api/render", s.auth(s.handleRender))
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.serveIndex)
|
||||
|
||||
Reference in new issue
Block a user