Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/stash

src/app.ts

266 lines · 12.5 KB · TypeScript
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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