diff --git a/apps/home/favicon.svg b/apps/home/favicon.svg new file mode 100644 index 0000000..313efed --- /dev/null +++ b/apps/home/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/home/home.js b/apps/home/home.js new file mode 100644 index 0000000..ab936b2 --- /dev/null +++ b/apps/home/home.js @@ -0,0 +1,535 @@ +// home — a launcher for whatever Solid apps are on this pod. +// +// Reads /public/apps/ as an LDP container, lists every app it finds, +// renders them in a macOS-style magnification dock. Visual lifted +// from JSS's Solid OS dashboard so it feels like the same suite. + +const app = document.getElementById('app') + +// Known apps: stable colors + emoji so familiar ones look the part. +// Anything not listed gets a colour from a hash of its name + the +// first letter as a glyph. +const KNOWN = { + plaza: { glyph: '\u{1F4AC}', color: '#7c4dff', color2: '#a78bfa' }, + chat: { glyph: '✉️', color: '#06b6d4', color2: '#22d3ee' }, + vellum: { glyph: '✍️', color: '#f59e0b', color2: '#fbbf24' }, + plume: { glyph: '\u{1FAB6}', color: '#a855f7', color2: '#c084fc' }, + taskify: { glyph: '✅', color: '#22c55e', color2: '#4ade80' }, + explorer: { glyph: '\u{1F4C1}', color: '#3b82f6', color2: '#60a5fa' }, + hub: { glyph: '\u{1F39B}️', color: '#ec4899', color2: '#f472b6' }, + chrome: { glyph: '\u{1FA9F}', color: '#10b981', color2: '#059669' }, + timeline: { glyph: '\u{1F4F0}', color: '#f97316', color2: '#fb923c' }, + win98: { glyph: '\u{1F4BB}', color: '#06b6d4', color2: '#22d3ee' }, + pdf: { glyph: '\u{1F4C4}', color: '#ef4444', color2: '#f87171' }, + alarm: { glyph: '⏰', color: '#fbbf24', color2: '#f59e0b' }, + playlist: { glyph: '\u{1F3B5}', color: '#a855f7', color2: '#c084fc' }, + mindstr: { glyph: '\u{1F9E0}', color: '#a855f7', color2: '#c084fc' }, + charlie: { glyph: '\u{1F916}', color: '#10b981', color2: '#059669' }, + forum: { glyph: '\u{1F4AD}', color: '#ec4899', color2: '#f472b6' }, + transcribe: { glyph: '\u{1F3A4}', color: '#06b6d4', color2: '#22d3ee' }, + store: { glyph: '\u{1F6CD}', color: '#7c4dff', color2: '#a78bfa' }, + git: { glyph: '\u{1F500}', color: '#1f8fff', color2: '#60a5fa' } +} + +const FALLBACK_COLORS = [ + ['#7c4dff', '#a78bfa'], ['#06b6d4', '#22d3ee'], ['#f59e0b', '#fbbf24'], + ['#22c55e', '#4ade80'], ['#3b82f6', '#60a5fa'], ['#ec4899', '#f472b6'], + ['#10b981', '#059669'], ['#f97316', '#fb923c'], ['#a855f7', '#c084fc'], + ['#ef4444', '#f87171'] +] +function pickColor(name) { + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0 + return FALLBACK_COLORS[h % FALLBACK_COLORS.length] +} + +function describe(name, url) { + const known = KNOWN[name.toLowerCase()] + if (known) return { name, url, glyph: known.glyph, color: known.color, color2: known.color2 } + const [color, color2] = pickColor(name) + return { name, url, glyph: (name[0] || '?').toUpperCase(), color, color2 } +} + +// Try the app's manifest.json. If it has theme_color + a 192px icon we +// prefer those over the hardcoded KNOWN map — each app brands itself. +async function fetchManifest(appUrl) { + try { + const r = await fetch(appUrl + 'manifest.json') + if (!r.ok) return null + return await r.json() + } catch { + return null + } +} + +// Merge manifest data onto the base descriptor. If manifest has an +// icon URL we set `iconUrl` and the renderer uses an ; otherwise +// we keep the glyph + gradient fallback. +async function enrichWithManifest(base) { + const m = await fetchManifest(base.url) + if (!m) return base + const icons = m.icons || [] + // Prefer a 192px icon — typical "small enough to be cheap, large + // enough to look sharp at the dock's 54px tile + retina". + const pick = icons.find(i => (i.sizes || '').includes('192')) || + icons.find(i => (i.sizes || '').includes('512')) || + icons[0] + const out = { ...base } + // Keep base.name (the path segment under /public/apps//) as the + // visible label — two installs of the same app under different paths + // (e.g. chat vs chat-x) should read distinctly. Manifest contributes + // icon + color only. + if (m.theme_color) { out.color = m.theme_color; out.color2 = m.theme_color } + if (pick && pick.src) { + try { out.iconUrl = new URL(pick.src, base.url).toString() } catch {} + } + return out +} + +async function fetchApps() { + try { + const r = await fetch('/public/apps/', { headers: { Accept: 'application/ld+json' } }) + if (!r.ok) return [] + const doc = await r.json() + const contains = doc['ldp:contains'] || doc['http://www.w3.org/ns/ldp#contains'] || doc['contains'] || [] + const arr = Array.isArray(contains) ? contains : [contains] + const bases = arr + .map(x => typeof x === 'string' ? x : x?.['@id']) + .filter(Boolean) + .filter(u => u.endsWith('/')) + .map(url => { + const segments = url.replace(/\/$/, '').split('/') + const name = segments[segments.length - 1] + return describe(name, url) + }) + // Read each app's manifest.json in parallel for theme + icon. + const enriched = await Promise.all(bases.map(enrichWithManifest)) + return enriched.sort((a, b) => a.name.localeCompare(b.name)) + } catch { + return [] + } +} + +// Parse `app:spec` strings the way `jspod install` does. Returns +// the renamed pod-path name (what would appear under /public/apps/) +// plus the canonical gh-pages URL of the app for preview links. +function parseSpec(input) { + let base = input + let renameName = null + const eqIx = base.lastIndexOf('=') + if (eqIx > 0) { renameName = base.slice(eqIx + 1); base = base.slice(0, eqIx) } + const hashIx = base.lastIndexOf('#') + if (hashIx > 0) base = base.slice(0, hashIx) + let name, url + if (/^https?:\/\//.test(base)) { + name = base.replace(/\/$/, '').split('/').pop() + url = base.endsWith('/') ? base : base + '/' + } else if (base.includes('/')) { + const [org, ...rest] = base.split('/') + const repo = rest.join('/') + name = repo.split('/').pop() + url = `https://${org}.github.io/${repo}/` + } else { + name = base + url = `https://solid-apps.github.io/${base}/` + } + if (renameName) name = renameName + return { name, url } +} + +// Fetch the canonical "jspod" bundle as a preview when the local +// pod has no apps installed (or when home is being viewed from +// gh-pages directly with no pod to read from). +async function fetchFallbackBundle() { + const FALLBACK_URL = 'https://raw.githubusercontent.com/solid-apps/bundles/HEAD/jspod.jsonld' + try { + const r = await fetch(FALLBACK_URL) + if (!r.ok) return [] + const doc = await r.json() + const items = doc['schema:itemListElement'] || doc['itemListElement'] || [] + const bases = items + .map(item => typeof item === 'string' ? item : item?.['app:spec']) + .filter(Boolean) + .map(spec => { + const { name, url } = parseSpec(spec) + return { ...describe(name, url), preview: true } + }) + // Pull each gh-pages app's manifest too so preview tiles get the + // app's own brand instead of our hash-derived fallback. + return Promise.all(bases.map(enrichWithManifest)) + } catch { + return [] + } +} + +async function render() { + const now = new Date() + const hour = now.getHours() + const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening' + let apps = await fetchApps() + let preview = false + if (apps.length === 0) { + apps = await fetchFallbackBundle() + preview = apps.length > 0 + } + + const style = document.createElement('style') + style.textContent = ` + @keyframes fadeUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } + } + + * { box-sizing: border-box; margin: 0; padding: 0; } + .h { position: fixed; inset: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow-y: auto; overflow-x: hidden; -webkit-font-smoothing: antialiased; } + + /* Static gradient background — orbs baked in as radial gradients, + no transform/blur animation, no backdrop-filter, GPU at idle. */ + .h-bg { + position: fixed; inset: 0; z-index: 0; + background: + radial-gradient(circle at 25% 8%, rgba(99,102,241,0.20) 0%, transparent 38%), + radial-gradient(circle at 85% 88%, rgba(168,85,247,0.16) 0%, transparent 38%), + radial-gradient(circle at 55% 50%, rgba(59,130,246,0.10) 0%, transparent 26%), + linear-gradient(160deg, #0a0618 0%, #1a1145 30%, #2d1b69 50%, #1a1145 70%, #0a0618 100%); + } + + .h-bar { + position: sticky; top: 0; z-index: 20; height: 38px; + background: rgba(10,6,24,0.82); + display: flex; align-items: center; justify-content: space-between; padding: 0 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + } + .h-logo { font-weight: 800; font-size: 13px; color: #fff; letter-spacing: 0.04em; } + .h-bar-r { display: flex; align-items: center; gap: 14px; color: rgba(255,255,255,0.6); font-size: 12px; font-weight: 500; } + .h-dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; display: inline-block; margin-right: 4px; box-shadow: 0 0 8px #22c55e88; } + + .h-content { + position: relative; z-index: 5; max-width: 900px; margin: 0 auto; padding: 40px 24px 80px; + animation: fadeUp 0.6s ease-out; + } + + .h-clock { text-align: center; margin-bottom: 4px; } + .h-time { + font-size: 96px; font-weight: 100; letter-spacing: -0.04em; line-height: 1; + background: linear-gradient(180deg, #fff 0%, rgba(255,255,255,0.6) 100%); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; + } + .h-date { font-size: 16px; color: rgba(255,255,255,0.4); margin-top: 2px; font-weight: 400; letter-spacing: 0.02em; } + .h-greet { text-align: center; margin: 16px 0 28px; } + .h-greet h1 { font-size: 20px; font-weight: 300; color: rgba(255,255,255,0.55); } + + .h-search { display: flex; justify-content: center; margin-bottom: 32px; } + .h-search-w { position: relative; } + .h-search-w::before { content: '\u{1F50D}'; position: absolute; left: 16px; top: 50%; transform: translateY(-50%); font-size: 13px; opacity: 0.3; } + .h-sinput { + width: 420px; max-width: 85vw; padding: 13px 18px 13px 44px; + background: rgba(255,255,255,0.09); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; color: #fff; font-size: 14px; font-family: inherit; outline: none; + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + } + .h-sinput::placeholder { color: rgba(255,255,255,0.25); } + .h-sinput:focus { + background: rgba(255,255,255,0.11); + border-color: rgba(124,58,237,0.4); + box-shadow: 0 0 0 4px rgba(124,58,237,0.1), 0 8px 32px rgba(0,0,0,0.2); + transform: scale(1.01); + } + + .h-sec { font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.25); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 16px; padding-left: 4px; } + .h-dock { + display: flex; justify-content: center; align-items: flex-end; gap: 4px; + padding: 16px 24px 14px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 28px; + position: relative; overflow: visible; flex-wrap: wrap; + } + .h-dock::before { + content: ''; position: absolute; top: 0; left: 20%; right: 20%; height: 1px; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent); + } + + .h-app { + display: flex; flex-direction: column; align-items: center; + padding: 8px 8px 6px; border-radius: 16px; + cursor: pointer; text-decoration: none; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + -webkit-tap-highlight-color: transparent; + } + .h-app:hover { transform: translateY(-8px); } + .h-app:active { transform: scale(0.92); } + .h-app.hidden { display: none; } + + /* Preview apps (fallback bundle, not yet installed on this pod): + subtle dim so they read as "not yours yet" without losing the + dock's liveliness. Hover restores full color. */ + .h-app-preview .h-icon { opacity: 0.7; filter: saturate(0.7); transition: opacity 0.2s, filter 0.2s; } + .h-app-preview .h-app-name { color: rgba(255,255,255,0.35); } + .h-app-preview:hover .h-icon { opacity: 1; filter: saturate(1); } + .h-app-preview:hover .h-app-name { color: rgba(255,255,255,0.9); } + .h-preview-note { + text-align: center; + font-size: 12px; + color: rgba(255,255,255,0.45); + margin: -8px 0 20px; + letter-spacing: 0.02em; + } + .h-preview-note strong { color: rgba(255,255,255,0.7); font-weight: 600; } + .h-preview-note code { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + background: rgba(255,255,255,0.06); + padding: 2px 8px; + border-radius: 6px; + color: rgba(255,255,255,0.75); + } + + .h-icon { + width: 54px; height: 54px; border-radius: 14px; + display: flex; align-items: center; justify-content: center; + font-size: 24px; position: relative; + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: + 0 1px 2px rgba(0,0,0,0.3), + 0 4px 8px rgba(0,0,0,0.2), + 0 10px 20px rgba(0,0,0,0.15), + inset 0 1px 0 rgba(255,255,255,0.3), + inset 0 -2px 4px rgba(0,0,0,0.1); + color: #fff; + font-weight: 700; + letter-spacing: -0.01em; + } + .h-icon::before { + content: ''; + position: absolute; top: 1px; left: 1px; right: 1px; height: 50%; + border-radius: 13px 13px 40% 40%; + background: linear-gradient(180deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.15) 40%, rgba(255,255,255,0) 100%); + pointer-events: none; + } + .h-icon img { + width: 100%; height: 100%; + object-fit: cover; + border-radius: 14px; + display: block; + } + .h-icon::after { + content: ''; position: absolute; inset: 0; border-radius: 14px; + border: 1px solid rgba(255,255,255,0.2); + border-bottom-color: rgba(0,0,0,0.1); + pointer-events: none; + } + .h-app:hover .h-icon { + box-shadow: + 0 2px 4px rgba(0,0,0,0.3), + 0 8px 16px rgba(0,0,0,0.2), + 0 16px 32px rgba(0,0,0,0.15), + 0 0 30px var(--glow), + inset 0 1px 0 rgba(255,255,255,0.35), + inset 0 -2px 4px rgba(0,0,0,0.1); + transform: scale(1.12); + } + + .h-app-dot { + width: 4px; height: 4px; border-radius: 50%; + background: rgba(255,255,255,0.35); + margin-top: 6px; transition: all 0.2s; + } + .h-app:hover .h-app-dot { background: #fff; box-shadow: 0 0 6px rgba(255,255,255,0.5); } + + .h-app-name { + font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.5); + text-align: center; margin-top: 4px; transition: all 0.2s; + } + .h-app:hover .h-app-name { color: rgba(255,255,255,0.9); } + + .h-tip { + position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%) translateY(4px); + background: rgba(10,6,24,0.95); + border: 1px solid rgba(255,255,255,0.1); + color: #fff; font-size: 12px; font-weight: 600; + padding: 6px 14px; border-radius: 10px; white-space: nowrap; + pointer-events: none; opacity: 0; transition: opacity 0.15s, transform 0.15s; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + } + .h-tip::after { + content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); + border: 5px solid transparent; border-top-color: rgba(10,6,24,0.9); + } + .h-app:hover .h-tip { opacity: 1; transform: translateX(-50%) translateY(0); } + + .h-empty { + text-align: center; padding: 40px 24px; + color: rgba(255,255,255,0.4); font-size: 14px; + max-width: 480px; margin: 0 auto; + } + .h-empty strong { color: #fff; font-size: 16px; display: block; margin-bottom: 8px; font-weight: 600; } + .h-empty code { + display: inline-block; margin-top: 12px; padding: 6px 12px; + background: rgba(255,255,255,0.06); border-radius: 8px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; + color: rgba(255,255,255,0.8); + } + + @media (max-width: 600px) { + .h-content { padding: 24px 16px 60px; } + .h-time { font-size: 64px; } + .h-dock { flex-wrap: wrap; justify-content: center; gap: 6px; padding: 16px; border-radius: 24px; } + .h-icon { width: 48px; height: 48px; font-size: 22px; border-radius: 13px; } + .h-app-name { opacity: 1; transform: none; font-size: 10.5px; } + .h-tip { display: none; } + } + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001s !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001s !important; + } + } + ` + app.appendChild(style) + + const root = document.createElement('div') + root.className = 'h' + + const bg = document.createElement('div') + bg.className = 'h-bg' + root.appendChild(bg) + + const bar = document.createElement('div') + bar.className = 'h-bar' + bar.innerHTML = '' + const barR = document.createElement('div') + barR.className = 'h-bar-r' + barR.innerHTML = '' + apps.length + (preview ? ' demos' : ' apps') + '' + const barClock = document.createElement('span') + barR.appendChild(barClock) + bar.appendChild(barR) + root.appendChild(bar) + + const content = document.createElement('div') + content.className = 'h-content' + + const clock = document.createElement('div') + clock.className = 'h-clock' + const timeEl = document.createElement('div'); timeEl.className = 'h-time'; clock.appendChild(timeEl) + const dateEl = document.createElement('div'); dateEl.className = 'h-date'; clock.appendChild(dateEl) + content.appendChild(clock) + + const tick = () => { + const now = new Date() + timeEl.textContent = now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + dateEl.textContent = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + barClock.textContent = now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + } + tick(); setInterval(tick, 10000) + + const greet = document.createElement('div') + greet.className = 'h-greet' + greet.innerHTML = '

