Basking: a place to dump thoughts, jot notes and keep a journal
Plain markdown files, tags by typing #hashtags, a page per day, search, pin and trash with undo, autosave with conflict warnings, API tokens, zip export, and the Lightwell dashboard. Includes install.sh, CI and release workflows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b548faa258
23 files changed
+4026
No files matched your search
@@ -0,0 +1,254 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
writeJSON(w, map[string]any{"version": s.version, "api": p.API})
|
||||
}
|
||||
|
||||
func (s *Server) summary(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
counts, tags := s.st.Summary()
|
||||
writeJSON(w, map[string]any{"counts": counts, "tags": tags})
|
||||
}
|
||||
|
||||
func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
text := q.Get("q")
|
||||
if len(text) > 200 {
|
||||
text = text[:200]
|
||||
}
|
||||
notes, total := s.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit})
|
||||
writeJSON(w, map[string]any{"notes": notes, "total": total})
|
||||
}
|
||||
|
||||
// createNote takes {"body": "..."} as JSON, or the note itself as plain text, so a script can
|
||||
// just post what it has: curl -d 'buy milk' -H 'Content-Type: text/plain' ...
|
||||
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var body string
|
||||
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" {
|
||||
var in struct{ Body, Text string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
body = in.Body + in.Text
|
||||
} else {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fail(w, http.StatusRequestEntityTooLarge, "request too large")
|
||||
return
|
||||
}
|
||||
body = string(b)
|
||||
}
|
||||
n, err := s.st.Create(body)
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONStatus(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
func (s *Server) getNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
n, err := s.st.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var in struct {
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
Base string `json:"base"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
var base time.Time
|
||||
if in.Base != "" {
|
||||
t, err := time.Parse(time.RFC3339Nano, in.Base)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "base must be the note's updated time")
|
||||
return
|
||||
}
|
||||
base = t
|
||||
}
|
||||
n, err := s.st.Update(r.PathValue("id"), in.Body, in.Pinned, base)
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This note changed somewhere else", "note": n})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
id := r.PathValue("id")
|
||||
var err error
|
||||
if r.URL.Query().Get("purge") == "1" {
|
||||
err = s.st.Purge(id)
|
||||
} else {
|
||||
_, err = s.st.Trash(id)
|
||||
}
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
n, err := s.st.Restore(r.PathValue("id"))
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) emptyTrash(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
n, err := s.st.EmptyTrash()
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]int{"deleted": n})
|
||||
}
|
||||
|
||||
func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var in struct{ Date string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
n, err := s.st.Daily(in.Date)
|
||||
if err != nil {
|
||||
storeFail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, n)
|
||||
}
|
||||
|
||||
func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="basking-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.st.WriteZip(w); err != nil {
|
||||
// The download has started, so all that can be done is to cut it short.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- password and tokens ----
|
||||
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var in struct{ Current, New string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !config.CheckPassword(s.cfg.Get().PasswordHash, in.Current) {
|
||||
fail(w, http.StatusForbidden, "the current password is wrong")
|
||||
return
|
||||
}
|
||||
if len(in.New) < 8 {
|
||||
fail(w, http.StatusBadRequest, "the new password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
hash, err := config.HashPassword(in.New)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.startSession(w, r) // the old cookie no longer matches, so give this browser a new one
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
type row struct {
|
||||
Name string `json:"name"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
out := []row{}
|
||||
for _, t := range s.cfg.Get().Tokens {
|
||||
out = append(out, row{t.Name, t.Created})
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
if p.API {
|
||||
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
|
||||
return
|
||||
}
|
||||
var in struct{ Name string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
token, hash, err := config.NewToken()
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
err = s.cfg.Update(func(c *config.Config) error {
|
||||
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now().UTC()})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"name": name, "token": token})
|
||||
}
|
||||
|
||||
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
if p.API {
|
||||
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
found := false
|
||||
err := s.cfg.Update(func(c *config.Config) error {
|
||||
keep := c.Tokens[:0]
|
||||
for _, t := range c.Tokens {
|
||||
if t.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
keep = append(keep, t)
|
||||
}
|
||||
c.Tokens = keep
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
case !found:
|
||||
fail(w, http.StatusNotFound, "no such token")
|
||||
default:
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Package web serves Basking's dashboard and its JSON API.
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/store"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
const (
|
||||
cookieName = "basking_session"
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
maxLoginFails = 6
|
||||
loginFailSpan = 5 * time.Minute
|
||||
maxRequestBody = store.MaxBody + 4096
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Holder
|
||||
st *store.Store
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
fails map[string]failure
|
||||
|
||||
dummyOnce sync.Once
|
||||
dummyHash string
|
||||
}
|
||||
|
||||
type failure struct {
|
||||
n int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// principal is who is making a request.
|
||||
type principal struct {
|
||||
Name string
|
||||
API bool // came with an API token rather than a browser session
|
||||
}
|
||||
|
||||
func New(h *config.Holder, st *store.Store, version string) *Server {
|
||||
return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}}
|
||||
}
|
||||
|
||||
// Handler returns the whole site.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
|
||||
|
||||
mux.HandleFunc("POST /api/login", s.login)
|
||||
mux.HandleFunc("POST /api/logout", s.api(s.logout))
|
||||
mux.HandleFunc("GET /api/me", s.api(s.me))
|
||||
mux.HandleFunc("GET /api/summary", s.api(s.summary))
|
||||
|
||||
mux.HandleFunc("GET /api/notes", s.api(s.listNotes))
|
||||
mux.HandleFunc("POST /api/notes", s.api(s.createNote))
|
||||
mux.HandleFunc("GET /api/notes/{id}", s.api(s.getNote))
|
||||
mux.HandleFunc("PUT /api/notes/{id}", s.api(s.updateNote))
|
||||
mux.HandleFunc("DELETE /api/notes/{id}", s.api(s.deleteNote))
|
||||
mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote))
|
||||
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
|
||||
mux.HandleFunc("POST /api/daily", s.api(s.daily))
|
||||
mux.HandleFunc("GET /api/export", s.api(s.export))
|
||||
|
||||
mux.HandleFunc("POST /api/password", s.api(s.changePassword))
|
||||
mux.HandleFunc("GET /api/tokens", s.api(s.listTokens))
|
||||
mux.HandleFunc("POST /api/tokens", s.api(s.newToken))
|
||||
mux.HandleFunc("DELETE /api/tokens/{name}", s.api(s.deleteToken))
|
||||
|
||||
static, _ := fs.Sub(staticFiles, "static")
|
||||
mux.Handle("GET /", http.FileServerFS(static))
|
||||
return secure(mux)
|
||||
}
|
||||
|
||||
// secure adds headers that keep the page from being framed, sniffed or fed outside scripts.
|
||||
func secure(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- authentication ----
|
||||
|
||||
type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal)
|
||||
|
||||
// api wraps a handler with sign-in and CSRF protection.
|
||||
func (s *Server) api(fn handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
fail(w, http.StatusUnauthorized, "sign in required")
|
||||
return
|
||||
}
|
||||
// Browser sessions must send a header a web page on another site cannot add.
|
||||
if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
|
||||
fail(w, http.StatusForbidden, "missing X-Basking header")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||
fn(w, r, p)
|
||||
}
|
||||
}
|
||||
|
||||
// A session cookie is "expiry.signature". The signature covers the password hash, so changing
|
||||
// the password signs every browser out, and nothing has to be remembered on the server.
|
||||
func sign(c config.Config, exp int64) string {
|
||||
m := hmac.New(sha256.New, []byte(c.Secret))
|
||||
fmt.Fprintf(m, "session|%d|%s", exp, c.PasswordHash)
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (principal, bool) {
|
||||
c := s.cfg.Get()
|
||||
if c.PasswordHash == "" {
|
||||
return principal{}, false
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
sum := config.HashToken(strings.TrimPrefix(h, "Bearer "))
|
||||
for _, t := range c.Tokens {
|
||||
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 {
|
||||
return principal{Name: "token:" + t.Name, API: true}, true
|
||||
}
|
||||
}
|
||||
return principal{}, false
|
||||
}
|
||||
ck, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return principal{}, false
|
||||
}
|
||||
expStr, sig, ok := strings.Cut(ck.Value, ".")
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if !ok || err != nil || time.Now().Unix() > exp {
|
||||
return principal{}, false
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(sign(c, exp))) {
|
||||
return principal{}, false
|
||||
}
|
||||
return principal{Name: "you"}, true
|
||||
}
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.cfg.Get().TrustProxy {
|
||||
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) secureCookie(r *http.Request) bool {
|
||||
c := s.cfg.Get()
|
||||
return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https")
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.cfg.Get()
|
||||
exp := time.Now().Add(sessionTTL).Unix()
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: fmt.Sprintf("%d.%s", exp, sign(c, exp)), Path: "/", HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if r.Header.Get("X-Basking") != "1" {
|
||||
fail(w, http.StatusForbidden, "missing X-Basking header")
|
||||
return
|
||||
}
|
||||
var in struct{ Password string }
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
||||
fail(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
s.mu.Lock()
|
||||
f := s.fails[ip]
|
||||
if f.n >= maxLoginFails && time.Now().Before(f.until) {
|
||||
s.mu.Unlock()
|
||||
fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes")
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
hash := s.cfg.Get().PasswordHash
|
||||
if hash == "" {
|
||||
// Spend the same time as for a real check.
|
||||
s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") })
|
||||
hash = s.dummyHash
|
||||
}
|
||||
ok := config.CheckPassword(hash, in.Password) && s.cfg.Get().PasswordHash != ""
|
||||
s.mu.Lock()
|
||||
if !ok {
|
||||
if time.Now().After(f.until) {
|
||||
f = failure{}
|
||||
}
|
||||
f.n++
|
||||
f.until = time.Now().Add(loginFailSpan)
|
||||
s.fails[ip] = f
|
||||
s.mu.Unlock()
|
||||
fail(w, http.StatusUnauthorized, "wrong password")
|
||||
return
|
||||
}
|
||||
delete(s.fails, ip)
|
||||
s.mu.Unlock()
|
||||
s.startSession(w, r)
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) }
|
||||
|
||||
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// fail answers with an error. Messages are capitalized here, once, so they read as sentences.
|
||||
func fail(w http.ResponseWriter, code int, msg string) {
|
||||
if r := []rune(msg); len(r) > 0 {
|
||||
msg = strings.ToUpper(string(r[:1])) + string(r[1:])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
fail(w, http.StatusRequestEntityTooLarge, "request too large")
|
||||
} else {
|
||||
fail(w, http.StatusBadRequest, "bad request: "+err.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// storeFail turns a store error into the right status.
|
||||
func storeFail(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
fail(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
fail(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID):
|
||||
fail(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, store.ErrTooLong):
|
||||
fail(w, http.StatusRequestEntityTooLarge, err.Error())
|
||||
default:
|
||||
fail(w, http.StatusInternalServerError, "could not save: "+err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- tiny toolkit ----------
|
||||
// h builds an element. Strings become text nodes, never HTML, so note text can never inject markup.
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
let value;
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (k === 'value') value = v;
|
||||
else if (k === 'checked') el.checked = true;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
if (value !== undefined) el.value = value;
|
||||
return el;
|
||||
}
|
||||
const $app = document.getElementById('app');
|
||||
|
||||
// theme: follows the system unless the person picked one
|
||||
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ }
|
||||
function toggleTheme() {
|
||||
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const next = dark ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ }
|
||||
}
|
||||
const store = {
|
||||
get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } },
|
||||
set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } },
|
||||
};
|
||||
|
||||
// short confirmations at the bottom right; errors stay longer and are announced as alerts
|
||||
let toastBox;
|
||||
function toast(msg, bad, action) {
|
||||
if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); }
|
||||
const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg),
|
||||
action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null);
|
||||
toastBox.append(t);
|
||||
setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000);
|
||||
}
|
||||
const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
|
||||
|
||||
async function api(method, url, body) {
|
||||
const opt = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin' };
|
||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.status === 401 && url !== '/api/login' && url !== '/api/me') {
|
||||
if (S.ready) { S.ready = false; boot(); }
|
||||
throw new Error(SIGNED_OUT);
|
||||
}
|
||||
if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j });
|
||||
return j;
|
||||
}
|
||||
// run an action; show errors as a toast instead of throwing into the void
|
||||
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } };
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; };
|
||||
const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||
const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); };
|
||||
function dayLabel(d) {
|
||||
const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date();
|
||||
if (day === isoDay(now)) return 'Today';
|
||||
if (day === addDays(isoDay(now), -1)) return 'Yesterday';
|
||||
const dt = parseDay(day);
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
|
||||
}
|
||||
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', { class: 'sub' }, hint) : null);
|
||||
const chk = (label, checked) => { const i = h('input', { type: 'checkbox', checked: !!checked }); return Object.assign(h('label', { class: 'chk' }, i, label), { box: i }); };
|
||||
// status pill: color always travels with a text label
|
||||
const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text);
|
||||
const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
|
||||
// placeholder rows while a page loads, so the layout does not jump
|
||||
const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'),
|
||||
['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w })));
|
||||
// replaceChildren stringifies null and arrays, so everything goes through here
|
||||
const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false));
|
||||
const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids);
|
||||
|
||||
// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ----------
|
||||
const ICONS = {
|
||||
fin: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
|
||||
notes: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
|
||||
today: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
journal: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
pin: '<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||||
tag: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
|
||||
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>',
|
||||
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
||||
view: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
prev: '<path d="m15 18-6-6 6-6"/>',
|
||||
next: '<path d="m9 18 6-6-6-6"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
||||
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
|
||||
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
close: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
add: '<path d="M5 12h14M12 5v14"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
|
||||
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
||||
};
|
||||
function icon(name, size = 18) {
|
||||
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
|
||||
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
|
||||
return s;
|
||||
}
|
||||
const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Basking');
|
||||
const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label);
|
||||
|
||||
// ---------- dialogs ----------
|
||||
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
|
||||
let dialogSeq = 0;
|
||||
function modal(title, body, opts = {}) {
|
||||
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
|
||||
const shade = h('div', { class: 'shade' });
|
||||
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, h('h2', { id }, title), body);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return; closed = true;
|
||||
document.removeEventListener('keydown', onKey, true); shade.remove();
|
||||
if (opener && opener.isConnected && opener.focus) opener.focus();
|
||||
if (opts.onClose) opts.onClose();
|
||||
};
|
||||
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
|
||||
function onKey(e) {
|
||||
if ([...document.querySelectorAll('.shade')].pop() !== shade) return;
|
||||
if (e.key === 'Escape') { e.stopPropagation(); close(); }
|
||||
else if (e.key === 'Tab') {
|
||||
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
|
||||
const first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
|
||||
shade.append(box);
|
||||
document.body.append(shade);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
|
||||
return close;
|
||||
}
|
||||
const modalOpen = () => !!document.querySelector('.shade');
|
||||
|
||||
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
|
||||
function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) {
|
||||
return new Promise(resolve => {
|
||||
let close;
|
||||
const done = v => { resolve(v); close(); };
|
||||
close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body),
|
||||
h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel),
|
||||
h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) });
|
||||
});
|
||||
}
|
||||
|
||||
// formDialog shows a form and resolves with its values, or null if it was cancelled.
|
||||
// Field types: text, password, textarea, note.
|
||||
function formDialog({ title, intro, fields, submit = 'Save' }) {
|
||||
return new Promise(resolve => {
|
||||
const parts = {};
|
||||
const err = h('p', { class: 'err', role: 'alert' });
|
||||
const rows = fields.map(f => {
|
||||
if (f.type === 'note') return h('p', { class: 'dim' }, f.text);
|
||||
let el, get;
|
||||
if (f.type === 'textarea') {
|
||||
el = h('textarea', { placeholder: f.placeholder, rows: 5 }); el.value = f.value || ''; get = () => el.value.trim();
|
||||
} else {
|
||||
el = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off' });
|
||||
el.value = f.value ?? ''; get = () => f.type === 'password' ? el.value : el.value.trim();
|
||||
}
|
||||
parts[f.name] = { f, get, el };
|
||||
return field(f.label, el, f.hint);
|
||||
});
|
||||
let close;
|
||||
const send = () => {
|
||||
const out = {};
|
||||
for (const [name, p] of Object.entries(parts)) {
|
||||
const v = p.get();
|
||||
if (p.f.required && !v) { err.textContent = `${p.f.label} is required.`; p.el.setAttribute('aria-invalid', 'true'); p.el.focus(); return; }
|
||||
out[name] = v;
|
||||
}
|
||||
done(out);
|
||||
};
|
||||
const done = v => { resolve(v); close(); };
|
||||
const form = h('form', { class: 'stack', onsubmit: e => { e.preventDefault(); send(); } }, intro ? h('p', { class: 'dim' }, intro) : null, rows, err,
|
||||
h('div', { class: 'actions form-actions' }, h('button', { type: 'button', class: 'ghost', onclick: () => done(null) }, 'Cancel'), h('button', { type: 'submit', class: 'btn' }, submit)));
|
||||
close = modal(title, form, { sticky: true, onClose: () => resolve(null) });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- markdown ----------
|
||||
// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links.
|
||||
const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^*\\n]*?)\\*(?![\\w*])|\\[([^\\]\\n]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)|(https?:\\/\\/[^\\s<]*[^\\s<.,;:!?)\\]\'"])|(?<=^|[\\s(])#([\\p{L}\\p{N}_][\\p{L}\\p{N}_/-]*)';
|
||||
const tagChip = t => h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t);
|
||||
const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label);
|
||||
|
||||
function inline(text) {
|
||||
const re = new RegExp(INLINE_SRC, 'gu'), out = [];
|
||||
let last = 0, m;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
last = re.lastIndex;
|
||||
if (m[1] != null) out.push(h('code', {}, m[1]));
|
||||
else if (m[2] != null) out.push(h('strong', {}, inline(m[2])));
|
||||
else if (m[3] != null) out.push(h('em', {}, inline(m[3])));
|
||||
else if (m[4] != null) out.push(extLink(m[5], m[4]));
|
||||
else if (m[6] != null) out.push(extLink(m[6], m[6]));
|
||||
else {
|
||||
const t = m[7].replace(/[/-]+$/, '');
|
||||
if (!t || /^\d+$/.test(t)) out.push('#' + m[7]);
|
||||
else out.push(tagChip(t.toLowerCase()), m[7].slice(t.length));
|
||||
}
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
// md renders a note. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
|
||||
function md(src, onTask) {
|
||||
const root = h('div', { class: 'md' }), lines = src.split('\n');
|
||||
let para = [], i = 0, m;
|
||||
const flush = () => {
|
||||
if (!para.length) return;
|
||||
const p = h('p');
|
||||
para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t)); });
|
||||
root.append(p); para = [];
|
||||
};
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^\s*```/.test(line)) {
|
||||
flush(); const code = []; i++;
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]);
|
||||
i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); continue;
|
||||
}
|
||||
if (!line.trim()) { flush(); i++; continue; }
|
||||
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2]))); i++; continue; }
|
||||
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); root.append(h('hr')); i++; continue; }
|
||||
if (/^>/.test(line)) {
|
||||
flush(); const q = [];
|
||||
while (i < lines.length && /^>/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
|
||||
const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t)); }); root.append(bq); continue;
|
||||
}
|
||||
if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) {
|
||||
flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul');
|
||||
while (i < lines.length && (m = /^(\s*)([-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(lines[i]))) {
|
||||
const lvl = Math.min(3, Math.floor(m[1].replace(/\t/g, ' ').length / 2)), at = i;
|
||||
const li = h('li', { class: 'ind-' + lvl + (m[3] != null ? ' task' : '') });
|
||||
if (m[3] != null) {
|
||||
const box = h('input', { type: 'checkbox', checked: m[3] !== ' ', disabled: !onTask, 'aria-label': m[4] || 'Task' });
|
||||
if (onTask) box.addEventListener('change', () => onTask(at, box.checked));
|
||||
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4])));
|
||||
} else li.append(...inline(m[4]));
|
||||
list.append(li); i++;
|
||||
}
|
||||
root.append(list); continue;
|
||||
}
|
||||
para.push(line); i++;
|
||||
}
|
||||
flush();
|
||||
return root;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
const S = { ready: false, counts: { all: 0, pinned: 0, daily: 0, trash: 0 }, tags: [], view: null, q: '', pending: new Set(), nav: {} };
|
||||
|
||||
async function refreshSummary() {
|
||||
const r = await api('GET', '/api/summary');
|
||||
S.counts = r.counts; S.tags = r.tags; updateNav();
|
||||
}
|
||||
// Anything still waiting to autosave is saved before the page changes or the tab is hidden.
|
||||
const flushAll = () => Promise.all([...S.pending].map(f => f()));
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) flushAll(); });
|
||||
window.addEventListener('pagehide', () => { flushAll(); });
|
||||
|
||||
async function boot() {
|
||||
S.view = null; S.pending.clear();
|
||||
try { await api('GET', '/api/me'); } catch { return $app.replaceChildren(loginView()); }
|
||||
await refreshSummary().catch(() => { });
|
||||
S.ready = true;
|
||||
render();
|
||||
}
|
||||
|
||||
function loginView() {
|
||||
const pw = h('input', { type: 'password', autocomplete: 'current-password' });
|
||||
const go = act(async () => {
|
||||
await api('POST', '/api/login', { password: pw.value });
|
||||
if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies
|
||||
boot();
|
||||
});
|
||||
pw.addEventListener('keydown', e => e.key === 'Enter' && go());
|
||||
setTimeout(() => pw.focus());
|
||||
return h('div', { class: 'center' }, card('', brand(), h('h2', {}, 'Sign in'), h('div', { class: 'stack' },
|
||||
field('Password', pw, 'Basking printed it in its log the first time it started.'), h('button', { class: 'btn', onclick: go }, 'Sign in'))));
|
||||
}
|
||||
|
||||
// ---------- shell ----------
|
||||
function setDrawer(open) {
|
||||
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
|
||||
if (!sb) return;
|
||||
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
|
||||
if (open) sb.querySelector('a,button')?.focus(); else mb.focus();
|
||||
}
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) document.getElementById('sidebar')?.classList.contains('open') && setDrawer(false); });
|
||||
|
||||
const route = () => { const p = location.hash.slice(2).split('/'); return { name: p[0] || 'notes', arg: decodeURIComponent(p.slice(1).join('/')) }; };
|
||||
const routeKey = r => r.name === 'tag' ? 'tag/' + r.arg : r.name === 'day' ? (r.arg === isoDay(new Date()) ? 'today' : 'journal') : r.name;
|
||||
|
||||
function navItem(href, key, label, ic, count) {
|
||||
const cur = routeKey(route()) === key;
|
||||
const el = h('a', { class: 'navitem' + (cur ? ' on' : ''), href, 'aria-current': cur ? 'page' : null, onclick: () => setDrawer(false) },
|
||||
icon(ic), h('span', { class: 'label' }, label), count == null ? null : h('span', { class: 'count' }, count));
|
||||
return el;
|
||||
}
|
||||
// The sidebar's lists and counts change as notes do, so they are rebuilt on their own.
|
||||
function updateNav() {
|
||||
const box = document.getElementById('nav-dynamic');
|
||||
if (!box) return;
|
||||
fill(box,
|
||||
navItem('#/', 'notes', 'Notes', 'notes', S.counts.all),
|
||||
navItem('#/today', 'today', 'Today', 'today'),
|
||||
navItem('#/journal', 'journal', 'Journal', 'journal', S.counts.daily),
|
||||
navItem('#/pinned', 'pinned', 'Pinned', 'pin', S.counts.pinned),
|
||||
S.tags.length ? h('div', { class: 'navsec' }, 'Tags') : null,
|
||||
S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count)),
|
||||
h('div', { class: 'navsec' }, 'Other'),
|
||||
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null));
|
||||
}
|
||||
|
||||
async function render() {
|
||||
await flushAll();
|
||||
S.pending.clear();
|
||||
const main = h('main', { id: 'view', tabindex: '-1' });
|
||||
$app.replaceChildren(h('a', { class: 'skip', href: '#view', onclick: e => { e.preventDefault(); main.focus(); } }, 'Skip to content'), h('div', { class: 'shell' },
|
||||
h('div', { class: 'topbar' }, brand(), h('button', { class: 'ghost quiet btn-icon', id: 'menu-btn', 'aria-label': 'Open menu', 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => setDrawer(true) }, icon('menu'))),
|
||||
h('div', { class: 'backdrop', id: 'backdrop', onclick: () => setDrawer(false) }),
|
||||
h('aside', { class: 'sidebar', id: 'sidebar', 'aria-label': 'Main' },
|
||||
h('div', { class: 'sidebar-head' }, brand(), h('button', { class: 'ghost quiet btn-icon sidebar-close', 'aria-label': 'Close menu', onclick: () => setDrawer(false) }, icon('close'))),
|
||||
h('nav', { id: 'nav-dynamic', class: 'nav', 'aria-label': 'Notes' }),
|
||||
h('div', { class: 'grow' }),
|
||||
h('div', { class: 'userbox' },
|
||||
navItemLink('#/settings', 'settings', 'Settings', 'settings'),
|
||||
h('button', { class: 'navitem', onclick: toggleTheme }, icon('dark'), 'Switch theme'),
|
||||
h('button', { class: 'navitem', onclick: act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; boot(); }) }, icon('out'), 'Sign out'))),
|
||||
h('div', { class: 'content' }, main)));
|
||||
updateNav();
|
||||
show();
|
||||
}
|
||||
function navItemLink(href, key, label, ic) { return navItem(href, key, label, ic); }
|
||||
window.addEventListener('hashchange', () => { if (S.ready) render(); });
|
||||
|
||||
function pageHeader(title, desc, primary) {
|
||||
return h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, title), desc ? h('p', {}, desc) : null),
|
||||
primary ? h('button', { class: 'btn', onclick: primary[1] }, icon(primary[2] || 'add', 16), primary[0]) : null);
|
||||
}
|
||||
|
||||
function show() {
|
||||
const r = route(), main = document.getElementById('view');
|
||||
S.q = '';
|
||||
let page;
|
||||
switch (r.name) {
|
||||
case 'today': location.replace('#/day/' + isoDay(new Date())); return;
|
||||
case 'day': page = /^\d{4}-\d{2}-\d{2}$/.test(r.arg) ? dayPage(r.arg) : notesPage({ view: 'all' }); break;
|
||||
case 'journal': page = journalPage(); break;
|
||||
case 'pinned': page = notesPage({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break;
|
||||
case 'tag': page = notesPage({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every note with this tag.' }); break;
|
||||
case 'trash': page = notesPage({ view: 'trash', title: 'Trash', desc: 'Deleted notes wait here until you empty the trash.' }); break;
|
||||
case 'settings': page = settingsPage(); break;
|
||||
default: page = notesPage({ view: 'all', composer: true, title: 'Notes', desc: 'Write anything down. Sort it out later, or never.' });
|
||||
}
|
||||
S.view = page;
|
||||
main.replaceChildren(page.el);
|
||||
document.title = (page.title ? page.title + ' · ' : '') + 'Basking';
|
||||
page.start?.();
|
||||
}
|
||||
|
||||
// ---------- the editor: autosaves, and notices when a note changed somewhere else ----------
|
||||
function makeEditor(note, { rows = 4, onSaved, big } = {}) {
|
||||
let cur = note, dirty = false, saving = false, queued = false, blocked = false, timer = null;
|
||||
const ta = h('textarea', { class: 'write' + (big ? ' big' : ''), rows, 'aria-label': 'Note text', placeholder: big ? 'Write about your day...' : '' });
|
||||
ta.value = note.body;
|
||||
const status = h('span', { class: 'sub', role: 'status' });
|
||||
const notice = h('div', { class: 'notice notice-warning', hidden: true });
|
||||
const fit = () => { if (big) return; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, window.innerHeight * 0.7) + 'px'; };
|
||||
|
||||
async function save() {
|
||||
clearTimeout(timer); timer = null;
|
||||
if (!dirty || blocked) return;
|
||||
if (saving) { queued = true; return; }
|
||||
saving = true; dirty = false; status.textContent = 'Saving';
|
||||
try {
|
||||
cur = await api('PUT', '/api/notes/' + cur.id, { body: ta.value, base: cur.updated });
|
||||
if (!dirty) status.textContent = 'Saved';
|
||||
onSaved?.(cur);
|
||||
} catch (e) {
|
||||
dirty = true;
|
||||
if (e.status === 409) conflict(e.data.note);
|
||||
else { status.textContent = 'Not saved'; if (e.message !== SIGNED_OUT) toast(e.message, true); }
|
||||
} finally {
|
||||
saving = false;
|
||||
if (queued) { queued = false; save(); }
|
||||
}
|
||||
}
|
||||
function conflict(theirs) {
|
||||
blocked = true; status.textContent = 'Not saved';
|
||||
notice.hidden = false;
|
||||
notice.replaceChildren(icon('alert', 16), h('span', { class: 'grow' }, 'This note changed somewhere else, probably on another device.'),
|
||||
h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; notice.hidden = true; save(); } }, 'Keep my version'),
|
||||
h('button', { class: 'ghost', onclick: () => { cur = theirs; ta.value = theirs.body; dirty = false; blocked = false; notice.hidden = true; status.textContent = 'Saved'; fit(); onSaved?.(cur); } }, 'Use the other version'));
|
||||
}
|
||||
ta.addEventListener('input', () => {
|
||||
dirty = true; status.textContent = 'Unsaved'; fit();
|
||||
clearTimeout(timer); timer = setTimeout(save, 700);
|
||||
});
|
||||
S.pending.add(save);
|
||||
return {
|
||||
ta, status, notice, save,
|
||||
get note() { return cur; },
|
||||
flush: async () => { await save(); },
|
||||
close() { S.pending.delete(save); },
|
||||
fit,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- note cards ----------
|
||||
const LONG_LINES = 14, LONG_CHARS = 1200;
|
||||
|
||||
function noteCard(n, ctx) {
|
||||
const el = h('article', { class: 'note' + (n.pinned ? ' pinned' : ''), 'data-id': n.id });
|
||||
let ed = null;
|
||||
|
||||
const toggleTask = act(async (line, checked) => {
|
||||
const lines = n.body.split('\n');
|
||||
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
|
||||
n = await api('PUT', '/api/notes/' + n.id, { body: lines.join('\n'), base: n.updated });
|
||||
ctx.changed(n, true);
|
||||
});
|
||||
|
||||
function view() {
|
||||
ed = null;
|
||||
const long = n.body.split('\n').length > LONG_LINES || n.body.length > LONG_CHARS;
|
||||
let open = false;
|
||||
const body = n.body.trim() ? md(n.body, n.trashed ? null : toggleTask) : h('p', { class: 'dim' }, 'Empty note');
|
||||
const wrap = h('div', { class: 'note-body' + (long ? ' clamp' : '') }, body);
|
||||
const more = long ? h('button', { class: 'ghost quiet small-btn', 'aria-expanded': 'false', onclick: e => {
|
||||
open = !open; wrap.classList.toggle('clamp', !open); e.currentTarget.setAttribute('aria-expanded', open); e.currentTarget.textContent = open ? 'Show less' : 'Show more';
|
||||
} }, 'Show more') : null;
|
||||
const when = n.daily ? h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily)) : h('time', { datetime: n.created, title: 'Written ' + fmtTime(n.created) + (n.updated !== n.created ? '. Edited ' + fmtTime(n.updated) : '') }, hhmm(n.created));
|
||||
const actions = n.trashed
|
||||
? [btn('Restore', 'restore', act(() => ctx.restore(n))), btn('Delete for good', 'trash', act(() => ctx.purge(n)), 'ghost danger')]
|
||||
: [btn('Edit', 'edit', edit), btn(n.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { n = await api('PUT', '/api/notes/' + n.id, { pinned: !n.pinned }); ctx.changed(n, true); })), btn('Delete', 'trash', act(() => ctx.trash(n)))];
|
||||
el.className = 'note' + (n.pinned ? ' pinned' : '');
|
||||
fill(el, wrap, more, h('div', { class: 'note-foot' }, when, n.pinned ? badge('Pinned', 'accent') : null, h('span', { class: 'grow' }), h('div', { class: 'actions' }, actions)));
|
||||
}
|
||||
|
||||
function edit() {
|
||||
ed = makeEditor(n, { rows: 3, onSaved: s => { n = s; ctx.changed(n, false); } });
|
||||
el.className = 'note editing';
|
||||
el.replaceChildren(ed.notice, ed.ta, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }), h('div', { class: 'actions' },
|
||||
btn('Done', 'check', act(done), 'btn'))));
|
||||
ed.fit(); ed.ta.focus(); ed.ta.setSelectionRange(ed.ta.value.length, ed.ta.value.length);
|
||||
ed.ta.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { e.preventDefault(); e.stopPropagation(); done(); }
|
||||
});
|
||||
}
|
||||
async function done() {
|
||||
const e = ed; await e.flush(); e.close(); n = e.note;
|
||||
view(); ctx.changed(n, true); el.querySelector('button')?.focus();
|
||||
}
|
||||
|
||||
view();
|
||||
el.edit = edit;
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------- the composer ----------
|
||||
function composer(onSaved) {
|
||||
const ta = h('textarea', { class: 'write', rows: 3, placeholder: 'What is on your mind?', 'aria-label': 'New note' });
|
||||
ta.value = store.get('draft');
|
||||
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(Math.max(ta.scrollHeight + 2, 84), window.innerHeight * 0.5) + 'px'; };
|
||||
const go = h('button', { class: 'btn' }, icon('check', 16), 'Save note');
|
||||
const save = async () => {
|
||||
const text = ta.value.trim();
|
||||
if (!text) { ta.focus(); return; }
|
||||
go.disabled = true;
|
||||
try {
|
||||
await api('POST', '/api/notes', { body: text });
|
||||
ta.value = ''; store.set('draft', ''); fit();
|
||||
await onSaved();
|
||||
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); }
|
||||
go.disabled = false; ta.focus();
|
||||
};
|
||||
go.addEventListener('click', save);
|
||||
ta.addEventListener('input', () => { store.set('draft', ta.value); fit(); });
|
||||
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); } });
|
||||
setTimeout(fit);
|
||||
return Object.assign(h('div', { class: 'card composer' }, ta, h('div', { class: 'note-foot' }, h('span', { class: 'sub' }, 'Ctrl+Enter saves. Add #tags to find things later.'), h('span', { class: 'grow' }), go)), { ta });
|
||||
}
|
||||
|
||||
// ---------- notes list ----------
|
||||
function notesPage({ view, tag = '', title, desc, composer: withComposer }) {
|
||||
const list = h('div', { class: 'notes' }), moreBox = h('div', { class: 'more' });
|
||||
const search = h('input', { type: 'search', placeholder: 'Search notes, or type #tag', 'aria-label': 'Search notes' });
|
||||
let items = [], total = 0, loaded = false, timer = null, token = 0;
|
||||
|
||||
async function load(reset) {
|
||||
const mine = ++token, offset = reset ? 0 : items.length;
|
||||
const q = new URLSearchParams({ view, tag, q: search.value.trim(), offset, limit: 50 });
|
||||
const r = await api('GET', '/api/notes?' + q);
|
||||
if (mine !== token) return;
|
||||
items = reset ? r.notes : items.concat(r.notes); total = r.total; loaded = true; S.q = search.value.trim();
|
||||
draw();
|
||||
}
|
||||
const reload = act(() => Promise.all([load(true), refreshSummary()]));
|
||||
|
||||
const ctx = {
|
||||
changed(n, redraw) {
|
||||
const k = items.findIndex(x => x.id === n.id);
|
||||
if (k >= 0) items[k] = n;
|
||||
if (view === 'pinned' && !n.pinned) { items.splice(k, 1); total--; }
|
||||
if (redraw) { draw(); } refreshSummary().catch(() => { });
|
||||
},
|
||||
async trash(n) {
|
||||
await api('DELETE', '/api/notes/' + n.id);
|
||||
toast('Moved to the trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await reload(); }) });
|
||||
await reload();
|
||||
},
|
||||
async restore(n) { await api('POST', `/api/notes/${n.id}/restore`); toast('Note restored'); await reload(); },
|
||||
async purge(n) {
|
||||
const ok = await confirmDialog({ title: 'Delete this note for good?', body: `"${preview(n.body)}" will be deleted and cannot be brought back.`, confirm: 'Delete note' });
|
||||
if (!ok) return;
|
||||
await api('DELETE', `/api/notes/${n.id}?purge=1`); toast('Note deleted'); await reload();
|
||||
},
|
||||
};
|
||||
|
||||
function draw() {
|
||||
list.replaceChildren(); moreBox.replaceChildren();
|
||||
if (!items.length) {
|
||||
const searching = search.value.trim() || tag;
|
||||
const [ic, t, p] = view === 'trash' ? ['trash', 'The trash is empty', 'Notes you delete wait here until you empty the trash.']
|
||||
: view === 'pinned' ? ['pin', 'Nothing pinned', 'Pin a note to keep it here.']
|
||||
: searching ? ['view', 'No notes match', 'Try fewer words, or clear the search.']
|
||||
: ['notes', 'No notes yet', 'Write anything in the box above. It is saved when you press Ctrl+Enter.'];
|
||||
list.append(emptyState(ic, t, p));
|
||||
} else {
|
||||
let day = '', group;
|
||||
for (const n of items) {
|
||||
const d = isoDay(new Date(n.created));
|
||||
if (d !== day && view !== 'trash') {
|
||||
day = d; group = h('section', { class: 'day', 'aria-label': dayLabel(d) }, h('h2', { class: 'day-h' }, dayLabel(d)));
|
||||
list.append(group);
|
||||
} else if (view === 'trash' && !group) { group = h('section', { class: 'day' }); list.append(group); }
|
||||
group.append(noteCard(n, ctx));
|
||||
}
|
||||
}
|
||||
if (items.length < total) moreBox.append(h('button', { class: 'ghost', onclick: act(() => load(false)) }, `Show more (${total - items.length} left)`));
|
||||
}
|
||||
|
||||
const comp = withComposer ? composer(async () => { search.value = ''; await load(true); await refreshSummary(); }) : null;
|
||||
const emptyBtn = view === 'trash' ? btn('Empty trash', 'trash', act(async () => {
|
||||
if (!total) { toast('The trash is already empty'); return; }
|
||||
const ok = await confirmDialog({ title: 'Empty the trash?', body: `${total} ${total === 1 ? 'note' : 'notes'} will be deleted for good. This cannot be undone.`, confirm: 'Empty trash' });
|
||||
if (!ok) return;
|
||||
await api('DELETE', '/api/trash'); toast('Trash emptied'); await reload();
|
||||
}), 'ghost danger') : null;
|
||||
|
||||
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(act(() => load(true)), 250); });
|
||||
search.addEventListener('keydown', e => { if (e.key === 'Escape') { search.value = ''; load(true); search.blur(); } });
|
||||
|
||||
const el = h('div', {}, pageHeader(title || 'Notes', desc), comp,
|
||||
h('div', { class: 'toolbar' }, search, emptyBtn ? h('span', { class: 'push' }, emptyBtn) : null), list, moreBox);
|
||||
list.append(skeleton());
|
||||
return { el, title, search, composer: comp, start: act(() => load(true)), newNote: () => comp?.ta.focus() };
|
||||
}
|
||||
const preview = s => { const t = s.replace(/\s+/g, ' ').trim(); return t.length > 60 ? t.slice(0, 60) + '...' : t; };
|
||||
|
||||
// ---------- journal ----------
|
||||
function journalPage() {
|
||||
const list = h('div', { class: 'notes' });
|
||||
return {
|
||||
title: 'Journal',
|
||||
el: h('div', {}, pageHeader('Journal', 'One page for each day. Open a day to write it, or read back over the ones before.', ['Write today', () => { location.hash = '#/day/' + isoDay(new Date()); }, 'edit']), list),
|
||||
start: act(async () => {
|
||||
const r = await api('GET', '/api/notes?view=daily&limit=200');
|
||||
list.replaceChildren();
|
||||
const filled = r.notes.filter(n => n.body.trim());
|
||||
if (!filled.length) { list.append(emptyState('journal', 'No journal entries yet', 'Open today and write a few lines. Each day gets its own page.')); return; }
|
||||
for (const n of filled) {
|
||||
list.append(h('article', { class: 'note' }, h('h2', { class: 'day-h' }, h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily))),
|
||||
h('div', { class: 'note-body clamp' }, md(n.body)),
|
||||
h('div', { class: 'note-foot' }, h('span', { class: 'grow' }), h('div', { class: 'actions' }, btn('Open', 'edit', () => { location.hash = '#/day/' + n.daily; })))));
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- one day ----------
|
||||
function dayPage(date) {
|
||||
const body = h('div'), today = isoDay(new Date());
|
||||
let ed = null, previewing = false;
|
||||
const nav = h('div', { class: 'actions' },
|
||||
btn('Previous day', 'prev', () => { location.hash = '#/day/' + addDays(date, -1); }),
|
||||
date === today ? null : btn('Today', 'today', () => { location.hash = '#/day/' + today; }),
|
||||
date >= today ? null : btn('Next day', 'next', () => { location.hash = '#/day/' + addDays(date, 1); }));
|
||||
|
||||
function stamp() {
|
||||
const t = ed.ta, at = t.selectionStart, before = t.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1);
|
||||
const text = (line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : '') + hhmm(new Date()) + ' ';
|
||||
t.setRangeText(text, at, t.selectionEnd, 'end'); t.focus(); t.dispatchEvent(new Event('input'));
|
||||
}
|
||||
function draw(n) {
|
||||
ed = makeEditor(n, { rows: 14, big: true });
|
||||
const rendered = h('div', { class: 'card note-body', hidden: true });
|
||||
const toggle = btn('Preview', 'view', () => {
|
||||
previewing = !previewing;
|
||||
if (previewing) { rendered.replaceChildren(ed.ta.value.trim() ? md(ed.ta.value) : h('p', { class: 'dim' }, 'Nothing written yet')); }
|
||||
rendered.hidden = !previewing; ed.ta.hidden = previewing;
|
||||
toggle.replaceChildren(icon(previewing ? 'edit' : 'view', 16), previewing ? 'Edit' : 'Preview');
|
||||
});
|
||||
body.replaceChildren(ed.notice, ed.ta, rendered, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }),
|
||||
h('div', { class: 'actions' }, btn('Insert time', 'clock', stamp), toggle)));
|
||||
setTimeout(() => ed.ta.focus());
|
||||
}
|
||||
return {
|
||||
title: dayLabel(date),
|
||||
el: h('div', {}, pageHeader(dayLabel(date), date === today ? parseDay(date).toLocaleDateString(undefined, { dateStyle: 'full' }) : 'Your page for this day. It saves as you type.'),
|
||||
h('div', { class: 'toolbar' }, nav), h('div', { class: 'card' }, body)),
|
||||
start: act(async () => { body.replaceChildren(skeleton()); draw(await api('POST', '/api/daily', { date })); }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
async function copyText(text, input) {
|
||||
try { await navigator.clipboard.writeText(text); toast('Copied'); }
|
||||
catch { input.select(); toast('Press Ctrl+C to copy'); }
|
||||
}
|
||||
|
||||
function settingsPage() {
|
||||
const tokens = h('div');
|
||||
const loadTokens = act(async () => {
|
||||
const rows = await api('GET', '/api/tokens');
|
||||
tokens.replaceChildren(rows.length ? h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Created'), h('th', {}))),
|
||||
h('tbody', {}, rows.map(t => h('tr', {}, h('td', {}, t.name), h('td', {}, fmtTime(t.created)), h('td', { class: 'actions-cell' }, btn('Revoke', null, act(async () => {
|
||||
const ok = await confirmDialog({ title: `Revoke ${t.name}?`, body: 'Anything using this token stops working at once. You can make another one later.', confirm: 'Revoke token' });
|
||||
if (!ok) return;
|
||||
await api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)); toast('Token revoked'); loadTokens();
|
||||
}), 'ghost danger'))))))) : emptyState('tag', 'No tokens yet', 'Make a token to send notes from a script or a phone shortcut.'));
|
||||
});
|
||||
|
||||
const newToken = act(async () => {
|
||||
const r = await formDialog({ title: 'New API token', submit: 'Make token', fields: [{ name: 'name', label: 'Name', required: true, placeholder: 'phone', hint: 'Something that tells you where it is used.' }] });
|
||||
if (!r) return;
|
||||
const t = await api('POST', '/api/tokens', r);
|
||||
const box = h('input', { type: 'text', readonly: true, class: 'mono' }); box.value = t.token;
|
||||
let close;
|
||||
close = modal('Your new token', h('div', { class: 'stack' }, h('p', { class: 'dim' }, 'This is the only time it is shown. Copy it now.'), box,
|
||||
h('div', { class: 'actions form-actions' }, btn('Copy token', 'copy', () => copyText(t.token, box)), h('button', { class: 'btn', onclick: () => close() }, 'Done'))), { sticky: true });
|
||||
box.select(); loadTokens();
|
||||
});
|
||||
|
||||
const changePassword = act(async () => {
|
||||
const r = await formDialog({ title: 'Change password', submit: 'Change password', fields: [
|
||||
{ name: 'current', label: 'Current password', type: 'password', required: true },
|
||||
{ name: 'next', label: 'New password', type: 'password', required: true, hint: 'At least 8 characters.' },
|
||||
{ name: 'again', label: 'New password again', type: 'password', required: true }] });
|
||||
if (!r) return;
|
||||
if (r.next !== r.again) { toast('The two new passwords do not match', true); return; }
|
||||
await api('POST', '/api/password', { current: r.current, new: r.next });
|
||||
toast('Password changed');
|
||||
});
|
||||
|
||||
const origin = location.origin;
|
||||
const curl = `curl -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: text/plain" \\\n -d "Call the dentist #errands" \\\n ${origin}/api/notes`;
|
||||
return {
|
||||
title: 'Settings',
|
||||
el: h('div', {}, pageHeader('Settings', 'Your password, tokens for scripts, and a copy of everything.'),
|
||||
card('Password', h('p', { class: 'dim' }, 'Changing it signs out every other browser.'), btn('Change password', 'edit', changePassword)),
|
||||
card('', h('div', { class: 'card-h' }, h('h2', {}, 'API tokens'), btn('New token', 'add', newToken)),
|
||||
h('p', { class: 'dim' }, 'A token lets a script or a phone shortcut add notes without signing in.'), tokens),
|
||||
card('Send a note from anywhere', h('p', { class: 'dim' }, 'With a token, post the text of a note to Basking:'), h('pre', { class: 'snippet' }, h('code', {}, curl)),
|
||||
h('p', { class: 'dim' }, 'Or, from a terminal with the basking command:'), h('pre', { class: 'snippet' }, h('code', {}, `BASKING_URL=${origin} BASKING_TOKEN=... basking add "an idea"`))),
|
||||
card('Your notes', h('p', { class: 'dim' }, 'Every note is a plain markdown file in the folder Basking keeps its data in, so you can back that folder up or sync it as it is. This downloads the same files as one zip.'),
|
||||
btn('Download all notes', 'download', () => { location.href = '/api/export'; }))),
|
||||
start: loadTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- keyboard ----------
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!S.ready || modalOpen() || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const t = e.target;
|
||||
if (t.matches?.('input,textarea,select,[contenteditable]')) return;
|
||||
if (e.key === '/' && S.view?.search) { e.preventDefault(); S.view.search.focus(); }
|
||||
else if (e.key === 'n') {
|
||||
e.preventDefault();
|
||||
if (S.view?.composer) S.view.composer.ta.focus(); else { location.hash = '#/'; setTimeout(() => S.view?.composer?.ta.focus(), 400); }
|
||||
}
|
||||
});
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Basking</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231f5c5c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20c4-1 7-5 8-14 3 5 8 7 10 14z'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,239 @@
|
||||
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
||||
:root {
|
||||
--accent:#1f5c5c; --accent-contrast:#ffffff;
|
||||
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
|
||||
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
|
||||
--accent-text:var(--accent);
|
||||
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
|
||||
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
|
||||
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
|
||||
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
|
||||
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
|
||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
|
||||
--radius:6px; --radius-lg:10px; --border-w:1px;
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
|
||||
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
|
||||
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
|
||||
color-scheme:light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
|
||||
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
||||
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
||||
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
||||
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
||||
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
|
||||
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
||||
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
|
||||
} }
|
||||
:root[data-theme="dark"] {
|
||||
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
||||
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
||||
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
||||
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
||||
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
|
||||
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
||||
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
|
||||
}
|
||||
/* tokens:end */
|
||||
|
||||
*, *::before, *::after { box-sizing:border-box; }
|
||||
html, body { height:100%; }
|
||||
html { -webkit-text-size-adjust:100%; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
|
||||
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
|
||||
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
|
||||
p { margin:0 0 var(--s3); }
|
||||
a { color:var(--accent-text); text-underline-offset:2px; }
|
||||
a:hover { text-decoration-thickness:2px; }
|
||||
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
||||
[hidden] { display:none !important; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||||
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
|
||||
.skip:focus { top:var(--s3); }
|
||||
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
|
||||
.num { font-variant-numeric:tabular-nums; }
|
||||
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
|
||||
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
|
||||
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
|
||||
.inline-icon { display:inline-flex; }
|
||||
|
||||
/* shell */
|
||||
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
|
||||
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
|
||||
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
|
||||
.sidebar button.sidebar-close { display:none; }
|
||||
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
|
||||
.brand .icon { color:var(--accent-text); }
|
||||
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
|
||||
.nav { display:flex; flex-direction:column; gap:2px; }
|
||||
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
|
||||
.navitem:hover { background:var(--surface-2); color:var(--text); }
|
||||
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.navitem .label { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.grow { flex:1 1 auto; min-width:0; }
|
||||
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
|
||||
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.content { min-width:0; }
|
||||
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
|
||||
main:focus { outline:none; }
|
||||
.topbar, .backdrop { display:none; }
|
||||
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
|
||||
.page-h h1 { display:flex; align-items:center; gap:var(--s3); flex-wrap:wrap; }
|
||||
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
|
||||
.page-h .btn { white-space:nowrap; }
|
||||
.back { display:inline-block; margin-bottom:var(--s3); font-size:var(--text-sm); }
|
||||
|
||||
/* cards, layout */
|
||||
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
|
||||
.card.flush { padding:0; overflow:hidden; }
|
||||
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
|
||||
.card-h { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
|
||||
.card-h h2 { font-size:var(--text-lg); margin:0; }
|
||||
.meta { color:var(--text-muted); font-size:var(--text-sm); }
|
||||
.stack { display:flex; flex-direction:column; gap:var(--s4); }
|
||||
.scroll { overflow-x:auto; }
|
||||
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
||||
td.actions-cell { text-align:right; white-space:nowrap; }
|
||||
td.actions-cell button + button { margin-left:var(--s2); }
|
||||
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
|
||||
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
|
||||
.toolbar .push { margin-left:auto; }
|
||||
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
|
||||
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
|
||||
|
||||
/* forms */
|
||||
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
|
||||
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
|
||||
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
|
||||
textarea { min-height:96px; resize:vertical; font-family:var(--font-mono); font-size:var(--text-sm); }
|
||||
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
|
||||
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
|
||||
input:disabled, select:disabled { opacity:.6; }
|
||||
input[aria-invalid="true"] { border-color:var(--danger); }
|
||||
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
|
||||
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
|
||||
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
|
||||
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
|
||||
|
||||
/* buttons: btn is the primary action, ghost the default, danger and quiet the others */
|
||||
button.btn, button.ghost { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
|
||||
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
|
||||
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
||||
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
|
||||
button.ghost:hover { background:var(--surface-2); }
|
||||
button.quiet { background:transparent; border-color:transparent; }
|
||||
button.danger { color:var(--danger); border-color:var(--danger); }
|
||||
button.danger:hover { background:var(--danger-bg); }
|
||||
button.btn-icon { width:var(--control-h); padding:0; }
|
||||
td button.ghost, .actions button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
|
||||
button:disabled { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
/* tables */
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
|
||||
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
|
||||
tbody tr:hover td { background:var(--surface-2); }
|
||||
tr:last-child td { border-bottom:0; }
|
||||
td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:320px; }
|
||||
|
||||
/* badges: status always carries a text label, never color alone */
|
||||
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
|
||||
.badge-success { background:var(--success-bg); color:var(--success); }
|
||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
|
||||
|
||||
/* the event and chat feed */
|
||||
|
||||
/* empty state, notice, loading */
|
||||
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
|
||||
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
|
||||
.empty p { margin:0 auto var(--s3); max-width:56ch; }
|
||||
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
|
||||
.card > .empty { margin:0; }
|
||||
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); display:flex; gap:var(--s3); align-items:center; flex-wrap:wrap; }
|
||||
.notice-warning { background:var(--warning-bg); border-color:var(--warning); color:var(--warning); }
|
||||
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
|
||||
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); height:16px; margin-bottom:14px; }
|
||||
.w60 { width:60%; } .w75 { width:75%; } .w90 { width:90%; } .w100 { width:100%; }
|
||||
@keyframes shimmer { to { background-position:-200% 0; } }
|
||||
|
||||
/* dialogs, tabs, toasts */
|
||||
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
|
||||
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
|
||||
.modal.wide { width:min(640px,100%); }
|
||||
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
|
||||
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
|
||||
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); overflow-wrap:anywhere; }
|
||||
.toast-error { background:var(--danger); color:var(--accent-contrast); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
|
||||
@media (pointer: coarse) {
|
||||
button.btn, button.ghost, .navitem, input, select, .tabs2 a { min-height:var(--touch); }
|
||||
td button.ghost, .actions button.ghost { min-height:36px; }
|
||||
input, select, textarea { font-size:16px; }
|
||||
}
|
||||
@media (max-width:860px) {
|
||||
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
|
||||
.sidebar { display:none; }
|
||||
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
|
||||
.sidebar button.sidebar-close { display:inline-flex; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
|
||||
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
|
||||
main { padding:var(--s5) var(--s4) var(--s7); }
|
||||
.ev { grid-template-columns:4.5rem minmax(0,1fr); } .ev .badge { display:none; }
|
||||
}
|
||||
@media (max-width:640px) {
|
||||
.modal { padding:var(--s4); }
|
||||
.shade { padding:var(--s4) var(--s3); }
|
||||
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
|
||||
.page-h > .btn { width:100%; }
|
||||
}
|
||||
.icon-16 { width:16px; height:16px; } .icon-22 { width:22px; height:22px; }
|
||||
|
||||
/* notes */
|
||||
textarea.write { width:100%; min-height:84px; font-family:var(--font); font-size:var(--text-base); line-height:1.6; resize:none; border:0; padding:0; background:transparent; }
|
||||
textarea.write:focus { outline:none; border:0; }
|
||||
textarea.write.big { min-height:50vh; resize:vertical; }
|
||||
.composer, .note { padding:var(--s4) var(--s5); }
|
||||
.composer:focus-within, .note.editing { border-color:var(--accent); }
|
||||
.note { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); margin-bottom:var(--s3); overflow:visible; }
|
||||
.note.pinned { border-left:3px solid var(--accent); }
|
||||
.note-body { overflow-wrap:anywhere; }
|
||||
.note-body.clamp { max-height:15em; overflow:hidden; -webkit-mask-image:linear-gradient(black 70%, transparent); mask-image:linear-gradient(black 70%, transparent); }
|
||||
.note-foot { display:flex; align-items:center; gap:var(--s3); margin-top:var(--s3); flex-wrap:wrap; font-size:var(--text-sm); color:var(--text-muted); }
|
||||
.note-foot a, .note-foot time { color:var(--text-muted); }
|
||||
.notes .empty { margin:0; }
|
||||
.day-h { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); text-transform:none; margin:var(--s5) 0 var(--s2); letter-spacing:0; }
|
||||
.day-h a { color:inherit; }
|
||||
.day:first-child .day-h { margin-top:var(--s2); }
|
||||
.more { text-align:center; margin:var(--s4) 0; }
|
||||
button.small-btn { min-height:28px; padding:0 var(--s2); font-size:var(--text-sm); }
|
||||
.navitem .count { font-size:var(--text-xs); color:var(--text-faint); font-variant-numeric:tabular-nums; }
|
||||
.chip { display:inline-block; padding:0 var(--s2); border-radius:999px; background:var(--accent-soft); color:var(--accent-text); text-decoration:none; font-size:var(--text-sm); font-weight:500; }
|
||||
.chip:hover { text-decoration:underline; }
|
||||
.toast { display:flex; gap:var(--s3); align-items:center; justify-content:space-between; }
|
||||
.toast-act { background:transparent; border:0; color:inherit; font:inherit; font-weight:600; text-decoration:underline; cursor:pointer; padding:0; }
|
||||
.toolbar .push { margin-left:auto; }
|
||||
.toolbar input[type=search] { max-width:360px; }
|
||||
.snippet { margin:0 0 var(--s4); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
|
||||
.snippet code { white-space:pre; }
|
||||
|
||||
/* rendered markdown */
|
||||
.md > *:first-child { margin-top:0; } .md > *:last-child { margin-bottom:0; }
|
||||
.md p { margin:0 0 var(--s3); }
|
||||
.md .md-h { margin:var(--s4) 0 var(--s2); font-size:var(--text-lg); }
|
||||
.md h4.md-h { font-size:var(--text-base); } .md h5.md-h { font-size:var(--text-base); color:var(--text-muted); }
|
||||
.md ul, .md ol { margin:0 0 var(--s3); padding-left:var(--s5); }
|
||||
.md li.task { list-style:none; margin-left:calc(var(--s5) * -1); display:flex; gap:var(--s2); align-items:flex-start; }
|
||||
.md li.task input { margin-top:5px; }
|
||||
.md li .done { color:var(--text-muted); text-decoration:line-through; }
|
||||
.md li.ind-1 { margin-left:var(--s4); } .md li.ind-2 { margin-left:var(--s6); } .md li.ind-3 { margin-left:var(--s7); }
|
||||
.md li.task.ind-1 { margin-left:calc(var(--s5) * -1 + var(--s4)); } .md li.task.ind-2 { margin-left:calc(var(--s5) * -1 + var(--s6)); } .md li.task.ind-3 { margin-left:calc(var(--s5) * -1 + var(--s7)); }
|
||||
.md blockquote { margin:0 0 var(--s3); padding:var(--s1) var(--s4); border-left:3px solid var(--border-strong); color:var(--text-muted); }
|
||||
.md pre { margin:0 0 var(--s3); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
|
||||
.md code { background:var(--surface-2); padding:1px 4px; border-radius:4px; } .md pre code { background:none; padding:0; }
|
||||
.md hr { border:0; border-top:var(--border-w) solid var(--border); margin:var(--s4) 0; }
|
||||
@@ -0,0 +1,339 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
"git.veillax.com/Veillax/basking/internal/store"
|
||||
)
|
||||
|
||||
const password = "correct horse battery"
|
||||
|
||||
type rig struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
cfg *config.Holder
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func newRig(t *testing.T) *rig {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
h, err := config.Load(filepath.Join(dir, "basking.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := config.HashPassword(password)
|
||||
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "data"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(New(h, st, "test").Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return &rig{t: t, srv: srv, cfg: h}
|
||||
}
|
||||
|
||||
func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) {
|
||||
r.t.Helper()
|
||||
req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body))
|
||||
if ctype != "" {
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
if r.cookie != nil {
|
||||
req.AddCookie(r.cookie)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
|
||||
// js sends JSON as a browser would, with the CSRF header.
|
||||
func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) {
|
||||
r.t.Helper()
|
||||
var body string
|
||||
if v != nil {
|
||||
b, _ := json.Marshal(v)
|
||||
body = string(b)
|
||||
}
|
||||
resp, b := r.do(method, path, "application/json", body, "X-Basking", "1")
|
||||
var out map[string]any
|
||||
json.Unmarshal(b, &out)
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func (r *rig) signIn() {
|
||||
r.t.Helper()
|
||||
resp, _ := r.js("POST", "/api/login", map[string]string{"password": password})
|
||||
if resp.StatusCode != 200 {
|
||||
r.t.Fatalf("login: %d", resp.StatusCode)
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == cookieName {
|
||||
r.cookie = c
|
||||
}
|
||||
}
|
||||
if r.cookie == nil {
|
||||
r.t.Fatal("no session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresSignIn(t *testing.T) {
|
||||
r := newRig(t)
|
||||
for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} {
|
||||
if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 {
|
||||
t.Errorf("GET %s without signing in: %d", p, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 {
|
||||
t.Error("healthz should be open")
|
||||
}
|
||||
if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 {
|
||||
t.Error("the page itself should load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndHeaders(t *testing.T) {
|
||||
r := newRig(t)
|
||||
if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 {
|
||||
t.Errorf("login without the CSRF header: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
|
||||
t.Errorf("wrong password: %d", resp.StatusCode)
|
||||
}
|
||||
r.signIn()
|
||||
resp, _ := r.do("GET", "/api/me", "", "")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
}
|
||||
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
|
||||
t.Errorf("CSP %q", csp)
|
||||
}
|
||||
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("cookie flags %+v", r.cookie)
|
||||
}
|
||||
// a browser write needs the header
|
||||
if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 {
|
||||
t.Errorf("write without the header: %d", resp.StatusCode)
|
||||
}
|
||||
// a forged cookie does nothing
|
||||
r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"}
|
||||
if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 {
|
||||
t.Errorf("forged cookie: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginIsRateLimited(t *testing.T) {
|
||||
r := newRig(t)
|
||||
var last int
|
||||
for i := 0; i < maxLoginFails+2; i++ {
|
||||
resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"})
|
||||
last = resp.StatusCode
|
||||
}
|
||||
if last != 429 {
|
||||
t.Fatalf("after many failures got %d", last)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}); resp.StatusCode != 429 {
|
||||
t.Fatalf("even the right password waits: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteLifecycle(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
|
||||
resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"})
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, n)
|
||||
}
|
||||
id, _ := n["id"].(string)
|
||||
if id == "" || n["body"] != "remember the milk #errands" {
|
||||
t.Fatalf("created %v", n)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 {
|
||||
t.Errorf("empty note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := r.js("GET", "/api/notes?q=milk", nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Fatalf("search: %v", list)
|
||||
}
|
||||
_, list = r.js("GET", "/api/notes?tag=errands", nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Fatalf("tag: %v", list)
|
||||
}
|
||||
|
||||
// edit with the version we have, then again with a stale one
|
||||
resp, n2 := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "remember the oat milk", "base": n["updated"]})
|
||||
if resp.StatusCode != 200 || n2["body"] != "remember the oat milk" {
|
||||
t.Fatalf("update: %d %v", resp.StatusCode, n2)
|
||||
}
|
||||
resp, c := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "stale", "base": n["updated"]})
|
||||
if resp.StatusCode != 409 {
|
||||
t.Fatalf("stale update: %d", resp.StatusCode)
|
||||
}
|
||||
if cur, _ := c["note"].(map[string]any); cur["body"] != "remember the oat milk" {
|
||||
t.Errorf("conflict should return the current note: %v", c)
|
||||
}
|
||||
if resp, _ := r.js("PUT", "/api/notes/"+id, map[string]any{"pinned": true}); resp.StatusCode != 200 {
|
||||
t.Errorf("pin: %d", resp.StatusCode)
|
||||
}
|
||||
_, list = r.js("GET", "/api/notes?view=pinned", nil)
|
||||
if list["total"] != float64(1) {
|
||||
t.Errorf("pinned view: %v", list)
|
||||
}
|
||||
|
||||
if resp, _ := r.js("DELETE", "/api/notes/"+id+"?purge=1", nil); resp.StatusCode != 400 {
|
||||
t.Errorf("purge of a live note: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := r.js("DELETE", "/api/notes/"+id, nil); resp.StatusCode != 200 {
|
||||
t.Errorf("trash: %d", resp.StatusCode)
|
||||
}
|
||||
_, sum := r.js("GET", "/api/summary", nil)
|
||||
if counts := sum["counts"].(map[string]any); counts["trash"] != float64(1) || counts["all"] != float64(0) {
|
||||
t.Errorf("summary after trashing: %v", sum)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/notes/"+id+"/restore", nil); resp.StatusCode != 200 {
|
||||
t.Errorf("restore: %d", resp.StatusCode)
|
||||
}
|
||||
r.js("DELETE", "/api/notes/"+id, nil)
|
||||
if resp, _ := r.js("DELETE", "/api/trash", nil); resp.StatusCode != 200 {
|
||||
t.Errorf("empty trash: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := r.js("GET", "/api/notes/"+id, nil); resp.StatusCode != 404 {
|
||||
t.Errorf("gone: %d", resp.StatusCode)
|
||||
}
|
||||
for _, bad := range []string{"..%2f..%2fbasking.json", "UPPER", "a.b"} {
|
||||
if resp, _ := r.js("GET", "/api/notes/"+bad, nil); resp.StatusCode != 404 {
|
||||
t.Errorf("odd id %q: %d", bad, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyEndpoint(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
resp, n := r.js("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
|
||||
if resp.StatusCode != 200 || n["id"] != "daily-2026-10-31" {
|
||||
t.Fatalf("%d %v", resp.StatusCode, n)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/daily", map[string]string{"date": "yesterday"}); resp.StatusCode != 400 {
|
||||
t.Errorf("bad date: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAndPlainTextCapture(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
resp, tok := r.js("POST", "/api/tokens", map[string]string{"name": "phone"})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("new token: %d %v", resp.StatusCode, tok)
|
||||
}
|
||||
token := tok["token"].(string)
|
||||
if !strings.HasPrefix(token, "bsk_") {
|
||||
t.Fatalf("token %q", token)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 {
|
||||
t.Errorf("duplicate name: %d", resp.StatusCode)
|
||||
}
|
||||
if b, _ := json.Marshal(r.cfg.Get()); strings.Contains(string(b), token) {
|
||||
t.Error("the token itself must not be stored")
|
||||
}
|
||||
|
||||
// a script with only the token: no cookie, no CSRF header, plain text body
|
||||
api := &rig{t: t, srv: r.srv, cfg: r.cfg}
|
||||
resp, b := api.do("POST", "/api/notes", "text/plain", "call mum #family", "Authorization", "Bearer "+token)
|
||||
if resp.StatusCode != 201 {
|
||||
t.Fatalf("capture: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
if !strings.Contains(string(b), `"body":"call mum #family"`) {
|
||||
t.Errorf("body %s", b)
|
||||
}
|
||||
if resp, _ := api.do("POST", "/api/notes", "text/plain", "x", "Authorization", "Bearer bsk_wrong"); resp.StatusCode != 401 {
|
||||
t.Errorf("wrong token: %d", resp.StatusCode)
|
||||
}
|
||||
// a token cannot mint more tokens
|
||||
if resp, _ := api.do("POST", "/api/tokens", "application/json", `{"name":"x"}`, "Authorization", "Bearer "+token); resp.StatusCode != 403 {
|
||||
t.Errorf("token making a token: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := r.js("GET", "/api/tokens", nil)
|
||||
_ = list
|
||||
if resp, _ := r.js("DELETE", "/api/tokens/phone", nil); resp.StatusCode != 200 {
|
||||
t.Errorf("revoke: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := api.do("GET", "/api/notes", "", "", "Authorization", "Bearer "+token); resp.StatusCode != 401 {
|
||||
t.Errorf("revoked token: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
old := r.cookie
|
||||
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": "wrong", "new": "a brand new password"}); resp.StatusCode != 403 {
|
||||
t.Errorf("wrong current: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "short"}); resp.StatusCode != 400 {
|
||||
t.Errorf("short: %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "a brand new password"})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("change: %d", resp.StatusCode)
|
||||
}
|
||||
// the old cookie stops working, the new one (sent back in the response) works
|
||||
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 401 {
|
||||
t.Errorf("old cookie after a password change: %d", resp2.StatusCode)
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == cookieName {
|
||||
r.cookie = c
|
||||
}
|
||||
}
|
||||
if r.cookie == old {
|
||||
t.Fatal("no new cookie")
|
||||
}
|
||||
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 {
|
||||
t.Errorf("new cookie: %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExport(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.signIn()
|
||||
r.js("POST", "/api/notes", map[string]string{"body": "one"})
|
||||
r.js("POST", "/api/notes", map[string]string{"body": "two"})
|
||||
resp, b := r.do("GET", "/api/export", "", "")
|
||||
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Disposition"), "attachment") {
|
||||
t.Fatalf("export: %d %v", resp.StatusCode, resp.Header)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil || len(zr.File) != 2 {
|
||||
t.Fatalf("zip: %v, %d files", err, len(zr.File))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailCapitalizes(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
fail(w, 400, "écrit mal")
|
||||
if !strings.Contains(w.Body.String(), "Écrit mal") {
|
||||
t.Errorf("%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user