[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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user