Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

starter-fetch/src/client/notes-island.ts

216 lines · 6.0 KB · TypeScript
/**
 * Notes interactive island via @remix-run/ui (browser ESM + import map).
 * Mounts into [data-notes-root]. Progressive enhancement: server HTML keeps the shell.
 * Double-check delete is implemented in the island (Remix createRoot + on()).
 */
import { createElement as h, createRoot, on } from '@remix-run/ui'
import { apiUrl, readClientConfig } from './config.ts'

function toast(title, description, tone) {
	window.pakToast?.show({
		title,
		description,
		tone: tone || 'default',
		durationMs: 2800,
	})
}

function mountNotes(host) {
	if (!host || host.getAttribute('data-notes-mounted') === '1') return
	host.setAttribute('data-notes-mounted', '1')

	const config = readClientConfig()
	const root = createRoot(host)
	const state = {
		notes: [],
		title: '',
		loading: true,
		adding: false,
		inflight: Object.create(null),
		armedId: null,
		error: null,
	}

	function render() {
		const listChildren = []
		if (state.loading) {
			listChildren.push(
				h('li', { class: 'pak-list-item', 'aria-hidden': 'true' },
					h('span', { class: 'pak-skeleton', style: 'height:1em;width:70%' }),
					h('span', { class: 'pak-skeleton', style: 'height:2.5em;width:5.5em' }),
				),
			)
		} else if (state.error) {
			listChildren.push(
				h('li', { class: 'pak-list-item' },
					h('span', { class: 'pak-muted' }, state.error),
				),
			)
		} else if (!state.notes.length) {
			listChildren.push(
				h('li', { class: 'pak-list-item' },
					h('span', { class: 'pak-muted' }, 'No notes yet — add one above.'),
				),
			)
		} else {
			for (const note of state.notes) {
				const pending = Boolean(state.inflight[note.id])
				const armed = state.armedId === note.id
				listChildren.push(
					h('li', {
						class: 'pak-list-item',
						'data-note-id': note.id,
						...(pending ? { 'data-pending': 'true' } : {}),
					},
						h('div', null,
							h('strong', null, note.title),
							pending ? h('span', { class: 'pak-pending-label' }, ' Deleting…') : null,
							h('div', { class: 'pak-muted', style: 'font-size:0.8rem' }, note.createdAt || ''),
						),
						h('button', {
							type: 'button',
							class: 'pak-btn pak-btn-danger',
							...(pending ? { disabled: true } : {}),
							'data-armed': armed ? 'true' : 'false',
							'aria-pressed': armed ? 'true' : 'false',
							...on('click', () => onDeleteClick(note.id)),
							...on('blur', () => {
								if (state.armedId === note.id) {
									state.armedId = null
									render()
								}
							}),
						}, armed ? 'Are you sure?' : 'Delete'),
					),
				)
			}
		}

		root.render(
			h('div', { class: 'pak-stack', 'data-remix-notes': 'true' },
				h('form', {
					class: 'pak-row',
					...on('submit', onSubmit),
				},
					h('label', { class: 'pak-muted', style: 'flex:1 1 180px' },
						h('span', { class: 'pak-eyebrow' }, 'New note'),
						h('input', {
							name: 'title',
							required: true,
							maxlength: '120',
							placeholder: 'Write a note…',
							value: state.title,
							style:
								'display:block;width:100%;min-height:var(--tap);margin-top:var(--space-1);padding:0 var(--space-3);border:1px solid var(--line);border-radius:var(--radius-sm);font:inherit;background:var(--surface);color:var(--ink)',
							...on('input', (e) => {
								state.title = e.target.value
							}),
						}),
					),
					h('button', {
						class: 'pak-btn pak-btn-accent',
						type: 'submit',
						...(state.adding ? { disabled: true } : {}),
						style: 'flex:0 0 auto;align-self:end',
					}, state.adding ? 'Adding…' : 'Add'),
				),
				h('ul', { class: 'pak-list', 'aria-live': 'polite' }, ...listChildren),
			),
		)
	}

	async function loadNotes() {
		state.loading = true
		state.error = null
		render()
		try {
			const res = await fetch(apiUrl(config, '/api/notes'), { cache: 'no-store' })
			if (!res.ok) throw new Error(`notes ${res.status}`)
			const data = await res.json()
			state.notes = Array.isArray(data.notes) ? data.notes : []
			state.loading = false
			render()
		} catch (e) {
			state.loading = false
			state.error = 'Could not load notes.'
			render()
			toast('Could not load notes', String(e?.message || e), 'error')
		}
	}

	async function onSubmit(e) {
		e.preventDefault()
		const title = String(state.title || '').trim()
		if (!title || state.adding) return
		state.adding = true
		render()
		try {
			const res = await fetch(apiUrl(config, '/api/notes'), {
				method: 'POST',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify({ title }),
			})
			if (!res.ok) throw new Error(`create ${res.status}`)
			state.title = ''
			state.adding = false
			toast('Note added', title, 'success')
			await loadNotes()
		} catch (err) {
			state.adding = false
			render()
			toast('Could not add note', String(err?.message || err), 'error')
		}
	}

	async function onDeleteClick(id) {
		if (state.inflight[id]) return
		if (state.armedId !== id) {
			state.armedId = id
			render()
			return
		}
		state.armedId = null
		state.inflight[id] = true
		render()
		try {
			const res = await fetch(apiUrl(config, `/api/notes/${encodeURIComponent(id)}`), {
				method: 'DELETE',
			})
			if (!res.ok) throw new Error(`delete ${res.status}`)
			delete state.inflight[id]
			state.notes = state.notes.filter((n) => n.id !== id)
			render()
			toast('Note deleted', 'Removed successfully.', 'success')
		} catch (err) {
			delete state.inflight[id]
			render()
			toast('Could not delete', String(err?.message || err), 'error')
		}
	}

	void loadNotes()
}

export function bootNotesIsland() {
	function tryMount() {
		const host = document.querySelector('[data-notes-root]')
		if (host) mountNotes(host)
	}
	document.addEventListener('pak:pagechange', () => tryMount())
	tryMount()
}

export function bootToastDemo() {
	document.addEventListener('click', (e) => {
		let t = e.target
		if (t && t.nodeType === 3) t = t.parentElement
		const el = t
		if (!el?.closest?.('[data-toast-demo]')) return
		window.pakToast?.show({
			title: 'Toaster ready',
			description: 'Sonner-inspired toast from package-app-kit.',
			tone: 'success',
			durationMs: 2800,
		})
	})
}