' + greeting + '

' + content.appendChild(greet) + + const search = document.createElement('div') + search.className = 'h-search' + const sw = document.createElement('div'); sw.className = 'h-search-w' + const si = document.createElement('input') + si.className = 'h-sinput' + si.placeholder = 'Search apps…' + si.type = 'text' + sw.appendChild(si); search.appendChild(sw) + content.appendChild(search) + + const sec = document.createElement('div') + sec.className = 'h-sec' + sec.textContent = preview ? 'Preview' : 'Apps' + content.appendChild(sec) + + if (preview) { + const note = document.createElement('div') + note.className = 'h-preview-note' + note.innerHTML = 'No apps installed yet. Try the live demos below, then run jspod install --bundle jspod to make them yours.' + content.appendChild(note) + } + + if (apps.length === 0) { + const empty = document.createElement('div') + empty.className = 'h-empty' + empty.innerHTML = 'No apps installed yet.' + + 'Drop apps into /public/apps/ on this pod and they\'ll appear here.' + + '
jspod install --bundle teams' + content.appendChild(empty) + } else { + const dock = document.createElement('div') + dock.className = 'h-dock' + for (const a of apps) { + const el = document.createElement('a') + el.className = 'h-app' + (preview ? ' h-app-preview' : '') + el.href = a.url + if (preview) { + el.target = '_blank' + el.rel = 'noopener noreferrer' + } + el.dataset.name = a.name.toLowerCase() + + const icon = document.createElement('div') + icon.className = 'h-icon' + icon.style.setProperty('--glow', a.color + '44') + if (a.iconUrl) { + // App declared its own icon via manifest.json — use it directly. + const img = document.createElement('img') + img.src = a.iconUrl + img.alt = '' + img.loading = 'lazy' + icon.appendChild(img) + icon.style.background = a.color + } else { + icon.style.background = 'linear-gradient(145deg, ' + a.color2 + ', ' + a.color + ')' + icon.textContent = a.glyph + } + el.appendChild(icon) + + const dot = document.createElement('div'); dot.className = 'h-app-dot'; el.appendChild(dot) + const name = document.createElement('div'); name.className = 'h-app-name'; name.textContent = a.name; el.appendChild(name) + const tip = document.createElement('div'); tip.className = 'h-tip'; tip.textContent = a.name + (preview ? ' (demo)' : ''); el.appendChild(tip) + + dock.appendChild(el) + } + content.appendChild(dock) + + // macOS-style magnification on hover + dock.addEventListener('mousemove', (e) => { + const mouseX = e.clientX + for (const a of dock.querySelectorAll('.h-app')) { + const rect = a.getBoundingClientRect() + const center = rect.left + rect.width / 2 + const dist = Math.abs(mouseX - center) + const maxDist = 120 + if (dist < maxDist) { + const scale = 1 + 0.2 * (1 - dist / maxDist) + const lift = -6 * (1 - dist / maxDist) + a.style.transform = 'translateY(' + lift + 'px) scale(' + scale + ')' + } else { + a.style.transform = '' + } + } + }) + dock.addEventListener('mouseleave', () => { + for (const a of dock.querySelectorAll('.h-app')) a.style.transform = '' + }) + + si.addEventListener('input', () => { + const q = si.value.toLowerCase().trim() + for (const el of dock.querySelectorAll('.h-app')) { + const match = !q || el.dataset.name.includes(q) + el.classList.toggle('hidden', !match) + } + }) + } + + root.appendChild(content) + app.appendChild(root) +} + +render() diff --git a/apps/home/icon-192.png b/apps/home/icon-192.png new file mode 100644 index 0000000..4e24d97 Binary files /dev/null and b/apps/home/icon-192.png differ diff --git a/apps/home/icon-512.png b/apps/home/icon-512.png new file mode 100644 index 0000000..472c6dc Binary files /dev/null and b/apps/home/icon-512.png differ diff --git a/apps/home/index.html b/apps/home/index.html new file mode 100644 index 0000000..5557235 --- /dev/null +++ b/apps/home/index.html @@ -0,0 +1,36 @@ + + + + + +home — launcher for your Solid pod + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/apps/home/manifest.json b/apps/home/manifest.json new file mode 100644 index 0000000..d75fcf9 --- /dev/null +++ b/apps/home/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "home", + "short_name": "home", + "description": "Launcher for every Solid app on your pod", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#7c4dff", + "theme_color": "#7c4dff", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" } + ] +} diff --git a/apps/home/og.png b/apps/home/og.png new file mode 100644 index 0000000..d5c5d82 Binary files /dev/null and b/apps/home/og.png differ diff --git a/apps/profile/app.js b/apps/profile/app.js new file mode 100644 index 0000000..69c6c0a --- /dev/null +++ b/apps/profile/app.js @@ -0,0 +1,497 @@ +// profile — a Solid app that maintains a pod's identity in three projections: +// /profile/card.jsonld — the WebID. Source of truth. Identity + soul:* fields. +// /settings/publicTypeIndex.jsonld — endpoint registrations (which app at which URL). +// /profile/SOUL.md — generated markdown for agents that prefer plain text. +// +// One source (card.jsonld) → one editor → two outputs (card + SOUL.md). The +// typeIndex is read for endpoint discovery; for v0 it's not edited from here. + +const POD = location.origin +const CARD_URL = `${POD}/profile/card.jsonld` +const SOUL_URL = `${POD}/profile/SOUL.md` +const TYPE_INDEX_URL = `${POD}/settings/publicTypeIndex.jsonld` +const APPS_CONTAINER = `${POD}/public/apps/` +const AVATAR_URL = `${POD}/profile/avatar.png` + +const ctx = { + card: null, // parsed JSON-LD + editable: false, // user is the pod owner + dirty: false, + // Use the Solid OIDC session (set by xlogin.js) when available, otherwise + // fall back to plain fetch with credentials — that covers the common case + // of being signed into the same-origin JSS pod via cookie. + fetch: (...args) => (window.solid?.session?.fetch || window.fetch)(...args), +} + +// ---------- Boot ---------- + +async function boot() { + const app = document.getElementById('app') + + ctx.editable = await detectOwner() + + let card + try { + const r = await ctx.fetch(CARD_URL, { credentials: 'include' }) + if (r.ok) card = await r.json() + } catch {} + + if (!card) { + app.classList.remove('loading') + if (ctx.editable) openWizard() + else renderEmpty() + return + } + + ctx.card = card + app.classList.remove('loading') + + // JSS auto-seeds card.jsonld with just foaf:name="me" on first boot. + // If we land on that stub and the owner is signed in, jump straight to + // the wizard — saves them hunting for click-to-edit affordances on an + // otherwise empty page. + if (ctx.editable && isStubCard(card)) { + openWizard() + return + } + + render() + renderAgentView() + loadInstalledApps() +} + +function isStubCard(c) { + if (!c) return true + const meaningful = [ + 'schema:description', 'description', + 'schema:alternateName', 'alternateName', + 'foaf:img', 'schema:image', 'image', + 'schema:url', 'url', + 'soul:values', 'soul:hardLimits', 'soul:commsStyle', 'soul:memoryPolicy', + ] + return !meaningful.some(k => c[k]) +} + +// ---------- Auth detection ---------- + +async function detectOwner() { + try { + const r = await ctx.fetch(`${POD}/profile/`, { + method: 'HEAD', + credentials: 'include', + }) + const wacAllow = r.headers.get('WAC-Allow') || '' + // WAC-Allow: user="read write append control" + return /user="[^"]*\bwrite\b/.test(wacAllow) + } catch { + return false + } +} + +// ---------- Render: human view ---------- + +function render() { + const c = ctx.card + setField('foaf:name', c['foaf:name'] || c.name) + setField('schema:alternateName', c['schema:alternateName'] || c.alternateName) + setField('schema:description', c['schema:description'] || c.description) + + // Avatar: only attempt to load if an explicit image URL is in the card. + // Otherwise show a clean initials circle — no 404 noise, looks intentional. + const img = c['foaf:img'] || c.image || c['schema:image'] + const avatarEl = document.getElementById('avatar') + const fallbackEl = document.getElementById('avatarFallback') + if (img) { + avatarEl.src = img + avatarEl.hidden = false + fallbackEl.style.display = 'none' + avatarEl.onerror = function () { + this.hidden = true + fallbackEl.style.display = '' + } + } else { + avatarEl.hidden = true + fallbackEl.style.display = '' + } + fallbackEl.textContent = initial(c['foaf:name'] || c.name) + + renderLinks(c['schema:url'] || c.url || []) + + if (ctx.editable) { + document.querySelectorAll('[data-field]').forEach(el => { + el.setAttribute('contenteditable', 'true') + el.addEventListener('blur', onFieldBlur) + }) + document.getElementById('addLink').hidden = false + document.getElementById('addLink').addEventListener('click', onAddLink) + document.getElementById('avatar').classList.add('editable') + document.getElementById('avatarFallback').classList.add('editable') + document.getElementById('avatar').addEventListener('click', triggerAvatarUpload) + document.getElementById('avatarFallback').addEventListener('click', triggerAvatarUpload) + document.getElementById('cover').classList.add('editable') + + // First-time edit hint — fades on first interaction, persists per pod. + if (!localStorage.getItem('profile:edit-hint-seen')) { + const hint = document.getElementById('editHint') + hint.hidden = false + const dismiss = () => { + hint.classList.add('fade-out') + localStorage.setItem('profile:edit-hint-seen', '1') + } + document.querySelectorAll('[data-field]').forEach(el => { + el.addEventListener('focus', dismiss, { once: true }) + }) + } + } +} + +function initial(name) { + if (!name) return '·' + const ch = name.trim().charAt(0) + return ch ? ch.toUpperCase() : '·' +} + +function setField(field, value) { + const el = document.querySelector(`[data-field="${field}"]`) + if (!el) return + el.textContent = value || '' + if (!value) el.classList.add('empty') + else el.classList.remove('empty') +} + +function renderLinks(urls) { + const list = document.getElementById('linkList') + list.innerHTML = '' + const arr = Array.isArray(urls) ? urls : (urls ? [urls] : []) + for (const url of arr) { + const li = document.createElement('li') + const a = document.createElement('a') + a.href = url + a.textContent = shortLink(url) + a.target = '_blank' + a.rel = 'noopener' + li.appendChild(a) + if (ctx.editable) { + const rm = document.createElement('button') + rm.className = 'remove-link' + rm.textContent = '×' + rm.title = 'Remove' + rm.addEventListener('click', () => removeLink(url)) + li.appendChild(rm) + } + list.appendChild(li) + } +} + +function shortLink(url) { + try { + const u = new URL(url) + if (u.protocol === 'nostr:') return 'nostr:' + u.pathname.slice(0, 16) + '…' + return u.hostname.replace(/^www\./, '') + (u.pathname === '/' ? '' : u.pathname) + } catch { + return url + } +} + +function renderEmpty() { + document.querySelector('.human-view').hidden = true + const empty = document.getElementById('emptyState') + empty.hidden = false + + // Tailor the message + button based on whether we're inside a pod + // (same-origin sign-in works) vs. served standalone from a static host + // (e.g. solid-apps.github.io — user needs to pick a pod via xlogin). + const onPod = isLikelyPod() + const sub = document.getElementById('emptySub') + const btn = document.getElementById('signInBtn') + + if (onPod) { + sub.textContent = 'Sign in as the pod owner to create one.' + btn.textContent = 'Sign in' + btn.addEventListener('click', () => { + const returnTo = encodeURIComponent(location.pathname + location.search) + location.href = `${POD}/signin?returnTo=${returnTo}` + }, { once: true }) + } else { + sub.textContent = 'This is the standalone view. Connect your pod to load (or create) your profile.' + btn.textContent = 'Connect your pod' + btn.addEventListener('click', () => { + document.querySelector('.xl-btn')?.click() + }, { once: true }) + } +} + +function isLikelyPod() { + // Heuristic: a JSS pod responds at /idp/ (the OIDC issuer) with 200/302. + // We can't do that synchronously here — instead use a coarse proxy: if + // location.host looks like a github.io / netlify / vercel / pages.dev + // domain, treat as standalone; otherwise assume pod. Refined in v0.1 + // with an actual /idp/ probe at boot. + const h = location.host + return !/\.(github|netlify|vercel|pages)\.(io|app|dev)$/i.test(h) +} + +// ---------- Editing ---------- + +function onFieldBlur(e) { + const el = e.currentTarget + const field = el.getAttribute('data-field') + const value = el.textContent.trim() + if (ctx.card[field] === value) return + ctx.card[field] = value || undefined + scheduleSave() +} + +function onAddLink() { + const url = prompt('Link URL?') + if (!url) return + const list = ctx.card['schema:url'] + const arr = Array.isArray(list) ? list : (list ? [list] : []) + if (arr.includes(url)) return + arr.push(url) + ctx.card['schema:url'] = arr + renderLinks(arr) + scheduleSave() +} + +function removeLink(url) { + const list = ctx.card['schema:url'] + const arr = Array.isArray(list) ? list : (list ? [list] : []) + ctx.card['schema:url'] = arr.filter(u => u !== url) + renderLinks(ctx.card['schema:url']) + scheduleSave() +} + +function triggerAvatarUpload() { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/*' + input.onchange = async () => { + const file = input.files[0] + if (!file) return + await uploadAvatar(file) + } + input.click() +} + +async function uploadAvatar(file) { + try { + const r = await fetch(AVATAR_URL, { + method: 'PUT', + headers: { 'Content-Type': file.type || 'image/png' }, + credentials: 'include', + body: file, + }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + ctx.card['foaf:img'] = '/profile/avatar.png' + document.getElementById('avatar').src = AVATAR_URL + '?t=' + Date.now() + toast('avatar updated') + scheduleSave() + } catch (e) { + toast('avatar upload failed: ' + e.message, true) + } +} + +let saveTimer = null +function scheduleSave() { + ctx.dirty = true + clearTimeout(saveTimer) + saveTimer = setTimeout(save, 700) +} + +async function save() { + if (!ctx.dirty) return + ctx.dirty = false + try { + const r = await fetch(CARD_URL, { + method: 'PUT', + headers: { 'Content-Type': 'application/ld+json' }, + credentials: 'include', + body: JSON.stringify(ctx.card, null, 2), + }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const md = renderSoulMd(ctx.card) + await fetch(SOUL_URL, { + method: 'PUT', + headers: { 'Content-Type': 'text/markdown' }, + credentials: 'include', + body: md, + }).catch(() => {}) + document.getElementById('soulPreview').textContent = md + toast('saved') + } catch (e) { + toast('save failed: ' + e.message, true) + ctx.dirty = true + } +} + +// ---------- Render: agent view (SOUL.md) ---------- + +function renderAgentView() { + const md = renderSoulMd(ctx.card) + document.getElementById('soulPreview').textContent = md + document.getElementById('soulUrl').textContent = new URL(SOUL_URL).pathname +} + +function renderSoulMd(card) { + const name = card['foaf:name'] || card.name || 'Anonymous' + const handle = card['schema:alternateName'] || card.alternateName + const status = card['schema:description'] || card.description + const values = toArray(card['soul:values']) + const comms = card['soul:commsStyle'] + const limits = toArray(card['soul:hardLimits']) + const memory = card['soul:memoryPolicy'] + const urls = toArray(card['schema:url'] || card.url) + + const lines = [] + lines.push(`# SOUL.md — ${name}`) + lines.push('') + lines.push('## Identity') + lines.push(`${name}${handle ? ` (${handle})` : ''}.${status ? ' ' + status : ''}`) + lines.push('') + + if (values.length) { + lines.push('## Values') + for (const v of values) lines.push(`- ${v}`) + lines.push('') + } + + if (comms) { + lines.push('## Communication Style') + if (typeof comms === 'string') lines.push(comms) + else for (const [k, v] of Object.entries(comms)) lines.push(`- ${k}: ${v}`) + lines.push('') + } + + if (limits.length) { + lines.push('## Hard Limits') + for (const l of limits) lines.push(`- ${l}`) + lines.push('') + } + + if (memory) { + lines.push('## Memory Policy') + lines.push(memory) + lines.push('') + } + + if (urls.length) { + lines.push('## Find me at') + for (const u of urls) lines.push(`- ${u}`) + lines.push('') + } + + lines.push(`*Generated from <${CARD_URL}>. Edit via the profile app, not this file.*`) + return lines.join('\n') +} + +function toArray(x) { + if (x == null) return [] + return Array.isArray(x) ? x : [x] +} + +// ---------- Apps installed on this pod ---------- + +async function loadInstalledApps() { + try { + const r = await fetch(APPS_CONTAINER, { + headers: { Accept: 'application/ld+json' }, + credentials: 'include', + }) + if (!r.ok) return + const j = await r.json() + const contains = j['@graph']?.[0]?.['ldp:contains'] || j['ldp:contains'] || [] + const arr = Array.isArray(contains) ? contains : [contains] + const list = document.getElementById('appList') + list.innerHTML = '' + for (const item of arr) { + const url = typeof item === 'string' ? item : item['@id'] + if (!url) continue + const name = url.replace(/\/$/, '').split('/').pop() + const li = document.createElement('li') + const a = document.createElement('a') + a.href = url + a.textContent = name + li.appendChild(a) + list.appendChild(li) + } + } catch {} +} + +// ---------- View toggle ---------- + +document.getElementById('viewToggle').addEventListener('click', () => { + const body = document.body + body.dataset.view = body.dataset.view === 'human' ? 'agent' : 'human' +}) + +document.getElementById('copyLink').addEventListener('click', (e) => { + e.preventDefault() + navigator.clipboard.writeText(`${POD}/profile/`).then(() => toast('link copied')) +}) + +// ---------- Wizard ---------- + +function openWizard() { + const wiz = document.getElementById('wizard') + wiz.showModal() + document.getElementById('wizardForm').addEventListener('submit', onWizardSubmit, { once: true }) +} + +async function onWizardSubmit(e) { + e.preventDefault() + const form = new FormData(e.currentTarget) + const name = (form.get('name') || '').toString().trim() + const status = (form.get('status') || '').toString().trim() + const photo = form.get('photo') + + const card = { + '@context': { + schema: 'https://schema.org/', + foaf: 'http://xmlns.com/foaf/0.1/', + soul: 'urn:soul:', + }, + '@id': '#me', + '@type': ['schema:Person', 'foaf:Person'], + 'foaf:name': name, + 'schema:description': status || undefined, + } + + if (photo && photo.size > 0) { + try { + await fetch(AVATAR_URL, { + method: 'PUT', + headers: { 'Content-Type': photo.type || 'image/png' }, + credentials: 'include', + body: photo, + }) + card['foaf:img'] = '/profile/avatar.png' + } catch {} + } + + ctx.card = card + document.getElementById('wizard').close() + await save() + document.getElementById('app').classList.remove('loading') + render() + renderAgentView() + loadInstalledApps() +} + +// ---------- Toast ---------- + +function toast(msg, isError = false) { + const t = document.getElementById('toast') + t.textContent = msg + t.classList.toggle('error', isError) + t.classList.add('show') + clearTimeout(toast._timer) + toast._timer = setTimeout(() => t.classList.remove('show'), 1800) +} + +// ---------- Go ---------- + +boot().catch((e) => { + console.error(e) + toast('profile failed to load: ' + e.message, true) +}) diff --git a/apps/profile/favicon.svg b/apps/profile/favicon.svg new file mode 100644 index 0000000..52a64ff --- /dev/null +++ b/apps/profile/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/profile/icon-192.png b/apps/profile/icon-192.png new file mode 100644 index 0000000..2d68070 Binary files /dev/null and b/apps/profile/icon-192.png differ diff --git a/apps/profile/icon-512.png b/apps/profile/icon-512.png new file mode 100644 index 0000000..725d877 Binary files /dev/null and b/apps/profile/icon-512.png differ diff --git a/apps/profile/index.html b/apps/profile/index.html new file mode 100644 index 0000000..53c85ca --- /dev/null +++ b/apps/profile/index.html @@ -0,0 +1,114 @@ + + + + + +Profile + + + + + + + + + +
+
profile
+
+ + +
+
+ +
+ + +
+ + +
+
+ + +
+

