← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
src/client/notes-island.ts
215 lines · 5.9 KB · TypeScript/**
* Notes interactive island via platform remix/ui (no @remix-run/*, no import map).
* Mounts into [data-notes-root]. Progressive enhancement: server HTML keeps the shell.
*/
import { createElement as h, createRoot, on } from 'remix/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,
})
})
}