import { packageContext } from 'kody:runtime'
import {
addNote,
archiveNote,
getNote,
listNotesByTag,
listRecent,
searchNotes,
updateNote,
} from './lib/notes.ts'
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
})
}
async function readJson(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json()
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
} catch {
return {}
}
}
function requireAppContext() {
if (!packageContext?.hostedUrl) {
throw new Error('This module must run as a package app.')
}
return packageContext
}
function pageHtml(appBasePath: string): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stash</title>
<style>
:root { color-scheme: light dark; --bg: #f6f8fc; --panel: #fff; --text: #172033; --muted: #637089; --border: #d8deea; --primary: #4f46e5; --primary-text: #fff; --secondary: #eef1f7; --secondary-text: #172033; --tag-bg: #eef1ff; }
@media (prefers-color-scheme: dark) { :root { --bg: #0e1422; --panel: #151d2e; --text: #edf2ff; --muted: #9aa7bd; --border: #2b3548; --primary: #818cf8; --primary-text: #08111f; --secondary: #243149; --secondary-text: #edf2ff; --tag-bg: #26365f; } }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; max-width: 880px; margin: 0 auto; padding: clamp(1rem, 4vw, 2rem); color: var(--text); background: var(--bg); }
textarea, input, select, button { font: inherit; }
textarea { width: 100%; min-height: 120px; resize: vertical; padding: .85rem; border: 1px solid var(--border); border-radius: .75rem; color: var(--text); background: var(--panel); }
input, select { width: 100%; min-width: 0; padding: .65rem; border: 1px solid var(--border); border-radius: .65rem; color: var(--text); background: var(--panel); }
button { padding: .7rem 1rem; border: 0; border-radius: .65rem; background: var(--primary); color: var(--primary-text); cursor: pointer; font-weight: 650; }
button.secondary { background: var(--secondary); color: var(--secondary-text); }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .65rem; align-items: center; }
.actions { display: flex; gap: .65rem; flex-wrap: wrap; margin-top: 1rem; }
.actions input { flex: 1 1 220px; }
.note { border: 1px solid var(--border); border-radius: .9rem; padding: .95rem; margin: .85rem 0; background: var(--panel); }
.meta { color: var(--muted); font-size: .88rem; display: flex; gap: .5rem; flex-wrap: wrap; }
.tag { background: var(--tag-bg); border-radius: 999px; padding: .12rem .5rem; text-decoration: none; color: inherit; }
a { color: var(--primary); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: .55rem 0; }
</style>
</head>
<body>
<h1>Stash</h1>
<p class="meta">Zero-auth notes in this package's storage. Hashtags become tags.</p>
<form id="add-form">
<textarea id="text" placeholder="Example: Projector bulb is 250W #home"></textarea>
<div class="row" style="margin-top:.5rem">
<input id="tags" placeholder="optional tags, comma separated" />
<select id="kind">
<option value="">infer kind</option>
<option>note</option>
<option>reminder</option>
<option>contact</option>
<option>instruction</option>
<option>fact</option>
</select>
<input id="dueAt" placeholder="optional due date" />
<button>Add note</button>
</div>
</form>
<div class="actions">
<input id="query" placeholder="Search notes..." />
<button id="search" type="button">Search</button>
<button id="recent" class="secondary" type="button">Recent</button>
</div>
<div id="status" class="meta" style="margin-top:1rem"></div>
<div id="results"></div>
<script>
const packageBasePath = ${JSON.stringify(appBasePath)};
const apiUrl = (path) => packageBasePath + '/api' + path;
const results = document.querySelector('#results');
const statusEl = document.querySelector('#status');
const searchInput = document.querySelector('#query');
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
}
function tagPageUrl(tag) { return packageBasePath + '/tag/' + encodeURIComponent(tag); }
function notePageUrl(id) { return packageBasePath + '/note/' + encodeURIComponent(id); }
function renderNote(note, score) {
const tags = (note.tags || []).map(tag => '<a class="tag" href="' + tagPageUrl(tag) + '">#' + escapeHtml(tag) + '</a>').join(' ');
const due = note.dueAt ? '<span>due ' + escapeHtml(note.dueAt) + '</span>' : '';
const scoreText = typeof score === 'number' ? '<span>score ' + score.toFixed(2) + '</span>' : '';
return '<article class="note"><strong><a href="' + notePageUrl(note.id) + '">' + escapeHtml(note.summary) + '</a></strong><pre>' + escapeHtml(note.rawText || '') + '</pre><div class="meta"><span>' + escapeHtml(note.kind) + '</span>' + due + scoreText + '<span>' + escapeHtml(note.createdAt) + '</span>' + tags + '</div><div class="row" style="margin-top:.5rem"><button class="secondary" data-open="' + escapeHtml(note.id) + '">Edit</button><button class="secondary" data-archive="' + escapeHtml(note.id) + '">Archive</button></div></article>';
}
function renderNotes(entries) {
results.innerHTML = entries.map(({ note, score }) => renderNote(note, score)).join('') || '<p>No notes yet.</p>';
}
async function loadRecent() {
statusEl.textContent = 'Loading recent notes...';
const res = await fetch(apiUrl('/recent'));
const data = await res.json();
renderNotes((data.notes || []).map(note => ({ note })));
statusEl.textContent = 'Recent notes';
}
async function searchAll() {
const q = searchInput.value.trim();
statusEl.textContent = 'Searching...';
const res = await fetch(apiUrl('/search?q=' + encodeURIComponent(q)));
const data = await res.json();
renderNotes(data.matches || []);
statusEl.textContent = data.query ? 'Matches for "' + data.query + '"' : 'All notes';
}
async function openNote(noteId) {
statusEl.textContent = 'Loading note...';
const res = await fetch(apiUrl('/note?id=' + encodeURIComponent(noteId)));
const data = await res.json();
if (!data.note) { statusEl.textContent = 'Note not found'; return; }
const note = data.note;
results.innerHTML = '<article class="note"><button class="secondary" id="back-to-list">Back</button><h2>Edit note</h2><label>Text<textarea id="edit-text">' + escapeHtml(note.rawText) + '</textarea></label><div class="row" style="margin-top:.65rem"><input id="edit-tags" value="' + escapeHtml((note.tags || []).join(', ')) + '" /><select id="edit-kind"><option>note</option><option>reminder</option><option>contact</option><option>instruction</option><option>fact</option></select><input id="edit-due" value="' + escapeHtml(note.dueAt || '') + '" /></div><div class="row" style="margin-top:.75rem"><button id="save-note">Save changes</button></div></article>';
document.querySelector('#edit-kind').value = note.kind || 'note';
document.querySelector('#back-to-list').addEventListener('click', loadRecent);
document.querySelector('#save-note').addEventListener('click', async () => {
statusEl.textContent = 'Saving...';
const body = { id: note.id, text: document.querySelector('#edit-text').value, tags: document.querySelector('#edit-tags').value.split(',').map(tag => tag.trim()).filter(Boolean), kind: document.querySelector('#edit-kind').value, dueAt: document.querySelector('#edit-due').value.trim() || null };
const saveRes = await fetch(apiUrl('/note'), { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!saveRes.ok) { statusEl.textContent = 'Save failed'; return; }
await openNote(note.id);
});
statusEl.textContent = 'Editing note';
}
document.querySelector('#add-form').addEventListener('submit', async event => {
event.preventDefault();
const text = document.querySelector('#text').value.trim();
if (!text) return;
statusEl.textContent = 'Saving...';
const tags = document.querySelector('#tags').value.split(',').map(t => t.trim()).filter(Boolean);
const kind = document.querySelector('#kind').value;
const dueAt = document.querySelector('#dueAt').value.trim() || null;
const res = await fetch(apiUrl('/notes'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, tags, kind, dueAt }) });
if (!res.ok) { statusEl.textContent = 'Save failed'; return; }
document.querySelector('#text').value = '';
await loadRecent();
});
document.querySelector('#search').addEventListener('click', searchAll);
searchInput.addEventListener('keydown', event => { if (event.key === 'Enter') searchAll(); });
document.querySelector('#recent').addEventListener('click', loadRecent);
results.addEventListener('click', async event => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.dataset.open) return openNote(target.dataset.open);
if (target.dataset.archive) {
await fetch(apiUrl('/archive'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: target.dataset.archive }) });
await loadRecent();
}
});
const parts = window.location.pathname.split('/').filter(Boolean);
const noteIndex = parts.lastIndexOf('note');
const tagIndex = parts.lastIndexOf('tag');
if (noteIndex >= 0 && parts[noteIndex + 1]) openNote(decodeURIComponent(parts[noteIndex + 1]));
else if (tagIndex >= 0 && parts[tagIndex + 1]) {
const tag = decodeURIComponent(parts[tagIndex + 1]);
fetch(apiUrl('/tag?tag=' + encodeURIComponent(tag))).then(res => res.json()).then(data => {
renderNotes((data.notes || []).map(note => ({ note })));
statusEl.textContent = (data.count || 0) + ' notes tagged #' + tag;
});
} else loadRecent();
</script>
</body>
</html>`
}
function page(): Response {
const ctx = requireAppContext()
return new Response(pageHtml(ctx.appBasePath), {
headers: { 'content-type': 'text/html; charset=utf-8' },
})
}
function stringList(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined
return value.filter((tag): tag is string => typeof tag === 'string')
}
async function appFetch(input: Request): Promise<Response> {
const request =
input instanceof Request ? input : new Request('https://stash.invalid/')
const url = new URL(request.url)
if (request.method === 'GET' && !url.pathname.includes('/api/')) {
return page()
}
try {
if (request.method === 'POST' && url.pathname.endsWith('/api/notes')) {
const body = await readJson(request)
return json(
await addNote({
text: String(body.text ?? ''),
tags: stringList(body.tags) ?? [],
kind: typeof body.kind === 'string' ? body.kind : '',
dueAt: typeof body.dueAt === 'string' ? body.dueAt : null,
}),
)
}
if (request.method === 'GET' && url.pathname.endsWith('/api/note')) {
return json({ note: await getNote({ id: url.searchParams.get('id') ?? '' }) })
}
if (request.method === 'PATCH' && url.pathname.endsWith('/api/note')) {
const body = await readJson(request)
return json(
await updateNote({
id: typeof body.id === 'string' ? body.id : '',
text: typeof body.text === 'string' ? body.text : '',
tags: stringList(body.tags),
kind: typeof body.kind === 'string' ? body.kind : '',
dueAt:
'dueAt' in body
? typeof body.dueAt === 'string'
? body.dueAt
: (body.dueAt ?? null)
: undefined,
}),
)
}
if (request.method === 'GET' && url.pathname.endsWith('/api/search')) {
return json(
await searchNotes({
query: url.searchParams.get('q') ?? '',
limit: 25,
}),
)
}
if (request.method === 'GET' && url.pathname.endsWith('/api/recent')) {
return json(await listRecent({ limit: 25 }))
}
if (request.method === 'GET' && url.pathname.endsWith('/api/tag')) {
return json(
await listNotesByTag({
tag: url.searchParams.get('tag') ?? '',
limit: 100,
}),
)
}
if (request.method === 'POST' && url.pathname.endsWith('/api/archive')) {
const body = await readJson(request)
return json(await archiveNote({ id: typeof body.id === 'string' ? body.id : '' }))
}
return json({ error: 'Not found' }, 404)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return json({ error: message }, 500)
}
}
/**
* Stash package app `fetch(request)` handler for the notes UI and JSON API.
*/
export default appFetch