+

+

+ +
+ + + +
+

On this pod

+
    +
    + + +
    + + + + + +
    +

    + This is the markdown an AI agent sees when it visits + /profile/SOUL.md. It's regenerated from your profile on save. +

    +
    
    +  
    + +
    + + + +
    +

    Let's make your profile

    +

    Three questions. Takes 30 seconds. You can edit anything later.

    + + + + + + + +
    + +
    +
    +
    + + +
    + + + + + diff --git a/apps/profile/manifest.json b/apps/profile/manifest.json new file mode 100644 index 0000000..fe56458 --- /dev/null +++ b/apps/profile/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Profile", + "short_name": "Profile", + "description": "Your profile — human-friendly page, agent-friendly SOUL.md, one source of truth.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/profile/style.css b/apps/profile/style.css new file mode 100644 index 0000000..e23d844 --- /dev/null +++ b/apps/profile/style.css @@ -0,0 +1,414 @@ +/* profile — VK/Bluesky-shaped profile page, light theme. */ + +:root { + --bg: #ffffff; + --surface: #f7f8fa; + --surface-2: #edf0f5; + --border: #e4e7ee; + --text: #1a1d24; + --muted: #5b6477; + --dim: #8a90a0; + --accent: #5476d7; + --accent-2: #8b5cf6; + --danger: #dc2626; + --ok: #059669; + --shadow: 0 4px 14px rgba(20, 30, 50, 0.08); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font: 15px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif; + min-height: 100dvh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +button { font: inherit; cursor: pointer; } + +/* ---------- topbar ---------- */ +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: rgba(255,255,255,0.85); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} +.brand { + font-weight: 700; + letter-spacing: 0.5px; + color: var(--muted); + text-transform: lowercase; +} +.actions { + display: flex; + align-items: center; + gap: 10px; +} +.view-toggle { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 12px; + font-size: 13px; +} +.view-toggle:hover { border-color: var(--accent); color: var(--accent); } +.view-toggle [data-view-label="agent"] { display: none; } +body[data-view="agent"] .view-toggle [data-view-label="human"] { display: none; } +body[data-view="agent"] .view-toggle [data-view-label="agent"] { display: inline; } + +.copy-link { + font-size: 16px; + width: 32px; height: 32px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); +} +.copy-link:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } + +/* ---------- main ---------- */ +.app { + max-width: 720px; + margin: 0 auto; + padding-bottom: 60px; +} +.app.loading::after { + content: 'loading…'; + display: block; + padding: 60px 20px; + text-align: center; + color: var(--muted); +} + +/* ---------- views ---------- */ +body[data-view="human"] .agent-view { display: none; } +body[data-view="agent"] .human-view { display: none; } +.app.loading .human-view, .app.loading .agent-view { display: none; } + +/* ---------- cover + identity ---------- */ +.cover { + height: 180px; + background: + radial-gradient(circle at 20% 40%, rgba(84,118,215,0.18), transparent 55%), + radial-gradient(circle at 80% 60%, rgba(139,92,246,0.16), transparent 55%), + linear-gradient(135deg, #eef2ff, #f7f8fa); + border-bottom: 1px solid var(--border); +} +.cover.editable { cursor: pointer; } + +.identity { + position: relative; + padding: 0 20px; + margin-top: -60px; + text-align: left; +} +.avatar-wrap { + width: 120px; + height: 120px; + position: relative; +} +.avatar, .avatar-fallback { + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid var(--bg); + background: var(--surface-2); + object-fit: cover; + box-shadow: var(--shadow); + position: absolute; + inset: 0; +} +.avatar { display: block; } +.avatar-fallback { + display: grid; + place-items: center; + font-size: 56px; + font-weight: 600; + color: var(--muted); + background: linear-gradient(135deg, #eef2ff, #f7f8fa); + user-select: none; +} +.avatar[hidden], .avatar-fallback[hidden] { display: none; } +.avatar.editable, .avatar-fallback.editable { + cursor: pointer; + transition: transform 0.15s; +} +.avatar.editable:hover, .avatar-fallback.editable:hover { transform: scale(1.02); } + +.edit-hint { + font-size: 12px; + color: var(--dim); + margin: 12px 0 0; + font-style: italic; + transition: opacity 0.6s; +} +.edit-hint.fade-out { opacity: 0; } + +.name { + font-size: 28px; + font-weight: 700; + margin: 12px 0 2px; + line-height: 1.2; + outline: none; + border-radius: 4px; +} +.handle { + color: var(--muted); + font-size: 15px; + margin: 0 0 14px; + outline: none; + border-radius: 4px; +} +.status { + font-size: 16px; + color: var(--text); + margin: 0; + outline: none; + border-radius: 4px; + min-height: 1.5em; +} + +[data-field][contenteditable="true"]:focus { + background: var(--surface); + outline: 1px solid var(--accent); + outline-offset: 2px; +} +[data-field].empty::before { + content: attr(data-placeholder); + color: var(--dim); + font-style: italic; +} + +/* ---------- sections ---------- */ +section.links, section.apps, section.recent { + padding: 28px 20px 0; +} +section h2 { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--dim); + margin: 0 0 14px; +} + +/* ---------- links ---------- */ +.link-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.link-list li { + display: inline-flex; + align-items: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 4px 4px 14px; + gap: 8px; + font-size: 14px; +} +.link-list a { color: var(--text); } +.remove-link { + background: transparent; + border: none; + color: var(--dim); + width: 24px; height: 24px; + border-radius: 50%; + padding: 0; + font-size: 16px; + line-height: 1; +} +.remove-link:hover { color: var(--danger); background: var(--surface-2); } + +.add-link { + margin-top: 12px; + background: transparent; + color: var(--accent); + border: 1px dashed var(--border); + border-radius: 10px; + padding: 8px 14px; + font-size: 13px; +} +.add-link:hover { border-color: var(--accent); background: var(--surface); } + +/* ---------- apps ---------- */ +.app-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.app-list li a { + display: inline-block; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 12px; + font-size: 13px; + color: var(--muted); +} +.app-list li a:hover { color: var(--accent); border-color: var(--accent); text-decoration: none; } + +/* ---------- agent view ---------- */ +.agent-view { + padding: 28px 20px; +} +.agent-lede { + color: var(--muted); + font-size: 14px; + margin: 0 0 16px; +} +.agent-lede code { + background: var(--surface); + border: 1px solid var(--border); + padding: 1px 6px; + border-radius: 4px; + font-size: 13px; +} +.soul { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 20px; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.65; + color: var(--text); + white-space: pre-wrap; + overflow-x: auto; +} + +/* ---------- empty state ---------- */ +.empty-state { + max-width: 420px; + margin: 60px auto 0; + padding: 0 20px; + text-align: center; +} +.empty-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + background: linear-gradient(135deg, var(--surface), var(--surface-2)); + border: 1px solid var(--border); + margin: 0 auto 20px; +} +.empty-title { + font-size: 22px; + font-weight: 700; + margin: 0 0 6px; +} +.empty-sub { + color: var(--muted); + font-size: 14px; + margin: 0 0 22px; +} +.empty-state .primary { + background: var(--accent); + color: #fff; + border: none; + border-radius: 10px; + padding: 12px 22px; + font-size: 15px; + font-weight: 600; +} +.empty-state .primary:hover { filter: brightness(1.05); } + +/* ---------- wizard ---------- */ +.wizard { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 14px; + max-width: 440px; + width: 92vw; + padding: 28px; + box-shadow: 0 20px 60px rgba(20,30,50,0.15); +} +.wizard::backdrop { + background: rgba(20,30,50,0.35); + backdrop-filter: blur(4px); +} +.wizard h2 { margin: 0 0 6px; font-size: 22px; } +.wizard-sub { color: var(--muted); margin: 0 0 22px; font-size: 14px; } +.wizard label { + display: block; + margin-bottom: 18px; +} +.wizard label span:first-child { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.6px; +} +.wizard input { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + color: var(--text); + font: inherit; +} +.wizard input:focus { outline: none; border-color: var(--accent); background: var(--bg); } +.photo-hint { display: block; font-size: 12px; color: var(--dim); margin-top: 4px; } +.wizard-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } +.wizard .primary { + background: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + padding: 10px 18px; + font-weight: 600; +} +.wizard .primary:hover { filter: brightness(1.05); } + +/* ---------- toast ---------- */ +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--text); + border: 1px solid var(--text); + color: var(--bg); + padding: 10px 18px; + border-radius: 999px; + font-size: 13px; + box-shadow: 0 6px 20px rgba(20,30,50,0.2); + opacity: 0; + transition: opacity 0.18s, transform 0.18s; + pointer-events: none; +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.toast.error { background: var(--danger); border-color: var(--danger); color: #fff; } + +/* ---------- mobile ---------- */ +@media (max-width: 520px) { + .topbar { padding: 10px 14px; } + .identity { padding: 0 14px; } + .name { font-size: 24px; } + section.links, section.apps, section.recent { padding-left: 14px; padding-right: 14px; } +} diff --git a/apps/profile/xlogin.js b/apps/profile/xlogin.js new file mode 100644 index 0000000..24c5195 --- /dev/null +++ b/apps/profile/xlogin.js @@ -0,0 +1,737 @@ +/** + * xlogin.js - Universal login widget for Nostr and Solid + * + * A single-script login button (bottom-right) with a tabbed modal dialog + * supporting both Nostr (NIP-07) and Solid (OIDC) authentication. + * + * Usage: + * + * Data attributes: + * data-idp="https://solidcommunity.net" — default Solid identity provider + * data-guest="<64-char-hex>" — enable Nostr guest key + * + * After login: + * window.xlogin.type — "nostr" or "solid" + * window.xlogin.id — pubkey (nostr) or webId (solid) + * window.nostr — NIP-07 API (when logged in via Nostr) + * window.solid.session — solid-oidc Session (when logged in via Solid) + * + * Events on document: + * "xlogin" → detail: { type, id } + * "xlogout" → detail: { type: "logout" } + * + * @license AGPL-3.0-or-later + * @author Melvin Carvalho + */ +(function () { + 'use strict' + if (window.__xloginLoaded) return + window.__xloginLoaded = true + + // --- Config --- + var _script = document.currentScript + var _defaultIdp = (_script && _script.dataset.idp) || '' + var _guestKey = _script && _script.dataset.guest + if (_guestKey && !/^[0-9a-f]{64}$/.test(_guestKey)) _guestKey = null + + // --- State --- + var _type = null // "nostr" or "solid" + var _id = null + var _ui = null + + // Nostr state + var _ext = null // captured browser extension + var _nostrProvider = null + var _nostrPrivKey = null + var _nostrPubKey = null + var _secp = null + var _keyResolvers = [] + + // Solid state + var _solidSession = null + + // --- Capture existing NIP-07 extension --- + _ext = window.nostr || null + + // --- Dynamic imports --- + var _secpReady = import('https://esm.sh/@noble/secp256k1@1.7.1').then(async function (mod) { + _secp = mod + // @noble/secp256k1@1.7.x's async sha256 always reaches into + // `crypto.subtle.digest`, which is undefined on non-secure + // contexts (plain-HTTP LAN/IP). Install a sync sha256 backed by + // @noble/hashes and route signing through `schnorr.signSync` so + // the async path (and crypto.subtle dependency) is never taken. + // See #10 for the call chain that surfaced this. + await loadHashesOnce() + _secp.utils.sha256Sync = function (...messages) { + var total = 0 + for (var i = 0; i < messages.length; i++) total += messages[i].length + var buf = new Uint8Array(total) + var off = 0 + for (var j = 0; j < messages.length; j++) { + buf.set(messages[j], off) + off += messages[j].length + } + return _nobleSha256(buf) + } + }) + + var _SolidSession = null + var _solidReady = import('https://esm.sh/solid-oidc@0.0.8').then(function (mod) { + _SolidSession = mod.Session || mod.default + }) + + var _nip98AuthFetch = null + var _nip98Ready = import('https://esm.sh/nip98').then(function (mod) { + _nip98AuthFetch = mod.authFetch + }) + + // Pure-JS SHA-256 fallback for non-secure contexts (plain-HTTP LAN/IP), + // where window.crypto.subtle is undefined (#8). Loaded only when needed. + var _nobleSha256 = null + var _hashesReady = null + function loadHashesOnce() { + if (!_hashesReady) { + _hashesReady = import('https://esm.sh/@noble/hashes@1.4.0/sha256').then(function (mod) { + // Validate the import resolved to the shape we expect — if esm.sh + // ever changes how it re-exports @noble/hashes/sha256 we want a + // clear error from this Promise rejection rather than the opaque + // `TypeError: _nobleSha256 is not a function` later in sha256(). + if (typeof mod.sha256 !== 'function') { + throw new Error('xlogin: @noble/hashes/sha256 import did not expose a `sha256` function (export shape changed)') + } + _nobleSha256 = mod.sha256 + }) + } + return _hashesReady + } + + // --- localStorage (Nostr accounts, compatible with nip07/Jumble) --- + function loadAccounts() { + try { return JSON.parse(localStorage.getItem('accounts')) || [] } catch (e) { return [] } + } + function saveAccounts(accounts) { localStorage.setItem('accounts', JSON.stringify(accounts)) } + function loadCurrentAccount() { + try { return JSON.parse(localStorage.getItem('currentAccount')) } catch (e) { return null } + } + function saveCurrentAccount(account) { + if (account) localStorage.setItem('currentAccount', JSON.stringify(account)) + else localStorage.removeItem('currentAccount') + } + + // --- Hex utilities --- + function hexToBytes(hex) { + if (_secp) return _secp.utils.hexToBytes(hex) + var b = new Uint8Array(hex.length / 2) + for (var i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.substr(i, 2), 16) + return b + } + function bytesToHex(bytes) { + if (_secp) return _secp.utils.bytesToHex(bytes) + return Array.from(bytes, function (b) { return b.toString(16).padStart(2, '0') }).join('') + } + async function sha256(msg) { + // crypto.subtle is exposed only in secure contexts (HTTPS or + // localhost). On plain-HTTP LAN/IP origins (e.g. + // http://192.168.0.10:4443/) it is undefined, which would throw + // "Cannot read properties of undefined (reading 'digest')". Fall + // back to a pure-JS SHA-256 there. See #8. + if (globalThis.crypto && globalThis.crypto.subtle) { + return new Uint8Array(await crypto.subtle.digest('SHA-256', msg)) + } + await loadHashesOnce() + return _nobleSha256(msg) + } + + // --- NIP-01 --- + async function computeEventId(ev) { + var ser = JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]) + return bytesToHex(await sha256(new TextEncoder().encode(ser))) + } + async function nostrSignEvent(event) { + // _secpReady installs `_secp.utils.sha256Sync`; signSync then + // never touches `crypto.subtle` and works on non-secure contexts + // (#10). Awaiting _secpReady guarantees the sync hash is set + // before signSync runs. + await _secpReady + var ev = Object.assign({}, event, { pubkey: _nostrPubKey }) + ev.id = await computeEventId(ev) + var sig = _secp.schnorr.signSync(ev.id, _nostrPrivKey) + ev.sig = bytesToHex(sig) + return ev + } + + // --- NIP-04 --- + async function getSharedSecret(theirPubkey) { + await _secpReady + var shared = _secp.getSharedSecret(_nostrPrivKey, '02' + theirPubkey) + return shared.slice(1, 33) + } + async function nip04Encrypt(pubkey, plaintext) { + var secret = await getSharedSecret(pubkey) + var key = await crypto.subtle.importKey('raw', secret, { name: 'AES-CBC' }, false, ['encrypt']) + var iv = crypto.getRandomValues(new Uint8Array(16)) + var cipher = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: iv }, key, new TextEncoder().encode(plaintext)) + return btoa(String.fromCharCode.apply(null, new Uint8Array(cipher))) + '?iv=' + btoa(String.fromCharCode.apply(null, iv)) + } + async function nip04Decrypt(pubkey, ciphertext) { + var parts = ciphertext.split('?iv=') + var secret = await getSharedSecret(pubkey) + var key = await crypto.subtle.importKey('raw', secret, { name: 'AES-CBC' }, false, ['decrypt']) + var iv = Uint8Array.from(atob(parts[1]), function (c) { return c.charCodeAt(0) }) + var cipher = Uint8Array.from(atob(parts[0]), function (c) { return c.charCodeAt(0) }) + var plain = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: iv }, key, cipher) + return new TextDecoder().decode(plain) + } + + // --- Display helpers --- + function shortNostr(pubkey) { + return pubkey.slice(0, 8) + '\u2026' + pubkey.slice(-4) + } + function shortSolid(uri) { + if (!uri) return '' + try { + var u = new URL(uri) + // Just the subdomain, with a trailing dot to read as one: + // "nostr." for nostr.solid.social, "alice." for alice.example.com. + return u.hostname.split('.')[0] + '.' + } catch (e) { + return uri + } + } + + // --- Unified login success --- + function onLogin(type, id, btn) { + _type = type + _id = id + if (btn) { + btn.textContent = type === 'nostr' ? shortNostr(id) : shortSolid(id) + btn.title = id + } + window.xlogin = window.xlogin || {} + window.xlogin.type = type + window.xlogin.id = id + document.dispatchEvent(new CustomEvent('xlogin', { detail: { type: type, id: id } })) + } + + // --- Unified logout --- + function onLogout(btn) { + if (_type === 'nostr') { + _nostrProvider = null + _nostrPrivKey = null + _nostrPubKey = null + saveCurrentAccount(null) + } else if (_type === 'solid') { + if (_solidSession) _solidSession.logout() + _solidSession = null + window.solid = window.solid || {} + window.solid.session = null + window.solid.webId = null + } + _type = null + _id = null + if (btn) { + btn.textContent = 'Login' + btn.title = '' + } + window.xlogin = window.xlogin || {} + window.xlogin.type = null + window.xlogin.id = null + document.dispatchEvent(new CustomEvent('xlogout', { detail: { type: 'logout' } })) + } + + // --- Nostr login success --- + function nostrLoginSuccess(btn, pubkey, method) { + _nostrPubKey = pubkey + _nostrProvider = method + + // Persist + var signerType = method === 'extension' ? 'nip-07' : method + var account = { '@id': 'did:nostr:' + pubkey, pubkey: pubkey, signerType: signerType } + if ((method === 'key' || method === 'guest') && _nostrPrivKey) account.privkey = _nostrPrivKey + var accounts = loadAccounts() + var idx = accounts.findIndex(function (a) { return a.pubkey === pubkey }) + if (idx >= 0) accounts[idx] = account; else accounts.push(account) + saveAccounts(accounts) + saveCurrentAccount(account) + + _keyResolvers.forEach(function (r) { r.resolve() }) + _keyResolvers = [] + + onLogin('nostr', pubkey, btn) + } + + // --- NIP-07 API --- + function ensureNostrProvider() { + if (_nostrProvider) return Promise.resolve() + return new Promise(function (resolve, reject) { + _keyResolvers.push({ resolve: resolve, reject: reject }) + showModal() + }) + } + + window.nostr = { + getPublicKey: async function () { + await ensureNostrProvider() + if (_nostrProvider === 'extension') return _ext.getPublicKey() + return _nostrPubKey + }, + signEvent: async function (event) { + await ensureNostrProvider() + if (_nostrProvider === 'extension') return _ext.signEvent(event) + return nostrSignEvent(event) + }, + nip04: { + encrypt: async function (pubkey, plaintext) { + await ensureNostrProvider() + if (_nostrProvider === 'extension' && _ext.nip04) return _ext.nip04.encrypt(pubkey, plaintext) + return nip04Encrypt(pubkey, plaintext) + }, + decrypt: async function (pubkey, ciphertext) { + await ensureNostrProvider() + if (_nostrProvider === 'extension' && _ext.nip04) return _ext.nip04.decrypt(pubkey, ciphertext) + return nip04Decrypt(pubkey, ciphertext) + } + } + } + + // ========================================================================= + // UI + // ========================================================================= + + var CSS = [ + '.xl-btn{position:fixed;bottom:16px;right:16px;z-index:999999;background:#8B5CF6;color:#fff;border:none;border-radius:20px;padding:8px 16px;font:14px/1.4 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.2);transition:background .2s}', + '.xl-btn:hover{background:#7C3AED}', + '.xl-overlay{display:none;position:fixed;inset:0;z-index:1000000;background:rgba(0,0,0,.5);align-items:center;justify-content:center}', + '.xl-overlay.active{display:flex}', + '.xl-modal{background:#1a1a2e;color:#e0e0e0;border-radius:12px;padding:24px;width:380px;max-width:90vw;font:14px/1.4 system-ui,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,.4)}', + '.xl-modal h2{margin:0 0 16px;font-size:18px;color:#fff}', + // Tabs + '.xl-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid #333}', + '.xl-tab{flex:1;padding:10px 0;border:none;background:transparent;color:#888;font:14px/1.4 system-ui,sans-serif;cursor:pointer;border-bottom:2px solid transparent;transition:all .2s}', + '.xl-tab:hover{color:#ccc}', + '.xl-tab.active{color:#8B5CF6;border-bottom-color:#8B5CF6}', + // Panels + '.xl-panel{display:none}', + '.xl-panel.active{display:block}', + // Buttons + '.xl-provider{width:100%;box-sizing:border-box;padding:10px 16px;border:1px solid #8B5CF6;border-radius:8px;background:transparent;color:#8B5CF6;font-size:14px;cursor:pointer;transition:background .2s;text-align:left;margin-bottom:8px}', + '.xl-provider:hover{background:#8B5CF620}', + '.xl-guest{width:100%;box-sizing:border-box;padding:10px 16px;border:1px solid #666;border-radius:8px;background:transparent;color:#aaa;font-size:14px;cursor:pointer;margin-bottom:8px;transition:background .2s}', + '.xl-guest:hover{background:#66666620}', + '.xl-sep{text-align:center;color:#666;font-size:12px;margin:12px 0}', + '.xl-modal input{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #333;border-radius:8px;background:#0d0d1a;color:#e0e0e0;font:13px system-ui,sans-serif;margin-bottom:8px}', + '.xl-modal .xl-nostr-key{font:13px monospace}', + '.xl-modal input:focus{outline:none;border-color:#8B5CF6}', + '.xl-error{color:#ef4444;font-size:12px;margin-bottom:8px;min-height:16px}', + '.xl-actions{display:flex;gap:8px;justify-content:flex-end}', + '.xl-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:14px}', + '.xl-cancel{background:#333;color:#aaa}', + '.xl-cancel:hover{background:#444}', + '.xl-submit{background:#8B5CF6;color:#fff}', + '.xl-submit:hover{background:#7C3AED}', + '.xl-submit:disabled{opacity:.5;cursor:not-allowed}', + '.xl-signup{text-align:center;font-size:12px;color:#666;margin-top:12px}', + '.xl-signup a{color:#8B5CF6;text-decoration:none}', + '.xl-signup a:hover{text-decoration:underline}' + ].join('') + + function createWidget() { + var host = document.createElement('div') + host.id = 'xlogin-widget' + document.body.appendChild(host) + var shadow = host.attachShadow({ mode: 'closed' }) + + var style = document.createElement('style') + style.textContent = CSS + shadow.appendChild(style) + + // --- Button --- + // Plaza vendored xlogin and suppressed this button because plaza + // renders its own login pill in the topbar. Profile shows the + // floating bottom-right pill — re-enabled. + var btn = document.createElement('button') + btn.className = 'xl-btn' + btn.textContent = 'Login' + btn.onclick = function () { + if (_type) onLogout(btn) + else showModal() + } + shadow.appendChild(btn) + + // --- Overlay --- + var overlay = document.createElement('div') + overlay.className = 'xl-overlay' + + // --- Modal --- + var nostrExtBtn = _ext + ? '' + : '' + var nostrGuestBtn = _guestKey + ? '' + : '' + var nostrSep = (_ext || _guestKey) ? '
    or paste a private key
    ' : '' + + var solidProviders = [ + { name: window.location.host, url: window.location.origin }, + { name: 'solidcommunity.net', url: 'https://solidcommunity.net' }, + { name: 'solidweb.me', url: 'https://solidweb.me' }, + { name: 'solidweb.org', url: 'https://solidweb.org' }, + { name: 'solidweb.app', url: 'https://solidweb.app' }, + { name: 'solid.social', url: 'https://solid.social' } + ] + var solidBtns = solidProviders.map(function (p) { + return '' + }).join('') + + overlay.innerHTML = + '
    ' + + '

    Login

    ' + + '
    ' + + '' + + '' + + '
    ' + + // Nostr panel + '
    ' + + nostrExtBtn + + nostrGuestBtn + + nostrSep + + '' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + // Solid panel + '
    ' + + solidBtns + + '
    or enter your identity provider
    ' + + '' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '' + + '
    ' + + '
    ' + shadow.appendChild(overlay) + + // --- Wire up tabs --- + var tabs = overlay.querySelectorAll('.xl-tab') + var panels = overlay.querySelectorAll('.xl-panel') + tabs.forEach(function (tab) { + tab.onclick = function () { + tabs.forEach(function (t) { t.classList.remove('active') }) + panels.forEach(function (p) { p.classList.remove('active') }) + tab.classList.add('active') + overlay.querySelector('[data-panel="' + tab.dataset.tab + '"]').classList.add('active') + } + }) + + // --- Cancel --- + var cancelBtns = overlay.querySelectorAll('.xl-cancel') + function cancel() { + hideModal() + _keyResolvers.forEach(function (r) { r.reject(new Error('User cancelled login')) }) + _keyResolvers = [] + } + cancelBtns.forEach(function (b) { b.onclick = cancel }) + overlay.onclick = function (e) { if (e.target === overlay) cancel() } + + // --- Nostr: Extension --- + var extBtn = overlay.querySelector('.xl-nostr-ext') + var nostrError = overlay.querySelector('.xl-nostr-error') + if (extBtn) { + extBtn.onclick = async function () { + try { + var pubkey = await _ext.getPublicKey() + hideModal() + nostrLoginSuccess(btn, pubkey, 'extension') + } catch (e) { + nostrError.textContent = 'Extension error: ' + e.message + } + } + } + + // --- Nostr: Guest --- + var guestBtn = overlay.querySelector('.xl-nostr-guest') + if (guestBtn) { + guestBtn.onclick = async function () { + await _secpReady + _nostrPrivKey = _guestKey + var pubkey = bytesToHex(_secp.schnorr.getPublicKey(_guestKey)) + hideModal() + nostrLoginSuccess(btn, pubkey, 'guest') + } + } + + // --- Nostr: Key --- + var nostrKeyInput = overlay.querySelector('.xl-nostr-key') + var nostrSubmit = overlay.querySelector('.xl-nostr-submit') + nostrSubmit.onclick = async function () { + var val = nostrKeyInput.value.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(val)) { + nostrError.textContent = 'Must be exactly 64 hex characters' + return + } + await _secpReady + _nostrPrivKey = val + var pubkey = bytesToHex(_secp.schnorr.getPublicKey(val)) + hideModal() + nostrKeyInput.value = '' + nostrError.textContent = '' + nostrLoginSuccess(btn, pubkey, 'key') + } + nostrKeyInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') nostrSubmit.onclick() + }) + nostrKeyInput.addEventListener('input', function () { + if (/^[0-9a-fA-F]{64}$/.test(nostrKeyInput.value.trim())) nostrSubmit.onclick() + }) + + // --- Solid: Provider buttons --- + var solidError = overlay.querySelector('.xl-solid-error') + overlay.querySelectorAll('.xl-solid-provider').forEach(function (b) { + b.onclick = function () { doSolidLogin(b.dataset.idp, btn, solidError) } + }) + + // --- Solid: Custom IDP --- + var solidIdpInput = overlay.querySelector('.xl-solid-idp') + var solidSubmit = overlay.querySelector('.xl-solid-submit') + solidSubmit.onclick = function () { + var val = solidIdpInput.value.trim() + if (!val) { + solidError.textContent = 'Enter an identity provider URL' + return + } + if (!/^https?:\/\//.test(val)) val = 'https://' + val + doSolidLogin(val, btn, solidError) + } + solidIdpInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') solidSubmit.onclick() + }) + + return { btn: btn, overlay: overlay } + } + + // --- Solid login --- + async function doSolidLogin(idp, btn, errorEl) { + try { + errorEl.textContent = '' + await _solidReady + _solidSession = new _SolidSession() + _solidSession.addEventListener('sessionStateChange', function (e) { + if (e.detail.isActive) { + var webId = e.detail.webId + window.solid = window.solid || {} + window.solid.session = _solidSession + window.solid.webId = webId + onLogin('solid', webId, btn) + } + }) + hideModal() + await _solidSession.login(idp, window.location.href) + } catch (e) { + if (errorEl) errorEl.textContent = e.message || 'Login failed' + } + } + + // --- Solid redirect callback --- + async function handleSolidRedirect() { + var url = new URL(window.location.href) + if (!url.searchParams.get('code')) return false + await _solidReady + _solidSession = new _SolidSession() + var ui = getUI() + _solidSession.addEventListener('sessionStateChange', function (e) { + if (e.detail.isActive) { + var webId = e.detail.webId + window.solid = window.solid || {} + window.solid.session = _solidSession + window.solid.webId = webId + if (ui && ui.btn) onLogin('solid', webId, ui.btn) + } + }) + await _solidSession.handleRedirectFromLogin() + return true + } + + // --- Solid session restore --- + async function trySolidRestore() { + await _solidReady + _solidSession = new _SolidSession() + var ui = getUI() + _solidSession.addEventListener('sessionStateChange', function (e) { + if (e.detail.isActive) { + var webId = e.detail.webId + window.solid = window.solid || {} + window.solid.session = _solidSession + window.solid.webId = webId + if (ui && ui.btn) onLogin('solid', webId, ui.btn) + } + }) + await _solidSession.restore() + } + + // --- Nostr session restore --- + // Returns a Promise so the caller can await the async nip-07 + // polling tail (#13). All other paths resolve synchronously. + function tryNostrRestore() { + var restored = loadCurrentAccount() + if (!restored) return Promise.resolve() + if ((restored.signerType === 'key' || restored.signerType === 'guest') && restored.privkey) { + _nostrPrivKey = restored.privkey + _nostrPubKey = restored.pubkey + _nostrProvider = restored.signerType + var ui = getUI() + if (ui && ui.btn) onLogin('nostr', restored.pubkey, ui.btn) + return Promise.resolve() + } + if (restored.signerType === 'nip-07') { + _nostrPubKey = restored.pubkey + return (async function () { + for (var i = 0; i < 50; i++) { + if (_ext) { + _nostrProvider = 'extension' + var ui = getUI() + if (ui && ui.btn) onLogin('nostr', _nostrPubKey, ui.btn) + return + } + await new Promise(function (r) { setTimeout(r, 100) }) + } + _nostrPubKey = null + saveCurrentAccount(null) + })() + } + return Promise.resolve() + } + + function getUI() { + if (!_ui && document.body) _ui = createWidget() + return _ui + } + + function showModal() { + var ui = getUI() + if (ui) ui.overlay.classList.add('active') + } + + function hideModal() { + var ui = getUI() + if (ui) ui.overlay.classList.remove('active') + } + + // --- Global API --- + window.xlogin = window.xlogin || {} + window.xlogin.type = null + window.xlogin.id = null + window.xlogin.login = function () { showModal() } + window.xlogin.logout = function () { onLogout(getUI().btn) } + // Resolves when init() has finished restoring (or settled on no + // session). Lets consumers `await window.xlogin.ready` instead of + // polling `window.xlogin.type` with a timeout. See #13. + var _readyResolve + window.xlogin.ready = new Promise(function (r) { _readyResolve = r }) + + /** + * Unified authenticated fetch. + * - Nostr login → NIP-98 Authorization header via nip98 + * - Solid login → DPoP Authorization header via solid-oidc + * - Not logged in → plain fetch + */ + window.xlogin.authFetch = async function (url, options) { + if (_type === 'nostr') { + await _nip98Ready + return _nip98AuthFetch(url, options) + } + if (_type === 'solid' && _solidSession) { + return _solidSession.authFetch(url, options) + } + return fetch(url, options) + } + + // --- Init --- + async function init() { + // Wrap in try/finally so `window.xlogin.ready` always settles — + // any unexpected throw inside (beyond the known catches below) + // would otherwise leave consumers awaiting it forever (#14). + try { + getUI() + + // 1. Solid redirect callback + var wasRedirect = await handleSolidRedirect().catch(function () { return false }) + + if (!wasRedirect) { + // 2. Try Nostr restore (await the async nip-07 tail too) + await tryNostrRestore() + + // 3. Try Solid restore if not already logged in via Nostr + if (!_type) { + await trySolidRestore().catch(function () {}) + } + + // 4. SSO-arrival handling. When a Solid app (e.g. jss.live/sso/) + // redirects the user to their pod with a ?webid= hint, xlogin + // owns that param — clean it from the URL on sight (phase 2c), + // and if no prior session restored AND a signer extension is + // present, auto-run the same getPublicKey + nostrLoginSuccess + // pair the "Use Browser Extension" button click runs (phase + // 2b). Saves the user a click; the signer is still in charge + // of approval. The pubkey returned by the signer wins — the + // hint is a "should we try this" signal, not a credential. + try { + var hint = new URLSearchParams(window.location.search).get('webid') + if (hint) { + // Phase 2c: scrub `?webid=` whether or not we can act on + // it (session already restored, no extension, etc.). + // Keeps refresh / bookmark / share clean. Other query + // params (if any) are preserved. + try { + var clean = new URL(window.location.href) + clean.searchParams.delete('webid') + history.replaceState(null, '', clean.href) + } catch (_) { /* history API unavailable — harmless, skip */ } + + // Phase 2b auto-trigger keeps its original guards. + if (!_type && _ext) { + var ui = getUI() + if (ui && ui.btn) { + // Fire-and-forget — do NOT await the signer prompt. + // The signer's getPublicKey() may block indefinitely + // while waiting for user approval (especially on + // extensions that show a popup), which would in turn + // delay window.xlogin.ready (the whole point of #14 + // was to settle ready promptly regardless of pending + // user interaction). Consumers see ready resolve as + // "no session yet"; nostrLoginSuccess fires its own + // state-change event when/if the signer approves + // later, so async consumers still see the eventual + // login. + _ext.getPublicKey().then(function (pubkey) { + // Mirror the manual extBtn.click() path: hide the + // modal first so it doesn't stay open in the corner + // case where the user opened it during the brief + // auto-trigger window. + hideModal() + nostrLoginSuccess(ui.btn, pubkey, 'extension') + }).catch(function () { /* signer declined or unavailable — fall through to manual login */ }) + } + } + } + } catch (_) { /* missing URLSearchParams or unexpected runtime — no-op */ } + } + } finally { + // Tell consumers (e.g., LOSOS shell) that restore has settled + // — success, no-session, or unexpected throw. See #13. + _readyResolve() + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init) + } else { + init() + } +})()