Skip to content

Commit 714342b

Browse files
preact 02-inline-edit: useState + debounced autosave
Click any field to edit. Text fields swap to <input>, bio swaps to <textarea>, skills/languages become add/remove pill lists. Esc cancels, Enter commits (Shift+Enter newlines for textarea via default behavior). Autosave writes back to the data island element's textContent (keeping it as the live source of truth, not just initial seed) and logs the body that would PUT to the resource URL. Plug fetch in once wired to a pod. Notable: array editing works here, which ui-pane still defers.
1 parent f56b582 commit 714342b

2 files changed

Lines changed: 374 additions & 1 deletion

File tree

preact/02-inline-edit/index.html

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
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>Profile (editable) — solid-preact 02</title>
7+
<style>
8+
* { margin: 0; box-sizing: border-box; }
9+
body {
10+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
11+
background: #f5f5f9; color: #222; min-height: 100vh;
12+
}
13+
.page {
14+
max-width: 1120px; margin: 0 auto;
15+
padding: 40px 24px 80px;
16+
display: grid;
17+
grid-template-columns: minmax(0, 1fr) 320px;
18+
gap: 24px; align-items: start;
19+
}
20+
@media (max-width: 820px) { .page { grid-template-columns: 1fr; } }
21+
22+
.card {
23+
background: #fff;
24+
border: 1px solid #e8e8ee;
25+
border-radius: 12px;
26+
padding: 28px;
27+
box-shadow: 0 1px 2px rgba(0,0,0,.03);
28+
}
29+
.card + .card { margin-top: 18px; }
30+
31+
.hero { display: flex; gap: 24px; align-items: flex-start; }
32+
.photo, .photo-fallback {
33+
width: 132px; height: 132px; border-radius: 16px; flex: 0 0 auto;
34+
}
35+
.photo { object-fit: cover; background: #eef; }
36+
.photo-fallback {
37+
background: linear-gradient(135deg, #6366f1, #8b5cf6);
38+
color: #fff; font: 600 56px/132px Georgia, serif;
39+
text-align: center;
40+
}
41+
.hero-meta { flex: 1; min-width: 0; }
42+
.name {
43+
font: 700 28px/1.2 'Inter', sans-serif;
44+
color: #1a1a1a; letter-spacing: -0.3px;
45+
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
46+
}
47+
.pronoun { font: 400 15px/1; color: #6366f1; }
48+
.facts {
49+
margin-top: 18px;
50+
display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px;
51+
font-size: 14px; color: #444;
52+
}
53+
@media (max-width: 560px) { .facts { grid-template-columns: 1fr; } }
54+
.fact { display: flex; align-items: center; gap: 8px; min-width: 0; }
55+
.fact .ic { opacity: 0.55; flex: 0 0 auto; }
56+
.fact a { color: inherit; text-decoration: none; }
57+
.fact a:hover { color: #6366f1; }
58+
59+
.section-title { font: 700 18px/1.2 'Inter', sans-serif; color: #1a1a1a; margin-bottom: 14px; }
60+
.prose { font: 400 15px/1.65 Georgia, serif; color: #333; }
61+
.side-title { font: 700 16px/1 'Inter', sans-serif; color: #1a1a1a; margin-bottom: 14px; }
62+
.pills { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
63+
.pill {
64+
display: inline-flex; align-items: center; gap: 4px;
65+
padding: 4px 11px; border-radius: 999px;
66+
background: #eef0fe; color: #4338ca;
67+
font-size: 13px; font-weight: 500;
68+
}
69+
.pill .x {
70+
cursor: pointer; opacity: 0.5; padding: 0 2px;
71+
border-radius: 50%; line-height: 1; font-size: 14px;
72+
}
73+
.pill .x:hover { opacity: 1; color: #c00; }
74+
.pill-add {
75+
border: 1px dashed #c7c9d6; color: #888; background: transparent; cursor: pointer;
76+
}
77+
.pill-add:hover { border-color: #6366f1; color: #6366f1; }
78+
.pill-input {
79+
border: 1px solid #6366f1; border-radius: 999px;
80+
padding: 3px 10px; font: 500 13px/1 inherit; outline: none;
81+
width: 120px; color: #4338ca;
82+
}
83+
.list-plain { list-style: none; display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #333; }
84+
85+
.contact-row { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-top: 1px solid #eef; }
86+
.contact-row:first-child { border-top: 0; padding-top: 0; }
87+
.contact-ic {
88+
width: 32px; height: 32px; border-radius: 8px;
89+
background: #f5f5fb; display: flex; align-items: center; justify-content: center;
90+
flex: 0 0 auto;
91+
}
92+
.contact-body { flex: 1; font-size: 14px; color: #333; word-break: break-word; }
93+
.contact-label { font-size: 12px; color: #888; margin-top: 2px; }
94+
95+
/* Editable bits */
96+
.e {
97+
cursor: text; border-radius: 4px; padding: 1px 5px; margin: -1px -5px;
98+
transition: background .12s ease; outline: none;
99+
display: inline-block; max-width: 100%;
100+
}
101+
.e:hover { background: #eef0fe; }
102+
.e.empty { color: #aaa; font-style: italic; }
103+
.e-block { display: block; }
104+
.e-input, .e-area {
105+
border: 1px solid #6366f1; border-radius: 4px;
106+
padding: 2px 6px; font: inherit; color: inherit;
107+
outline: none; background: #fff; width: 100%;
108+
box-shadow: 0 0 0 2px rgba(99,102,241,.18);
109+
}
110+
.e-area { min-height: 8em; resize: vertical; line-height: 1.65; font-family: Georgia, serif; }
111+
112+
/* Save indicator */
113+
.save-toast {
114+
position: fixed; bottom: 18px; right: 18px;
115+
background: #1a1a1a; color: #fff;
116+
padding: 8px 14px; border-radius: 999px;
117+
font: 500 12px/1 'Inter', sans-serif;
118+
box-shadow: 0 8px 28px rgba(0,0,0,.15);
119+
opacity: 0; transform: translateY(8px);
120+
transition: opacity .18s, transform .18s;
121+
pointer-events: none;
122+
}
123+
.save-toast.show { opacity: 1; transform: translateY(0); }
124+
.save-toast .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #4ade80; margin-right: 6px; vertical-align: middle; }
125+
.save-toast.saving .dot { background: #fbbf24; animation: pulse 1s infinite; }
126+
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
127+
128+
.footer-tag {
129+
margin-top: 32px; text-align: center;
130+
font: 500 11px/1.4 ui-monospace, SFMono-Regular, monospace;
131+
color: #888; letter-spacing: 0.05em;
132+
}
133+
.footer-tag code { padding: 2px 7px; border-radius: 4px; background: #eef0fe; color: #4338ca; }
134+
</style>
135+
</head>
136+
<body>
137+
138+
<script type="application/ld+json">
139+
{
140+
"@context": { "@vocab": "urn:solid:" },
141+
"@id": "#this",
142+
"@type": "Person",
143+
"name": "Sir Tim Berners-Lee",
144+
"nick": "timbl",
145+
"pronouns": "he/him",
146+
"email": "timbl@w3.org",
147+
"phone": "+1 617 000 0000",
148+
"homepage": "https://www.w3.org/People/Berners-Lee/",
149+
"img": "https://www.w3.org/People/Berners-Lee/Press/stock-photos/tim-berners-lee-inrupt-2024.jpg",
150+
"address": "Cambridge, MA, United States",
151+
"bio": "Inventor of the World Wide Web. Director of the World Wide Web Consortium. Co-founder of Inrupt and the Solid project — returning agency over data to the people it describes.",
152+
"skills": ["Web Architecture", "RDF", "Linked Data", "HTTP", "Standards"],
153+
"languages": ["English", "French"]
154+
}
155+
</script>
156+
157+
<div id="app"></div>
158+
<div id="toast" class="save-toast"><span class="dot"></span><span id="toast-text">Saved</span></div>
159+
160+
<script type="module">
161+
import { h, render } from 'https://esm.sh/preact@10'
162+
import { useState, useEffect, useRef, useCallback } from 'https://esm.sh/preact@10/hooks'
163+
import htm from 'https://esm.sh/htm@3'
164+
165+
const html = htm.bind(h)
166+
167+
// --- save infrastructure ---------------------------------------------------
168+
169+
const dataEl = document.querySelector('script[type="application/ld+json"]')
170+
const initialData = JSON.parse(dataEl.textContent)
171+
const dataUrl = window.location.href // would PUT here in production
172+
173+
function showToast(text, kind) {
174+
const t = document.getElementById('toast')
175+
document.getElementById('toast-text').textContent = text
176+
t.classList.toggle('saving', kind === 'saving')
177+
t.classList.add('show')
178+
clearTimeout(showToast._h)
179+
if (kind !== 'saving') showToast._h = setTimeout(() => t.classList.remove('show'), 1400)
180+
}
181+
182+
// Debounced autosave. Two things happen:
183+
// 1. The data island element's textContent is updated — keeps it as the
184+
// live source of truth (not just an initial seed). Other code reading
185+
// the island after this gets the current state.
186+
// 2. The body that would PUT to the resource URL is logged. Plug in fetch
187+
// when wired up to a pod.
188+
function useAutosave(data) {
189+
const timer = useRef(null)
190+
useEffect(() => {
191+
if (!useAutosave._dirty) { useAutosave._dirty = true; return } // skip first
192+
showToast('Saving…', 'saving')
193+
clearTimeout(timer.current)
194+
timer.current = setTimeout(() => {
195+
const body = JSON.stringify(data, null, 2)
196+
dataEl.textContent = body // sync the island
197+
console.log('[autosave] would PUT', dataUrl, '\n' + body)
198+
showToast('Saved')
199+
}, 600)
200+
}, [data])
201+
}
202+
203+
// --- editable primitives ---------------------------------------------------
204+
205+
function EditableText({ value, placeholder, onChange, multiline, block }) {
206+
const [editing, setEditing] = useState(false)
207+
const ref = useRef(null)
208+
useEffect(() => { if (editing && ref.current) { ref.current.focus(); if (ref.current.select && !multiline) ref.current.select() } }, [editing])
209+
210+
if (editing) {
211+
const commit = () => { onChange(ref.current.value); setEditing(false) }
212+
const cancel = () => setEditing(false)
213+
const onKey = (e) => {
214+
if (e.key === 'Escape') { e.preventDefault(); cancel() }
215+
if (e.key === 'Enter' && !multiline) { e.preventDefault(); commit() }
216+
}
217+
return multiline
218+
? html`<textarea ref=${ref} class="e-area" onBlur=${commit} onKeyDown=${onKey}>${value || ''}</textarea>`
219+
: html`<input ref=${ref} class="e-input" type="text" defaultValue=${value || ''} onBlur=${commit} onKeyDown=${onKey} />`
220+
}
221+
222+
const isEmpty = !value
223+
const cls = `e ${block ? 'e-block' : ''} ${isEmpty ? 'empty' : ''}`.trim()
224+
return html`<span class=${cls} tabIndex="0" onClick=${() => setEditing(true)} onFocus=${() => setEditing(true)}>
225+
${isEmpty ? (placeholder || '—') : value}
226+
</span>`
227+
}
228+
229+
function EditablePills({ items, onChange }) {
230+
const [adding, setAdding] = useState(false)
231+
const ref = useRef(null)
232+
useEffect(() => { if (adding && ref.current) ref.current.focus() }, [adding])
233+
234+
const remove = (i) => onChange(items.filter((_, j) => j !== i))
235+
const add = () => {
236+
const v = ref.current.value.trim()
237+
if (v) onChange([...items, v])
238+
setAdding(false)
239+
}
240+
const onKey = (e) => {
241+
if (e.key === 'Escape') { e.preventDefault(); setAdding(false) }
242+
if (e.key === 'Enter') { e.preventDefault(); add() }
243+
}
244+
245+
return html`
246+
<div class="pills">
247+
${items.map((s, i) => html`
248+
<span class="pill" key=${s + i}>
249+
${s}<span class="x" title="Remove" onClick=${() => remove(i)}>\u00d7</span>
250+
</span>
251+
`)}
252+
${adding
253+
? html`<input ref=${ref} class="pill-input" placeholder="add…" onBlur=${add} onKeyDown=${onKey} />`
254+
: html`<span class="pill pill-add" onClick=${() => setAdding(true)}>+ add</span>`}
255+
</div>
256+
`
257+
}
258+
259+
// --- presentational components --------------------------------------------
260+
261+
const niceLink = (u) => (u || '').replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')
262+
const initial = (s) => (s || '?').trim().split(/\s+/).pop().charAt(0).toUpperCase()
263+
264+
function Hero({ data, set }) {
265+
const { name, pronouns, address, email, phone, homepage, img } = data
266+
return html`
267+
<div class="hero">
268+
${img
269+
? html`<img class="photo" src=${img} alt=${name} onerror=${(e) => { const f = document.createElement('div'); f.className = 'photo-fallback'; f.textContent = initial(name); e.target.replaceWith(f) }} />`
270+
: html`<div class="photo-fallback">${initial(name)}</div>`}
271+
<div class="hero-meta">
272+
<h1 class="name">
273+
<${EditableText} value=${name} placeholder="Name" onChange=${(v) => set('name', v)} />
274+
<span class="pronoun">(<${EditableText} value=${pronouns} placeholder="they/them" onChange=${(v) => set('pronouns', v)} />)</span>
275+
</h1>
276+
<div class="facts">
277+
<div class="fact"><span class="ic">\u{1F4CD}</span><${EditableText} value=${address} placeholder="Add address" onChange=${(v) => set('address', v)} /></div>
278+
<div class="fact"><span class="ic">\u{260E}\uFE0F</span><${EditableText} value=${phone} placeholder="Add phone" onChange=${(v) => set('phone', v)} /></div>
279+
<div class="fact"><span class="ic">\u{2709}\uFE0F</span><${EditableText} value=${email} placeholder="Add email" onChange=${(v) => set('email', v)} /></div>
280+
<div class="fact"><span class="ic">\u{1F310}</span><${EditableText} value=${homepage} placeholder="Add homepage" onChange=${(v) => set('homepage', v)} /></div>
281+
</div>
282+
</div>
283+
</div>
284+
`
285+
}
286+
287+
function Bio({ bio, set }) {
288+
return html`
289+
<div class="card">
290+
<h2 class="section-title">Bio</h2>
291+
<p class="prose">
292+
<${EditableText} value=${bio} placeholder="Write a short bio…" multiline=${true} block=${true} onChange=${(v) => set('bio', v)} />
293+
</p>
294+
</div>
295+
`
296+
}
297+
298+
function Skills({ skills, set }) {
299+
return html`
300+
<div class="card">
301+
<h3 class="side-title">Skills</h3>
302+
<${EditablePills} items=${skills || []} onChange=${(v) => set('skills', v)} />
303+
</div>
304+
`
305+
}
306+
307+
function Languages({ languages, set }) {
308+
return html`
309+
<div class="card">
310+
<h3 class="side-title">Languages</h3>
311+
<${EditablePills} items=${languages || []} onChange=${(v) => set('languages', v)} />
312+
</div>
313+
`
314+
}
315+
316+
function Contacts({ data }) {
317+
const rows = []
318+
if (data.phone) rows.push({ icon: '\u{260E}\uFE0F', value: data.phone, label: 'Mobile', href: `tel:${data.phone.replace(/\s+/g,'')}` })
319+
if (data.email) rows.push({ icon: '\u{2709}\uFE0F', value: data.email, label: 'Personal', href: `mailto:${data.email}` })
320+
if (data.address) rows.push({ icon: '\u{1F4CD}', value: data.address, label: 'Address' })
321+
if (!rows.length) return null
322+
return html`
323+
<div class="card">
324+
<h3 class="side-title">More Contacts</h3>
325+
<div>
326+
${rows.map(r => html`
327+
<div class="contact-row">
328+
<div class="contact-ic">${r.icon}</div>
329+
<div class="contact-body">
330+
${r.href ? html`<a href=${r.href}>${r.value}</a>` : r.value}
331+
<div class="contact-label">${r.label}</div>
332+
</div>
333+
</div>
334+
`)}
335+
</div>
336+
</div>
337+
`
338+
}
339+
340+
function Profile() {
341+
const [data, setData] = useState(initialData)
342+
useAutosave(data)
343+
const set = useCallback((k, v) => {
344+
setData(d => {
345+
const next = { ...d }
346+
if (v === '' || v == null) delete next[k]; else next[k] = v
347+
return next
348+
})
349+
}, [])
350+
351+
return html`
352+
<div class="page">
353+
<div>
354+
<div class="card">
355+
<${Hero} data=${data} set=${set} />
356+
</div>
357+
<${Bio} bio=${data.bio} set=${set} />
358+
</div>
359+
<div>
360+
<${Skills} skills=${data.skills} set=${set} />
361+
<${Languages} languages=${data.languages} set=${set} />
362+
<${Contacts} data=${data} />
363+
</div>
364+
</div>
365+
<div class="footer-tag">stage 02 \u2014 click any field to edit \u2014 saves debounced (open console to see the would-be PUT body)</div>
366+
`
367+
}
368+
369+
render(html`<${Profile} />`, document.getElementById('app'))
370+
</script>
371+
372+
</body>
373+
</html>

preact/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Each stage is a single self-contained HTML file (zero build). A stage graduates
1111
| Stage | Focus |
1212
|-------|-------|
1313
| 01-hello | Minimal Preact render: mount a `<Profile>` component, read JSON-LD from a `<script type="application/ld+json">` island, display it. Inspired by the Pivot profile layout at sharon.pivot-test.solidproject.org. Static; no state, no edits. |
14-
| 02-inline-edit | `useState` / signals; click a field to edit; debounced PUT on blur. |
14+
| 02-inline-edit | `useState` for editable fields, debounced autosave that logs the would-be PUT body and writes back to the data island. Adds array editing (skills/languages pills with + add / × remove) — the thing ui-pane still defers. |
1515
| 03-routing | Hash routes, multi-page SPA shell. |
1616
| 04-fetch | Load external WebIDs. |
1717
| 05+ | TBD — whatever the previous stages surface as the next limit. |

0 commit comments

Comments
 (0)