Skip to content

Commit 3fb8979

Browse files
preact 06-tasks: full read/write Tasks app
First single-purpose Preact app that closes the read/write loop: - Login via xlogin - Discover wf:Tracker registrations in publicTypeIndex - Fetch each tracker, render as a kanban column - Add / toggle / edit / delete tasks with optimistic UI - Debounced PUT to the pod via xlogin.authFetch - Per-column save status: saving / saved / error useTracker hook owns one tracker doc + its mutations + save scheduling. useXlogin handles the live identity. Everything else is presentational. ~370 lines of JS, single HTML file, zero build.
1 parent 312c081 commit 3fb8979

2 files changed

Lines changed: 382 additions & 1 deletion

File tree

preact/06-tasks/index.html

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Tasks — solid-preact 06</title>
7+
<style>
8+
* { margin: 0; box-sizing: border-box; }
9+
html, body { height: 100%; }
10+
:root {
11+
--bg: #f5f5f9; --text: #222; --muted: #888;
12+
--card: #fff; --border: #e8e8ee; --shadow: 0 1px 2px rgba(0,0,0,.03);
13+
--accent: #6366f1; --done: #10b981; --pending: #f59e0b; --danger: #ef4444;
14+
}
15+
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
16+
17+
.topbar {
18+
display: flex; align-items: center; gap: 16px;
19+
padding: 18px 24px; border-bottom: 1px solid var(--border);
20+
background: var(--card);
21+
}
22+
.brand { display: flex; align-items: center; gap: 10px; font: 700 18px/1 'Inter'; }
23+
.brand .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #6366f1, #10b981); display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; }
24+
.topbar .grow { flex: 1; }
25+
.who {
26+
font: 500 12.5px/1.4 'Inter'; color: var(--muted);
27+
text-align: right;
28+
}
29+
.who code { color: var(--text); font-size: 12px; }
30+
31+
main { padding: 32px 24px 96px; max-width: 1280px; margin: 0 auto; }
32+
h1.heading { font: italic 400 36px/1.1 Georgia, serif; color: var(--text); letter-spacing: -0.6px; margin-bottom: 6px; }
33+
.sub { font: 400 14px/1.5 'Inter'; color: var(--muted); margin-bottom: 28px; }
34+
35+
.empty {
36+
background: var(--card); border: 1px dashed var(--border); border-radius: 12px;
37+
padding: 60px 20px; text-align: center; color: var(--muted);
38+
font: 400 15px/1.5 'Inter';
39+
}
40+
41+
.columns {
42+
display: grid;
43+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
44+
gap: 18px;
45+
}
46+
.col {
47+
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
48+
padding: 16px; display: flex; flex-direction: column;
49+
box-shadow: var(--shadow);
50+
min-height: 260px;
51+
}
52+
.col-head {
53+
display: flex; align-items: baseline; gap: 10px;
54+
padding: 0 4px 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;
55+
}
56+
.col-title { font: 700 16px/1 'Inter'; color: var(--text); }
57+
.col-count { font: 600 11px/1 'Inter'; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
58+
.col-status { margin-left: auto; font: 500 11px/1 'Inter'; color: var(--muted); }
59+
.col-status.saving { color: var(--pending); }
60+
.col-status.saved { color: var(--done); }
61+
.col-status.error { color: var(--danger); }
62+
63+
.issues { display: flex; flex-direction: column; gap: 6px; flex: 1; min-height: 0; }
64+
.issue {
65+
display: flex; align-items: flex-start; gap: 10px;
66+
padding: 8px 8px;
67+
border-radius: 8px;
68+
transition: background .12s;
69+
position: relative;
70+
}
71+
.issue:hover { background: rgba(99,102,241,.04); }
72+
.issue.done .summary { color: var(--muted); text-decoration: line-through; }
73+
.check {
74+
width: 18px; height: 18px; border-radius: 4px;
75+
border: 1.5px solid #c8c8d0; background: #fff;
76+
flex: 0 0 auto; cursor: pointer; display: inline-flex;
77+
align-items: center; justify-content: center;
78+
font-size: 12px; color: transparent; margin-top: 2px;
79+
transition: all .12s;
80+
}
81+
.check:hover { border-color: var(--accent); }
82+
.check.on { background: var(--done); border-color: var(--done); color: #fff; }
83+
.summary {
84+
flex: 1; min-width: 0; word-break: break-word;
85+
cursor: text; padding: 1px 4px; margin: -1px -4px;
86+
border-radius: 4px; font: 400 14px/1.4 'Inter';
87+
}
88+
.summary:hover { background: rgba(0,0,0,.03); }
89+
.summary.editing { background: #fff; padding: 0; margin: 0; }
90+
.summary input {
91+
width: 100%; padding: 1px 4px;
92+
border: 1px solid var(--accent); border-radius: 4px;
93+
font: inherit; outline: none;
94+
box-shadow: 0 0 0 2px rgba(99,102,241,.18);
95+
}
96+
.del {
97+
opacity: 0; transition: opacity .12s, color .12s;
98+
cursor: pointer; padding: 0 4px;
99+
color: var(--muted); font-size: 16px; line-height: 1;
100+
flex: 0 0 auto;
101+
}
102+
.issue:hover .del { opacity: 1; }
103+
.del:hover { color: var(--danger); }
104+
105+
.add-row {
106+
margin-top: 12px; padding-top: 12px;
107+
border-top: 1px dashed var(--border);
108+
}
109+
.add-row input {
110+
width: 100%; padding: 8px 10px;
111+
border: 1px solid var(--border); border-radius: 8px;
112+
font: 400 14px/1.4 'Inter'; outline: none;
113+
background: var(--bg);
114+
}
115+
.add-row input:focus { border-color: var(--accent); background: var(--card); box-shadow: 0 0 0 2px rgba(99,102,241,.18); }
116+
117+
.footer-tag {
118+
margin-top: 36px; text-align: center;
119+
font: 500 11px/1.4 ui-monospace, monospace;
120+
color: var(--muted); letter-spacing: 0.05em;
121+
}
122+
.footer-tag code { padding: 2px 7px; border-radius: 4px; background: #eef0fe; color: #4338ca; }
123+
124+
.err {
125+
background: #fee2e2; color: #991b1b;
126+
padding: 10px 14px; border-radius: 8px;
127+
font: 400 13px/1.4 'Inter'; margin-bottom: 18px;
128+
}
129+
</style>
130+
</head>
131+
<body>
132+
133+
<script src="https://unpkg.com/xlogin"></script>
134+
135+
<div id="app"></div>
136+
137+
<script type="module">
138+
import { h, render } from 'https://esm.sh/preact@10'
139+
import { useState, useEffect, useCallback, useRef } from 'https://esm.sh/preact@10/hooks'
140+
import htm from 'https://esm.sh/htm@3'
141+
142+
const html = htm.bind(h)
143+
144+
// --- xlogin -----------------------------------------------------------------
145+
146+
function useXlogin() {
147+
const [identity, setIdentity] = useState(() =>
148+
window.xlogin && window.xlogin.id ? { type: window.xlogin.type, id: window.xlogin.id } : null
149+
)
150+
useEffect(() => {
151+
const onIn = (e) => setIdentity({ type: e.detail.type, id: e.detail.id })
152+
const onOut = () => setIdentity(null)
153+
document.addEventListener('xlogin', onIn)
154+
document.addEventListener('xlogout', onOut)
155+
const t = setInterval(() => {
156+
if (window.xlogin && window.xlogin.id && !identity) setIdentity({ type: window.xlogin.type, id: window.xlogin.id })
157+
}, 500)
158+
return () => { document.removeEventListener('xlogin', onIn); document.removeEventListener('xlogout', onOut); clearInterval(t) }
159+
}, [])
160+
return identity
161+
}
162+
163+
// --- pod fetch + write ------------------------------------------------------
164+
165+
const fetcher = () => (window.xlogin && window.xlogin.authFetch) || fetch
166+
const idOf = (v) => typeof v === 'string' ? v : (v && v['@id']) || null
167+
const TRACKER_CLASS = 'http://www.w3.org/2005/01/wf/flow#Tracker'
168+
169+
async function fetchJsonLd(url) {
170+
const r = await fetcher()(url, { headers: { Accept: 'application/ld+json' } })
171+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
172+
return r.json()
173+
}
174+
175+
async function putJsonLd(url, body) {
176+
const r = await fetcher()(url, {
177+
method: 'PUT',
178+
headers: { 'Content-Type': 'application/ld+json' },
179+
body: JSON.stringify(body, null, 2),
180+
})
181+
if (!r.ok) throw new Error(`PUT failed: ${r.status} ${r.statusText}`)
182+
return r
183+
}
184+
185+
async function discoverTrackerUrls(webid) {
186+
const webidDoc = await fetchJsonLd(webid.replace(/#.*$/, ''))
187+
const graph = Array.isArray(webidDoc['@graph']) ? webidDoc['@graph'] : (Array.isArray(webidDoc) ? webidDoc : [webidDoc])
188+
const frag = webid.includes('#') ? webid.split('#')[1] : null
189+
const me = (frag ? graph.find(n => (n['@id'] || '').endsWith('#' + frag)) : null) || graph[0]
190+
const tiRef = me['solid:publicTypeIndex'] || me['http://www.w3.org/ns/solid/terms#publicTypeIndex']
191+
const tiId = idOf(tiRef)
192+
if (!tiId) throw new Error('no solid:publicTypeIndex on WebID')
193+
const tiUrl = new URL(tiId, webid).href
194+
const ti = await fetchJsonLd(tiUrl)
195+
196+
const nodes = []
197+
const collect = (x) => {
198+
if (!x || typeof x !== 'object') return
199+
if (Array.isArray(x)) { x.forEach(collect); return }
200+
if (x['solid:forClass'] || x['http://www.w3.org/ns/solid/terms#forClass']) nodes.push(x)
201+
for (const v of Object.values(x)) if (typeof v === 'object') collect(v)
202+
}
203+
collect(ti)
204+
205+
return nodes
206+
.filter(n => idOf(n['solid:forClass'] || n['http://www.w3.org/ns/solid/terms#forClass']) === TRACKER_CLASS)
207+
.map(n => idOf(n['solid:instance'] || n['http://www.w3.org/ns/solid/terms#instance']))
208+
.filter(Boolean)
209+
}
210+
211+
// --- one-tracker state, with optimistic edits + debounced PUT --------------
212+
213+
function useTracker(url) {
214+
const [state, setState] = useState({ loading: !!url, doc: null, error: null, status: '' })
215+
const saveTimer = useRef(null)
216+
217+
// Initial fetch
218+
useEffect(() => {
219+
if (!url) return
220+
let cancelled = false
221+
fetchJsonLd(url.replace(/#.*$/, ''))
222+
.then(doc => { if (!cancelled) setState(s => ({ ...s, loading: false, doc })) })
223+
.catch(err => { if (!cancelled) setState(s => ({ ...s, loading: false, error: err.message })) })
224+
return () => { cancelled = true }
225+
}, [url])
226+
227+
// Schedule a debounced PUT after each local mutation
228+
const scheduleSave = useCallback((doc) => {
229+
setState(s => ({ ...s, status: 'saving' }))
230+
clearTimeout(saveTimer.current)
231+
saveTimer.current = setTimeout(() => {
232+
putJsonLd(url.replace(/#.*$/, ''), doc)
233+
.then(() => setState(s => ({ ...s, status: 'saved' })))
234+
.catch(err => setState(s => ({ ...s, status: 'error', error: err.message })))
235+
}, 500)
236+
}, [url])
237+
238+
// Mutators — each updates state optimistically then triggers save
239+
const mutate = useCallback((fn) => {
240+
setState(s => {
241+
if (!s.doc) return s
242+
const next = fn(s.doc)
243+
scheduleSave(next)
244+
return { ...s, doc: next, status: 'saving' }
245+
})
246+
}, [scheduleSave])
247+
248+
const issuesOf = (doc) => Array.isArray(doc.issue) ? doc.issue : (doc.issue ? [doc.issue] : [])
249+
const setIssues = (doc, issues) => ({ ...doc, issue: issues })
250+
const nowIso = () => new Date().toISOString()
251+
252+
return {
253+
...state,
254+
addIssue: (summary) => mutate(d => setIssues(d, [...issuesOf(d), {
255+
'@id': `#Iss${Date.now()}`,
256+
'@type': d.issue?.[0]?.['@type'] || 'Vtodo',
257+
summary,
258+
status: d.initialState || 'NEEDS-ACTION',
259+
created: nowIso(),
260+
modified: nowIso(),
261+
}])),
262+
toggleIssue: (issueId) => mutate(d => setIssues(d, issuesOf(d).map(it =>
263+
it['@id'] === issueId
264+
? { ...it, status: it.status === 'COMPLETED' ? 'NEEDS-ACTION' : 'COMPLETED', modified: nowIso() }
265+
: it))),
266+
editIssue: (issueId, summary) => mutate(d => setIssues(d, issuesOf(d).map(it =>
267+
it['@id'] === issueId ? { ...it, summary, modified: nowIso() } : it))),
268+
deleteIssue: (issueId) => mutate(d => setIssues(d, issuesOf(d).filter(it => it['@id'] !== issueId))),
269+
}
270+
}
271+
272+
// --- UI components ----------------------------------------------------------
273+
274+
function IssueRow({ issue, onToggle, onEdit, onDelete }) {
275+
const [editing, setEditing] = useState(false)
276+
const inputRef = useRef(null)
277+
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
278+
279+
const done = issue.status === 'COMPLETED'
280+
const commit = () => { const v = inputRef.current.value.trim(); if (v && v !== issue.summary) onEdit(v); setEditing(false) }
281+
282+
return html`
283+
<div class=${'issue ' + (done ? 'done' : '')}>
284+
<div class=${'check ' + (done ? 'on' : '')} onClick=${onToggle}>${done ? '\u2713' : ''}</div>
285+
${editing
286+
? html`<div class="summary editing"><input ref=${inputRef} defaultValue=${issue.summary || ''} onBlur=${commit} onKeyDown=${(e) => { if (e.key === 'Enter') { e.preventDefault(); commit() } if (e.key === 'Escape') { e.preventDefault(); setEditing(false) } }} /></div>`
287+
: html`<div class="summary" onClick=${() => setEditing(true)}>${issue.summary || '(untitled)'}</div>`}
288+
<div class="del" title="Delete" onClick=${onDelete}>\u00d7</div>
289+
</div>`
290+
}
291+
292+
function Tracker({ url }) {
293+
const t = useTracker(url)
294+
const [draft, setDraft] = useState('')
295+
296+
const submit = (e) => {
297+
e?.preventDefault?.()
298+
const v = draft.trim()
299+
if (!v) return
300+
t.addIssue(v)
301+
setDraft('')
302+
}
303+
304+
if (t.loading) return html`<div class="col"><div class="col-head"><div class="col-title">Loading…</div></div></div>`
305+
if (t.error && !t.doc) return html`<div class="col"><div class="col-head"><div class="col-title">Error</div></div><div class="err">${t.error}</div></div>`
306+
const doc = t.doc || {}
307+
const issues = Array.isArray(doc.issue) ? doc.issue : (doc.issue ? [doc.issue] : [])
308+
const open = issues.filter(i => i.status !== 'COMPLETED').length
309+
310+
return html`
311+
<div class="col">
312+
<div class="col-head">
313+
<div class="col-title">${doc.title || url.split('/').pop()}</div>
314+
<div class="col-count">${open}/${issues.length}</div>
315+
${t.status && html`<div class=${'col-status ' + t.status}>${t.status === 'saving' ? '\u2026 saving' : t.status === 'saved' ? '\u2713 saved' : '\u26a0 ' + (t.error || 'error')}</div>`}
316+
</div>
317+
<div class="issues">
318+
${issues.map(it => html`<${IssueRow} key=${it['@id']} issue=${it}
319+
onToggle=${() => t.toggleIssue(it['@id'])}
320+
onEdit=${(v) => t.editIssue(it['@id'], v)}
321+
onDelete=${() => t.deleteIssue(it['@id'])} />`)}
322+
</div>
323+
<form class="add-row" onSubmit=${submit}>
324+
<input type="text" placeholder="+ add task…" value=${draft} onInput=${(e) => setDraft(e.target.value)} />
325+
</form>
326+
</div>`
327+
}
328+
329+
// --- App --------------------------------------------------------------------
330+
331+
function App() {
332+
const identity = useXlogin()
333+
const webid = identity?.type === 'solid' ? identity.id : null
334+
const [trackerUrls, setTrackerUrls] = useState(null)
335+
const [discErr, setDiscErr] = useState(null)
336+
337+
useEffect(() => {
338+
if (!webid) { setTrackerUrls(null); setDiscErr(null); return }
339+
let cancelled = false
340+
setTrackerUrls(null); setDiscErr(null)
341+
discoverTrackerUrls(webid)
342+
.then(urls => { if (!cancelled) setTrackerUrls(urls) })
343+
.catch(err => { if (!cancelled) setDiscErr(err.message) })
344+
return () => { cancelled = true }
345+
}, [webid])
346+
347+
const shortId = identity?.id?.length > 36 ? identity.id.slice(0, 34) + '\u2026' : identity?.id
348+
349+
return html`
350+
<div class="topbar">
351+
<div class="brand"><span class="logo">\u2705</span><span>Tasks</span></div>
352+
<div class="grow"></div>
353+
<div class="who">
354+
${identity
355+
? html`Signed in · <strong>${identity.type}</strong><br/><code>${shortId}</code>`
356+
: 'Not signed in'}
357+
</div>
358+
</div>
359+
<main>
360+
<h1 class="heading">Tasks</h1>
361+
<p class="sub">Trackers discovered through your TypeIndex. All edits PUT back to your pod.</p>
362+
363+
${!webid && html`<div class="empty">Sign in via the button bottom-right to load your trackers.</div>`}
364+
${webid && discErr && html`<div class="err">Discovery failed: ${discErr}</div>`}
365+
${webid && !trackerUrls && !discErr && html`<div class="empty">Discovering trackers in your pod\u2026</div>`}
366+
${webid && trackerUrls && trackerUrls.length === 0 && html`<div class="empty">No <code>wf:Tracker</code> registrations in your TypeIndex yet.</div>`}
367+
${webid && trackerUrls && trackerUrls.length > 0 && html`
368+
<div class="columns">
369+
${trackerUrls.map(u => html`<${Tracker} key=${u} url=${u} />`)}
370+
</div>`}
371+
372+
<div class="footer-tag">stage 06 \u2014 full read/write \u2014 add, toggle, edit, delete \u2192 PUT via <code>xlogin.authFetch</code></div>
373+
</main>`
374+
}
375+
376+
render(html`<${App} />`, document.getElementById('app'))
377+
</script>
378+
379+
</body>
380+
</html>

preact/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ Each stage is a single self-contained HTML file (zero build). A stage graduates
1515
| 03-routing | Hand-rolled `useHashRoute` hook, persistent left sidebar nav, three views (Profile / Contacts / Calendar) in one SPA. One data island per view, typed. Read-only — edit is stage 2's concern, not repeated here. |
1616
| 04-login | xlogin integration (Nostr/Solid). Listens for `xlogin`/`xlogout` events, shows the live identity in the sidebar, fetches the Solid WebID doc as JSON-LD after login, normalizes common foaf/vcard predicates, and overlays fetched values onto the sample island. Graceful fallback when the pod doesn't speak JSON-LD. |
1717
| 05-discovery | TypeIndex discovery — fetches `solid:publicTypeIndex`, walks `solid:TypeRegistration` nodes for `solid:forClass` + `solid:instance`, and renders a Tasks view backed by the user's `wf:Tracker` resources in their pod. Settings exposes the registration list as a debug aid. No Turtle parser — relies on pods that serve JSON-LD via conneg. |
18-
| 06+ | TBD — whatever the previous stages surface as the next limit. |
18+
| 06-tasks | First focused single-purpose app: Tasks. Discovers `wf:Tracker` registrations via TypeIndex, fetches each, renders kanban columns. Add / toggle / edit / delete tasks → debounced PUT back to the pod via `xlogin.authFetch`. The closing of the read/write loop. |
19+
| 07+ | TBD — whatever the previous stages surface as the next limit. |
1920

2021
## Findings
2122

0 commit comments

Comments
 (0)