feat: menus mobile corrigidos, sincronia multi-dispositivo e editor WYSIWYG

- Menus: dropdowns agora com position:fixed posicionado via JS — no celular
  o overflow-x:auto da menubar cortava os itens (bug relatado)
- Sincronia celular/desktop: PUT aceita base_hash opcional; divergência →
  409 {server_hash} e diálogo "Conflito de edição" (recarregar | salvar por
  cima); abas limpas recarregam sozinhas no focus/visibilitychange (debounce)
- WYSIWYG (Ctrl+M): contenteditable com render md→html no servidor e
  serialização html→md própria (títulos, negrito/itálico/tachado, listas
  com task, citação, código, blocos, links, tabelas, entidades); HTML edita
  preservando tags/atributos, sanitizando script/on* na entrada; toolbar
  usa execCommand no modo rico; colar é sempre texto puro; txt não aplica
- store/api: ErrConflict + teste de conflito na store e na API
- README: WYSIWYG, sincronia, base_hash, nota do fix de menu
This commit is contained in:
lgcosta committed 2026-11-04 13:03:58 -03:00
1 parent 8b355e8edb
commit 3b91c1a509
8 files changed
+456 -57

No files matched your search

+16 -5
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"io/fs"
"net/http"
"strings"
@@ -28,14 +29,15 @@ type noteInput struct {
Content string `json:"content"`
Author string `json:"author"`
Message string `json:"message"`
BaseHash string `json:"base_hash"` // hash em que a edição se baseia (opcional; evita sobrescrever mudanças remotas)
}
func (in noteInput) fields() (string, string, []string, string, string, string) {
func (in noteInput) fields() (string, string, []string, string, string, string, string) {
f := in.Format
if f == "" {
f = store.FormatTxt
}
return in.Title, f, in.Categories, in.Content, in.Author, in.Message
return in.Title, f, in.Categories, in.Content, in.Author, in.Message, in.BaseHash
}
// noteView é a representação completa (meta + conteúdo) de uma nota.
@@ -69,7 +71,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message := in.fields()
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)
@@ -93,8 +95,17 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message)
title, format, categories, content, author, message, baseHash := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message, baseHash)
if errors.Is(err, store.ErrConflict) {
// 409 com o estado atual: o cliente decide entre recarregar ou forçar.
writeJSON(w, http.StatusConflict, map[string]any{
"error": "a nota foi alterada em outro dispositivo",
"server_hash": n.Hash,
"updated_at": n.UpdatedAt,
})
return
}
if err != nil {
mapStoreError(w, err)
return