[feat](web): serve the SPA and web actions on the single listener

Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/,
an embedded placeholder bundle, the /web/* auth and action handlers, and
the explicit browser-action allowlist. The SPA and the API share the one
plaintext :8080 listener; there is no second port and no peer edge, so
main.go also drops the mTLS listener and the identity mounts in the debug
compose fall away. /_manifest announces the web block only while the SPA is
mounted (honesty rule), always with the single service port.

The DSNs move to the shared database's scram-sha-256 credentials, matching
rev 9's removal of the PKI layer.

Committed web/bun.lock because web/package.json and vite.config.ts are new
in this commit. src/web/dist/index.html is a hand-written placeholder, not
a generated bundle: it keeps bare `go build`/`go vet` working from a clean
checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
2026-10-19 09:28:59 +01:00
parent c58e193038
commit a791232f18
46 changed files with 5392 additions and 61 deletions
+16
View File
@@ -2,3 +2,19 @@
src/tmp/
src/src/
src/logs/
# Frontend build output and dependencies (the committed placeholder at
# src/web/dist/index.html is deliberately NOT ignored — it keeps bare
# `go build`/`go vet` working from a clean checkout). Patterns are anchored to
# the repo root so `src/web/dist/` stays tracked.
/web/dist/
/web/node_modules/
/web/.bun/
/web/.buntmp/
# Go build cache (sandbox: default GOCACHE is read-only)
/.gocache/
/.gotmp/
/.docker-config/
/.vision/
/.vision/
+18 -1
View File
@@ -1,3 +1,13 @@
# --- Web build stage (browser-edge SPA, contract §10.2) ---
# Produces /web/dist, copied into the Go build context as web/dist so the
# //go:embed all:web/dist directive in src/web_assets.go picks it up.
FROM oven/bun:1.3.9-alpine AS web-builder
WORKDIR /web
COPY web/package.json web/bun.lock ./
RUN bun ci
COPY web/ ./
RUN bun run build
# --- Build Stage ---
FROM golang:1.26 AS builder
WORKDIR /app
@@ -13,8 +23,11 @@ COPY src/go.mod src/go.sum ./
RUN go mod download
COPY src/ ./
COPY --from=web-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -o server main.go
# Build the whole main package (not just main.go): the browser-edge SPA embed
# lives in web_assets.go, so a single-file build would not see embeddedWeb.
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# --- Run Stage ---
FROM alpine:latest
@@ -30,4 +43,8 @@ LABEL org.opencontainers.image.revision=$IMAGE_REVISION
COPY --from=builder /app/server .
# Contract rev 9 §3.3: exactly ONE plaintext listener (:8080) serves the API
# and the embedded browser web app (SPA + /web/*). No second port, no PKI.
EXPOSE 8080
CMD ./server
+18 -11
View File
@@ -44,12 +44,10 @@ services:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- /app/src/tmp # Exclude Air's tmp dir from syncing
# mTLS identity for the auth edge: the certs Maestro issued for the dev
# fleet (devboot, DCNTRLIZED_DEV=true) at the shared-module default paths,
# so the authproxy client authenticates to the mTLS auth hub.
- ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/ca.crt:/dcntrlized/pki/ca.crt:ro
- ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/services/keepnote/tls.crt:/dcntrlized/pki/tls.crt:ro
- ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/services/keepnote/tls.key:/dcntrlized/pki/tls.key:ro
# Browser-edge SPA overlay (contract §10.3): host `bun run build` output is
# served without a Go rebuild; falls back to the embedded placeholder when
# absent. DCNTRLIZED_WEB_DIR is honoured only in dev/debug mode.
- ./web/dist:/web-dist:ro
working_dir: /app/
@@ -57,17 +55,26 @@ services:
environment:
# DCNTRLIZED_DEV is the single dev selector (registry/auth URLs).
- DCNTRLIZED_DEV=true
# Security theme M2 §2: keep THIS listener plaintext so the RN client
# (direct mode) and hot reload work without certs. The auth proxy edge
# is still mTLS (mounted identity above).
# Contract rev 9: internal traffic is plaintext, so the debug selector no
# longer gates any listener — this listener is ALWAYS plaintext now.
- DCNTRLIZED_DEBUG=true
# Browser edge (contract rev 9 §17.2): the SPA and /web/* ride the single
# :8080 port, served from the host-built overlay; dev is HTTP so
# COOKIE_SECURE=false.
- DCNTRLIZED_WEB_ENABLED=true
- DCNTRLIZED_WEB_DIR=/web-dist
- COOKIE_SECURE=false
# Hub base URL for the plaintext internal hop (rev 9: no mTLS identity).
# Overridable so a throwaway hub can be exercised without touching the
# shared dev stack.
- AUTH_SERVICE_URL=${AUTH_SERVICE_URL:-http://dcntrlized-auth:8080}
# Application configuration
# - PORT=8080
# - ENVIRONMENT=development
# - LOG_LEVEL=debug
# Database connection (shared database on the dcntrlized network)
- DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized
- DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
# Go specific settings
- GO111MODULE=on
@@ -102,7 +109,7 @@ services:
- PGHOST=dcntrlized-db
- PGPORT=5432
- PGUSER=postgres
- PGPASSWORD=
- PGPASSWORD=dcntrlized
- PGDATABASE=dcntrlized
command: >
sh -c 'until pg_isready -h dcntrlized-db -U postgres -d dcntrlized; do sleep 2; done;
+10 -2
View File
@@ -9,9 +9,17 @@ services:
ports:
- "8081:8080"
- "9090:90"
# Contract rev 9 §3.4: ONE plaintext listener, published as 8081:8080. The
# browser web app (SPA + /web/*) rides the same port — there is no separate
# browser port. Opt-in in prod via DCNTRLIZED_WEB_ENABLED=true;
# web.Validate() then fails closed unless DCNTRLIZED_WEB_ORIGINS is
# non-empty and COOKIE_SECURE=true.
environment:
- DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized
- DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
- DCNTRLIZED_WEB_ENABLED=${DCNTRLIZED_WEB_ENABLED:-false}
- DCNTRLIZED_WEB_ORIGINS=${DCNTRLIZED_WEB_ORIGINS:-}
- COOKIE_SECURE=${COOKIE_SECURE:-true}
depends_on:
- migrate
build:
@@ -31,7 +39,7 @@ services:
- PGHOST=dcntrlized-db
- PGPORT=5432
- PGUSER=postgres
- PGPASSWORD=
- PGPASSWORD=dcntrlized
- PGDATABASE=dcntrlized
command: >
sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done;
+48 -3
View File
@@ -2,17 +2,62 @@ package manifest
import (
_ "embed"
"encoding/json"
"net/http"
"git.dcntrlized.org/Dcntrlized/KeepNote/logger"
)
//go:embed manifest.json
var manifest string
var rawManifest string
func ManifestHandler(w http.ResponseWriter, r *http.Request) {
// base is the embedded manifest parsed once. The handler merges the dynamic
// `web` capability block onto it per request.
var base = mustParse(rawManifest)
func mustParse(raw string) map[string]json.RawMessage {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &m); err != nil {
// The manifest is an embedded build asset: a malformed one is a
// build-time defect, not a runtime condition to paper over.
panic("manifest: embedded manifest.json is not a JSON object: " + err.Error())
}
return m
}
// WebBlock is the optional top-level Web App capability (contract rev 8.2/9).
// It is announced ONLY when the service actually serves the browser web app
// (honesty rule, fail-closed) and carries the shared single service port.
type WebBlock struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
URL string `json:"url"`
}
// Handler serves /_manifest.
//
// The `web` block is computed per request from the live listener state:
// enabled reports whether the browser surface is mounted, and port is the
// service's single listener port (the SPA shares it with the API in rev 9, so
// there is no separate DCNTRLIZED_WEB_PORT). When enabled() is false the block
// is omitted entirely — a disabled listener is never announced.
func Handler(enabled func() bool, port int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Debug("Sending manifest file")
out := make(map[string]json.RawMessage, len(base)+1)
for k, v := range base {
out[k] = v
}
if enabled() {
block, err := json.Marshal(WebBlock{Enabled: true, Port: port})
if err != nil {
http.Error(w, "manifest encoding failed", http.StatusInternalServerError)
return
}
out["web"] = block
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(manifest))
_ = json.NewEncoder(w).Encode(out)
}
}
+70
View File
@@ -0,0 +1,70 @@
package manifest
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Contract rev 8.2/9: the `web` block is announced ONLY when the browser
// surface is actually mounted, and it carries the shared single service port.
func TestManifestWebBlockHonestyRule(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
want bool
}{
{"enabled", true, true},
{"disabled", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
Handler(func() bool { return tc.enabled }, 8080)(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", got)
}
var body map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("manifest is not a JSON object: %v (%s)", err, rec.Body.String())
}
raw, ok := body["web"]
if ok != tc.want {
t.Fatalf("web block present = %v, want %v", ok, tc.want)
}
if !tc.want {
return
}
var block WebBlock
if err := json.Unmarshal(raw, &block); err != nil {
t.Fatalf("web block: %v", err)
}
if !block.Enabled {
t.Fatalf("web.enabled = false, want true")
}
if block.Port != 8080 {
t.Fatalf("web.port = %d, want 8080 (the single service port)", block.Port)
}
})
}
}
// The dynamic block is merged onto the embedded manifest without dropping any
// static capability.
func TestManifestKeepsStaticFields(t *testing.T) {
rec := httptest.NewRecorder()
Handler(func() bool { return true }, 8080)(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
var body map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
for _, key := range []string{"name", "has_auth", "capabilities"} {
if _, ok := body[key]; !ok {
t.Fatalf("static field %q missing from the served manifest", key)
}
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"net/http"
"strings"
)
// ActionFunc mirrors handlers/actions.ActionActionName<Method>.
type ActionFunc func(w http.ResponseWriter, r *http.Request, params map[string]string)
// Dispatch mirrors the /action/<name> method routing. Wire it to the same
// functions main.go mounts on the API listener.
type Dispatch struct {
Get ActionFunc
Post ActionFunc
Put ActionFunc
Delete ActionFunc
// Allowed is the explicit per-service browser-action allowlist (contract
// §4.4). It is a name set, NOT a denylist: anything absent -> 404, so a
// newly added action stays closed on the browser edge until it is listed.
// The auth family is absent by construction.
Allowed map[string]bool
}
// maxActionBody caps /web/action/* request bodies. 4 MiB is above the largest
// documented action payload (Calendar importIcs: 1 MiB decoded ~ 1.4 MiB base64).
const maxActionBody = 4 << 20
func ActionHandler(d Dispatch) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/web/action/")
if name == "" || strings.Contains(name, "/") {
writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
return
}
if !d.Allowed[name] { // explicit allowlist (RC2); no name, no route
writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
return
}
if _, ok := principal(w, r); !ok {
return
}
r2 := r.Clone(r.Context())
r2.Header = r.Header.Clone()
r2.Header.Del("Cookie")
if c, err := r.Cookie(accessCookie); err == nil {
r2.Header.Set("X-Access-Token", c.Value) // overwrite any browser value
}
if r.Body != nil { // RC7: bounded memory; decode failures stay 400s
r2.Body = http.MaxBytesReader(w, r.Body, maxActionBody)
}
params := map[string]string{"actionName": name}
switch r.Method {
case http.MethodGet:
d.Get(w, r2, params)
case http.MethodPost:
d.Post(w, r2, params)
case http.MethodPut:
d.Put(w, r2, params)
case http.MethodDelete:
d.Delete(w, r2, params)
default:
w.Header().Set("Allow", "GET, POST, PUT, DELETE")
writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
}
}
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"encoding/json"
"io"
"net/http"
"strings"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
)
const maxAuthBody = 8 << 10 // 8 KiB
type hubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}
type hubAuthResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
User hubUser `json:"user"`
}
func userResponse(u hubUser) map[string]any { return map[string]any{"user": u} }
func LoginHandler() http.HandlerFunc { return authProxyHandler("login") }
func RegisterHandler() http.HandlerFunc { return authProxyHandler("register") }
func authProxyHandler(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodPost) {
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxAuthBody))
if err != nil || !json.Valid(raw) {
writeCode(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
return
}
status, body, err := callHub(r.Context(), http.MethodPost, path, json.RawMessage(raw))
if err != nil {
writeCode(w, http.StatusBadGateway, "ERR_INTERNAL")
return
}
if status != http.StatusOK {
relay(w, status, body)
return
}
var ar hubAuthResponse
if err := json.Unmarshal(body, &ar); err != nil || ar.AccessToken == "" || ar.RefreshToken == "" {
writeCode(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
setSessionCookies(w, ar.AccessToken, ar.RefreshToken)
writeJSON(w, http.StatusOK, userResponse(ar.User))
}
}
func RefreshHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodPost) {
return
}
c, err := r.Cookie(refreshCookie)
if err != nil || c.Value == "" {
clearSessionCookies(w)
writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return
}
status, body, err := callHub(r.Context(), http.MethodPost, "refresh",
map[string]string{"refreshToken": c.Value})
if err != nil {
writeCode(w, http.StatusBadGateway, "ERR_INTERNAL")
return
}
if status != http.StatusOK {
clearSessionCookies(w)
relay(w, status, body)
return
}
var ar hubAuthResponse
if err := json.Unmarshal(body, &ar); err != nil || ar.AccessToken == "" || ar.RefreshToken == "" {
clearSessionCookies(w)
writeCode(w, http.StatusInternalServerError, "ERR_INTERNAL")
return
}
setSessionCookies(w, ar.AccessToken, ar.RefreshToken)
writeJSON(w, http.StatusOK, userResponse(ar.User))
}
}
func LogoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodPost) {
return
}
if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" {
_, _, _ = callHub(r.Context(), http.MethodPost, "logout",
map[string]string{"refreshToken": c.Value})
}
clearSessionCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "logged-out"})
}
}
func MeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
p, ok := principal(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, userResponse(hubUser{
ID: p.ID.String(), Username: p.Username, Email: p.Email,
}))
}
}
// principal validates the access cookie with the shared authclient path
// (RS256 + iss + exp + type=access). It writes the uniform 401 itself.
func principal(w http.ResponseWriter, r *http.Request) (*authclient.Principal, bool) {
c, err := r.Cookie(accessCookie)
if err != nil || c.Value == "" {
writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return nil, false
}
// Cheap syntactic gate first: a malformed value must never reach
// authclient's lazy hub key-fetch path (which can cost a 10s outbound
// attempt when the hub is down). This is NOT verification.
if strings.Count(c.Value, ".") != 2 {
writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return nil, false
}
clone := r.Clone(r.Context())
clone.Header = r.Header.Clone()
clone.Header.Set("X-Access-Token", c.Value)
p, err := authclient.Authorize(clone)
if err != nil {
writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
return nil, false
}
return p, true
}
func relay(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"errors"
"net/url"
"os"
"strings"
)
const (
EnvEnabled = "DCNTRLIZED_WEB_ENABLED"
EnvOrigins = "DCNTRLIZED_WEB_ORIGINS"
EnvDir = "DCNTRLIZED_WEB_DIR"
EnvCookieSecure = "COOKIE_SECURE"
EnvAccessMaxAge = "DCNTRLIZED_WEB_ACCESS_MAX_AGE"
EnvRefreshMaxAge = "DCNTRLIZED_WEB_REFRESH_MAX_AGE"
DefaultAccessMaxAge = 900
DefaultRefreshMaxAge = 604800
)
// Enabled reports whether the browser surface must be mounted on the service's
// single :8080 listener. Unset/unknown values default to on in dev/debug and
// off in prod (opt-in).
//
// Contract rev 9: there is no separate browser port any more
// (DCNTRLIZED_WEB_PORT is gone) — the SPA and /web/* ride the same listener as
// the API, so this is purely a mount switch.
func Enabled() bool {
switch os.Getenv(EnvEnabled) {
case "true":
return true
case "false":
return false
default:
return devMode()
}
}
// devMode reports the dev/debug selector directly from the environment. It no
// longer depends on the shared tlsutil package: rev 9 removed the transport
// security layer (and with it the reason to consult it here).
func devMode() bool {
return os.Getenv("DCNTRLIZED_DEBUG") == "true" || os.Getenv("DCNTRLIZED_DEV") == "true"
}
// ErrDisabled is returned by Mount when the browser surface is disabled. The
// disabled state is enforced in code, not by caller convention, so a disabled
// edge can never mount a route even if a caller forgets the `if web.Enabled()`
// guard (S31 rewrite: there is no second socket to bind any more).
var ErrDisabled = errors.New("web: browser edge disabled")
// Validate fails closed when the browser edge is enabled outside dev/debug: an
// Origin allowlist is mandatory (DNS-rebinding hardening) and session cookies
// must be Secure (F3 — never rely on the compose default alone). The old "https
// hub base" requirement is gone with the de-mTLS migration.
func Validate() error {
if !Enabled() || devMode() {
return nil
}
if len(origins()) == 0 {
return errors.New("web: DCNTRLIZED_WEB_ORIGINS is required when the browser edge is enabled outside dev/debug")
}
if !cookieSecure() {
return errors.New("web: COOKIE_SECURE=true is required when the browser edge is enabled outside dev/debug")
}
return nil
}
func origins() []string {
raw := os.Getenv(EnvOrigins)
if strings.TrimSpace(raw) == "" {
return nil
}
var out []string
for _, part := range strings.Split(raw, ",") {
if o := normalizeOrigin(part); o != "" {
out = append(out, o)
}
}
return out
}
// normalizeOrigin returns the canonical scheme://host form (lowercase, default
// port stripped) or "" when the entry is unusable.
func normalizeOrigin(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.User != nil {
return ""
}
scheme := strings.ToLower(u.Scheme)
host := strings.ToLower(u.Host)
if (scheme == "https" && strings.HasSuffix(host, ":443")) ||
(scheme == "http" && strings.HasSuffix(host, ":80")) {
host = host[:strings.LastIndex(host, ":")]
}
return scheme + "://" + host
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"net/http"
"os"
"strconv"
)
const (
accessCookie = "dcntrlized_access"
refreshCookie = "dcntrlized_refresh"
cookiePath = "/web"
)
func cookieSecure() bool { return os.Getenv(EnvCookieSecure) == "true" }
func maxAge(env string, def int) int {
if v := os.Getenv(env); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return def
}
func sessionCookie(name, value string, age int) *http.Cookie {
return &http.Cookie{
Name: name, Value: value, Path: cookiePath,
HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: cookieSecure(), MaxAge: age,
}
}
func setSessionCookies(w http.ResponseWriter, access, refresh string) {
http.SetCookie(w, sessionCookie(accessCookie, access, maxAge(EnvAccessMaxAge, DefaultAccessMaxAge)))
http.SetCookie(w, sessionCookie(refreshCookie, refresh, maxAge(EnvRefreshMaxAge, DefaultRefreshMaxAge)))
}
func clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{accessCookie, refreshCookie} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: cookiePath,
HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: cookieSecure(), MaxAge: -1,
})
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
)
const maxHubBody = 1 << 20 // 1 MiB
// hubClient is the outbound hub transport. Contract rev 9 removed the mTLS
// identity layer fleet-wide, so this is a plain HTTP client — no client
// certificate and no custom TLS configuration. The timeout bounds the whole
// hub exchange so a stalled hub cannot pin a browser request.
var hubClient = &http.Client{Timeout: 10 * time.Second}
// callHub performs one fixed hub /auth/* call over the plaintext internal
// network. It deliberately does NOT use authproxy.Proxy: that helper copies
// every inbound header (including Cookie and X-Access-Token) to the hub, which
// is forbidden on the browser edge.
func callHub(ctx context.Context, method, path string, payload any) (int, []byte, error) {
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, authproxy.Target()+"/auth/"+path, body)
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := hubClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(io.LimitReader(resp.Body, maxHubBody))
if err != nil {
return 0, nil, err
}
return resp.StatusCode, out, nil
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"encoding/json"
"net/http"
)
type errorBody struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeCode(w http.ResponseWriter, status int, code string) {
var b errorBody
b.Error.Code = code
writeJSON(w, status, b)
}
func requireMethod(w http.ResponseWriter, r *http.Request, method string) bool {
if r.Method != method {
w.Header().Set("Allow", method)
writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
return false
}
return true
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"io/fs"
"log"
"net/http"
)
// Config wires the browser surface.
type Config struct {
Embedded fs.FS
Dispatch Dispatch
Logger Logger
}
// NewMux builds the exact browser-edge surface. Nothing else is mounted.
func NewMux(cfg Config) (*http.ServeMux, error) {
spa, err := NewSPA(cfg.Embedded)
if err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.HandleFunc("/web/auth/login", LoginHandler())
mux.HandleFunc("/web/auth/register", RegisterHandler())
mux.HandleFunc("/web/auth/refresh", RefreshHandler())
mux.HandleFunc("/web/auth/logout", LogoutHandler())
mux.HandleFunc("/web/auth/me", MeHandler())
mux.HandleFunc("/web/action/", ActionHandler(cfg.Dispatch))
mux.Handle("/", spa)
return mux, nil
}
// Mount registers the browser surface on the service's single listener mux.
//
// Contract rev 9: there is no dedicated browser port and no second socket. The
// SPA and /web/* ride the same :8080 listener as the API, so this only wires
// routes — the caller owns the http.Server and its RC17 timeouts.
//
// The registered chain is AccessLog(APIHeaders(CSRF(mux))). It deliberately
// does NOT include LoggingMiddleware (which logs body=%q, including login
// bodies) or CorsMiddleware (which echoes any Origin and allows
// X-Access-Token) — S48.
//
// Only "/" is registered: the browser mux's own exact /web/auth/* routes win
// over its SPA fallback, and the API mux's "/auth/", "/action/" and
// "/_manifest" patterns are more specific than "/" so they keep routing to the
// API chain. That also preserves the reserved-prefix behaviour for the bare
// "/web" path (JSON 404 from the SPA, not a redirect).
//
// Mount returns ErrDisabled when the edge is off, so a caller can never mount a
// dead app by accident (S31).
func Mount(outer *http.ServeMux, cfg Config) error {
if !Enabled() {
return ErrDisabled
}
if err := Validate(); err != nil {
return err
}
mux, err := NewMux(cfg)
if err != nil {
return err
}
logger := cfg.Logger
if logger == nil {
logger = log.Default()
}
outer.Handle("/", AccessLog(logger, APIHeaders(CSRF(mux))))
return nil
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
func mutating(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
}
return false
}
func originAllowed(r *http.Request) bool {
raw := r.Header.Get("Origin")
if raw == "" {
raw = r.Header.Get("Referer")
}
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.User != nil { // F5: no userinfo form
return false
}
allow := origins()
if len(allow) == 0 {
return strings.EqualFold(u.Host, r.Host)
}
got := normalizeOrigin(u.Scheme + "://" + u.Host)
for _, a := range allow {
if got == a {
return true
}
}
return false
}
// CSRF mirrors Maestro's ERR_CSRF contract (403 + {"error":{"code"}}) but is
// stricter: the browser edge is browser-only, so a missing Origin/Referer is
// refused rather than allowed.
func CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if mutating(r.Method) && !originAllowed(r) {
writeCode(w, http.StatusForbidden, "ERR_CSRF")
return
}
next.ServeHTTP(w, r)
})
}
// APIHeaders hardens every browser-API response (RC1). It is mounted around
// the whole browser mux, so it also covers 401/403/404/405/413 error paths.
// SPA responses are deliberately excluded (they keep their own headers and
// must stay cacheable).
func APIHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/web/") {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
}
next.ServeHTTP(w, r)
})
}
// Logger is satisfied by *log.Logger and by tiny service adapters.
type Logger interface{ Printf(format string, v ...any) }
// safeLogPath neutralises log-injection characters (F2). EscapedPath keeps the
// original percent-encoding, so an encoded %0a/%0d can never become a real
// newline; Quote makes any residual control character explicit and visible.
func safeLogPath(r *http.Request) string {
p := r.URL.EscapedPath()
if p == "" {
p = "/"
}
if len(p) > 256 {
p = p[:256] + "..."
}
return strconv.Quote(p)
}
// AccessLog never logs headers, bodies or query strings.
func AccessLog(logger Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Printf("web %s %s status=%d duration=%s", r.Method, safeLogPath(r), rec.status, time.Since(start))
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"io/fs"
"net/http"
"net/url"
"os"
"path"
"strings"
)
var reserved = []string{"/web/", "/auth/", "/action/", "/config", "/_manifest", "/api/"}
// isReserved reports whether p is, or is below, a reserved prefix. Matching is
// exact or path-boundary only (RC18): /configx, /configuration and /_manifestx
// are NOT reserved (they may be real SPA routes), while /config, /config/...,
// /_manifest, /_manifest/..., /web, /web/..., /auth/..., /action/... and
// /api/... are.
func isReserved(p string) bool {
for _, pre := range reserved {
base := strings.TrimSuffix(pre, "/")
if p == base || strings.HasPrefix(p, base+"/") {
return true
}
}
return false
}
// reservedPath decides whether the request must be answered with the reserved
// JSON 404 instead of the SPA shell. Go decodes the request path once, so a
// double-encoded traversal such as /%252e%252e/config arrives as
// /%2e%2e/config and only becomes /config after a second decode; checking up to
// two extra decodes keeps that (and any undecodable encoding) fail-closed.
func reservedPath(r *http.Request) bool {
if isReserved(path.Clean("/" + r.URL.Path)) {
return true
}
esc := r.URL.EscapedPath()
if esc == "" {
return false
}
for i := 0; i < 2; i++ {
dec, err := url.PathUnescape(esc)
if err != nil {
return true // undecodable encoding: never serve the shell
}
if isReserved(path.Clean("/" + dec)) {
return true
}
esc = dec
}
return false
}
// SPA serves the embedded bundle at web/dist, or the live directory named by
// DCNTRLIZED_WEB_DIR when it exists. The disk overlay is a DEV-ONLY hot-reload
// affordance (F4): outside devMode() it is ignored entirely, so the variable can
// never make the browser listener serve arbitrary container files.
type SPA struct{ fs http.FileSystem }
func NewSPA(embedded fs.FS) (*SPA, error) {
if devMode() {
if dir := strings.TrimSpace(os.Getenv(EnvDir)); dir != "" {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return &SPA{fs: http.Dir(dir)}, nil
}
}
}
sub, err := fs.Sub(embedded, "web/dist")
if err != nil {
return nil, err
}
return &SPA{fs: http.FS(sub)}, nil
}
func securityHeaders(w http.ResponseWriter) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "+
"base-uri 'none'; form-action 'self'")
}
func (s *SPA) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
return
}
if reservedPath(r) {
writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
return
}
upath := path.Clean("/" + r.URL.Path)
securityHeaders(w)
if f, err := s.fs.Open(upath); err == nil {
st, serr := f.Stat()
if serr == nil && !st.IsDir() {
if strings.HasPrefix(upath, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, path.Base(upath), st.ModTime(), f)
_ = f.Close()
return
}
_ = f.Close()
}
s.serveIndex(w, r)
}
func (s *SPA) serveIndex(w http.ResponseWriter, r *http.Request) {
f, err := s.fs.Open("/index.html")
if err != nil {
writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() {
writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "index.html", st.ModTime(), f)
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package web
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
// prodEnv clears the dev selectors so the fail-closed prod branches run.
func prodEnv(t *testing.T) {
t.Helper()
t.Setenv(EnvEnabled, "true")
t.Setenv("DCNTRLIZED_DEBUG", "")
t.Setenv("DCNTRLIZED_DEV", "")
}
// S31 (RC10/F1): Mount refuses to register the browser surface when the edge is
// disabled, even if the caller forgot the `if web.Enabled()` guard. Rev 9 has no
// second socket to bind — the guard is now a mount guard.
func TestMountRefusesWhenDisabled(t *testing.T) {
t.Setenv(EnvEnabled, "false")
outer := http.NewServeMux()
err := Mount(outer, Config{Embedded: fstest.MapFS{}})
if !errors.Is(err, ErrDisabled) {
t.Fatalf("Mount error = %v, want ErrDisabled", err)
}
rec := httptest.NewRecorder()
outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/notes", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("disabled edge served %d on /notes, want 404 (nothing mounted)", rec.Code)
}
}
// S23/RC6 + S33 (RC12/F3): outside dev/debug the edge requires an Origin
// allowlist and COOKIE_SECURE=true. The old https-hub requirement is gone with
// the fleet-wide de-mTLS migration (rev 9).
func TestValidateProdFailClosed(t *testing.T) {
prodEnv(t)
t.Setenv(EnvOrigins, "")
t.Setenv(EnvCookieSecure, "")
if err := Validate(); err == nil || !strings.Contains(err.Error(), EnvOrigins) {
t.Fatalf("Validate without origins = %v, want origins error", err)
}
t.Setenv(EnvOrigins, "https://notes.example.org")
t.Setenv(EnvCookieSecure, "false")
if err := Validate(); err == nil || !strings.Contains(err.Error(), EnvCookieSecure) {
t.Fatalf("Validate without COOKIE_SECURE = %v, want cookie error", err)
}
t.Setenv(EnvCookieSecure, "true")
if err := Validate(); err != nil {
t.Fatalf("Validate with full prod config = %v, want nil", err)
}
}
// S34 (RC13/F4): DCNTRLIZED_WEB_DIR is a dev-only overlay; in prod the
// embedded FS is always used.
func TestSPADirIsDevOnly(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("OVERLAY"), 0o600); err != nil {
t.Fatal(err)
}
embedded := fstest.MapFS{"web/dist/index.html": {Data: []byte("EMBEDDED")}}
prodEnv(t)
t.Setenv(EnvDir, dir)
spa, err := NewSPA(embedded)
if err != nil {
t.Fatal(err)
}
if body := getBody(t, spa, "/"); !strings.Contains(body, "EMBEDDED") || strings.Contains(body, "OVERLAY") {
t.Fatalf("prod SPA body = %q, want embedded content only", body)
}
t.Setenv("DCNTRLIZED_DEBUG", "true")
spa, err = NewSPA(embedded)
if err != nil {
t.Fatal(err)
}
if body := getBody(t, spa, "/"); !strings.Contains(body, "OVERLAY") {
t.Fatalf("debug SPA body = %q, want disk overlay", body)
}
}
// S28 (RC2): the action allowlist is fail-closed and is checked before the
// session, so unknown and auth-family names are 404 even without a cookie.
func TestActionAllowlistIsFailClosed(t *testing.T) {
reached := false
handler := ActionHandler(Dispatch{
Get: func(http.ResponseWriter, *http.Request, map[string]string) { reached = true },
Allowed: map[string]bool{"getNotes": true},
})
for _, name := range []string{"login", "register", "refresh", "logout", "delete", "me", "nope"} {
reached = false
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/web/action/"+name, nil))
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "ERR_NOT_FOUND") {
t.Fatalf("action %q = %d %s, want 404 ERR_NOT_FOUND", name, rec.Code, rec.Body.String())
}
if reached {
t.Fatalf("action %q reached the dispatch despite the allowlist", name)
}
}
}
// S29 (RC1): every /web/* response carries no-store + nosniff, including
// error paths produced before the handler runs.
func TestAPIHeadersOnErrorPaths(t *testing.T) {
mux, err := NewMux(Config{Embedded: fstest.MapFS{"web/dist/index.html": {Data: []byte("x")}}})
if err != nil {
t.Fatal(err)
}
h := APIHeaders(CSRF(mux))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/web/auth/login", strings.NewReader("{}"))
req.Header.Set("Origin", "https://evil.example")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("foreign-origin login = %d, want 403", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}
// S48 (rev 9): the browser chain never inherits CorsMiddleware. A mutating
// browser request must not receive any Access-Control-* header, whatever the
// Origin, and a foreign Origin must still be refused by CSRF.
func TestBrowserChainHasNoCORS(t *testing.T) {
t.Setenv(EnvEnabled, "true")
t.Setenv("DCNTRLIZED_DEBUG", "true")
outer := http.NewServeMux()
if err := Mount(outer, Config{Embedded: fstest.MapFS{"web/dist/index.html": {Data: []byte("x")}}}); err != nil {
t.Fatal(err)
}
for _, origin := range []string{"https://evil.example", "http://localhost:8081"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/web/auth/login", strings.NewReader("{}"))
req.Header.Set("Origin", origin)
outer.ServeHTTP(rec, req)
for name := range rec.Header() {
if strings.HasPrefix(http.CanonicalHeaderKey(name), "Access-Control-") {
t.Fatalf("browser chain emitted %s for Origin %q (CorsMiddleware leaked)", name, origin)
}
}
}
// The SPA shell itself carries no CORS header either.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/notes", nil)
req.Header.Set("Origin", "https://evil.example")
outer.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("SPA deep link = %d, want 200", rec.Code)
}
if h := rec.Header().Get("Access-Control-Allow-Origin"); h != "" {
t.Fatalf("SPA response echoed Origin %q", h)
}
}
// S32 (RC11/F2): log paths are quoted and control characters never survive.
func TestSafeLogPathNoInjection(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/notes%0aforge", nil)
got := safeLogPath(req)
if strings.ContainsAny(got, "\n\r") {
t.Fatalf("safeLogPath = %q contains a raw control character", got)
}
if !strings.Contains(got, `%0a`) && !strings.Contains(got, `\n`) {
t.Fatalf("safeLogPath = %q, want the encoded newline preserved/quoted", got)
}
if long := safeLogPath(httptest.NewRequest(http.MethodGet, "/"+strings.Repeat("a", 400), nil)); len(long) > 300 {
t.Fatalf("safeLogPath did not cap length: %d", len(long))
}
}
// RC18: reserved matching is exact or path-boundary, and a double-encoded
// traversal still lands on the reserved JSON 404.
func TestReservedBoundaryAndDoubleEncoding(t *testing.T) {
reserved := []string{"/web", "/web/x", "/config", "/config/x", "/_manifest", "/api/v1"}
for _, p := range reserved {
if !isReserved(p) {
t.Fatalf("isReserved(%q) = false, want true", p)
}
}
allowed := []string{"/configx", "/configuration", "/_manifestx", "/webx", "/apiary", "/notes"}
for _, p := range allowed {
if isReserved(p) {
t.Fatalf("isReserved(%q) = true, want false (boundary matching)", p)
}
}
for _, raw := range []string{"/%252e%252e/config", "/%2e%2e/config"} {
req := httptest.NewRequest(http.MethodGet, raw, nil)
if !reservedPath(req) {
t.Fatalf("reservedPath(%q) = false, want true", raw)
}
}
}
// SPA fallback: an existing asset is served, an unknown route falls back to
// index.html, and a reserved prefix never returns HTML.
func TestSPAFallbackAndReserved(t *testing.T) {
embedded := fstest.MapFS{
"web/dist/index.html": {Data: []byte("SHELL")},
"web/dist/assets/app.js": {Data: []byte("JS")},
}
spa, err := NewSPA(embedded)
if err != nil {
t.Fatal(err)
}
if body := getBody(t, spa, "/notes/abc"); body != "SHELL" {
t.Fatalf("fallback body = %q, want SHELL", body)
}
if body := getBody(t, spa, "/assets/app.js"); body != "JS" {
t.Fatalf("asset body = %q, want JS", body)
}
rec := httptest.NewRecorder()
spa.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "ERR_NOT_FOUND") {
t.Fatalf("/config on SPA = %d %s, want 404 JSON", rec.Code, rec.Body.String())
}
}
func getBody(t *testing.T, h http.Handler, target string) string {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", target, rec.Code)
}
return rec.Body.String()
}
+77 -42
View File
@@ -2,25 +2,36 @@ package main
import (
"context"
"crypto/tls"
"net"
"log"
"net/http"
"os"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/KeepNote/db"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/actions"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/auth"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/manifest"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/web"
"git.dcntrlized.org/Dcntrlized/KeepNote/logger"
"git.dcntrlized.org/Dcntrlized/KeepNote/middleware"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/tlsutil"
)
func main() {
// servicePort is the service's ONLY listening port (contract rev 9 §3.3). The
// API and the browser web app (SPA + /web/*) share it; there is no second
// listener, no DCNTRLIZED_WEB_PORT and no peer edge.
const servicePort = "8080"
// browserActions is the explicit browser-edge allowlist (contract §4.4).
// KeepNote; Calendar uses its own §4.4 list. Unknown names -> 404.
var browserActions = map[string]bool{
"getNotes": true, "getNote": true,
"writeNote": true, "updateNote": true, "deleteNote": true,
}
func main() {
db := db.NewDB()
defer db.Close()
@@ -29,24 +40,30 @@ func main() {
}
logger.Info("Server started")
mux := http.NewServeMux()
mux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) {
// The API surface. It is reached through the API chain
// (LoggingMiddleware(CorsMiddleware(apiMux))), never through the browser
// chain — the browser chain must not log bodies or echo Origins (S48).
apiMux := http.NewServeMux()
apiMux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
manifest.ManifestHandler(w, r)
// Honesty rule (rev 8.2/9): the `web` block is announced only while the
// browser surface is actually mounted, on the shared single port.
manifest.Handler(web.Enabled, 8080)(w, r)
})
mux.HandleFunc("/auth/login", auth.LoginHandler())
mux.HandleFunc("/auth/register", auth.RegisterHandler())
mux.HandleFunc("/auth/logout", auth.LogoutHandler())
mux.HandleFunc("/auth/refresh", auth.RefreshHandler())
mux.HandleFunc("/auth/me", auth.MeHandler())
mux.HandleFunc("/auth/delete", auth.DeleteHandler())
apiMux.HandleFunc("/auth/login", auth.LoginHandler())
apiMux.HandleFunc("/auth/register", auth.RegisterHandler())
apiMux.HandleFunc("/auth/logout", auth.LogoutHandler())
apiMux.HandleFunc("/auth/refresh", auth.RefreshHandler())
apiMux.HandleFunc("/auth/me", auth.MeHandler())
apiMux.HandleFunc("/auth/delete", auth.DeleteHandler())
mux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) {
apiMux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/action/") {
http.NotFound(w, r)
return
@@ -72,38 +89,56 @@ func main() {
}
})
handler := middleware.LoggingMiddleware(middleware.CorsMiddleware(mux))
// The single listener mux. Pattern precedence (Go 1.22 ServeMux) keeps the
// API prefixes off the browser chain:
//
// /_manifest, /auth/, /action/ -> apiChain
// / -> browser chain (SPA + /web/*)
outer := http.NewServeMux()
// Security theme M2 §2: the service's listener requires a CA-issued client
// cert (Maestro's identity) so only Maestro can drive actions (T4) and the
// peer edge is encrypted. DCNTRLIZED_DEBUG=true keeps the debug fleet plaintext.
tlsCfg, err := tlsutil.ServerConfig()
if err != nil {
logger.Error("TLS setup failed: %v", err)
apiChain := middleware.LoggingMiddleware(middleware.CorsMiddleware(apiMux))
outer.Handle("/_manifest", apiChain)
outer.Handle("/auth/", apiChain)
outer.Handle("/action/", apiChain)
// Browser edge (contract rev 9 §17.2): the SPA and /web/* on the SAME
// :8080. DCNTRLIZED_WEB_ENABLED is the mount switch; disabled means no
// browser routes at all (there is no socket to bind any more).
if web.Enabled() {
if err := web.Mount(outer, web.Config{
Embedded: embeddedWeb,
Dispatch: web.Dispatch{
Get: actions.ActionActionNameGet,
Post: actions.ActionActionNamePost,
Put: actions.ActionActionNamePut,
Delete: actions.ActionActionNameDelete,
Allowed: browserActions,
},
Logger: log.New(os.Stdout, "web ", log.LstdFlags|log.LUTC),
}); err != nil {
logger.Error("browser edge mount failed: %v", err)
os.Exit(1)
}
if tlsCfg == nil {
logger.Info("KeepNote listening plaintext on :8080 (DCNTRLIZED_DEBUG dev mode)")
if err := http.ListenAndServe(":8080", handler); err != nil {
logger.Info("browser edge mounted on :%s (SPA + /web/*)", servicePort)
} else {
logger.Info("browser edge disabled (DCNTRLIZED_WEB_ENABLED); SPA routes not mounted")
}
// Contract rev 9 §3.3: ONE plaintext listener per service. No TLS server
// configuration, no client certificates, no second socket. RC17 timeouts
// cover the API and the SPA alike.
server := &http.Server{
Addr: ":" + servicePort,
Handler: outer,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
logger.Info("KeepNote listening plaintext on :%s (single port: API + SPA)", servicePort)
if err := server.ListenAndServe(); err != nil {
logger.Error("HTTP server failed: %v", err)
os.Exit(1)
}
return
}
logger.Info("KeepNote listening on :8080 (mTLS, ClientAuth=RequireAndVerify)")
if err := serveTLS(":8080", tlsCfg, handler); err != nil {
logger.Error("HTTPS server failed: %v", err)
os.Exit(1)
}
}
// serveTLS runs an HTTPS listener with the given mTLS config.
func serveTLS(addr string, cfg *tls.Config, handler http.Handler) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
tlsLn := tls.NewListener(ln, cfg)
return http.Serve(tlsLn, handler)
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KeepNote</title>
</head>
<body>
<!--
Committed placeholder so `go build ./...`, `go vet ./...` and
`go test ./...` work from a clean checkout before the frontend is built.
The real bundle is produced by `bun run build` in KeepNote/web and copied
here (or embedded at image build time); .gitignore ignores web/dist but
keeps this file.
-->
<div id="app"></div>
<p>KeepNote web bundle not built. Run <code>cd web &amp;&amp; bun run build</code>.</p>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import "embed"
//go:embed all:web/dist
var embeddedWeb embed.FS
+11
View File
@@ -0,0 +1,11 @@
# bun install/temp dirs (sandbox: default bun dirs are not writable)
.bun/
.buntmp/
# build output + deps
dist/
node_modules/
# editor/OS
.DS_Store
*.log
+377
View File
@@ -0,0 +1,377 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "keepnote-web",
"dependencies": {
"@preact/signals": "^2.0.1",
"preact": "^10.25.4",
},
"devDependencies": {
"@preact/preset-vite": "^2.9.4",
"@tailwindcss/vite": "^4.0.6",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.3",
"vite": "^6.0.11",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-transform-react-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="],
"@babel/plugin-transform-react-jsx-development": ["@babel/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@preact/preset-vite": ["@preact/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="],
"@preact/signals": ["@preact/[email protected]", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-mttwFM98G/JiriTgMwU+UWygL6sfVmYEiNcPSKkUnv4PxPpZhmP2ugGC4qi360+ZIHyQ8NY05bfJT3b4RUIGlw=="],
"@preact/signals-core": ["@preact/[email protected]", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="],
"@prefresh/babel-plugin": ["@prefresh/[email protected]", "", { "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0" } }, "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA=="],
"@prefresh/core": ["@prefresh/[email protected]", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ=="],
"@prefresh/utils": ["@prefresh/[email protected]", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="],
"@prefresh/vite": ["@prefresh/[email protected]", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="],
"@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A=="],
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"babel-plugin-transform-hook-names": ["[email protected]", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="],
"boolbase": ["[email protected]", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css-select": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["[email protected]", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="],
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="],
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kolorist": ["[email protected]", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"node-html-parser": ["[email protected]", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="],
"node-releases": ["[email protected]", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="],
"nth-check": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
"preact": ["[email protected]", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.5", "@rollup/rollup-android-arm64": "4.64.5", "@rollup/rollup-darwin-arm64": "4.64.5", "@rollup/rollup-darwin-x64": "4.64.5", "@rollup/rollup-freebsd-arm64": "4.64.5", "@rollup/rollup-freebsd-x64": "4.64.5", "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", "@rollup/rollup-linux-arm-musleabihf": "4.64.5", "@rollup/rollup-linux-arm64-gnu": "4.64.5", "@rollup/rollup-linux-arm64-musl": "4.64.5", "@rollup/rollup-linux-loong64-gnu": "4.64.5", "@rollup/rollup-linux-loong64-musl": "4.64.5", "@rollup/rollup-linux-ppc64-gnu": "4.64.5", "@rollup/rollup-linux-ppc64-musl": "4.64.5", "@rollup/rollup-linux-riscv64-gnu": "4.64.5", "@rollup/rollup-linux-riscv64-musl": "4.64.5", "@rollup/rollup-linux-s390x-gnu": "4.64.5", "@rollup/rollup-linux-x64-gnu": "4.64.5", "@rollup/rollup-linux-x64-musl": "4.64.5", "@rollup/rollup-openbsd-x64": "4.64.5", "@rollup/rollup-openharmony-arm64": "4.64.5", "@rollup/rollup-win32-arm64-msvc": "4.64.5", "@rollup/rollup-win32-ia32-msvc": "4.64.5", "@rollup/rollup-win32-x64-gnu": "4.64.5", "@rollup/rollup-win32-x64-msvc": "4.64.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-code-frame": ["[email protected]", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"stack-trace": ["[email protected]", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="],
"tailwindcss": ["[email protected]", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q=="],
"vite-prerender-plugin": ["[email protected]", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zimmerframe": ["[email protected]", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@prefresh/vite/@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@prefresh/vite/@rollup/pluginutils/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="description" content="KeepNote — your notes on Dcntrlized" />
<title>KeepNote</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "keepnote-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview"
},
"dependencies": {
"@preact/signals": "^2.0.1",
"preact": "^10.25.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.9.4",
"@tailwindcss/vite": "^4.0.6",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.3",
"vite": "^6.0.11"
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { ComponentChildren } from 'preact';
interface BannerProps {
kind: 'error' | 'warn' | 'info';
children: ComponentChildren;
role?: 'alert' | 'status';
}
export function Banner({ kind, children, role = 'alert' }: BannerProps) {
return (
<div className={`banner banner-${kind}`} role={role}>
{children}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
interface ConfirmDialogProps {
title: string;
body: ComponentChildren;
confirmLabel: string;
cancelLabel?: string;
busy: boolean;
busyLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
title,
body,
confirmLabel,
cancelLabel = 'Cancel',
busy,
busyLabel,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useRef(`dlg-${Math.random().toString(36).slice(2, 8)}`).current;
const bodyId = `body-${titleId}`;
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// focus the Cancel (default) button on open
cancelRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// simple focus trap
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusables = [cancelRef.current, confirmRef.current].filter(Boolean) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
return (
<div className="scrim">
<div
className="card"
style={{ width: '100%', maxWidth: 400, padding: 24 }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
>
<h2 id={titleId} style={{ margin: '0 0 8px', fontSize: 20, lineHeight: 1.3 }}>
{title}
</h2>
<p id={bodyId} style={{ margin: '0 0 24px', color: 'var(--dn-ink-soft)' }}>
{body}
</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={onCancel}
>
{cancelLabel}
</button>
<button
ref={confirmRef}
type="button"
className="btn btn-danger"
disabled={busy}
onClick={onConfirm}
>
{busy ? (
<>
<span className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{busyLabel ?? confirmLabel}
</>
) : (
confirmLabel
)}
</button>
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { ComponentChildren } from 'preact';
import { Link } from '../router';
import { authState, boot, bootError, cachedProfileUsername } from '../lib/auth';
import { banner, clearBanner, profile } from '../lib/api';
import { pendingCount } from '../state/store';
import type { RouteName } from '../router';
function Wordmark({ compact = false }: { compact?: boolean }) {
return (
<span
className="font-bold"
style={{ fontSize: compact ? 20 : 22, color: 'var(--dn-accent)', letterSpacing: '-0.02em' }}
>
KeepNote
</span>
);
}
function SettingsIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 15a3 3 0 100-6 3 3 0 000 6z"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
function ChevronLeft() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
interface ShellProps {
route: RouteName;
title?: string;
backTo?: string;
children: ComponentChildren;
}
export function Shell({ route, title, backTo, children }: ShellProps) {
const unknown = authState.value === 'unknown';
const tBanner = banner.value;
const pending = pendingCount();
const username = profile.value?.username ?? cachedProfileUsername();
const retryBoot = () => {
clearBanner();
boot();
};
const railActive = (r: RouteName) => (route === r ? 'true' : undefined);
return (
<div className="min-h-screen flex flex-col">
<a href="#main" className="skip-link">
Skip to content
</a>
{unknown && (
<div className="banner banner-error" role="alert">
<span>Can&apos;t reach the server.</span>
<button type="button" className="banner-action" onClick={retryBoot}>
Retry
</button>
</div>
)}
{tBanner && (
<div className={`banner banner-${tBanner.kind}`} role="status">
<span>{tBanner.text}</span>
<button type="button" className="banner-action" onClick={clearBanner}>
Dismiss
</button>
</div>
)}
<div className="flex flex-1 min-h-0">
{/* Persistent left rail (>=1024) */}
<nav className="rail hidden lg:flex" aria-label="Primary">
<div className="px-5 pt-5 pb-4">
<Wordmark />
</div>
<ul className="flex-1 px-3 space-y-1">
<li>
<Link
href="/notes"
aria-current={railActive('/notes')}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium"
style={{ minHeight: 44, color: route === '/notes' ? 'var(--dn-accent)' : 'var(--dn-ink)' }}
>
<span style={{ fontSize: 18 }} aria-hidden="true">
📝
</span>
Notes
</Link>
</li>
<li>
<Link
href="/settings"
aria-current={railActive('/settings')}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium"
style={{ minHeight: 44, color: route === '/settings' ? 'var(--dn-accent)' : 'var(--dn-ink)' }}
>
<SettingsIcon />
Settings
</Link>
</li>
</ul>
<div className="px-5 py-4 border-t" style={{ borderColor: 'var(--dn-line)' }}>
<div className="mono" style={{ color: 'var(--dn-muted)' }}>
{pending > 0 ? `${pending} pending change${pending === 1 ? '' : 's'}` : 'All synced'}
</div>
{username && (
<div className="mono truncate" style={{ color: 'var(--dn-muted)' }} title={username}>
{username}
</div>
)}
</div>
</nav>
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (<1024) */}
<header className="topbar lg:hidden">
{route === '/notes' && (
<>
<span className="flex-1">
<Wordmark compact />
</span>
<Link
href="/settings"
className="icon-btn"
aria-label="Settings"
title="Settings"
>
<SettingsIcon />
</Link>
</>
)}
{route === '/settings' && (
<>
<Link href={backTo ?? '/notes'} className="icon-btn" aria-label="Back">
<ChevronLeft />
</Link>
<span className="flex-1 font-semibold" style={{ fontSize: 20 }}>
{title ?? 'Settings'}
</span>
</>
)}
{(route === '/notes/new' || route === '/notes/:noteId') && (
<>
<Link href="/notes" className="icon-btn" aria-label="Close editor" title="Close editor">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Link>
<span className="flex-1 font-medium truncate px-3" style={{ fontSize: 18 }}>
{title ?? ''}
</span>
</>
)}
</header>
<main id="main" className="flex-1 min-w-0 overflow-y-auto" tabIndex={-1}>
{children}
</main>
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export function Spinner() {
return <span className="spinner" aria-hidden="true" />;
}
export function LoadingRow({ text = 'Loading…' }: { text?: string }) {
return (
<div className="flex items-center gap-3 py-8" role="status">
<Spinner />
<span>{text}</span>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useRef, useState } from 'preact/hooks';
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
}
function normalizeTag(raw: string): string | null {
let t = raw.trim();
if (t.startsWith('#')) t = t.slice(1).trim();
if (!t) return null;
return t.slice(0, 32);
}
export function TagInput({ tags, onChange }: TagInputProps) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const add = (raw: string) => {
const t = normalizeTag(raw);
if (!t) return;
if (!tags.includes(t)) onChange([...tags, t]);
setValue('');
};
const remove = (tag: string) => {
onChange(tags.filter((t) => t !== tag));
inputRef.current?.focus();
};
return (
<div>
<span className="field-label" id="tags-label">
Tags
</span>
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
<button
type="button"
className="tag-remove"
aria-label={`Remove tag ${tag}`}
onClick={() => remove(tag)}
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M3 3l6 6M9 3l-6 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</span>
))}
<input
ref={inputRef}
className="field"
style={{ maxWidth: 200, minWidth: 140, minHeight: 40, padding: '4px 12px' }}
type="text"
value={value}
maxLength={32}
placeholder="# Add tag"
aria-label="Add tag"
aria-labelledby="tags-label"
spellcheck={false}
onInput={(e) => setValue((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
add(value);
} else if (e.key === 'Backspace' && value === '' && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
onBlur={() => {
if (value.trim()) add(value);
}}
/>
</div>
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
@import "tailwindcss";
/* ============================================================================
KeepNote Web — frozen design tokens (planning/web/ux/keepnote.md §6)
The token layer is plain CSS custom properties on :root / [data-theme="dark"].
Tailwind is used only for layout utilities; semantic colours always resolve
through these variables.
========================================================================== */
:root {
color-scheme: light;
/* 6.1 colour tokens — light */
--dn-ground: #e9ecf1;
--dn-surface: #f7f8fa;
--dn-panel: #ffffff;
--dn-ink: #171a21;
--dn-ink-soft: #333b47;
--dn-muted: #5a6473;
--dn-line: #e0e4ea;
--dn-line-strong: #c3cad6;
--dn-accent: #b00b69;
--dn-accent-ink: #ffffff;
--dn-accent-on-soft: #b00b69;
--dn-accent-soft: rgba(176, 11, 105, 0.1);
--dn-ok: #1f7a4d;
--dn-warn: #96580a;
--dn-warn-soft: rgba(150, 88, 10, 0.12);
--dn-bad: #b3261e;
--dn-bad-ink: #ffffff;
--dn-bad-soft: rgba(179, 38, 30, 0.1);
/* 6.3 spacing rhythm */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* 6.3 radius */
--radius-control: 8px;
--radius-card: 12px;
--radius-sheet: 18px;
--radius-pill: 9999px;
/* 6.3 elevation / scrim */
--shadow-card: 0 0 0 1px var(--dn-line);
--shadow-lift: 0 2px 8px rgba(23, 26, 33, 0.14);
--scrim: rgba(23, 26, 33, 0.5);
}
[data-theme="dark"] {
color-scheme: dark;
--dn-ground: #101318;
--dn-surface: #171b22;
--dn-panel: #1f242d;
--dn-ink: #e8ecf2;
--dn-ink-soft: #c3cbd6;
--dn-muted: #98a2b3;
--dn-line: #2c333e;
--dn-line-strong: #4a5364;
--dn-accent: #e556a4;
--dn-accent-ink: #171a21;
--dn-accent-on-soft: #f08bc4;
--dn-accent-soft: rgba(229, 86, 164, 0.16);
--dn-ok: #41c98a;
--dn-warn: #e3a63c;
--dn-warn-soft: rgba(227, 166, 60, 0.14);
--dn-bad: #f2807a;
--dn-bad-ink: #171a21;
--dn-bad-soft: rgba(242, 128, 122, 0.14);
--shadow-card: 0 0 0 1px var(--dn-line);
--shadow-lift: 0 2px 8px rgba(0, 0, 0, 0.5);
--scrim: rgba(0, 0, 0, 0.6);
}
/* prefers-contrast: more — swap line/muted tokens (6.2/6.3) */
@media (prefers-contrast: more) {
:root {
--dn-line: var(--dn-line-strong);
--dn-muted: var(--dn-ink-soft);
}
[data-theme="dark"] {
--dn-line: var(--dn-line-strong);
--dn-muted: var(--dn-ink-soft);
}
}
/* prefers-reduced-motion — remove non-essential transitions (6.3) */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
/* ---------------------------------------------------------------------------
Base
------------------------------------------------------------------------ */
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: var(--dn-ground);
color: var(--dn-ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.466;
-webkit-font-smoothing: antialiased;
}
/* Semantic aliases used across components */
.dn-surface {
background: var(--dn-surface);
color: var(--dn-ink);
}
.dn-panel {
background: var(--dn-panel);
color: var(--dn-ink);
}
.dn-accent-bg {
background: var(--dn-accent);
color: var(--dn-accent-ink);
}
.dn-bad-bg {
background: var(--dn-bad);
color: var(--dn-bad-ink);
}
.dn-chip {
background: var(--dn-accent-soft);
color: var(--dn-accent-on-soft);
}
.dn-warn-chip {
background: var(--dn-warn-soft);
color: var(--dn-ink);
}
/* ---------------------------------------------------------------------------
Focus ring: 2px accent, 2px offset, :focus-visible only
------------------------------------------------------------------------ */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--dn-accent);
outline-offset: 2px;
}
/* Skip-to-content link — first tab stop */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: 8px 12px;
background: var(--dn-accent);
color: var(--dn-accent-ink);
border-radius: 0 0 8px 0;
font-weight: 600;
}
.skip-link:focus {
left: 0;
}
/* Visually hidden (a11y helper) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------------------------------------------------------------------------
Buttons / inputs
------------------------------------------------------------------------ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-control);
font-size: 15px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
transition: background 150ms ease-out, color 150ms ease-out;
user-select: none;
}
.btn:disabled {
opacity: 0.6;
cursor: default;
}
.btn-primary {
background: var(--dn-accent);
color: var(--dn-accent-ink);
}
.btn-primary:not(:disabled):hover {
filter: brightness(1.04);
}
.btn-danger {
background: var(--dn-bad);
color: var(--dn-bad-ink);
}
.btn-danger:not(:disabled):hover {
filter: brightness(1.04);
}
.btn-ghost {
background: transparent;
color: var(--dn-ink);
border-color: var(--dn-line);
}
.btn-ghost:not(:disabled):hover {
background: var(--dn-surface);
}
.btn-link {
background: transparent;
color: var(--dn-accent);
min-height: 44px;
padding: 0 8px;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
}
/* Icon buttons ≥ 44×44 */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-control);
border: none;
background: transparent;
color: var(--dn-ink);
cursor: pointer;
transition: background 150ms ease-out;
}
.icon-btn:hover {
background: var(--dn-surface);
}
.icon-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* Text fields */
.field {
width: 100%;
min-height: 44px;
padding: 8px 12px;
background: var(--dn-surface);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-control);
font-size: 15px;
line-height: 1.4;
transition: border-color 150ms ease-out;
}
.field::placeholder {
color: var(--dn-muted);
}
.field:focus-visible {
border-color: var(--dn-accent);
}
.field-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--dn-muted);
}
/* ---------------------------------------------------------------------------
Cards / panels
------------------------------------------------------------------------ */
.card {
background: var(--dn-panel);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.note-card {
display: block;
padding: 16px;
background: var(--dn-panel);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-card);
text-decoration: none;
transition: border-color 150ms ease-out;
}
.note-card:hover {
border-color: var(--dn-accent);
}
/* ---------------------------------------------------------------------------
Banners
------------------------------------------------------------------------ */
.banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
font-size: 13px;
line-height: 1.4;
}
.banner-error {
background: var(--dn-bad-soft);
color: var(--dn-ink);
}
.banner-warn {
background: var(--dn-warn-soft);
color: var(--dn-ink);
}
.banner-info {
background: var(--dn-surface);
color: var(--dn-ink);
border-bottom: 1px solid var(--dn-line);
}
.banner .banner-action {
margin-left: auto;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-control);
background: transparent;
color: var(--dn-accent);
font-weight: 600;
border: none;
cursor: pointer;
}
.banner .banner-action:hover {
text-decoration: underline;
}
/* ---------------------------------------------------------------------------
Spinner
------------------------------------------------------------------------ */
.spinner {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--dn-line);
border-top-color: var(--dn-accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------------------------------------------------------------------------
Login card
------------------------------------------------------------------------ */
.logo-tile {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--dn-accent);
color: var(--dn-accent-ink);
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------------------
Tags / chips
------------------------------------------------------------------------ */
.tag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--dn-accent-soft);
color: var(--dn-accent-on-soft);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.tag-chip .tag-remove {
width: 20px;
height: 20px;
flex: none;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
}
/* Mono origin / ids */
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
/* Rail — persistent only at >=1024px (UX §3.3). These rules are unlayered, so
they outrank Tailwind's layered `hidden lg:flex`; the breakpoint lives here
to keep one source of truth. */
.rail {
width: 240px;
flex: none;
display: none;
flex-direction: column;
border-right: 1px solid var(--dn-line);
background: var(--dn-surface);
}
@media (min-width: 1024px) {
.rail {
display: flex;
}
}
/* Top bar — compact/tablet only, hidden once the rail appears. */
.topbar {
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--dn-line);
background: var(--dn-surface);
}
@media (min-width: 1024px) {
.topbar {
display: none;
}
}
/* FAB */
.fab {
position: fixed;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
border-radius: var(--radius-pill);
background: var(--dn-accent);
color: var(--dn-accent-ink);
border: none;
box-shadow: var(--shadow-lift);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.fab:hover {
filter: brightness(1.04);
}
/* Switch */
.switch {
position: relative;
width: 48px;
height: 28px;
border-radius: var(--radius-pill);
border: 1px solid var(--dn-line);
background: var(--dn-surface);
cursor: pointer;
flex: none;
transition: background 150ms ease-out;
}
.switch[aria-checked="true"] {
background: var(--dn-accent);
border-color: var(--dn-accent);
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--dn-ink);
transition: transform 150ms ease-out;
}
.switch[aria-checked="true"] .knob {
transform: translateX(20px);
background: var(--dn-accent-ink);
}
/* Dialog scrim */
.scrim {
position: fixed;
inset: 0;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 50;
}
+239
View File
@@ -0,0 +1,239 @@
// Global fetch wrapper for the frozen cookie edge (UX §4.6).
// - same-origin relative /web/* URLs only; never sends an auth header
// - on 401 from /web/action/* or /web/auth/me: ONE shared (single-flight)
// bodyless POST /web/auth/refresh, then retry the original once
// - refresh 401 (ERR_REFRESH_*) -> session cleared -> /login?next=...
// - refresh 502 -> keep session, transient banner (no retry loop, no redirect)
// - error extraction: JSON error.message -> code map -> plain-text map ->
// "Something went wrong (HTTP <status>)"
import { signal } from '@preact/signals';
import type { User } from '../types';
import { KEYS, readJSON, writeJSON } from './storage';
// --- Session / profile signals -------------------------------------------------
export const profile = signal<{ username: string; email: string } | null>(null);
// Set when the session is cleared mid-session so the app can redirect to login.
export const sessionCleared = signal(false);
// --- Transient connection banner ------------------------------------------------
export type BannerKind = 'error' | 'warn' | 'info';
export const banner = signal<{ kind: BannerKind; text: string } | null>(null);
let bannerTimer: number | undefined;
export function showBanner(kind: BannerKind, text: string, autoClearMs = 6000): void {
banner.value = { kind, text };
if (bannerTimer) window.clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => {
if (banner.value?.text === text) banner.value = null;
}, autoClearMs);
}
export function clearBanner(): void {
banner.value = null;
}
// --- ApiError --------------------------------------------------------------------
export class ApiError extends Error {
readonly status: number | null;
readonly code: string | null;
readonly offline: boolean;
constructor(status: number | null, message: string, code?: string | null, offline = false) {
super(message);
this.status = status;
this.code = code ?? null;
this.offline = offline;
}
}
export function isOfflineError(e: unknown): e is ApiError {
return e instanceof ApiError && e.offline;
}
const CODE_MAP: Record<string, string> = {
ERR_CSRF: 'Request blocked — reload the page and try again',
ERR_NOT_FOUND: 'Not found',
ERR_METHOD_NOT_ALLOWED: 'Something went wrong (unsupported request)',
ERR_INVALID_REQUEST: 'The server rejected the request',
ERR_INTERNAL: 'Server error — try again',
ERR_AUTH_FAILED: 'Incorrect email or password',
ERR_EMAIL_TAKEN: 'An account with this email already exists',
ERR_USERNAME_TAKEN: 'That username is taken',
ERR_TOKEN_INVALID: 'Please sign in again',
ERR_TOKEN_EXPIRED: 'Please sign in again',
};
const TEXT_MAP: Record<string, string> = {
'note not found': 'Not found',
'missing or invalid note id': 'Not found',
'invalid payload': 'The server rejected the request',
'action not implemented': 'Something went wrong (unsupported request)',
'method not allowed': 'Something went wrong (unsupported request)',
};
export function profileFromUser(user: User): { username: string; email: string } {
return { username: user.username, email: user.email };
}
export function cacheProfile(profileValue: { username: string; email: string }): void {
writeJSON(KEYS.profile, profileValue);
}
// --- Error extraction --------------------------------------------------------------
export async function toApiError(res: Response): Promise<ApiError> {
const status = res.status;
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
if (body && typeof body === 'object') {
const err = (body as { error?: unknown }).error;
if (err && typeof err === 'object') {
const e = err as { code?: unknown; message?: unknown };
if (typeof e.message === 'string' && e.message.length > 0) {
return new ApiError(status, e.message, typeof e.code === 'string' ? e.code : null);
}
if (typeof e.code === 'string') {
const code = e.code;
const msg =
CODE_MAP[code] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : 'Something went wrong');
return new ApiError(status, msg, code);
}
}
}
// plain text
let text = '';
try {
text = await res.text();
} catch {
text = '';
}
const lower = text.trim().toLowerCase();
const mapped =
TEXT_MAP[lower] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : `Something went wrong (HTTP ${status})`);
return new ApiError(status, mapped);
}
// --- Refresh (single-flight) --------------------------------------------------------
export type RefreshResult = 'ok' | 'cleared' | 'transient';
let refreshInFlight: Promise<RefreshResult> | null = null;
async function doRefresh(): Promise<RefreshResult> {
let res: Response;
try {
res = await fetch('/web/auth/refresh', { method: 'POST' });
} catch {
// network error / offline — keep session, do not clear
return 'transient';
}
if (res.status === 200) {
try {
const data = await res.json();
if (data && data.user) {
const p = profileFromUser(data.user);
profile.value = p;
cacheProfile(p);
}
} catch {
/* ignore body parse */
}
return 'ok';
}
if (res.status === 502) {
showBanner('warn', 'Connection problem — your session is still active.');
return 'transient';
}
// 401 (ERR_REFRESH_* / ERR_UNAUTHENTICATED) or other — cookies already cleared
return 'cleared';
}
export function singleFlightRefresh(): Promise<RefreshResult> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = doRefresh().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
export function handleSessionCleared(nextPath: string): void {
sessionCleared.value = true;
const target = `/login?next=${encodeURIComponent(nextPath)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}
// --- Main fetch wrapper -------------------------------------------------------------
export interface FetchOpts extends RequestInit {
refreshOn401?: boolean;
timeoutMs?: number;
}
export async function apiFetch(path: string, opts: FetchOpts = {}): Promise<Response> {
const { refreshOn401 = true, timeoutMs, ...init } = opts;
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new ApiError(null, 'Network error', null, true);
}
let controller: AbortController | undefined;
let timer: number | undefined;
if (timeoutMs) {
controller = new AbortController();
timer = window.setTimeout(() => controller?.abort(), timeoutMs);
}
let res: Response;
try {
res = await fetch(path, { ...init, signal: controller?.signal });
} catch {
if (controller?.signal.aborted) throw new ApiError(null, 'Request timed out', null, true);
throw new ApiError(null, 'Network error', null, true);
} finally {
if (timer) window.clearTimeout(timer);
}
if (res.status === 401 && refreshOn401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
try {
res = await fetch(path, init);
} catch {
throw new ApiError(null, 'Network error', null, true);
}
} else if (result === 'cleared') {
handleSessionCleared(path);
throw new ApiError(401, 'Please sign in again', 'ERR_UNAUTHENTICATED');
} else {
// transient — keep session, surface banner (already shown by refresh)
throw new ApiError(null, 'Connection problem — your session is still active.', null, true);
}
}
return res;
}
/** Perform a request and throw ApiError on any non-2xx (also used for 204). */
export async function apiRequest<T = Response>(
path: string,
init: RequestInit = {},
opts: FetchOpts = {}
): Promise<T> {
const res = await apiFetch(path, { ...init, ...opts });
if (!res.ok) throw await toApiError(res);
if (res.status === 204 || res.status === 200 && res.headers.get('content-length') === '0') {
return undefined as unknown as T;
}
return (await res.json()) as T;
}
export function jsonBody(body: unknown, method = 'POST'): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
+110
View File
@@ -0,0 +1,110 @@
// Boot / auth gate (UX §3.2).
// GET /web/auth/me (8s) -> 200 authed | 401 -> single-flight refresh -> retry
// refresh 401 -> anonymous -> /login?next= | network/502 -> "unknown" state
// (shell + cached profile + "Can't reach the server." + Retry). Never
// auto-redirect to /login on unknown.
import { signal } from '@preact/signals';
import {
apiFetch,
ApiError,
profile,
profileFromUser,
cacheProfile,
singleFlightRefresh,
} from './api';
import type { AuthState, User } from '../types';
import { KEYS, readJSON, removeKey } from './storage';
export const authState = signal<AuthState>('loading');
// Message shown in the shell banner when state === 'unknown'.
export const bootError = signal<string | null>(null);
// Bump to re-run boot (Retry).
export const bootTick = signal(0);
export function cachedProfileUsername(): string {
const p = readJSON<{ username?: string } | null>(KEYS.profile, null);
return p?.username ?? '';
}
export function setAuthed(user: User): void {
authState.value = 'authed';
const p = profileFromUser(user);
profile.value = p;
cacheProfile(p);
}
export function setAnonymous(): void {
authState.value = 'anonymous';
// web.profile is kept for the boot shell (only logout clears it).
}
export async function boot(): Promise<void> {
authState.value = 'loading';
bootError.value = null;
try {
const res = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res.status === 200) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (res.status === 401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
const res2 = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res2.status === 200) {
const data = (await res2.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (result === 'cleared') {
setAnonymous();
return;
}
// transient
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
// 502 or any other non-200
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
} catch (e) {
// network error / timeout / ApiError -> unknown
if (e instanceof ApiError) {
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
}
}
/** Optimistic sign-out: clear profile, go to /login, fire-and-forget logout. */
export function logout(): void {
removeKey(KEYS.profile);
profile.value = null;
authState.value = 'anonymous';
const next = '/notes';
const target = `/login?next=${encodeURIComponent(next)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
// bodyless fire-and-forget (edge always answers 200)
fetch('/web/auth/logout', { method: 'POST' }).catch(() => {
/* best effort */
});
}
+27
View File
@@ -0,0 +1,27 @@
// Per-route editor drafts (UX §3.2 / O3). Keyed by route ("new" or noteId) in
// web.drafts. Debounced persistence + restore-with-notice on return.
import type { Draft } from '../types';
import { KEYS, readJSON, writeJSON, removeKey } from './storage';
type DraftsMap = Record<string, Draft>;
export function getDraft(key: string): Draft | null {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
return map[key] ?? null;
}
export function saveDraft(key: string, draft: Omit<Draft, 'savedAt'>): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
map[key] = { ...draft, savedAt: Date.now() };
writeJSON(KEYS.drafts, map);
}
export function clearDraft(key: string): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
if (map[key]) {
delete map[key];
writeJSON(KEYS.drafts, map);
}
if (Object.keys(map).length === 0) removeKey(KEYS.drafts);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'preact/hooks';
/** Re-render the caller on an interval (60s tick for relative times). */
export function useNow(intervalMs = 60000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
const onVis = () => {
if (!document.hidden) setNow(Date.now());
};
const onFocus = () => setNow(Date.now());
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onFocus);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('focus', onFocus);
};
}, [intervalMs]);
return now;
}
+48
View File
@@ -0,0 +1,48 @@
// Relative-time formatting, ported verbatim from keepnote-client
// HomeScreen.tsx:15-31: "just now", Nm ago, Nh ago, Nd ago, then "Mon d".
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export function relativeTime(iso: string | undefined, now: number): string {
if (!iso) return '';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '';
const diff = Math.max(0, now - t);
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const d = new Date(t);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
// Whitespace-collapse content and truncate at 140 chars + "…".
export function snippet(content: string): string {
const collapsed = content.replace(/\s+/g, ' ').trim();
if (!collapsed) return '';
if (collapsed.length <= 140) return collapsed;
return collapsed.slice(0, 140) + '…';
}
export function uid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
}
+55
View File
@@ -0,0 +1,55 @@
// localStorage persistence helpers + the frozen key table (UX §3.2).
// Keys: settings.theme, notes.cache, notes.sync.pending, notes.sync.lastSynced,
// web.profile, web.drafts. No tokens are ever stored (HttpOnly cookies only).
export const KEYS = {
theme: 'settings.theme',
cache: 'notes.cache',
pending: 'notes.sync.pending',
lastSynced: 'notes.sync.lastSynced',
profile: 'web.profile',
drafts: 'web.drafts',
} as const;
export function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function writeJSON(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage full/unavailable — keep the app functional in-memory */
}
}
export function readString(key: string, fallback: string): string {
try {
const v = localStorage.getItem(key);
return v == null ? fallback : v;
} catch {
return fallback;
}
}
export function writeString(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
export function removeKey(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
+28
View File
@@ -0,0 +1,28 @@
import { signal } from '@preact/signals';
import { KEYS, readString, writeString } from './storage';
export type Theme = 'light' | 'dark';
export const theme = signal<Theme>('light');
export function initTheme(): void {
const stored = readString(KEYS.theme, 'light');
const t: Theme = stored === 'dark' ? 'dark' : 'light';
theme.value = t;
applyTheme(t);
}
export function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
document.documentElement.style.colorScheme = t;
writeString(KEYS.theme, t);
}
export function setTheme(t: Theme): void {
theme.value = t;
applyTheme(t);
}
export function toggleTheme(): void {
setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
+140
View File
@@ -0,0 +1,140 @@
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
// Design tokens + Tailwind layer. Must be imported for the bundle to emit CSS
// (and for the server's strict CSP to keep style-src 'self' happy).
import './index.css';
import {
currentPath,
currentSearch,
matchRoute,
navigate,
navigateReplace,
} from './router';
import { authState, boot } from './lib/auth';
import { initTheme } from './lib/theme';
import {
loadCache,
initSyncChannel,
initOnlineListeners,
} from './state/store';
import { sessionCleared } from './lib/api';
import { Shell } from './components/Shell';
import { Login } from './screens/Login';
import { NotesList } from './screens/NotesList';
import { Editor } from './screens/Editor';
import { Settings } from './screens/Settings';
import { Spinner } from './components/Spinner';
// Boot once before first render: theme + cached notes + cross-tab wiring,
// then run the auth gate (boot). The boot probe decides session state; it is
// never guessed from a cached value (UX §3.2).
initTheme();
loadCache();
initSyncChannel();
initOnlineListeners();
boot();
function Gate() {
const [, setTick] = useState(0);
const state = authState.value;
// Re-render on auth/session/path changes.
useEffect(() => {
const subs = [
authState.subscribe(() => setTick((t) => t + 1)),
sessionCleared.subscribe(() => setTick((t) => t + 1)),
];
const onPath = () => setTick((t) => t + 1);
window.addEventListener('popstate', onPath);
return () => {
subs.forEach((s) => s());
window.removeEventListener('popstate', onPath);
};
}, []);
const path = currentPath.value;
const search = currentSearch.value;
const route = matchRoute(path);
// Root "/" redirects based on the boot decision (replaces the history entry).
if (route?.name === '/') {
const target = state === 'authed' ? '/notes' : '/login';
navigateReplace(target);
return <LoadingShell />;
}
// Auth-less route: /login (also handles the session-cleared redirect target).
if (route?.name === '/login') {
if (state === 'authed') {
navigate('/notes');
return <LoadingShell />;
}
return <Login />;
}
// Session was cleared mid-session (401 after failed refresh): go to login.
if (sessionCleared.value) {
navigateReplace(`/login?next=${encodeURIComponent(path + search)}`);
return <LoadingShell />;
}
// Everything else is authed. While the boot probe is still in flight, show a
// loading shell — never a premature redirect.
if (state === 'loading') {
return <LoadingShell />;
}
if (state === 'anonymous') {
navigateReplace(`/login?next=${encodeURIComponent(path + search)}`);
return <LoadingShell />;
}
// state === 'authed' | 'unknown' — render the authed shell. 'unknown' renders
// the shell with the offline banner (Shell) and never redirects to /login.
if (!route) {
return (
<Shell route={null}>
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 20, fontWeight: 600, margin: '0 0 8px' }}>Not found</p>
<p style={{ color: 'var(--dn-muted)' }}>This page doesn't exist.</p>
</div>
</Shell>
);
}
const backTo = route.name === '/settings' ? '/notes' : undefined;
return (
<Shell route={route.name} backTo={backTo}>
<RouteView route={route} />
</Shell>
);
}
function RouteView({ route }: { route: NonNullable<ReturnType<typeof matchRoute>> }) {
switch (route.name) {
case '/notes':
return <NotesList />;
case '/notes/new':
return <Editor mode="create" />;
case '/notes/:noteId':
return <Editor mode="edit" noteId={route.params.noteId} />;
case '/settings':
return <Settings />;
default:
return null;
}
}
function LoadingShell() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3" role="status">
<Spinner />
<span>Loading…</span>
</div>
</div>
);
}
render(<Gate />, document.getElementById('app') as HTMLElement);
+112
View File
@@ -0,0 +1,112 @@
// Tiny History-API router (no react-router). Real paths, pushState/replaceState,
// back/forward via popstate.
import { ComponentChildren } from 'preact';
import { signal } from '@preact/signals';
export const currentPath = signal<string>(window.location.pathname);
export const currentSearch = signal<string>(window.location.search);
// True once the app navigated client-side (used to decide /login autofocus —
// only focus on client-side navigation, never on cold page load).
export let didClientNavigate = false;
export type RouteName =
| '/'
| '/login'
| '/notes'
| '/notes/new'
| '/notes/:noteId'
| '/settings'
| null;
export interface Route {
name: Exclude<RouteName, null>;
params: Record<string, string>;
}
const ROUTES: { name: Exclude<RouteName, null>; pattern: string }[] = [
{ name: '/', pattern: '/' },
{ name: '/login', pattern: '/login' },
{ name: '/notes', pattern: '/notes' },
{ name: '/notes/new', pattern: '/notes/new' },
{ name: '/notes/:noteId', pattern: '/notes/:noteId' },
{ name: '/settings', pattern: '/settings' },
];
function compile(pattern: string): { regex: RegExp; keys: string[] } {
const keys: string[] = [];
const src = pattern.replace(/:[^/]+/g, (m) => {
keys.push(m.slice(1));
return '([^/]+)';
});
return { regex: new RegExp('^' + src + '$'), keys };
}
const COMPILED = ROUTES.map((r) => ({ ...r, ...compile(r.pattern) }));
export function matchRoute(path: string): Route | null {
for (const r of COMPILED) {
const m = path.match(r.regex);
if (m) {
const params: Record<string, string> = {};
r.keys.forEach((k, i) => {
params[k] = decodeURIComponent(m[i + 1]);
});
return { name: r.name, params };
}
}
return null;
}
function sync(): void {
currentPath.value = window.location.pathname;
currentSearch.value = window.location.search;
}
export function navigate(path: string): void {
didClientNavigate = true;
window.history.pushState({}, '', path);
sync();
}
export function navigateReplace(path: string): void {
didClientNavigate = true;
window.history.replaceState({}, '', path);
sync();
}
// Only accepts same-origin paths (must start with "/" and not "//").
export function sanitizeNext(next: string | null | undefined): string {
if (next && next.startsWith('/') && !next.startsWith('//')) return next;
return '/notes';
}
export function getQueryParam(search: string, key: string): string | null {
const params = new URLSearchParams(search);
return params.get(key);
}
export interface LinkProps
extends preact.JSX.HTMLAttributes<HTMLAnchorElement> {
href: string;
children?: ComponentChildren;
}
export function Link({ href, children, onClick, ...rest }: LinkProps) {
return (
<a
href={href}
{...rest}
onClick={(e) => {
if (onClick) onClick(e as preact.JSX.TargetedMouseEvent<HTMLAnchorElement>);
if (e.defaultPrevented) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}
+566
View File
@@ -0,0 +1,566 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { navigate } from '../router';
import { ApiError, apiFetch, isOfflineError } from '../lib/api';
import { TagInput } from '../components/TagInput';
import { ConfirmDialog } from '../components/ConfirmDialog';
import { Spinner, LoadingRow } from '../components/Spinner';
import { getDraft, saveDraft, clearDraft } from '../lib/drafts';
import type { Note } from '../types';
import {
findNote,
isOfflineNote,
optimisticInsert,
optimisticUpdate,
optimisticDelete,
enqueueCreate,
enqueueUpdate,
enqueueDelete,
saveCreateOnline,
saveUpdateOnline,
deleteNoteOnline,
createNotice,
} from '../state/store';
interface EditorProps {
mode: 'create' | 'edit';
noteId?: string;
}
type LoadState = 'resolving' | 'ready' | 'not-found' | 'load-error' | 'offline';
export function Editor({ mode, noteId }: EditorProps) {
const isCreate = mode === 'create';
const draftKey = isCreate ? 'new' : noteId ?? '';
const noteIdResolved = useMemo(
() => (isCreate ? null : noteId ?? null),
[isCreate, noteId]
);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [validation, setValidation] = useState(false);
const [loadState, setLoadState] = useState<LoadState>(
isCreate ? 'ready' : 'resolving'
);
const [loadError, setLoadError] = useState<string | null>(null);
const [retryTick, setRetryTick] = useState(0);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [draftRestored, setDraftRestored] = useState(false);
const [isPendingNote, setIsPendingNote] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const localId = () =>
'local-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
// --- Deep-link / edit resolution (UX §4.4) --------------------------------------
useEffect(() => {
if (isCreate) {
setLoadState('ready');
return;
}
const id = noteId ?? '';
// local-… id → cache-only editor; getNote is never called.
if (isOfflineNote(id)) {
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setIsPendingNote(true);
setLoadState('ready');
} else {
setLoadState('offline');
}
return;
}
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setLoadState('ready');
return;
}
// Not cached → deep link: GET /web/action/getNote?id= (via apiFetch so a
// 401 triggers the single-flight refresh + one retry, UX §4.6).
let cancelled = false;
(async () => {
setLoadState('resolving');
setLoadError(null);
try {
const res = await apiFetch(`/web/action/getNote?id=${encodeURIComponent(id)}`, {
refreshOn401: true,
timeoutMs: 8000,
});
if (cancelled) return;
if (res.status === 200) {
const note = await res.json();
note.tags = note.tags ?? [];
if (cancelled) return;
optimisticInsert(note);
setTitle(note.title);
setContent(note.content);
setTags(note.tags);
setLoadState('ready');
} else if (res.status === 400 || res.status === 404) {
// 400 (invalid id) or 404 → not-found, never retried in a loop
setLoadState('not-found');
} else {
setLoadState('load-error');
setLoadError(`Something went wrong (HTTP ${res.status})`);
}
} catch (e) {
if (cancelled) return;
if (e instanceof ApiError && e.status === 401) {
// session cleared → apiFetch already redirected to /login
setLoadState('load-error');
setLoadError('Please sign in again');
} else if (isOfflineError(e)) {
setLoadState('offline');
} else {
setLoadState('load-error');
setLoadError(e instanceof Error ? e.message : 'Something went wrong');
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCreate, noteId, retryTick]);
// Auto-retry a deep-link offline load when the network returns (C16).
useEffect(() => {
if (loadState !== 'offline') return;
const onOnline = () => setRetryTick((t) => t + 1);
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
}, [loadState]);
// --- Restore draft (O3) + focus the title input ----------------------------------
useEffect(() => {
if (loadState !== 'ready') return;
const d = getDraft(draftKey);
if (d && !isPendingNote) {
setTitle(d.title);
setContent(d.content);
setTags(d.tags ?? []);
setDraftRestored(true);
}
titleRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadState]);
// --- Debounced draft persistence + beforeunload (O3) -----------------------------
useEffect(() => {
if (loadState !== 'ready' || !dirty) return;
const id = window.setTimeout(() => {
saveDraft(draftKey, { title, content, tags });
}, 400);
return () => window.clearTimeout(id);
}, [title, content, tags, dirty, loadState, draftKey]);
useEffect(() => {
if (loadState !== 'ready' || !dirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [loadState, dirty]);
// --- Keyboard: Ctrl/Cmd+S to save (unless a dialog is open) ----------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
if (confirmOpen) return;
e.preventDefault();
doSave();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, content, tags, loadState, confirmOpen, saving, noteIdResolved, isPendingNote]);
// Clear any create partial-success notice when opening the editor.
useEffect(() => {
createNotice.value = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const markDirty = (fn: () => void) => {
setDirty(true);
fn();
};
const markSaved = () => {
clearDraft(draftKey);
setDirty(false);
navigate('/notes');
};
const doSave = async () => {
if (saving || loadState !== 'ready') return;
if (!title.trim() && !content.trim()) {
setValidation(true);
return;
}
setValidation(false);
setSaveError(null);
setSaving(true);
try {
const finalTitle = title.trim() || 'Untitled';
if (isCreate) {
// Offline → enqueue ONE folded create op.
if (typeof navigator !== 'undefined' && !navigator.onLine) {
const id = localId();
optimisticInsert({
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: new Date().toISOString(),
});
enqueueCreate(id, { title: finalTitle, content, tags });
markSaved();
return;
}
try {
const { note, partialSuccess } = await saveCreateOnline(finalTitle, content, tags);
if (partialSuccess) {
createNotice.value = { noteId: note.id, noteTitle: finalTitle };
}
markSaved();
} catch (e) {
if (isOfflineError(e)) {
const id = localId();
optimisticInsert({
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: new Date().toISOString(),
});
enqueueCreate(id, { title: finalTitle, content, tags });
markSaved();
return;
}
// create failed non-network → nothing created, editor stays
setSaveError(errMessage(e, 'Failed to save note'));
}
return;
}
// Edit mode
const id = noteIdResolved;
if (!id) {
setSaveError('Failed to save note');
return;
}
const base = findNote(id);
const optimistic: Note = {
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: base?.created ?? new Date().toISOString(),
};
const enqueueEdit = () => enqueueUpdate(id, { title: finalTitle, content, tags });
if (typeof navigator !== 'undefined' && !navigator.onLine) {
optimisticUpdate(optimistic);
enqueueEdit();
markSaved();
return;
}
try {
await saveUpdateOnline({ id, title: finalTitle, content, tags });
markSaved();
} catch (e) {
if (isOfflineError(e)) {
optimisticUpdate(optimistic);
enqueueEdit();
markSaved();
return;
}
// non-network → saveUpdateOnline already reverted the optimistic patch
setSaveError(errMessage(e, 'Failed to save note'));
}
} finally {
setSaving(false);
}
};
const doDelete = async () => {
if (deleting || !noteIdResolved) return;
setDeleting(true);
setDeleteError(null);
try {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
enqueueDelete(noteIdResolved);
optimisticDelete(noteIdResolved);
markSaved();
return;
}
try {
await deleteNoteOnline(noteIdResolved);
} catch (e) {
if (isOfflineError(e)) {
enqueueDelete(noteIdResolved);
optimisticDelete(noteIdResolved);
markSaved();
return;
}
throw e;
}
optimisticDelete(noteIdResolved);
markSaved();
} catch (e) {
// non-network delete error → dialog closed, note intact, banner
setDeleteError(errMessage(e, 'Failed to delete note'));
setConfirmOpen(false);
} finally {
setDeleting(false);
}
};
// --- Render states -----------------------------------------------------------------
if (loadState === 'resolving') {
return (
<div className="max-w-[720px] mx-auto px-4 py-6">
<LoadingRow text="Loading…" />
</div>
);
}
if (loadState === 'not-found') {
return (
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 20, fontWeight: 600, margin: '0 0 8px' }}>Note not found</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 24px' }}>
This note may have been deleted.
</p>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/notes')}>
Back to notes
</button>
</div>
);
}
if (loadState === 'offline') {
return (
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 17, fontWeight: 600, margin: '0 0 8px' }}>
You&apos;re offline — this note isn&apos;t in your saved copy.
</p>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/notes')}>
Back to notes
</button>
</div>
);
}
if (loadState === 'load-error') {
return (
<div className="max-w-[720px] mx-auto px-4 py-6">
<div className="banner banner-error" role="alert">
<span>{loadError ?? 'Something went wrong'}</span>
<button
type="button"
className="banner-action"
onClick={() => {
const id = noteId ?? '';
if (isOfflineNote(id)) return;
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setLoadState('ready');
} else {
setRetryTick((t) => t + 1);
}
}}
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="max-w-[720px] mx-auto px-4 sm:px-6 py-6">
{/* visually hidden h1 */}
<h1 className="sr-only">{isCreate ? 'New note' : 'Edit note'}</h1>
{draftRestored && (
<div className="banner banner-info mb-4" role="status">
<span>Restored your unsaved changes.</span>
<button
type="button"
className="banner-action"
onClick={() => {
clearDraft(draftKey);
setDraftRestored(false);
}}
>
Discard
</button>
</div>
)}
{isPendingNote && (
<div className="banner banner-warn mb-4" role="status">
Pending — not yet synced.
</div>
)}
{saveError && (
<div className="banner banner-error mb-4" role="alert">
<span>{saveError}</span>
<button type="button" className="banner-action" onClick={doSave}>
Retry
</button>
</div>
)}
{deleteError && (
<div className="banner banner-error mb-4" role="alert">
<span>{deleteError}</span>
<button
type="button"
className="banner-action"
onClick={() => {
setDeleteError(null);
setConfirmOpen(true);
}}
>
Retry
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
doSave();
}}
className="flex flex-col gap-5"
>
<div>
<label className="sr-only" htmlFor="ed-title">
Title
</label>
<input
ref={titleRef}
id="ed-title"
className="w-full"
style={{
fontSize: 24,
lineHeight: 1.3,
fontWeight: 700,
color: 'var(--dn-ink)',
background: 'transparent',
border: 'none',
padding: '4px 0',
outline: 'none',
}}
type="text"
value={title}
placeholder="Title"
maxLength={200}
onInput={(e) => markDirty(() => setTitle((e.target as HTMLInputElement).value))}
/>
</div>
<div>
<label className="sr-only" htmlFor="ed-content">
Note content
</label>
<textarea
id="ed-content"
className="w-full"
style={{
minHeight: 280,
fontSize: 15,
lineHeight: 1.5,
color: 'var(--dn-ink)',
background: 'transparent',
border: 'none',
resize: 'vertical',
outline: 'none',
}}
value={content}
placeholder="Write something…"
onInput={(e) => markDirty(() => setContent((e.target as HTMLInputElement).value))}
/>
</div>
<div>
<TagInput tags={tags} onChange={(t) => markDirty(() => setTags(t))} />
</div>
{validation && !title.trim() && !content.trim() && (
<p className="text-[13px]" role="alert" style={{ color: 'var(--dn-bad)', margin: 0 }}>
Enter a title or some text first
</p>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pt-2">
<button
type="submit"
className="btn btn-primary"
disabled={saving}
aria-busy={saving || undefined}
style={{ minWidth: 120 }}
>
{saving && <Spinner />}
{saving ? 'Saving…' : 'Save'}
</button>
{!isCreate && (
<button
type="button"
className="btn btn-danger"
onClick={() => {
setDeleteError(null);
setConfirmOpen(true);
}}
>
Delete note
</button>
)}
</div>
</form>
{confirmOpen && (
<ConfirmDialog
title="Delete note"
body={<>Delete “{title.trim() || 'Untitled'}”? This cannot be undone.</>}
confirmLabel="Delete"
busy={deleting}
busyLabel="Deleting…"
onConfirm={doDelete}
onCancel={() => setConfirmOpen(false)}
/>
)}
</div>
);
}
function errMessage(e: unknown, fallback: string): string {
return e instanceof ApiError && e.message ? e.message : fallback;
}
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, ApiError, jsonBody, toApiError } from '../lib/api';
import { setAuthed } from '../lib/auth';
import { currentSearch, didClientNavigate, getQueryParam, navigate, sanitizeNext } from '../router';
import { Spinner } from '../components/Spinner';
import type { User } from '../types';
type Probe = 'checking' | 'connected' | 'unreachable';
interface FieldError {
email?: string;
password?: string;
username?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function Login() {
const [register, setRegister] = useState(() => getQueryParam(currentSearch.value, 'mode') === 'register');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldError>({});
const [serverError, setServerError] = useState<string | null>(null);
const [probe, setProbe] = useState<Probe>('checking');
const emailRef = useRef<HTMLInputElement>(null);
const next = sanitizeNext(getQueryParam(currentSearch.value, 'next'));
useEffect(() => {
probeServer();
// autofocus Email only on client-side navigation, never cold page load
if (didClientNavigate) emailRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
const toggleMode = () => {
const newMode = !register;
setRegister(newMode);
setFieldErrors({});
setServerError(null);
window.history.replaceState(
{},
'',
newMode ? '/login?mode=register' : '/login'
);
};
const validate = (): boolean => {
const errs: FieldError = {};
if (!EMAIL_RE.test(email.trim())) errs.email = 'Enter a valid email address';
if (!password) errs.password = 'Password is required';
if (register && !username.trim()) errs.username = 'Username is required';
setFieldErrors(errs);
return Object.keys(errs).length === 0;
};
const submit = async (e: Event) => {
e.preventDefault();
setServerError(null);
if (submitting) return;
if (!validate()) return;
setSubmitting(true);
const body = register
? { username: username.trim(), password, email: email.trim() }
: { email: email.trim(), password };
try {
const res = await apiFetch(register ? '/web/auth/register' : '/web/auth/login', {
refreshOn401: false,
...jsonBody(body),
});
if (res.ok) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
navigate(next);
return;
}
throw new ApiError(0, 'Something went wrong');
}
const err = await toApiError(res);
throw err;
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Something went wrong';
setServerError(msg);
} finally {
setSubmitting(false);
}
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<a href="#login-main" className="skip-link">
Skip to content
</a>
<main id="login-main" className="w-full max-w-[400px] card p-6 sm:p-8" tabIndex={-1}>
<div className="flex flex-col items-center text-center">
<div className="logo-tile mb-4" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path
d="M4 20h4l10.5-10.5a2.83 2.83 0 10-4-4L4 16v4z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<path d="M13.5 6.5l4 4" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<h1 className="text-[24px] font-bold leading-[1.25]" style={{ margin: 0 }}>
KeepNote
</h1>
<p className="mt-1 mb-6" style={{ color: 'var(--dn-muted)', fontSize: 15 }}>
Your notes on Dcntrlized
</p>
</div>
<form onSubmit={submit} noValidate>
<div className="mb-4">
<label className="field-label" htmlFor="email">
Email
</label>
<input
ref={emailRef}
id="email"
className="field"
type="email"
value={email}
placeholder="[email protected]"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
{fieldErrors.email && (
<p id="email-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.email}
</p>
)}
</div>
{register && (
<div className="mb-4">
<label className="field-label" htmlFor="username">
Username
</label>
<input
id="username"
className="field"
type="text"
value={username}
placeholder="johndoe"
autoComplete="username"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.username}
aria-describedby={fieldErrors.username ? 'username-error' : undefined}
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
/>
{fieldErrors.username && (
<p id="username-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.username}
</p>
)}
</div>
)}
<div className="mb-5">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className="field"
type="password"
value={password}
placeholder="••••••••"
autoComplete={register ? 'new-password' : 'current-password'}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
{fieldErrors.password && (
<p id="password-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.password}
</p>
)}
</div>
{serverError && (
<div className="mb-4 banner banner-error" role="alert">
{serverError}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={submitting}>
{submitting && <Spinner />}
{register ? 'Create account' : 'Sign in'}
</button>
</form>
<button
type="button"
className="mt-3 w-full"
aria-pressed={register}
style={{ minHeight: 44, color: 'var(--dn-accent)', fontWeight: 600, background: 'transparent', border: 'none', cursor: 'pointer' }}
onClick={toggleMode}
>
{register ? 'Already have an account? Sign in' : 'New here? Create an account'}
</button>
</main>
<footer
className="w-full max-w-[400px] mt-6 flex items-center gap-2"
role="status"
style={{ color: 'var(--dn-muted)', fontSize: 12, minHeight: 44 }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected' ? 'var(--dn-ok)' : probe === 'unreachable' ? 'var(--dn-bad)' : 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button
type="button"
className="banner-action"
style={{ marginLeft: 'auto', minHeight: 44 }}
onClick={probeServer}
>
Retry
</button>
</>
)}
</footer>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Link, navigateReplace, getQueryParam, currentSearch } from '../router';
import { useNow } from '../lib/hooks';
import { relativeTime, snippet } from '../lib/relativeTime';
import { LoadingRow } from '../components/Spinner';
import {
notes,
notesLoading,
notesError,
offline,
refreshNotes,
pendingCount,
isOfflineNote,
syncNotice,
createNotice,
} from '../state/store';
import type { Note } from '../types';
export function NotesList() {
const now = useNow(60000);
const searchInputRef = useRef<HTMLInputElement>(null);
const h1Ref = useRef<HTMLHeadingElement>(null);
const [q, setQ] = useState(() => getQueryParam(currentSearch.value, 'q') ?? '');
const list = notes.value;
const loading = notesLoading.value;
const error = notesError.value;
const isOffline = offline.value;
const pending = pendingCount();
const notice = syncNotice.value;
// Re-render when the create partial-success notice changes.
const [cNotice, setCNotice] = useState(() => createNotice.value);
useEffect(() => {
const sub = createNotice.subscribe((v) => setCNotice(v));
return () => sub();
}, []);
// focus the visible h1 on route change
useEffect(() => {
h1Ref.current?.focus();
}, []);
// load notes on mount (if signed in) + refresh on focus
useEffect(() => {
refreshNotes();
const onFocus = () => {
if (!document.hidden) refreshNotes();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
// debounced ?q= replaceState sync (300ms)
useEffect(() => {
const id = window.setTimeout(() => {
if (q) navigateReplace(`/notes?q=${encodeURIComponent(q)}`);
else navigateReplace('/notes');
}, 300);
return () => window.clearTimeout(id);
}, [q]);
// "/" focuses search unless typing
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const typing =
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable);
if (e.key === '/' && !typing) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const query = q.trim().toLowerCase();
if (!query) return list;
return list.filter((n) => {
if ((n.title || '').toLowerCase().includes(query)) return true;
if ((n.content || '').toLowerCase().includes(query)) return true;
if (n.tags.some((t) => t.toLowerCase().includes(query))) return true;
return false;
});
}, [list, q]);
const noNotes = list.length === 0;
const noMatches = !noNotes && filtered.length === 0;
const clearSearch = () => {
setQ('');
searchInputRef.current?.focus();
navigateReplace('/notes');
};
return (
<div className="max-w-[720px] mx-auto px-4 sm:px-6 py-6">
<h1 ref={h1Ref} tabIndex={-1} className="text-[20px] leading-[1.4] font-bold mb-5" style={{ margin: 0, marginBottom: 20, outline: 'none' }}>
KeepNote
</h1>
{/* toolbar */}
<div className="flex items-center gap-2 mb-5">
<input
ref={searchInputRef}
className="field flex-1"
type="search"
value={q}
placeholder="Search notes"
aria-label="Search notes"
onInput={(e) => setQ((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setQ('');
(e.target as HTMLInputElement).blur();
navigateReplace('/notes');
}
}}
/>
<button
type="button"
className="icon-btn"
aria-label={loading ? 'Refreshing…' : 'Refresh notes'}
aria-busy={loading || undefined}
disabled={loading}
onClick={() => refreshNotes()}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
style={loading ? { animation: 'spin 0.8s linear infinite' } : undefined}
>
<path
d="M21 12a9 9 0 11-2.64-6.36M21 3v6h-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{/* aria-live status region */}
<div className="sr-only" role="status" aria-live="polite">
{loading && noNotes ? 'Loading…' : noMatches ? 'No matches' : `${filtered.length} notes`}
</div>
{notice && (
<div className="banner banner-warn mb-4" role="status">
{notice}
</div>
)}
{cNotice && (
<div className="banner banner-warn mb-4" role="status">
<span>
Note saved — tags weren&apos;t added.
</span>
<Link href={`/notes/${cNotice.noteId}`} className="banner-action">
Edit note
</Link>
</div>
)}
{loading && noNotes && <LoadingRow />}
{!loading && noNotes && (
<div className="card p-8 text-center" style={{ padding: '48px 24px' }}>
<p className="font-semibold" style={{ fontSize: 17, margin: '0 0 4px' }}>
No notes yet
</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 20px' }}>Create your first note</p>
<Link href="/notes/new" className="btn btn-primary">
New note
</Link>
</div>
)}
{!loading && noMatches && (
<div className="card p-8 text-center" style={{ padding: '48px 24px' }}>
<p className="font-semibold" style={{ fontSize: 17, margin: '0 0 4px' }}>
No matches
</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 20px' }}>Try a different search</p>
<button type="button" className="btn btn-ghost" onClick={clearSearch}>
Clear search
</button>
</div>
)}
{error && (
<div className="banner banner-error mb-4" role="alert">
<span>{error}</span>
<button type="button" className="banner-action" onClick={() => refreshNotes()}>
Retry
</button>
</div>
)}
{isOffline && !error && (
<div className="banner banner-warn mb-4" role="status">
<span>
Offline — showing saved notes. Changes will sync when you reconnect.
{pending > 0 && (
<>
{' '}
<span className="dn-warn-chip" style={{ padding: '2px 10px', borderRadius: 999, display: 'inline-block' }}>
{pending} pending change{pending === 1 ? '' : 's'}
</span>
</>
)}
</span>
</div>
)}
{!loading && filtered.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 list-none p-0 m-0" style={{ gap: 16 }}>
{filtered.map((note) => (
<li key={note.id}>
<NoteCard note={note} now={now} />
</li>
))}
</ul>
)}
<Link href="/notes/new" className="fab" aria-label="New note" title="New note">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Link>
</div>
);
}
function NoteCard({ note, now }: { note: Note; now: number }) {
const pending = isOfflineNote(note.id);
const title = note.title.trim() || 'Untitled';
const snip = snippet(note.content);
const time = relativeTime(note.modified || note.created, now);
return (
<Link href={`/notes/${note.id}`} className="note-card h-full flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold truncate" style={{ fontSize: 15, color: 'var(--dn-ink)' }}>
{title}
</span>
{pending && (
<span className="dn-warn-chip" style={{ padding: '1px 8px', borderRadius: 999, fontSize: 12, flex: 'none' }}>
Pending
</span>
)}
</div>
{snip && (
<span className="line-clamp-2" style={{ color: 'var(--dn-ink-soft)', fontSize: 13, lineHeight: 1.45 }}>
{snip}
</span>
)}
<span className="mt-auto" style={{ color: 'var(--dn-muted)', fontSize: 12 }}>
<time dateTime={note.modified || note.created}>{time}</time>
</span>
{note.tags.length > 0 && (
<span className="flex flex-wrap gap-1.5 mt-1">
{note.tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
</span>
))}
</span>
)}
</Link>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, profile } from '../lib/api';
import { logout } from '../lib/auth';
import { theme, toggleTheme } from '../lib/theme';
import { pendingCount, lastSynced, syncing, syncNow, syncNotice } from '../state/store';
type Probe = 'checking' | 'connected' | 'unreachable';
export function Settings() {
const [probe, setProbe] = useState<Probe>('checking');
const [localPending, setLocalPending] = useState(0);
const [localSyncing, setLocalSyncing] = useState(false);
const [lastSync, setLastSync] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const h1Ref = useRef<HTMLHeadingElement>(null);
const username = profile.value?.username || '—';
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
useEffect(() => {
probeServer();
setLocalPending(pendingCount());
setLocalSyncing(syncing.value);
setLastSync(lastSynced.value);
setNotice(syncNotice.value);
h1Ref.current?.focus();
const subs = [
syncing.subscribe((v) => setLocalSyncing(v)),
lastSynced.subscribe((v) => setLastSync(v)),
syncNotice.subscribe((v) => setNotice(v)),
];
const onStorage = () => setLocalPending(pendingCount());
window.addEventListener('storage', onStorage);
return () => {
subs.forEach((s) => s());
window.removeEventListener('storage', onStorage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const doSyncNow = async () => {
setNotice(null);
const result = await syncNow();
setLocalPending(pendingCount());
setLastSync(lastSynced.value);
setNotice(syncNotice.value);
};
const doLogout = () => {
logout();
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const lastSyncText = lastSync ? new Date(lastSync).toLocaleString() : 'Never';
return (
<div className="max-w-[640px] mx-auto px-4 sm:px-6 py-6">
<h1 ref={h1Ref} tabIndex={-1} className="text-[20px] font-bold mb-5" style={{ outline: 'none' }}>
Settings
</h1>
{notice && (
<div className="banner banner-warn mb-4" role="status">
<span>{notice}</span>
<button type="button" className="banner-action" onClick={() => setNotice(null)}>
Dismiss
</button>
</div>
)}
<section className="card p-5 mb-4" aria-labelledby="s-conn">
<h2 id="s-conn" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Connection
</h2>
<div className="flex items-center gap-2" style={{ minHeight: 44 }}>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected'
? 'var(--dn-ok)'
: probe === 'unreachable'
? 'var(--dn-bad)'
: 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button type="button" className="banner-action" style={{ minHeight: 44 }} onClick={probeServer}>
Retry
</button>
</>
)}
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-appearance">
<h2 id="s-appearance" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Appearance
</h2>
<div className="flex items-center justify-between" style={{ minHeight: 44 }}>
<span className="font-medium">Dark mode</span>
<button
type="button"
role="switch"
aria-checked={theme.value === 'dark'}
className="switch"
onClick={toggleTheme}
>
<span className="knob" />
</button>
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-account">
<h2 id="s-account" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Account
</h2>
<div className="flex items-center justify-between gap-4" style={{ minHeight: 44 }}>
<div>
<div className="text-[13px]" style={{ color: 'var(--dn-muted)' }}>
Signed in as
</div>
<div className="font-semibold truncate" style={{ maxWidth: 220 }}>
{username}
</div>
</div>
<button type="button" className="btn btn-danger" onClick={doLogout}>
Log out
</button>
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-sync">
<h2 id="s-sync" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Sync
</h2>
<div className="flex items-center justify-between gap-4" style={{ minHeight: 44 }}>
<div>
<div className="text-[13px]" style={{ color: 'var(--dn-muted)' }}>
Pending changes
</div>
<div aria-live="polite" className="font-semibold">
{localPending}
</div>
<div className="text-[13px] mt-2" style={{ color: 'var(--dn-muted)' }}>
Last synced
</div>
<div>{lastSyncText}</div>
</div>
<button
type="button"
className="btn btn-ghost"
disabled={localSyncing}
onClick={doSyncNow}
>
{localSyncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
</section>
<p className="text-center" style={{ color: 'var(--dn-muted)', fontSize: 13, marginTop: 24 }}>
KeepNote for Dcntrlized
</p>
</div>
);
}
+435
View File
@@ -0,0 +1,435 @@
// Global notes state + outbox (UX §4.2-§4.4).
// Notes cache + pending ops persist in localStorage; every commit re-reads
// storage before writing (multi-tab safe), and a storage listener +
// BroadcastChannel("keepnote.sync") rehydrates foreign changes. Flush is
// cross-tab single-flight via the Web Locks API.
import { signal } from '@preact/signals';
import type { Note, PendingOp } from '../types';
import {
apiFetch,
apiRequest,
ApiError,
isOfflineError,
jsonBody,
toApiError,
} from '../lib/api';
import { KEYS, readJSON, writeJSON, writeString, readString } from '../lib/storage';
import { uid } from '../lib/relativeTime';
export const notes = signal<Note[]>([]);
export const notesLoaded = signal(false);
export const notesLoading = signal(false);
export const notesError = signal<string | null>(null);
export const offline = signal(false);
export const pendingOps = signal<PendingOp[]>([]);
export const lastSynced = signal<string | null>(null);
export const syncing = signal(false);
// Transient sync outcome message surfaced on /settings.
export const syncNotice = signal<string | null>(null);
// Partial-success notice from a create whose chained tag-update failed.
export const createNotice = signal<{ noteId: string; noteTitle: string } | null>(null);
const BROADCAST_CHANNEL = 'keepnote.sync';
export function loadCache(): void {
notes.value = readJSON<Note[]>(KEYS.cache, []);
pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
lastSynced.value = readString(KEYS.lastSynced, '') || null;
notesLoaded.value = true;
}
export function pendingCount(): number {
return pendingOps.value.length;
}
export function isOfflineNote(id: string): boolean {
return id.startsWith('local-');
}
export function findNote(id: string): Note | undefined {
return notes.value.find((n) => n.id === id);
}
// --- low-level commit helpers (re-read + merge for cross-tab) --------------------
export function commitNotes(list: Note[]): void {
notes.value = list;
writeJSON(KEYS.cache, list);
broadcast({ type: 'cache' });
}
export function commitPending(list: PendingOp[]): void {
pendingOps.value = list;
writeJSON(KEYS.pending, list);
broadcast({ type: 'pending', count: list.length });
}
// --- optimistic helpers -------------------------------------------------------------
export function optimisticInsert(note: Note): void {
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === note.id);
if (idx >= 0) list[idx] = note;
else list.unshift(note);
commitNotes(list);
}
export function optimisticUpdate(note: Note): void {
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === note.id);
if (idx >= 0) list[idx] = note;
else list.unshift(note);
commitNotes(list);
}
export function optimisticDelete(id: string): void {
const list = readJSON<Note[]>(KEYS.cache, []).filter((n) => n.id !== id);
commitNotes(list);
}
// --- outbox enqueue (UX §4.3) ----------------------------------------------------------
export function enqueueCreate(localId: string, payload: { title: string; content: string; tags?: string[] }): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
ops.push({ id: uid(), type: 'create', localId, payload });
commitPending(ops);
}
export function enqueueUpdate(noteId: string, payload: { title: string; content: string; tags: string[] }): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
const createIdx = ops.findIndex((o) => o.type === 'create' && o.localId === noteId);
if (createIdx >= 0) {
const op = ops[createIdx];
if (op.type === 'create') {
ops[createIdx] = {
...op,
payload: { title: payload.title, content: payload.content, tags: payload.tags },
};
commitPending(ops);
return;
}
}
const filtered = ops.filter((o) => !(o.type === 'update' && o.noteId === noteId));
filtered.push({ id: uid(), type: 'update', noteId, payload: { id: noteId, ...payload } });
commitPending(filtered);
}
export function enqueueDelete(noteId: string): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
const createIdx = ops.findIndex((o) => o.type === 'create' && o.localId === noteId);
if (createIdx >= 0) {
// deleting a pending create removes the queued op
ops.splice(createIdx, 1);
commitPending(ops);
return;
}
const filtered = ops.filter((o) => !(o.type === 'update' && o.noteId === noteId));
filtered.push({ id: uid(), type: 'delete', noteId });
commitPending(filtered);
}
// --- online save / delete ----------------------------------------------------------------
export async function saveCreateOnline(
title: string,
content: string,
tags: string[]
): Promise<{ note: Note; partialSuccess: boolean }> {
const note = await apiRequest<Note>('/web/action/writeNote', jsonBody({ payload: { title, content } }));
note.tags = note.tags ?? [];
if (tags.length > 0) {
try {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload: { id: note.id, title, content, tags } }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticInsert(updated);
return { note: updated, partialSuccess: false };
} catch (e) {
// chained update failed after a successful create
if (isOfflineError(e)) {
// network: keep the real note, enqueue an update so tags sync later
optimisticInsert(note);
enqueueUpdate(note.id, { title, content, tags });
return { note, partialSuccess: true };
}
// non-network: note WAS created -> partial success, never duplicate
optimisticInsert(note);
return { note, partialSuccess: true };
}
}
optimisticInsert(note);
return { note, partialSuccess: false };
}
export async function saveUpdateOnline(payload: {
id: string;
title: string;
content: string;
tags: string[];
}): Promise<Note> {
// optimistic patch first; reverted by caller on server error
optimisticUpdate({
id: payload.id,
title: payload.title,
content: payload.content,
tags: payload.tags,
modified: new Date().toISOString(),
created: findNote(payload.id)?.created ?? new Date().toISOString(),
});
try {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticUpdate(updated);
return updated;
} catch (e) {
// revert optimistic patch on non-network error
if (!isOfflineError(e)) {
const cached = readJSON<Note[]>(KEYS.cache, []);
const original = cached.find((n) => n.id === payload.id);
if (original) optimisticUpdate(original);
}
throw e;
}
}
export async function deleteNoteOnline(id: string): Promise<void> {
const res = await apiFetch('/web/action/deleteNote', jsonBody({ payload: { id } }, 'DELETE'));
if (!res.ok) {
const err = await toApiError(res);
// 404 = already deleted -> treat as success
if (err.status === 404) return;
throw err;
}
}
// --- refresh list --------------------------------------------------------------------------
export async function refreshNotes(): Promise<void> {
notesLoading.value = true;
notesError.value = null;
try {
const res = await apiFetch('/web/action/getNotes');
if (res.status === 200) {
const list = (await res.json()) as Note[];
for (const n of list) n.tags = n.tags ?? [];
notes.value = list;
writeJSON(KEYS.cache, list);
offline.value = false;
} else {
throw await toApiError(res);
}
} catch (e) {
if (isOfflineError(e)) {
offline.value = true;
// keep the cached list
} else {
notesError.value = e instanceof Error ? e.message : 'Something went wrong';
}
} finally {
notesLoading.value = false;
}
}
// --- flush (cross-tab single-flight via Web Locks) --------------------------------------
async function flushOp(op: PendingOp): Promise<void> {
if (op.type === 'create') {
const created = await apiRequest<Note>(
'/web/action/writeNote',
jsonBody({ payload: { title: op.payload.title, content: op.payload.content } })
);
created.tags = created.tags ?? [];
// replace the local- entry with the server note (id swap happens here, once)
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === op.localId);
const merged: Note = {
...created,
title: op.payload.title,
content: op.payload.content,
tags: op.payload.tags ?? [],
};
if (idx >= 0) list[idx] = merged;
else list.unshift(merged);
commitNotes(list);
if (op.payload.tags && op.payload.tags.length > 0) {
try {
await apiRequest<Note>(
'/web/action/updateNote',
jsonBody(
{
payload: {
id: created.id,
title: op.payload.title,
content: op.payload.content,
tags: op.payload.tags,
},
},
'PUT'
)
);
} catch (e) {
if (isOfflineError(e)) throw e; // network -> pause flush
syncNotice.value = 'Note synced without tags';
}
}
return;
}
if (op.type === 'update') {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload: op.payload }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticUpdate({ ...updated, title: op.payload.title, content: op.payload.content, tags: op.payload.tags });
return;
}
// delete
const res = await apiFetch('/web/action/deleteNote', jsonBody({ payload: { id: op.noteId } }, 'DELETE'));
if (!res.ok) {
const err = await toApiError(res);
if (err.offline) throw err;
if (err.status === 404) return; // already gone -> success
throw err;
}
}
type FlushResult = 'success' | 'offline' | 'partial';
async function doFlush(): Promise<FlushResult> {
let ops = readJSON<PendingOp[]>(KEYS.pending, []);
let dropped = 0;
while (ops.length > 0) {
const op = ops[0];
try {
await flushOp(op);
} catch (e) {
if (isOfflineError(e)) {
return 'offline'; // pause flush, keep the queue
}
// non-network rejection -> drop the op
ops = ops.slice(1);
commitPending(ops);
dropped++;
continue;
}
ops = ops.slice(1);
commitPending(ops);
}
if (dropped > 0) {
syncNotice.value = `${dropped} change(s) couldn't be synced and were discarded`;
return 'partial';
}
return 'success';
}
export async function flushOutbox(): Promise<FlushResult> {
if (syncing.value) return 'success';
syncing.value = true;
try {
const locks = navigator.locks;
if (locks && typeof locks.request === 'function') {
return await new Promise<FlushResult>((resolve) => {
locks
.request('keepnote.flush', { ifAvailable: true }, async (signal) => {
// signal is null when the lock could not be granted (another tab is
// flushing); lock.aborted means it was released early. In both cases
// skip — another tab owns the flush.
const lock = signal as { aborted?: boolean } | null;
if (!lock || lock.aborted) {
resolve('success');
return;
}
resolve(await doFlush());
})
.catch(() => resolve('success'));
});
}
// fallback: localStorage lock key with expiry
const lockKey = 'keepnote.flush.lock';
const held = readString(lockKey, '');
if (held && Date.now() - Number(held) < 15000) return 'success';
writeString(lockKey, String(Date.now()));
try {
return await doFlush();
} finally {
try {
localStorage.removeItem(lockKey);
} catch {
/* ignore */
}
}
} finally {
syncing.value = false;
}
}
// "Sync now" on /settings: flush then refresh; lastSynced advances only on a
// network-clean sync (C15).
export async function syncNow(): Promise<FlushResult> {
syncing.value = true;
try {
const flushResult = await flushOutbox();
let refreshNetworkOk = true;
try {
await refreshNotes();
} catch {
refreshNetworkOk = false;
}
const stillOffline = !navigator.onLine;
if (flushResult === 'offline' || !refreshNetworkOk || stillOffline) {
syncNotice.value =
"Couldn't sync — you're offline. Your changes are still saved on this device.";
return 'offline';
}
lastSynced.value = new Date().toISOString();
writeString(KEYS.lastSynced, lastSynced.value);
return flushResult;
} finally {
syncing.value = false;
}
}
// --- cross-tab wiring ---------------------------------------------------------------------
function broadcast(msg: { type: 'cache' } | { type: 'pending'; count: number }): void {
try {
if (typeof BroadcastChannel !== 'undefined') {
new BroadcastChannel(BROADCAST_CHANNEL).postMessage(msg);
}
} catch {
/* ignore */
}
}
export function initSyncChannel(): void {
window.addEventListener('storage', (e) => {
if (e.key === KEYS.pending) pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
else if (e.key === KEYS.cache) notes.value = readJSON<Note[]>(KEYS.cache, []);
else if (e.key === KEYS.lastSynced) {
lastSynced.value = readString(KEYS.lastSynced, '') || null;
}
});
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel(BROADCAST_CHANNEL);
bc.addEventListener('message', (ev) => {
const msg = ev.data as { type?: string };
if (msg?.type === 'pending') pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
else if (msg?.type === 'cache') notes.value = readJSON<Note[]>(KEYS.cache, []);
});
}
}
export function initOnlineListeners(): void {
const onOnline = () => {
offline.value = false;
if (pendingCount() > 0) {
flushOutbox().then(() => refreshNotes());
} else {
refreshNotes();
}
};
const onOffline = () => {
offline.value = true;
};
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
}
+43
View File
@@ -0,0 +1,43 @@
export interface User {
id: string;
username: string;
email: string;
}
export interface Note {
id: string;
title: string;
content: string;
tags: string[];
modified: string;
created: string;
}
export type PendingOp =
| {
id: string;
type: 'create';
localId: string;
payload: { title: string; content: string; tags?: string[] };
}
| {
id: string;
type: 'update';
noteId: string;
payload: { id: string; title: string; content: string; tags: string[] };
}
| { id: string; type: 'delete'; noteId: string };
export type AuthState = 'loading' | 'authed' | 'anonymous' | 'unknown';
export interface Profile {
username?: string;
email?: string;
}
export interface Draft {
title: string;
content: string;
tags: string[];
savedAt: number;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noFallthroughCasesInSwitch": true,
"types": ["preact"]
},
"include": ["src", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
// Default base "/" emits relative-free absolute /assets/... URLs (CSP-safe,
// external module script). dist/ is the embed target for the KeepNote edge.
export default defineConfig({
plugins: [preact(), tailwindcss()],
build: {
outDir: 'dist',
assetsDir: 'assets',
target: 'es2020',
sourcemap: false,
},
});