|
| 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> |
0 commit comments