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

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

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

Committed web/bun.lock because web/package.json and vite.config.ts are new
in this commit. src/web/dist/index.html is a hand-written placeholder, not
a generated bundle: it keeps bare `go build`/`go vet` working from a clean
checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
2026-10-19 09:28:59 +01:00
parent c58e193038
commit a791232f18
46 changed files with 5392 additions and 61 deletions
+15
View File
@@ -0,0 +1,15 @@
import type { ComponentChildren } from 'preact';
interface BannerProps {
kind: 'error' | 'warn' | 'info';
children: ComponentChildren;
role?: 'alert' | 'status';
}
export function Banner({ kind, children, role = 'alert' }: BannerProps) {
return (
<div className={`banner banner-${kind}`} role={role}>
{children}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
interface ConfirmDialogProps {
title: string;
body: ComponentChildren;
confirmLabel: string;
cancelLabel?: string;
busy: boolean;
busyLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
title,
body,
confirmLabel,
cancelLabel = 'Cancel',
busy,
busyLabel,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useRef(`dlg-${Math.random().toString(36).slice(2, 8)}`).current;
const bodyId = `body-${titleId}`;
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// focus the Cancel (default) button on open
cancelRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// simple focus trap
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusables = [cancelRef.current, confirmRef.current].filter(Boolean) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
return (
<div className="scrim">
<div
className="card"
style={{ width: '100%', maxWidth: 400, padding: 24 }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
>
<h2 id={titleId} style={{ margin: '0 0 8px', fontSize: 20, lineHeight: 1.3 }}>
{title}
</h2>
<p id={bodyId} style={{ margin: '0 0 24px', color: 'var(--dn-ink-soft)' }}>
{body}
</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={onCancel}
>
{cancelLabel}
</button>
<button
ref={confirmRef}
type="button"
className="btn btn-danger"
disabled={busy}
onClick={onConfirm}
>
{busy ? (
<>
<span className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{busyLabel ?? confirmLabel}
</>
) : (
confirmLabel
)}
</button>
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { ComponentChildren } from 'preact';
import { Link } from '../router';
import { authState, boot, bootError, cachedProfileUsername } from '../lib/auth';
import { banner, clearBanner, profile } from '../lib/api';
import { pendingCount } from '../state/store';
import type { RouteName } from '../router';
function Wordmark({ compact = false }: { compact?: boolean }) {
return (
<span
className="font-bold"
style={{ fontSize: compact ? 20 : 22, color: 'var(--dn-accent)', letterSpacing: '-0.02em' }}
>
KeepNote
</span>
);
}
function SettingsIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 15a3 3 0 100-6 3 3 0 000 6z"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
function ChevronLeft() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
interface ShellProps {
route: RouteName;
title?: string;
backTo?: string;
children: ComponentChildren;
}
export function Shell({ route, title, backTo, children }: ShellProps) {
const unknown = authState.value === 'unknown';
const tBanner = banner.value;
const pending = pendingCount();
const username = profile.value?.username ?? cachedProfileUsername();
const retryBoot = () => {
clearBanner();
boot();
};
const railActive = (r: RouteName) => (route === r ? 'true' : undefined);
return (
<div className="min-h-screen flex flex-col">
<a href="#main" className="skip-link">
Skip to content
</a>
{unknown && (
<div className="banner banner-error" role="alert">
<span>Can&apos;t reach the server.</span>
<button type="button" className="banner-action" onClick={retryBoot}>
Retry
</button>
</div>
)}
{tBanner && (
<div className={`banner banner-${tBanner.kind}`} role="status">
<span>{tBanner.text}</span>
<button type="button" className="banner-action" onClick={clearBanner}>
Dismiss
</button>
</div>
)}
<div className="flex flex-1 min-h-0">
{/* Persistent left rail (>=1024) */}
<nav className="rail hidden lg:flex" aria-label="Primary">
<div className="px-5 pt-5 pb-4">
<Wordmark />
</div>
<ul className="flex-1 px-3 space-y-1">
<li>
<Link
href="/notes"
aria-current={railActive('/notes')}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium"
style={{ minHeight: 44, color: route === '/notes' ? 'var(--dn-accent)' : 'var(--dn-ink)' }}
>
<span style={{ fontSize: 18 }} aria-hidden="true">
📝
</span>
Notes
</Link>
</li>
<li>
<Link
href="/settings"
aria-current={railActive('/settings')}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 font-medium"
style={{ minHeight: 44, color: route === '/settings' ? 'var(--dn-accent)' : 'var(--dn-ink)' }}
>
<SettingsIcon />
Settings
</Link>
</li>
</ul>
<div className="px-5 py-4 border-t" style={{ borderColor: 'var(--dn-line)' }}>
<div className="mono" style={{ color: 'var(--dn-muted)' }}>
{pending > 0 ? `${pending} pending change${pending === 1 ? '' : 's'}` : 'All synced'}
</div>
{username && (
<div className="mono truncate" style={{ color: 'var(--dn-muted)' }} title={username}>
{username}
</div>
)}
</div>
</nav>
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (<1024) */}
<header className="topbar lg:hidden">
{route === '/notes' && (
<>
<span className="flex-1">
<Wordmark compact />
</span>
<Link
href="/settings"
className="icon-btn"
aria-label="Settings"
title="Settings"
>
<SettingsIcon />
</Link>
</>
)}
{route === '/settings' && (
<>
<Link href={backTo ?? '/notes'} className="icon-btn" aria-label="Back">
<ChevronLeft />
</Link>
<span className="flex-1 font-semibold" style={{ fontSize: 20 }}>
{title ?? 'Settings'}
</span>
</>
)}
{(route === '/notes/new' || route === '/notes/:noteId') && (
<>
<Link href="/notes" className="icon-btn" aria-label="Close editor" title="Close editor">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Link>
<span className="flex-1 font-medium truncate px-3" style={{ fontSize: 18 }}>
{title ?? ''}
</span>
</>
)}
</header>
<main id="main" className="flex-1 min-w-0 overflow-y-auto" tabIndex={-1}>
{children}
</main>
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export function Spinner() {
return <span className="spinner" aria-hidden="true" />;
}
export function LoadingRow({ text = 'Loading…' }: { text?: string }) {
return (
<div className="flex items-center gap-3 py-8" role="status">
<Spinner />
<span>{text}</span>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useRef, useState } from 'preact/hooks';
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
}
function normalizeTag(raw: string): string | null {
let t = raw.trim();
if (t.startsWith('#')) t = t.slice(1).trim();
if (!t) return null;
return t.slice(0, 32);
}
export function TagInput({ tags, onChange }: TagInputProps) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const add = (raw: string) => {
const t = normalizeTag(raw);
if (!t) return;
if (!tags.includes(t)) onChange([...tags, t]);
setValue('');
};
const remove = (tag: string) => {
onChange(tags.filter((t) => t !== tag));
inputRef.current?.focus();
};
return (
<div>
<span className="field-label" id="tags-label">
Tags
</span>
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
<button
type="button"
className="tag-remove"
aria-label={`Remove tag ${tag}`}
onClick={() => remove(tag)}
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M3 3l6 6M9 3l-6 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</span>
))}
<input
ref={inputRef}
className="field"
style={{ maxWidth: 200, minWidth: 140, minHeight: 40, padding: '4px 12px' }}
type="text"
value={value}
maxLength={32}
placeholder="# Add tag"
aria-label="Add tag"
aria-labelledby="tags-label"
spellcheck={false}
onInput={(e) => setValue((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
add(value);
} else if (e.key === 'Backspace' && value === '' && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
onBlur={() => {
if (value.trim()) add(value);
}}
/>
</div>
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
@import "tailwindcss";
/* ============================================================================
KeepNote Web — frozen design tokens (planning/web/ux/keepnote.md §6)
The token layer is plain CSS custom properties on :root / [data-theme="dark"].
Tailwind is used only for layout utilities; semantic colours always resolve
through these variables.
========================================================================== */
:root {
color-scheme: light;
/* 6.1 colour tokens — light */
--dn-ground: #e9ecf1;
--dn-surface: #f7f8fa;
--dn-panel: #ffffff;
--dn-ink: #171a21;
--dn-ink-soft: #333b47;
--dn-muted: #5a6473;
--dn-line: #e0e4ea;
--dn-line-strong: #c3cad6;
--dn-accent: #b00b69;
--dn-accent-ink: #ffffff;
--dn-accent-on-soft: #b00b69;
--dn-accent-soft: rgba(176, 11, 105, 0.1);
--dn-ok: #1f7a4d;
--dn-warn: #96580a;
--dn-warn-soft: rgba(150, 88, 10, 0.12);
--dn-bad: #b3261e;
--dn-bad-ink: #ffffff;
--dn-bad-soft: rgba(179, 38, 30, 0.1);
/* 6.3 spacing rhythm */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
/* 6.3 radius */
--radius-control: 8px;
--radius-card: 12px;
--radius-sheet: 18px;
--radius-pill: 9999px;
/* 6.3 elevation / scrim */
--shadow-card: 0 0 0 1px var(--dn-line);
--shadow-lift: 0 2px 8px rgba(23, 26, 33, 0.14);
--scrim: rgba(23, 26, 33, 0.5);
}
[data-theme="dark"] {
color-scheme: dark;
--dn-ground: #101318;
--dn-surface: #171b22;
--dn-panel: #1f242d;
--dn-ink: #e8ecf2;
--dn-ink-soft: #c3cbd6;
--dn-muted: #98a2b3;
--dn-line: #2c333e;
--dn-line-strong: #4a5364;
--dn-accent: #e556a4;
--dn-accent-ink: #171a21;
--dn-accent-on-soft: #f08bc4;
--dn-accent-soft: rgba(229, 86, 164, 0.16);
--dn-ok: #41c98a;
--dn-warn: #e3a63c;
--dn-warn-soft: rgba(227, 166, 60, 0.14);
--dn-bad: #f2807a;
--dn-bad-ink: #171a21;
--dn-bad-soft: rgba(242, 128, 122, 0.14);
--shadow-card: 0 0 0 1px var(--dn-line);
--shadow-lift: 0 2px 8px rgba(0, 0, 0, 0.5);
--scrim: rgba(0, 0, 0, 0.6);
}
/* prefers-contrast: more — swap line/muted tokens (6.2/6.3) */
@media (prefers-contrast: more) {
:root {
--dn-line: var(--dn-line-strong);
--dn-muted: var(--dn-ink-soft);
}
[data-theme="dark"] {
--dn-line: var(--dn-line-strong);
--dn-muted: var(--dn-ink-soft);
}
}
/* prefers-reduced-motion — remove non-essential transitions (6.3) */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
/* ---------------------------------------------------------------------------
Base
------------------------------------------------------------------------ */
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: var(--dn-ground);
color: var(--dn-ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.466;
-webkit-font-smoothing: antialiased;
}
/* Semantic aliases used across components */
.dn-surface {
background: var(--dn-surface);
color: var(--dn-ink);
}
.dn-panel {
background: var(--dn-panel);
color: var(--dn-ink);
}
.dn-accent-bg {
background: var(--dn-accent);
color: var(--dn-accent-ink);
}
.dn-bad-bg {
background: var(--dn-bad);
color: var(--dn-bad-ink);
}
.dn-chip {
background: var(--dn-accent-soft);
color: var(--dn-accent-on-soft);
}
.dn-warn-chip {
background: var(--dn-warn-soft);
color: var(--dn-ink);
}
/* ---------------------------------------------------------------------------
Focus ring: 2px accent, 2px offset, :focus-visible only
------------------------------------------------------------------------ */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--dn-accent);
outline-offset: 2px;
}
/* Skip-to-content link — first tab stop */
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: 8px 12px;
background: var(--dn-accent);
color: var(--dn-accent-ink);
border-radius: 0 0 8px 0;
font-weight: 600;
}
.skip-link:focus {
left: 0;
}
/* Visually hidden (a11y helper) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------------------------------------------------------------------------
Buttons / inputs
------------------------------------------------------------------------ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-control);
font-size: 15px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
transition: background 150ms ease-out, color 150ms ease-out;
user-select: none;
}
.btn:disabled {
opacity: 0.6;
cursor: default;
}
.btn-primary {
background: var(--dn-accent);
color: var(--dn-accent-ink);
}
.btn-primary:not(:disabled):hover {
filter: brightness(1.04);
}
.btn-danger {
background: var(--dn-bad);
color: var(--dn-bad-ink);
}
.btn-danger:not(:disabled):hover {
filter: brightness(1.04);
}
.btn-ghost {
background: transparent;
color: var(--dn-ink);
border-color: var(--dn-line);
}
.btn-ghost:not(:disabled):hover {
background: var(--dn-surface);
}
.btn-link {
background: transparent;
color: var(--dn-accent);
min-height: 44px;
padding: 0 8px;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
}
/* Icon buttons ≥ 44×44 */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-control);
border: none;
background: transparent;
color: var(--dn-ink);
cursor: pointer;
transition: background 150ms ease-out;
}
.icon-btn:hover {
background: var(--dn-surface);
}
.icon-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* Text fields */
.field {
width: 100%;
min-height: 44px;
padding: 8px 12px;
background: var(--dn-surface);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-control);
font-size: 15px;
line-height: 1.4;
transition: border-color 150ms ease-out;
}
.field::placeholder {
color: var(--dn-muted);
}
.field:focus-visible {
border-color: var(--dn-accent);
}
.field-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--dn-muted);
}
/* ---------------------------------------------------------------------------
Cards / panels
------------------------------------------------------------------------ */
.card {
background: var(--dn-panel);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.note-card {
display: block;
padding: 16px;
background: var(--dn-panel);
color: var(--dn-ink);
border: 1px solid var(--dn-line);
border-radius: var(--radius-card);
text-decoration: none;
transition: border-color 150ms ease-out;
}
.note-card:hover {
border-color: var(--dn-accent);
}
/* ---------------------------------------------------------------------------
Banners
------------------------------------------------------------------------ */
.banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
font-size: 13px;
line-height: 1.4;
}
.banner-error {
background: var(--dn-bad-soft);
color: var(--dn-ink);
}
.banner-warn {
background: var(--dn-warn-soft);
color: var(--dn-ink);
}
.banner-info {
background: var(--dn-surface);
color: var(--dn-ink);
border-bottom: 1px solid var(--dn-line);
}
.banner .banner-action {
margin-left: auto;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-control);
background: transparent;
color: var(--dn-accent);
font-weight: 600;
border: none;
cursor: pointer;
}
.banner .banner-action:hover {
text-decoration: underline;
}
/* ---------------------------------------------------------------------------
Spinner
------------------------------------------------------------------------ */
.spinner {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--dn-line);
border-top-color: var(--dn-accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------------------------------------------------------------------------
Login card
------------------------------------------------------------------------ */
.logo-tile {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--dn-accent);
color: var(--dn-accent-ink);
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------------------
Tags / chips
------------------------------------------------------------------------ */
.tag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--dn-accent-soft);
color: var(--dn-accent-on-soft);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.tag-chip .tag-remove {
width: 20px;
height: 20px;
flex: none;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
}
/* Mono origin / ids */
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
/* Rail — persistent only at >=1024px (UX §3.3). These rules are unlayered, so
they outrank Tailwind's layered `hidden lg:flex`; the breakpoint lives here
to keep one source of truth. */
.rail {
width: 240px;
flex: none;
display: none;
flex-direction: column;
border-right: 1px solid var(--dn-line);
background: var(--dn-surface);
}
@media (min-width: 1024px) {
.rail {
display: flex;
}
}
/* Top bar — compact/tablet only, hidden once the rail appears. */
.topbar {
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--dn-line);
background: var(--dn-surface);
}
@media (min-width: 1024px) {
.topbar {
display: none;
}
}
/* FAB */
.fab {
position: fixed;
right: 24px;
bottom: 24px;
width: 56px;
height: 56px;
border-radius: var(--radius-pill);
background: var(--dn-accent);
color: var(--dn-accent-ink);
border: none;
box-shadow: var(--shadow-lift);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.fab:hover {
filter: brightness(1.04);
}
/* Switch */
.switch {
position: relative;
width: 48px;
height: 28px;
border-radius: var(--radius-pill);
border: 1px solid var(--dn-line);
background: var(--dn-surface);
cursor: pointer;
flex: none;
transition: background 150ms ease-out;
}
.switch[aria-checked="true"] {
background: var(--dn-accent);
border-color: var(--dn-accent);
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--dn-ink);
transition: transform 150ms ease-out;
}
.switch[aria-checked="true"] .knob {
transform: translateX(20px);
background: var(--dn-accent-ink);
}
/* Dialog scrim */
.scrim {
position: fixed;
inset: 0;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 50;
}
+239
View File
@@ -0,0 +1,239 @@
// Global fetch wrapper for the frozen cookie edge (UX §4.6).
// - same-origin relative /web/* URLs only; never sends an auth header
// - on 401 from /web/action/* or /web/auth/me: ONE shared (single-flight)
// bodyless POST /web/auth/refresh, then retry the original once
// - refresh 401 (ERR_REFRESH_*) -> session cleared -> /login?next=...
// - refresh 502 -> keep session, transient banner (no retry loop, no redirect)
// - error extraction: JSON error.message -> code map -> plain-text map ->
// "Something went wrong (HTTP <status>)"
import { signal } from '@preact/signals';
import type { User } from '../types';
import { KEYS, readJSON, writeJSON } from './storage';
// --- Session / profile signals -------------------------------------------------
export const profile = signal<{ username: string; email: string } | null>(null);
// Set when the session is cleared mid-session so the app can redirect to login.
export const sessionCleared = signal(false);
// --- Transient connection banner ------------------------------------------------
export type BannerKind = 'error' | 'warn' | 'info';
export const banner = signal<{ kind: BannerKind; text: string } | null>(null);
let bannerTimer: number | undefined;
export function showBanner(kind: BannerKind, text: string, autoClearMs = 6000): void {
banner.value = { kind, text };
if (bannerTimer) window.clearTimeout(bannerTimer);
bannerTimer = window.setTimeout(() => {
if (banner.value?.text === text) banner.value = null;
}, autoClearMs);
}
export function clearBanner(): void {
banner.value = null;
}
// --- ApiError --------------------------------------------------------------------
export class ApiError extends Error {
readonly status: number | null;
readonly code: string | null;
readonly offline: boolean;
constructor(status: number | null, message: string, code?: string | null, offline = false) {
super(message);
this.status = status;
this.code = code ?? null;
this.offline = offline;
}
}
export function isOfflineError(e: unknown): e is ApiError {
return e instanceof ApiError && e.offline;
}
const CODE_MAP: Record<string, string> = {
ERR_CSRF: 'Request blocked — reload the page and try again',
ERR_NOT_FOUND: 'Not found',
ERR_METHOD_NOT_ALLOWED: 'Something went wrong (unsupported request)',
ERR_INVALID_REQUEST: 'The server rejected the request',
ERR_INTERNAL: 'Server error — try again',
ERR_AUTH_FAILED: 'Incorrect email or password',
ERR_EMAIL_TAKEN: 'An account with this email already exists',
ERR_USERNAME_TAKEN: 'That username is taken',
ERR_TOKEN_INVALID: 'Please sign in again',
ERR_TOKEN_EXPIRED: 'Please sign in again',
};
const TEXT_MAP: Record<string, string> = {
'note not found': 'Not found',
'missing or invalid note id': 'Not found',
'invalid payload': 'The server rejected the request',
'action not implemented': 'Something went wrong (unsupported request)',
'method not allowed': 'Something went wrong (unsupported request)',
};
export function profileFromUser(user: User): { username: string; email: string } {
return { username: user.username, email: user.email };
}
export function cacheProfile(profileValue: { username: string; email: string }): void {
writeJSON(KEYS.profile, profileValue);
}
// --- Error extraction --------------------------------------------------------------
export async function toApiError(res: Response): Promise<ApiError> {
const status = res.status;
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
if (body && typeof body === 'object') {
const err = (body as { error?: unknown }).error;
if (err && typeof err === 'object') {
const e = err as { code?: unknown; message?: unknown };
if (typeof e.message === 'string' && e.message.length > 0) {
return new ApiError(status, e.message, typeof e.code === 'string' ? e.code : null);
}
if (typeof e.code === 'string') {
const code = e.code;
const msg =
CODE_MAP[code] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : 'Something went wrong');
return new ApiError(status, msg, code);
}
}
}
// plain text
let text = '';
try {
text = await res.text();
} catch {
text = '';
}
const lower = text.trim().toLowerCase();
const mapped =
TEXT_MAP[lower] ??
(status >= 500 ? CODE_MAP.ERR_INTERNAL : `Something went wrong (HTTP ${status})`);
return new ApiError(status, mapped);
}
// --- Refresh (single-flight) --------------------------------------------------------
export type RefreshResult = 'ok' | 'cleared' | 'transient';
let refreshInFlight: Promise<RefreshResult> | null = null;
async function doRefresh(): Promise<RefreshResult> {
let res: Response;
try {
res = await fetch('/web/auth/refresh', { method: 'POST' });
} catch {
// network error / offline — keep session, do not clear
return 'transient';
}
if (res.status === 200) {
try {
const data = await res.json();
if (data && data.user) {
const p = profileFromUser(data.user);
profile.value = p;
cacheProfile(p);
}
} catch {
/* ignore body parse */
}
return 'ok';
}
if (res.status === 502) {
showBanner('warn', 'Connection problem — your session is still active.');
return 'transient';
}
// 401 (ERR_REFRESH_* / ERR_UNAUTHENTICATED) or other — cookies already cleared
return 'cleared';
}
export function singleFlightRefresh(): Promise<RefreshResult> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = doRefresh().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
export function handleSessionCleared(nextPath: string): void {
sessionCleared.value = true;
const target = `/login?next=${encodeURIComponent(nextPath)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}
// --- Main fetch wrapper -------------------------------------------------------------
export interface FetchOpts extends RequestInit {
refreshOn401?: boolean;
timeoutMs?: number;
}
export async function apiFetch(path: string, opts: FetchOpts = {}): Promise<Response> {
const { refreshOn401 = true, timeoutMs, ...init } = opts;
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new ApiError(null, 'Network error', null, true);
}
let controller: AbortController | undefined;
let timer: number | undefined;
if (timeoutMs) {
controller = new AbortController();
timer = window.setTimeout(() => controller?.abort(), timeoutMs);
}
let res: Response;
try {
res = await fetch(path, { ...init, signal: controller?.signal });
} catch {
if (controller?.signal.aborted) throw new ApiError(null, 'Request timed out', null, true);
throw new ApiError(null, 'Network error', null, true);
} finally {
if (timer) window.clearTimeout(timer);
}
if (res.status === 401 && refreshOn401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
try {
res = await fetch(path, init);
} catch {
throw new ApiError(null, 'Network error', null, true);
}
} else if (result === 'cleared') {
handleSessionCleared(path);
throw new ApiError(401, 'Please sign in again', 'ERR_UNAUTHENTICATED');
} else {
// transient — keep session, surface banner (already shown by refresh)
throw new ApiError(null, 'Connection problem — your session is still active.', null, true);
}
}
return res;
}
/** Perform a request and throw ApiError on any non-2xx (also used for 204). */
export async function apiRequest<T = Response>(
path: string,
init: RequestInit = {},
opts: FetchOpts = {}
): Promise<T> {
const res = await apiFetch(path, { ...init, ...opts });
if (!res.ok) throw await toApiError(res);
if (res.status === 204 || res.status === 200 && res.headers.get('content-length') === '0') {
return undefined as unknown as T;
}
return (await res.json()) as T;
}
export function jsonBody(body: unknown, method = 'POST'): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
+110
View File
@@ -0,0 +1,110 @@
// Boot / auth gate (UX §3.2).
// GET /web/auth/me (8s) -> 200 authed | 401 -> single-flight refresh -> retry
// refresh 401 -> anonymous -> /login?next= | network/502 -> "unknown" state
// (shell + cached profile + "Can't reach the server." + Retry). Never
// auto-redirect to /login on unknown.
import { signal } from '@preact/signals';
import {
apiFetch,
ApiError,
profile,
profileFromUser,
cacheProfile,
singleFlightRefresh,
} from './api';
import type { AuthState, User } from '../types';
import { KEYS, readJSON, removeKey } from './storage';
export const authState = signal<AuthState>('loading');
// Message shown in the shell banner when state === 'unknown'.
export const bootError = signal<string | null>(null);
// Bump to re-run boot (Retry).
export const bootTick = signal(0);
export function cachedProfileUsername(): string {
const p = readJSON<{ username?: string } | null>(KEYS.profile, null);
return p?.username ?? '';
}
export function setAuthed(user: User): void {
authState.value = 'authed';
const p = profileFromUser(user);
profile.value = p;
cacheProfile(p);
}
export function setAnonymous(): void {
authState.value = 'anonymous';
// web.profile is kept for the boot shell (only logout clears it).
}
export async function boot(): Promise<void> {
authState.value = 'loading';
bootError.value = null;
try {
const res = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res.status === 200) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (res.status === 401) {
const result = await singleFlightRefresh();
if (result === 'ok') {
const res2 = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
if (res2.status === 200) {
const data = (await res2.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
return;
}
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
if (result === 'cleared') {
setAnonymous();
return;
}
// transient
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
// 502 or any other non-200
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
} catch (e) {
// network error / timeout / ApiError -> unknown
if (e instanceof ApiError) {
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
return;
}
authState.value = 'unknown';
bootError.value = "Can't reach the server.";
}
}
/** Optimistic sign-out: clear profile, go to /login, fire-and-forget logout. */
export function logout(): void {
removeKey(KEYS.profile);
profile.value = null;
authState.value = 'anonymous';
const next = '/notes';
const target = `/login?next=${encodeURIComponent(next)}`;
window.history.replaceState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
// bodyless fire-and-forget (edge always answers 200)
fetch('/web/auth/logout', { method: 'POST' }).catch(() => {
/* best effort */
});
}
+27
View File
@@ -0,0 +1,27 @@
// Per-route editor drafts (UX §3.2 / O3). Keyed by route ("new" or noteId) in
// web.drafts. Debounced persistence + restore-with-notice on return.
import type { Draft } from '../types';
import { KEYS, readJSON, writeJSON, removeKey } from './storage';
type DraftsMap = Record<string, Draft>;
export function getDraft(key: string): Draft | null {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
return map[key] ?? null;
}
export function saveDraft(key: string, draft: Omit<Draft, 'savedAt'>): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
map[key] = { ...draft, savedAt: Date.now() };
writeJSON(KEYS.drafts, map);
}
export function clearDraft(key: string): void {
const map = readJSON<DraftsMap>(KEYS.drafts, {});
if (map[key]) {
delete map[key];
writeJSON(KEYS.drafts, map);
}
if (Object.keys(map).length === 0) removeKey(KEYS.drafts);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'preact/hooks';
/** Re-render the caller on an interval (60s tick for relative times). */
export function useNow(intervalMs = 60000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
const onVis = () => {
if (!document.hidden) setNow(Date.now());
};
const onFocus = () => setNow(Date.now());
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onFocus);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('focus', onFocus);
};
}, [intervalMs]);
return now;
}
+48
View File
@@ -0,0 +1,48 @@
// Relative-time formatting, ported verbatim from keepnote-client
// HomeScreen.tsx:15-31: "just now", Nm ago, Nh ago, Nd ago, then "Mon d".
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export function relativeTime(iso: string | undefined, now: number): string {
if (!iso) return '';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '';
const diff = Math.max(0, now - t);
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const d = new Date(t);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
// Whitespace-collapse content and truncate at 140 chars + "…".
export function snippet(content: string): string {
const collapsed = content.replace(/\s+/g, ' ').trim();
if (!collapsed) return '';
if (collapsed.length <= 140) return collapsed;
return collapsed.slice(0, 140) + '…';
}
export function uid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
}
+55
View File
@@ -0,0 +1,55 @@
// localStorage persistence helpers + the frozen key table (UX §3.2).
// Keys: settings.theme, notes.cache, notes.sync.pending, notes.sync.lastSynced,
// web.profile, web.drafts. No tokens are ever stored (HttpOnly cookies only).
export const KEYS = {
theme: 'settings.theme',
cache: 'notes.cache',
pending: 'notes.sync.pending',
lastSynced: 'notes.sync.lastSynced',
profile: 'web.profile',
drafts: 'web.drafts',
} as const;
export function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function writeJSON(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage full/unavailable — keep the app functional in-memory */
}
}
export function readString(key: string, fallback: string): string {
try {
const v = localStorage.getItem(key);
return v == null ? fallback : v;
} catch {
return fallback;
}
}
export function writeString(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
export function removeKey(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
+28
View File
@@ -0,0 +1,28 @@
import { signal } from '@preact/signals';
import { KEYS, readString, writeString } from './storage';
export type Theme = 'light' | 'dark';
export const theme = signal<Theme>('light');
export function initTheme(): void {
const stored = readString(KEYS.theme, 'light');
const t: Theme = stored === 'dark' ? 'dark' : 'light';
theme.value = t;
applyTheme(t);
}
export function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
document.documentElement.style.colorScheme = t;
writeString(KEYS.theme, t);
}
export function setTheme(t: Theme): void {
theme.value = t;
applyTheme(t);
}
export function toggleTheme(): void {
setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
+140
View File
@@ -0,0 +1,140 @@
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
// Design tokens + Tailwind layer. Must be imported for the bundle to emit CSS
// (and for the server's strict CSP to keep style-src 'self' happy).
import './index.css';
import {
currentPath,
currentSearch,
matchRoute,
navigate,
navigateReplace,
} from './router';
import { authState, boot } from './lib/auth';
import { initTheme } from './lib/theme';
import {
loadCache,
initSyncChannel,
initOnlineListeners,
} from './state/store';
import { sessionCleared } from './lib/api';
import { Shell } from './components/Shell';
import { Login } from './screens/Login';
import { NotesList } from './screens/NotesList';
import { Editor } from './screens/Editor';
import { Settings } from './screens/Settings';
import { Spinner } from './components/Spinner';
// Boot once before first render: theme + cached notes + cross-tab wiring,
// then run the auth gate (boot). The boot probe decides session state; it is
// never guessed from a cached value (UX §3.2).
initTheme();
loadCache();
initSyncChannel();
initOnlineListeners();
boot();
function Gate() {
const [, setTick] = useState(0);
const state = authState.value;
// Re-render on auth/session/path changes.
useEffect(() => {
const subs = [
authState.subscribe(() => setTick((t) => t + 1)),
sessionCleared.subscribe(() => setTick((t) => t + 1)),
];
const onPath = () => setTick((t) => t + 1);
window.addEventListener('popstate', onPath);
return () => {
subs.forEach((s) => s());
window.removeEventListener('popstate', onPath);
};
}, []);
const path = currentPath.value;
const search = currentSearch.value;
const route = matchRoute(path);
// Root "/" redirects based on the boot decision (replaces the history entry).
if (route?.name === '/') {
const target = state === 'authed' ? '/notes' : '/login';
navigateReplace(target);
return <LoadingShell />;
}
// Auth-less route: /login (also handles the session-cleared redirect target).
if (route?.name === '/login') {
if (state === 'authed') {
navigate('/notes');
return <LoadingShell />;
}
return <Login />;
}
// Session was cleared mid-session (401 after failed refresh): go to login.
if (sessionCleared.value) {
navigateReplace(`/login?next=${encodeURIComponent(path + search)}`);
return <LoadingShell />;
}
// Everything else is authed. While the boot probe is still in flight, show a
// loading shell — never a premature redirect.
if (state === 'loading') {
return <LoadingShell />;
}
if (state === 'anonymous') {
navigateReplace(`/login?next=${encodeURIComponent(path + search)}`);
return <LoadingShell />;
}
// state === 'authed' | 'unknown' — render the authed shell. 'unknown' renders
// the shell with the offline banner (Shell) and never redirects to /login.
if (!route) {
return (
<Shell route={null}>
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 20, fontWeight: 600, margin: '0 0 8px' }}>Not found</p>
<p style={{ color: 'var(--dn-muted)' }}>This page doesn't exist.</p>
</div>
</Shell>
);
}
const backTo = route.name === '/settings' ? '/notes' : undefined;
return (
<Shell route={route.name} backTo={backTo}>
<RouteView route={route} />
</Shell>
);
}
function RouteView({ route }: { route: NonNullable<ReturnType<typeof matchRoute>> }) {
switch (route.name) {
case '/notes':
return <NotesList />;
case '/notes/new':
return <Editor mode="create" />;
case '/notes/:noteId':
return <Editor mode="edit" noteId={route.params.noteId} />;
case '/settings':
return <Settings />;
default:
return null;
}
}
function LoadingShell() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3" role="status">
<Spinner />
<span>Loading…</span>
</div>
</div>
);
}
render(<Gate />, document.getElementById('app') as HTMLElement);
+112
View File
@@ -0,0 +1,112 @@
// Tiny History-API router (no react-router). Real paths, pushState/replaceState,
// back/forward via popstate.
import { ComponentChildren } from 'preact';
import { signal } from '@preact/signals';
export const currentPath = signal<string>(window.location.pathname);
export const currentSearch = signal<string>(window.location.search);
// True once the app navigated client-side (used to decide /login autofocus —
// only focus on client-side navigation, never on cold page load).
export let didClientNavigate = false;
export type RouteName =
| '/'
| '/login'
| '/notes'
| '/notes/new'
| '/notes/:noteId'
| '/settings'
| null;
export interface Route {
name: Exclude<RouteName, null>;
params: Record<string, string>;
}
const ROUTES: { name: Exclude<RouteName, null>; pattern: string }[] = [
{ name: '/', pattern: '/' },
{ name: '/login', pattern: '/login' },
{ name: '/notes', pattern: '/notes' },
{ name: '/notes/new', pattern: '/notes/new' },
{ name: '/notes/:noteId', pattern: '/notes/:noteId' },
{ name: '/settings', pattern: '/settings' },
];
function compile(pattern: string): { regex: RegExp; keys: string[] } {
const keys: string[] = [];
const src = pattern.replace(/:[^/]+/g, (m) => {
keys.push(m.slice(1));
return '([^/]+)';
});
return { regex: new RegExp('^' + src + '$'), keys };
}
const COMPILED = ROUTES.map((r) => ({ ...r, ...compile(r.pattern) }));
export function matchRoute(path: string): Route | null {
for (const r of COMPILED) {
const m = path.match(r.regex);
if (m) {
const params: Record<string, string> = {};
r.keys.forEach((k, i) => {
params[k] = decodeURIComponent(m[i + 1]);
});
return { name: r.name, params };
}
}
return null;
}
function sync(): void {
currentPath.value = window.location.pathname;
currentSearch.value = window.location.search;
}
export function navigate(path: string): void {
didClientNavigate = true;
window.history.pushState({}, '', path);
sync();
}
export function navigateReplace(path: string): void {
didClientNavigate = true;
window.history.replaceState({}, '', path);
sync();
}
// Only accepts same-origin paths (must start with "/" and not "//").
export function sanitizeNext(next: string | null | undefined): string {
if (next && next.startsWith('/') && !next.startsWith('//')) return next;
return '/notes';
}
export function getQueryParam(search: string, key: string): string | null {
const params = new URLSearchParams(search);
return params.get(key);
}
export interface LinkProps
extends preact.JSX.HTMLAttributes<HTMLAnchorElement> {
href: string;
children?: ComponentChildren;
}
export function Link({ href, children, onClick, ...rest }: LinkProps) {
return (
<a
href={href}
{...rest}
onClick={(e) => {
if (onClick) onClick(e as preact.JSX.TargetedMouseEvent<HTMLAnchorElement>);
if (e.defaultPrevented) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}
+566
View File
@@ -0,0 +1,566 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { navigate } from '../router';
import { ApiError, apiFetch, isOfflineError } from '../lib/api';
import { TagInput } from '../components/TagInput';
import { ConfirmDialog } from '../components/ConfirmDialog';
import { Spinner, LoadingRow } from '../components/Spinner';
import { getDraft, saveDraft, clearDraft } from '../lib/drafts';
import type { Note } from '../types';
import {
findNote,
isOfflineNote,
optimisticInsert,
optimisticUpdate,
optimisticDelete,
enqueueCreate,
enqueueUpdate,
enqueueDelete,
saveCreateOnline,
saveUpdateOnline,
deleteNoteOnline,
createNotice,
} from '../state/store';
interface EditorProps {
mode: 'create' | 'edit';
noteId?: string;
}
type LoadState = 'resolving' | 'ready' | 'not-found' | 'load-error' | 'offline';
export function Editor({ mode, noteId }: EditorProps) {
const isCreate = mode === 'create';
const draftKey = isCreate ? 'new' : noteId ?? '';
const noteIdResolved = useMemo(
() => (isCreate ? null : noteId ?? null),
[isCreate, noteId]
);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [validation, setValidation] = useState(false);
const [loadState, setLoadState] = useState<LoadState>(
isCreate ? 'ready' : 'resolving'
);
const [loadError, setLoadError] = useState<string | null>(null);
const [retryTick, setRetryTick] = useState(0);
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [draftRestored, setDraftRestored] = useState(false);
const [isPendingNote, setIsPendingNote] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const localId = () =>
'local-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
// --- Deep-link / edit resolution (UX §4.4) --------------------------------------
useEffect(() => {
if (isCreate) {
setLoadState('ready');
return;
}
const id = noteId ?? '';
// local-… id → cache-only editor; getNote is never called.
if (isOfflineNote(id)) {
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setIsPendingNote(true);
setLoadState('ready');
} else {
setLoadState('offline');
}
return;
}
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setLoadState('ready');
return;
}
// Not cached → deep link: GET /web/action/getNote?id= (via apiFetch so a
// 401 triggers the single-flight refresh + one retry, UX §4.6).
let cancelled = false;
(async () => {
setLoadState('resolving');
setLoadError(null);
try {
const res = await apiFetch(`/web/action/getNote?id=${encodeURIComponent(id)}`, {
refreshOn401: true,
timeoutMs: 8000,
});
if (cancelled) return;
if (res.status === 200) {
const note = await res.json();
note.tags = note.tags ?? [];
if (cancelled) return;
optimisticInsert(note);
setTitle(note.title);
setContent(note.content);
setTags(note.tags);
setLoadState('ready');
} else if (res.status === 400 || res.status === 404) {
// 400 (invalid id) or 404 → not-found, never retried in a loop
setLoadState('not-found');
} else {
setLoadState('load-error');
setLoadError(`Something went wrong (HTTP ${res.status})`);
}
} catch (e) {
if (cancelled) return;
if (e instanceof ApiError && e.status === 401) {
// session cleared → apiFetch already redirected to /login
setLoadState('load-error');
setLoadError('Please sign in again');
} else if (isOfflineError(e)) {
setLoadState('offline');
} else {
setLoadState('load-error');
setLoadError(e instanceof Error ? e.message : 'Something went wrong');
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCreate, noteId, retryTick]);
// Auto-retry a deep-link offline load when the network returns (C16).
useEffect(() => {
if (loadState !== 'offline') return;
const onOnline = () => setRetryTick((t) => t + 1);
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
}, [loadState]);
// --- Restore draft (O3) + focus the title input ----------------------------------
useEffect(() => {
if (loadState !== 'ready') return;
const d = getDraft(draftKey);
if (d && !isPendingNote) {
setTitle(d.title);
setContent(d.content);
setTags(d.tags ?? []);
setDraftRestored(true);
}
titleRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadState]);
// --- Debounced draft persistence + beforeunload (O3) -----------------------------
useEffect(() => {
if (loadState !== 'ready' || !dirty) return;
const id = window.setTimeout(() => {
saveDraft(draftKey, { title, content, tags });
}, 400);
return () => window.clearTimeout(id);
}, [title, content, tags, dirty, loadState, draftKey]);
useEffect(() => {
if (loadState !== 'ready' || !dirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [loadState, dirty]);
// --- Keyboard: Ctrl/Cmd+S to save (unless a dialog is open) ----------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
if (confirmOpen) return;
e.preventDefault();
doSave();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, content, tags, loadState, confirmOpen, saving, noteIdResolved, isPendingNote]);
// Clear any create partial-success notice when opening the editor.
useEffect(() => {
createNotice.value = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const markDirty = (fn: () => void) => {
setDirty(true);
fn();
};
const markSaved = () => {
clearDraft(draftKey);
setDirty(false);
navigate('/notes');
};
const doSave = async () => {
if (saving || loadState !== 'ready') return;
if (!title.trim() && !content.trim()) {
setValidation(true);
return;
}
setValidation(false);
setSaveError(null);
setSaving(true);
try {
const finalTitle = title.trim() || 'Untitled';
if (isCreate) {
// Offline → enqueue ONE folded create op.
if (typeof navigator !== 'undefined' && !navigator.onLine) {
const id = localId();
optimisticInsert({
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: new Date().toISOString(),
});
enqueueCreate(id, { title: finalTitle, content, tags });
markSaved();
return;
}
try {
const { note, partialSuccess } = await saveCreateOnline(finalTitle, content, tags);
if (partialSuccess) {
createNotice.value = { noteId: note.id, noteTitle: finalTitle };
}
markSaved();
} catch (e) {
if (isOfflineError(e)) {
const id = localId();
optimisticInsert({
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: new Date().toISOString(),
});
enqueueCreate(id, { title: finalTitle, content, tags });
markSaved();
return;
}
// create failed non-network → nothing created, editor stays
setSaveError(errMessage(e, 'Failed to save note'));
}
return;
}
// Edit mode
const id = noteIdResolved;
if (!id) {
setSaveError('Failed to save note');
return;
}
const base = findNote(id);
const optimistic: Note = {
id,
title: finalTitle,
content,
tags,
modified: new Date().toISOString(),
created: base?.created ?? new Date().toISOString(),
};
const enqueueEdit = () => enqueueUpdate(id, { title: finalTitle, content, tags });
if (typeof navigator !== 'undefined' && !navigator.onLine) {
optimisticUpdate(optimistic);
enqueueEdit();
markSaved();
return;
}
try {
await saveUpdateOnline({ id, title: finalTitle, content, tags });
markSaved();
} catch (e) {
if (isOfflineError(e)) {
optimisticUpdate(optimistic);
enqueueEdit();
markSaved();
return;
}
// non-network → saveUpdateOnline already reverted the optimistic patch
setSaveError(errMessage(e, 'Failed to save note'));
}
} finally {
setSaving(false);
}
};
const doDelete = async () => {
if (deleting || !noteIdResolved) return;
setDeleting(true);
setDeleteError(null);
try {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
enqueueDelete(noteIdResolved);
optimisticDelete(noteIdResolved);
markSaved();
return;
}
try {
await deleteNoteOnline(noteIdResolved);
} catch (e) {
if (isOfflineError(e)) {
enqueueDelete(noteIdResolved);
optimisticDelete(noteIdResolved);
markSaved();
return;
}
throw e;
}
optimisticDelete(noteIdResolved);
markSaved();
} catch (e) {
// non-network delete error → dialog closed, note intact, banner
setDeleteError(errMessage(e, 'Failed to delete note'));
setConfirmOpen(false);
} finally {
setDeleting(false);
}
};
// --- Render states -----------------------------------------------------------------
if (loadState === 'resolving') {
return (
<div className="max-w-[720px] mx-auto px-4 py-6">
<LoadingRow text="Loading…" />
</div>
);
}
if (loadState === 'not-found') {
return (
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 20, fontWeight: 600, margin: '0 0 8px' }}>Note not found</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 24px' }}>
This note may have been deleted.
</p>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/notes')}>
Back to notes
</button>
</div>
);
}
if (loadState === 'offline') {
return (
<div className="max-w-[720px] mx-auto px-4 py-16 text-center">
<p style={{ fontSize: 17, fontWeight: 600, margin: '0 0 8px' }}>
You&apos;re offline — this note isn&apos;t in your saved copy.
</p>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/notes')}>
Back to notes
</button>
</div>
);
}
if (loadState === 'load-error') {
return (
<div className="max-w-[720px] mx-auto px-4 py-6">
<div className="banner banner-error" role="alert">
<span>{loadError ?? 'Something went wrong'}</span>
<button
type="button"
className="banner-action"
onClick={() => {
const id = noteId ?? '';
if (isOfflineNote(id)) return;
const cached = findNote(id);
if (cached) {
setTitle(cached.title);
setContent(cached.content);
setTags(cached.tags ?? []);
setLoadState('ready');
} else {
setRetryTick((t) => t + 1);
}
}}
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="max-w-[720px] mx-auto px-4 sm:px-6 py-6">
{/* visually hidden h1 */}
<h1 className="sr-only">{isCreate ? 'New note' : 'Edit note'}</h1>
{draftRestored && (
<div className="banner banner-info mb-4" role="status">
<span>Restored your unsaved changes.</span>
<button
type="button"
className="banner-action"
onClick={() => {
clearDraft(draftKey);
setDraftRestored(false);
}}
>
Discard
</button>
</div>
)}
{isPendingNote && (
<div className="banner banner-warn mb-4" role="status">
Pending — not yet synced.
</div>
)}
{saveError && (
<div className="banner banner-error mb-4" role="alert">
<span>{saveError}</span>
<button type="button" className="banner-action" onClick={doSave}>
Retry
</button>
</div>
)}
{deleteError && (
<div className="banner banner-error mb-4" role="alert">
<span>{deleteError}</span>
<button
type="button"
className="banner-action"
onClick={() => {
setDeleteError(null);
setConfirmOpen(true);
}}
>
Retry
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
doSave();
}}
className="flex flex-col gap-5"
>
<div>
<label className="sr-only" htmlFor="ed-title">
Title
</label>
<input
ref={titleRef}
id="ed-title"
className="w-full"
style={{
fontSize: 24,
lineHeight: 1.3,
fontWeight: 700,
color: 'var(--dn-ink)',
background: 'transparent',
border: 'none',
padding: '4px 0',
outline: 'none',
}}
type="text"
value={title}
placeholder="Title"
maxLength={200}
onInput={(e) => markDirty(() => setTitle((e.target as HTMLInputElement).value))}
/>
</div>
<div>
<label className="sr-only" htmlFor="ed-content">
Note content
</label>
<textarea
id="ed-content"
className="w-full"
style={{
minHeight: 280,
fontSize: 15,
lineHeight: 1.5,
color: 'var(--dn-ink)',
background: 'transparent',
border: 'none',
resize: 'vertical',
outline: 'none',
}}
value={content}
placeholder="Write something…"
onInput={(e) => markDirty(() => setContent((e.target as HTMLInputElement).value))}
/>
</div>
<div>
<TagInput tags={tags} onChange={(t) => markDirty(() => setTags(t))} />
</div>
{validation && !title.trim() && !content.trim() && (
<p className="text-[13px]" role="alert" style={{ color: 'var(--dn-bad)', margin: 0 }}>
Enter a title or some text first
</p>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pt-2">
<button
type="submit"
className="btn btn-primary"
disabled={saving}
aria-busy={saving || undefined}
style={{ minWidth: 120 }}
>
{saving && <Spinner />}
{saving ? 'Saving…' : 'Save'}
</button>
{!isCreate && (
<button
type="button"
className="btn btn-danger"
onClick={() => {
setDeleteError(null);
setConfirmOpen(true);
}}
>
Delete note
</button>
)}
</div>
</form>
{confirmOpen && (
<ConfirmDialog
title="Delete note"
body={<>Delete “{title.trim() || 'Untitled'}”? This cannot be undone.</>}
confirmLabel="Delete"
busy={deleting}
busyLabel="Deleting…"
onConfirm={doDelete}
onCancel={() => setConfirmOpen(false)}
/>
)}
</div>
);
}
function errMessage(e: unknown, fallback: string): string {
return e instanceof ApiError && e.message ? e.message : fallback;
}
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, ApiError, jsonBody, toApiError } from '../lib/api';
import { setAuthed } from '../lib/auth';
import { currentSearch, didClientNavigate, getQueryParam, navigate, sanitizeNext } from '../router';
import { Spinner } from '../components/Spinner';
import type { User } from '../types';
type Probe = 'checking' | 'connected' | 'unreachable';
interface FieldError {
email?: string;
password?: string;
username?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function Login() {
const [register, setRegister] = useState(() => getQueryParam(currentSearch.value, 'mode') === 'register');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldError>({});
const [serverError, setServerError] = useState<string | null>(null);
const [probe, setProbe] = useState<Probe>('checking');
const emailRef = useRef<HTMLInputElement>(null);
const next = sanitizeNext(getQueryParam(currentSearch.value, 'next'));
useEffect(() => {
probeServer();
// autofocus Email only on client-side navigation, never cold page load
if (didClientNavigate) emailRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
const toggleMode = () => {
const newMode = !register;
setRegister(newMode);
setFieldErrors({});
setServerError(null);
window.history.replaceState(
{},
'',
newMode ? '/login?mode=register' : '/login'
);
};
const validate = (): boolean => {
const errs: FieldError = {};
if (!EMAIL_RE.test(email.trim())) errs.email = 'Enter a valid email address';
if (!password) errs.password = 'Password is required';
if (register && !username.trim()) errs.username = 'Username is required';
setFieldErrors(errs);
return Object.keys(errs).length === 0;
};
const submit = async (e: Event) => {
e.preventDefault();
setServerError(null);
if (submitting) return;
if (!validate()) return;
setSubmitting(true);
const body = register
? { username: username.trim(), password, email: email.trim() }
: { email: email.trim(), password };
try {
const res = await apiFetch(register ? '/web/auth/register' : '/web/auth/login', {
refreshOn401: false,
...jsonBody(body),
});
if (res.ok) {
const data = (await res.json()) as { user?: User };
if (data?.user) {
setAuthed(data.user);
navigate(next);
return;
}
throw new ApiError(0, 'Something went wrong');
}
const err = await toApiError(res);
throw err;
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Something went wrong';
setServerError(msg);
} finally {
setSubmitting(false);
}
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<a href="#login-main" className="skip-link">
Skip to content
</a>
<main id="login-main" className="w-full max-w-[400px] card p-6 sm:p-8" tabIndex={-1}>
<div className="flex flex-col items-center text-center">
<div className="logo-tile mb-4" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path
d="M4 20h4l10.5-10.5a2.83 2.83 0 10-4-4L4 16v4z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<path d="M13.5 6.5l4 4" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
<h1 className="text-[24px] font-bold leading-[1.25]" style={{ margin: 0 }}>
KeepNote
</h1>
<p className="mt-1 mb-6" style={{ color: 'var(--dn-muted)', fontSize: 15 }}>
Your notes on Dcntrlized
</p>
</div>
<form onSubmit={submit} noValidate>
<div className="mb-4">
<label className="field-label" htmlFor="email">
Email
</label>
<input
ref={emailRef}
id="email"
className="field"
type="email"
value={email}
placeholder="[email protected]"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
{fieldErrors.email && (
<p id="email-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.email}
</p>
)}
</div>
{register && (
<div className="mb-4">
<label className="field-label" htmlFor="username">
Username
</label>
<input
id="username"
className="field"
type="text"
value={username}
placeholder="johndoe"
autoComplete="username"
autoCapitalize="none"
spellcheck={false}
aria-invalid={!!fieldErrors.username}
aria-describedby={fieldErrors.username ? 'username-error' : undefined}
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
/>
{fieldErrors.username && (
<p id="username-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.username}
</p>
)}
</div>
)}
<div className="mb-5">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className="field"
type="password"
value={password}
placeholder="••••••••"
autoComplete={register ? 'new-password' : 'current-password'}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
{fieldErrors.password && (
<p id="password-error" className="mt-1 text-[13px]" style={{ color: 'var(--dn-bad)' }} role="alert">
{fieldErrors.password}
</p>
)}
</div>
{serverError && (
<div className="mb-4 banner banner-error" role="alert">
{serverError}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={submitting}>
{submitting && <Spinner />}
{register ? 'Create account' : 'Sign in'}
</button>
</form>
<button
type="button"
className="mt-3 w-full"
aria-pressed={register}
style={{ minHeight: 44, color: 'var(--dn-accent)', fontWeight: 600, background: 'transparent', border: 'none', cursor: 'pointer' }}
onClick={toggleMode}
>
{register ? 'Already have an account? Sign in' : 'New here? Create an account'}
</button>
</main>
<footer
className="w-full max-w-[400px] mt-6 flex items-center gap-2"
role="status"
style={{ color: 'var(--dn-muted)', fontSize: 12, minHeight: 44 }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected' ? 'var(--dn-ok)' : probe === 'unreachable' ? 'var(--dn-bad)' : 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button
type="button"
className="banner-action"
style={{ marginLeft: 'auto', minHeight: 44 }}
onClick={probeServer}
>
Retry
</button>
</>
)}
</footer>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Link, navigateReplace, getQueryParam, currentSearch } from '../router';
import { useNow } from '../lib/hooks';
import { relativeTime, snippet } from '../lib/relativeTime';
import { LoadingRow } from '../components/Spinner';
import {
notes,
notesLoading,
notesError,
offline,
refreshNotes,
pendingCount,
isOfflineNote,
syncNotice,
createNotice,
} from '../state/store';
import type { Note } from '../types';
export function NotesList() {
const now = useNow(60000);
const searchInputRef = useRef<HTMLInputElement>(null);
const h1Ref = useRef<HTMLHeadingElement>(null);
const [q, setQ] = useState(() => getQueryParam(currentSearch.value, 'q') ?? '');
const list = notes.value;
const loading = notesLoading.value;
const error = notesError.value;
const isOffline = offline.value;
const pending = pendingCount();
const notice = syncNotice.value;
// Re-render when the create partial-success notice changes.
const [cNotice, setCNotice] = useState(() => createNotice.value);
useEffect(() => {
const sub = createNotice.subscribe((v) => setCNotice(v));
return () => sub();
}, []);
// focus the visible h1 on route change
useEffect(() => {
h1Ref.current?.focus();
}, []);
// load notes on mount (if signed in) + refresh on focus
useEffect(() => {
refreshNotes();
const onFocus = () => {
if (!document.hidden) refreshNotes();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
// debounced ?q= replaceState sync (300ms)
useEffect(() => {
const id = window.setTimeout(() => {
if (q) navigateReplace(`/notes?q=${encodeURIComponent(q)}`);
else navigateReplace('/notes');
}, 300);
return () => window.clearTimeout(id);
}, [q]);
// "/" focuses search unless typing
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const typing =
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable);
if (e.key === '/' && !typing) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const query = q.trim().toLowerCase();
if (!query) return list;
return list.filter((n) => {
if ((n.title || '').toLowerCase().includes(query)) return true;
if ((n.content || '').toLowerCase().includes(query)) return true;
if (n.tags.some((t) => t.toLowerCase().includes(query))) return true;
return false;
});
}, [list, q]);
const noNotes = list.length === 0;
const noMatches = !noNotes && filtered.length === 0;
const clearSearch = () => {
setQ('');
searchInputRef.current?.focus();
navigateReplace('/notes');
};
return (
<div className="max-w-[720px] mx-auto px-4 sm:px-6 py-6">
<h1 ref={h1Ref} tabIndex={-1} className="text-[20px] leading-[1.4] font-bold mb-5" style={{ margin: 0, marginBottom: 20, outline: 'none' }}>
KeepNote
</h1>
{/* toolbar */}
<div className="flex items-center gap-2 mb-5">
<input
ref={searchInputRef}
className="field flex-1"
type="search"
value={q}
placeholder="Search notes"
aria-label="Search notes"
onInput={(e) => setQ((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setQ('');
(e.target as HTMLInputElement).blur();
navigateReplace('/notes');
}
}}
/>
<button
type="button"
className="icon-btn"
aria-label={loading ? 'Refreshing…' : 'Refresh notes'}
aria-busy={loading || undefined}
disabled={loading}
onClick={() => refreshNotes()}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
style={loading ? { animation: 'spin 0.8s linear infinite' } : undefined}
>
<path
d="M21 12a9 9 0 11-2.64-6.36M21 3v6h-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{/* aria-live status region */}
<div className="sr-only" role="status" aria-live="polite">
{loading && noNotes ? 'Loading…' : noMatches ? 'No matches' : `${filtered.length} notes`}
</div>
{notice && (
<div className="banner banner-warn mb-4" role="status">
{notice}
</div>
)}
{cNotice && (
<div className="banner banner-warn mb-4" role="status">
<span>
Note saved — tags weren&apos;t added.
</span>
<Link href={`/notes/${cNotice.noteId}`} className="banner-action">
Edit note
</Link>
</div>
)}
{loading && noNotes && <LoadingRow />}
{!loading && noNotes && (
<div className="card p-8 text-center" style={{ padding: '48px 24px' }}>
<p className="font-semibold" style={{ fontSize: 17, margin: '0 0 4px' }}>
No notes yet
</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 20px' }}>Create your first note</p>
<Link href="/notes/new" className="btn btn-primary">
New note
</Link>
</div>
)}
{!loading && noMatches && (
<div className="card p-8 text-center" style={{ padding: '48px 24px' }}>
<p className="font-semibold" style={{ fontSize: 17, margin: '0 0 4px' }}>
No matches
</p>
<p style={{ color: 'var(--dn-muted)', margin: '0 0 20px' }}>Try a different search</p>
<button type="button" className="btn btn-ghost" onClick={clearSearch}>
Clear search
</button>
</div>
)}
{error && (
<div className="banner banner-error mb-4" role="alert">
<span>{error}</span>
<button type="button" className="banner-action" onClick={() => refreshNotes()}>
Retry
</button>
</div>
)}
{isOffline && !error && (
<div className="banner banner-warn mb-4" role="status">
<span>
Offline — showing saved notes. Changes will sync when you reconnect.
{pending > 0 && (
<>
{' '}
<span className="dn-warn-chip" style={{ padding: '2px 10px', borderRadius: 999, display: 'inline-block' }}>
{pending} pending change{pending === 1 ? '' : 's'}
</span>
</>
)}
</span>
</div>
)}
{!loading && filtered.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 list-none p-0 m-0" style={{ gap: 16 }}>
{filtered.map((note) => (
<li key={note.id}>
<NoteCard note={note} now={now} />
</li>
))}
</ul>
)}
<Link href="/notes/new" className="fab" aria-label="New note" title="New note">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Link>
</div>
);
}
function NoteCard({ note, now }: { note: Note; now: number }) {
const pending = isOfflineNote(note.id);
const title = note.title.trim() || 'Untitled';
const snip = snippet(note.content);
const time = relativeTime(note.modified || note.created, now);
return (
<Link href={`/notes/${note.id}`} className="note-card h-full flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold truncate" style={{ fontSize: 15, color: 'var(--dn-ink)' }}>
{title}
</span>
{pending && (
<span className="dn-warn-chip" style={{ padding: '1px 8px', borderRadius: 999, fontSize: 12, flex: 'none' }}>
Pending
</span>
)}
</div>
{snip && (
<span className="line-clamp-2" style={{ color: 'var(--dn-ink-soft)', fontSize: 13, lineHeight: 1.45 }}>
{snip}
</span>
)}
<span className="mt-auto" style={{ color: 'var(--dn-muted)', fontSize: 12 }}>
<time dateTime={note.modified || note.created}>{time}</time>
</span>
{note.tags.length > 0 && (
<span className="flex flex-wrap gap-1.5 mt-1">
{note.tags.map((tag) => (
<span key={tag} className="tag-chip">
{tag}
</span>
))}
</span>
)}
</Link>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { apiFetch, profile } from '../lib/api';
import { logout } from '../lib/auth';
import { theme, toggleTheme } from '../lib/theme';
import { pendingCount, lastSynced, syncing, syncNow, syncNotice } from '../state/store';
type Probe = 'checking' | 'connected' | 'unreachable';
export function Settings() {
const [probe, setProbe] = useState<Probe>('checking');
const [localPending, setLocalPending] = useState(0);
const [localSyncing, setLocalSyncing] = useState(false);
const [lastSync, setLastSync] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const h1Ref = useRef<HTMLHeadingElement>(null);
const username = profile.value?.username || '—';
const probeServer = async () => {
setProbe('checking');
try {
await apiFetch('/web/auth/me', { refreshOn401: false });
setProbe('connected');
} catch {
setProbe('unreachable');
}
};
useEffect(() => {
probeServer();
setLocalPending(pendingCount());
setLocalSyncing(syncing.value);
setLastSync(lastSynced.value);
setNotice(syncNotice.value);
h1Ref.current?.focus();
const subs = [
syncing.subscribe((v) => setLocalSyncing(v)),
lastSynced.subscribe((v) => setLastSync(v)),
syncNotice.subscribe((v) => setNotice(v)),
];
const onStorage = () => setLocalPending(pendingCount());
window.addEventListener('storage', onStorage);
return () => {
subs.forEach((s) => s());
window.removeEventListener('storage', onStorage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const doSyncNow = async () => {
setNotice(null);
const result = await syncNow();
setLocalPending(pendingCount());
setLastSync(lastSynced.value);
setNotice(syncNotice.value);
};
const doLogout = () => {
logout();
};
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const lastSyncText = lastSync ? new Date(lastSync).toLocaleString() : 'Never';
return (
<div className="max-w-[640px] mx-auto px-4 sm:px-6 py-6">
<h1 ref={h1Ref} tabIndex={-1} className="text-[20px] font-bold mb-5" style={{ outline: 'none' }}>
Settings
</h1>
{notice && (
<div className="banner banner-warn mb-4" role="status">
<span>{notice}</span>
<button type="button" className="banner-action" onClick={() => setNotice(null)}>
Dismiss
</button>
</div>
)}
<section className="card p-5 mb-4" aria-labelledby="s-conn">
<h2 id="s-conn" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Connection
</h2>
<div className="flex items-center gap-2" style={{ minHeight: 44 }}>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
probe === 'connected'
? 'var(--dn-ok)'
: probe === 'unreachable'
? 'var(--dn-bad)'
: 'var(--dn-muted)',
flex: 'none',
}}
/>
{probe === 'checking' && <span>Checking connection…</span>}
{probe === 'connected' && (
<span className="mono truncate" title={origin}>
Connected to {origin}
</span>
)}
{probe === 'unreachable' && (
<>
<span>Server unreachable</span>
<button type="button" className="banner-action" style={{ minHeight: 44 }} onClick={probeServer}>
Retry
</button>
</>
)}
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-appearance">
<h2 id="s-appearance" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Appearance
</h2>
<div className="flex items-center justify-between" style={{ minHeight: 44 }}>
<span className="font-medium">Dark mode</span>
<button
type="button"
role="switch"
aria-checked={theme.value === 'dark'}
className="switch"
onClick={toggleTheme}
>
<span className="knob" />
</button>
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-account">
<h2 id="s-account" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Account
</h2>
<div className="flex items-center justify-between gap-4" style={{ minHeight: 44 }}>
<div>
<div className="text-[13px]" style={{ color: 'var(--dn-muted)' }}>
Signed in as
</div>
<div className="font-semibold truncate" style={{ maxWidth: 220 }}>
{username}
</div>
</div>
<button type="button" className="btn btn-danger" onClick={doLogout}>
Log out
</button>
</div>
</section>
<section className="card p-5 mb-4" aria-labelledby="s-sync">
<h2 id="s-sync" className="text-[13px] uppercase font-semibold mb-3" style={{ color: 'var(--dn-muted)', letterSpacing: '0.04em' }}>
Sync
</h2>
<div className="flex items-center justify-between gap-4" style={{ minHeight: 44 }}>
<div>
<div className="text-[13px]" style={{ color: 'var(--dn-muted)' }}>
Pending changes
</div>
<div aria-live="polite" className="font-semibold">
{localPending}
</div>
<div className="text-[13px] mt-2" style={{ color: 'var(--dn-muted)' }}>
Last synced
</div>
<div>{lastSyncText}</div>
</div>
<button
type="button"
className="btn btn-ghost"
disabled={localSyncing}
onClick={doSyncNow}
>
{localSyncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
</section>
<p className="text-center" style={{ color: 'var(--dn-muted)', fontSize: 13, marginTop: 24 }}>
KeepNote for Dcntrlized
</p>
</div>
);
}
+435
View File
@@ -0,0 +1,435 @@
// Global notes state + outbox (UX §4.2-§4.4).
// Notes cache + pending ops persist in localStorage; every commit re-reads
// storage before writing (multi-tab safe), and a storage listener +
// BroadcastChannel("keepnote.sync") rehydrates foreign changes. Flush is
// cross-tab single-flight via the Web Locks API.
import { signal } from '@preact/signals';
import type { Note, PendingOp } from '../types';
import {
apiFetch,
apiRequest,
ApiError,
isOfflineError,
jsonBody,
toApiError,
} from '../lib/api';
import { KEYS, readJSON, writeJSON, writeString, readString } from '../lib/storage';
import { uid } from '../lib/relativeTime';
export const notes = signal<Note[]>([]);
export const notesLoaded = signal(false);
export const notesLoading = signal(false);
export const notesError = signal<string | null>(null);
export const offline = signal(false);
export const pendingOps = signal<PendingOp[]>([]);
export const lastSynced = signal<string | null>(null);
export const syncing = signal(false);
// Transient sync outcome message surfaced on /settings.
export const syncNotice = signal<string | null>(null);
// Partial-success notice from a create whose chained tag-update failed.
export const createNotice = signal<{ noteId: string; noteTitle: string } | null>(null);
const BROADCAST_CHANNEL = 'keepnote.sync';
export function loadCache(): void {
notes.value = readJSON<Note[]>(KEYS.cache, []);
pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
lastSynced.value = readString(KEYS.lastSynced, '') || null;
notesLoaded.value = true;
}
export function pendingCount(): number {
return pendingOps.value.length;
}
export function isOfflineNote(id: string): boolean {
return id.startsWith('local-');
}
export function findNote(id: string): Note | undefined {
return notes.value.find((n) => n.id === id);
}
// --- low-level commit helpers (re-read + merge for cross-tab) --------------------
export function commitNotes(list: Note[]): void {
notes.value = list;
writeJSON(KEYS.cache, list);
broadcast({ type: 'cache' });
}
export function commitPending(list: PendingOp[]): void {
pendingOps.value = list;
writeJSON(KEYS.pending, list);
broadcast({ type: 'pending', count: list.length });
}
// --- optimistic helpers -------------------------------------------------------------
export function optimisticInsert(note: Note): void {
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === note.id);
if (idx >= 0) list[idx] = note;
else list.unshift(note);
commitNotes(list);
}
export function optimisticUpdate(note: Note): void {
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === note.id);
if (idx >= 0) list[idx] = note;
else list.unshift(note);
commitNotes(list);
}
export function optimisticDelete(id: string): void {
const list = readJSON<Note[]>(KEYS.cache, []).filter((n) => n.id !== id);
commitNotes(list);
}
// --- outbox enqueue (UX §4.3) ----------------------------------------------------------
export function enqueueCreate(localId: string, payload: { title: string; content: string; tags?: string[] }): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
ops.push({ id: uid(), type: 'create', localId, payload });
commitPending(ops);
}
export function enqueueUpdate(noteId: string, payload: { title: string; content: string; tags: string[] }): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
const createIdx = ops.findIndex((o) => o.type === 'create' && o.localId === noteId);
if (createIdx >= 0) {
const op = ops[createIdx];
if (op.type === 'create') {
ops[createIdx] = {
...op,
payload: { title: payload.title, content: payload.content, tags: payload.tags },
};
commitPending(ops);
return;
}
}
const filtered = ops.filter((o) => !(o.type === 'update' && o.noteId === noteId));
filtered.push({ id: uid(), type: 'update', noteId, payload: { id: noteId, ...payload } });
commitPending(filtered);
}
export function enqueueDelete(noteId: string): void {
const ops = readJSON<PendingOp[]>(KEYS.pending, []);
const createIdx = ops.findIndex((o) => o.type === 'create' && o.localId === noteId);
if (createIdx >= 0) {
// deleting a pending create removes the queued op
ops.splice(createIdx, 1);
commitPending(ops);
return;
}
const filtered = ops.filter((o) => !(o.type === 'update' && o.noteId === noteId));
filtered.push({ id: uid(), type: 'delete', noteId });
commitPending(filtered);
}
// --- online save / delete ----------------------------------------------------------------
export async function saveCreateOnline(
title: string,
content: string,
tags: string[]
): Promise<{ note: Note; partialSuccess: boolean }> {
const note = await apiRequest<Note>('/web/action/writeNote', jsonBody({ payload: { title, content } }));
note.tags = note.tags ?? [];
if (tags.length > 0) {
try {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload: { id: note.id, title, content, tags } }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticInsert(updated);
return { note: updated, partialSuccess: false };
} catch (e) {
// chained update failed after a successful create
if (isOfflineError(e)) {
// network: keep the real note, enqueue an update so tags sync later
optimisticInsert(note);
enqueueUpdate(note.id, { title, content, tags });
return { note, partialSuccess: true };
}
// non-network: note WAS created -> partial success, never duplicate
optimisticInsert(note);
return { note, partialSuccess: true };
}
}
optimisticInsert(note);
return { note, partialSuccess: false };
}
export async function saveUpdateOnline(payload: {
id: string;
title: string;
content: string;
tags: string[];
}): Promise<Note> {
// optimistic patch first; reverted by caller on server error
optimisticUpdate({
id: payload.id,
title: payload.title,
content: payload.content,
tags: payload.tags,
modified: new Date().toISOString(),
created: findNote(payload.id)?.created ?? new Date().toISOString(),
});
try {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticUpdate(updated);
return updated;
} catch (e) {
// revert optimistic patch on non-network error
if (!isOfflineError(e)) {
const cached = readJSON<Note[]>(KEYS.cache, []);
const original = cached.find((n) => n.id === payload.id);
if (original) optimisticUpdate(original);
}
throw e;
}
}
export async function deleteNoteOnline(id: string): Promise<void> {
const res = await apiFetch('/web/action/deleteNote', jsonBody({ payload: { id } }, 'DELETE'));
if (!res.ok) {
const err = await toApiError(res);
// 404 = already deleted -> treat as success
if (err.status === 404) return;
throw err;
}
}
// --- refresh list --------------------------------------------------------------------------
export async function refreshNotes(): Promise<void> {
notesLoading.value = true;
notesError.value = null;
try {
const res = await apiFetch('/web/action/getNotes');
if (res.status === 200) {
const list = (await res.json()) as Note[];
for (const n of list) n.tags = n.tags ?? [];
notes.value = list;
writeJSON(KEYS.cache, list);
offline.value = false;
} else {
throw await toApiError(res);
}
} catch (e) {
if (isOfflineError(e)) {
offline.value = true;
// keep the cached list
} else {
notesError.value = e instanceof Error ? e.message : 'Something went wrong';
}
} finally {
notesLoading.value = false;
}
}
// --- flush (cross-tab single-flight via Web Locks) --------------------------------------
async function flushOp(op: PendingOp): Promise<void> {
if (op.type === 'create') {
const created = await apiRequest<Note>(
'/web/action/writeNote',
jsonBody({ payload: { title: op.payload.title, content: op.payload.content } })
);
created.tags = created.tags ?? [];
// replace the local- entry with the server note (id swap happens here, once)
const list = readJSON<Note[]>(KEYS.cache, []);
const idx = list.findIndex((n) => n.id === op.localId);
const merged: Note = {
...created,
title: op.payload.title,
content: op.payload.content,
tags: op.payload.tags ?? [],
};
if (idx >= 0) list[idx] = merged;
else list.unshift(merged);
commitNotes(list);
if (op.payload.tags && op.payload.tags.length > 0) {
try {
await apiRequest<Note>(
'/web/action/updateNote',
jsonBody(
{
payload: {
id: created.id,
title: op.payload.title,
content: op.payload.content,
tags: op.payload.tags,
},
},
'PUT'
)
);
} catch (e) {
if (isOfflineError(e)) throw e; // network -> pause flush
syncNotice.value = 'Note synced without tags';
}
}
return;
}
if (op.type === 'update') {
const updated = await apiRequest<Note>(
'/web/action/updateNote',
jsonBody({ payload: op.payload }, 'PUT')
);
updated.tags = updated.tags ?? [];
optimisticUpdate({ ...updated, title: op.payload.title, content: op.payload.content, tags: op.payload.tags });
return;
}
// delete
const res = await apiFetch('/web/action/deleteNote', jsonBody({ payload: { id: op.noteId } }, 'DELETE'));
if (!res.ok) {
const err = await toApiError(res);
if (err.offline) throw err;
if (err.status === 404) return; // already gone -> success
throw err;
}
}
type FlushResult = 'success' | 'offline' | 'partial';
async function doFlush(): Promise<FlushResult> {
let ops = readJSON<PendingOp[]>(KEYS.pending, []);
let dropped = 0;
while (ops.length > 0) {
const op = ops[0];
try {
await flushOp(op);
} catch (e) {
if (isOfflineError(e)) {
return 'offline'; // pause flush, keep the queue
}
// non-network rejection -> drop the op
ops = ops.slice(1);
commitPending(ops);
dropped++;
continue;
}
ops = ops.slice(1);
commitPending(ops);
}
if (dropped > 0) {
syncNotice.value = `${dropped} change(s) couldn't be synced and were discarded`;
return 'partial';
}
return 'success';
}
export async function flushOutbox(): Promise<FlushResult> {
if (syncing.value) return 'success';
syncing.value = true;
try {
const locks = navigator.locks;
if (locks && typeof locks.request === 'function') {
return await new Promise<FlushResult>((resolve) => {
locks
.request('keepnote.flush', { ifAvailable: true }, async (signal) => {
// signal is null when the lock could not be granted (another tab is
// flushing); lock.aborted means it was released early. In both cases
// skip — another tab owns the flush.
const lock = signal as { aborted?: boolean } | null;
if (!lock || lock.aborted) {
resolve('success');
return;
}
resolve(await doFlush());
})
.catch(() => resolve('success'));
});
}
// fallback: localStorage lock key with expiry
const lockKey = 'keepnote.flush.lock';
const held = readString(lockKey, '');
if (held && Date.now() - Number(held) < 15000) return 'success';
writeString(lockKey, String(Date.now()));
try {
return await doFlush();
} finally {
try {
localStorage.removeItem(lockKey);
} catch {
/* ignore */
}
}
} finally {
syncing.value = false;
}
}
// "Sync now" on /settings: flush then refresh; lastSynced advances only on a
// network-clean sync (C15).
export async function syncNow(): Promise<FlushResult> {
syncing.value = true;
try {
const flushResult = await flushOutbox();
let refreshNetworkOk = true;
try {
await refreshNotes();
} catch {
refreshNetworkOk = false;
}
const stillOffline = !navigator.onLine;
if (flushResult === 'offline' || !refreshNetworkOk || stillOffline) {
syncNotice.value =
"Couldn't sync — you're offline. Your changes are still saved on this device.";
return 'offline';
}
lastSynced.value = new Date().toISOString();
writeString(KEYS.lastSynced, lastSynced.value);
return flushResult;
} finally {
syncing.value = false;
}
}
// --- cross-tab wiring ---------------------------------------------------------------------
function broadcast(msg: { type: 'cache' } | { type: 'pending'; count: number }): void {
try {
if (typeof BroadcastChannel !== 'undefined') {
new BroadcastChannel(BROADCAST_CHANNEL).postMessage(msg);
}
} catch {
/* ignore */
}
}
export function initSyncChannel(): void {
window.addEventListener('storage', (e) => {
if (e.key === KEYS.pending) pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
else if (e.key === KEYS.cache) notes.value = readJSON<Note[]>(KEYS.cache, []);
else if (e.key === KEYS.lastSynced) {
lastSynced.value = readString(KEYS.lastSynced, '') || null;
}
});
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel(BROADCAST_CHANNEL);
bc.addEventListener('message', (ev) => {
const msg = ev.data as { type?: string };
if (msg?.type === 'pending') pendingOps.value = readJSON<PendingOp[]>(KEYS.pending, []);
else if (msg?.type === 'cache') notes.value = readJSON<Note[]>(KEYS.cache, []);
});
}
}
export function initOnlineListeners(): void {
const onOnline = () => {
offline.value = false;
if (pendingCount() > 0) {
flushOutbox().then(() => refreshNotes());
} else {
refreshNotes();
}
};
const onOffline = () => {
offline.value = true;
};
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
}
+43
View File
@@ -0,0 +1,43 @@
export interface User {
id: string;
username: string;
email: string;
}
export interface Note {
id: string;
title: string;
content: string;
tags: string[];
modified: string;
created: string;
}
export type PendingOp =
| {
id: string;
type: 'create';
localId: string;
payload: { title: string; content: string; tags?: string[] };
}
| {
id: string;
type: 'update';
noteId: string;
payload: { id: string; title: string; content: string; tags: string[] };
}
| { id: string; type: 'delete'; noteId: string };
export type AuthState = 'loading' | 'authed' | 'anonymous' | 'unknown';
export interface Profile {
username?: string;
email?: string;
}
export interface Draft {
title: string;
content: string;
tags: string[];
savedAt: number;
}