[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:
+16
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+19
@@ -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 && bun run build</code>.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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're offline — this note isn'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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user