-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
11 lines (11 loc) · 16.4 KB
/
Copy pathapp.js
File metadata and controls
11 lines (11 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
const DATA_PATH = '/private/journal/';const POD_ROOT = location.origin;async function ensureJournalContainer(){try{const h=await authFetch(POD_ROOT+DATA_PATH,{method:'HEAD'});if(h.ok)return;await authFetch(POD_ROOT+DATA_PATH,{method:'PUT',headers:{'Content-Type':'text/turtle','Link':'<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"'},body:''});}catch(e){}}const $ = s => document.querySelector(s);const $$ = s => document.querySelectorAll(s);let editingUrl = null;
// Tolerant reader: agents write entries with prefix variants (dct: vs dcterms:, bare keys,
// full IRIs). Read charitably here; the app itself always WRITES the canonical keys.
const FIELD = {
title: ['dc:title', 'dct:title', 'dcterms:title', 'title', 'schema:name', 'name', 'http://purl.org/dc/elements/1.1/title', 'http://purl.org/dc/terms/title', 'https://schema.org/name'],
text: ['schema:text', 'text', 'schema:articleBody', 'articleBody', 'https://schema.org/text', 'http://schema.org/text'],
created: ['dcterms:created', 'dct:created', 'dc:created', 'created', 'schema:dateCreated', 'dateCreated', 'http://purl.org/dc/terms/created', 'https://schema.org/dateCreated'],
mood: ['schema:mood', 'mood', 'https://schema.org/mood'],
keywords: ['schema:keywords', 'keywords', 'https://schema.org/keywords']
};
function fieldOf(d, name) { for (const k of FIELD[name]) { const v = d && d[k]; if (v != null) return v; } return undefined; }function getToken() { return localStorage.getItem('j_token') || ''; }function setToken(t) { localStorage.setItem('j_token', t); }function getUser() { return localStorage.getItem('j_user') || ''; }function setUser(u) { localStorage.setItem('j_user', u); }function displayName(id, type){ if(!id) return 'me'; if(type==='nostr'){ return id.length>14 ? id.slice(0,10)+'…' : id; } try{ const u=new URL(id); const seg=u.pathname.split('/').filter(Boolean).pop(); return seg || u.hostname; }catch{ return id; } }function currentUser(){ if(window.xlogin && window.xlogin.id) return { name: displayName(window.xlogin.id, window.xlogin.type), via:'xlogin' }; if(getToken()) return { name: getUser() || 'me', via:'glm' }; return null; }function isLoggedIn() { return !!(window.xlogin && window.xlogin.id) || !!getToken(); }function authHeaders() { const t = getToken(); return t ? { 'Authorization': 'Bearer ' + t } : {};}function authFetch(u, o){ if(window.xlogin && window.xlogin.id && window.xlogin.authFetch) return window.xlogin.authFetch(u, o); o = o || {}; o.headers = { ...authHeaders(), ...(o.headers || {}) }; return fetch(u, o); }function updateUI() { const btn = $('#avatarBtn'); const initial = $('#avatarInitial'); const menuName = $('#menuName'); const menuSub = $('#menuSub'); const menuAvatar = $('#menuAvatar'); const signInItem = $('#menuSignIn'); const signOutItem = $('#menuSignOut'); const cu = currentUser(); if (cu) { const user = cu.name; const letter = user.charAt(0).toUpperCase(); btn.classList.add('signed-in'); initial.textContent = letter; menuAvatar.textContent = letter; menuName.textContent = user; menuSub.textContent = cu.via === 'xlogin' ? 'Signed in' : 'Signed in (password)'; signInItem.classList.add('hidden'); signOutItem.classList.remove('hidden'); } else { btn.classList.remove('signed-in'); initial.textContent = '?'; menuAvatar.textContent = '?'; menuName.textContent = 'Not signed in'; menuSub.textContent = 'Sign in to save entries'; signInItem.classList.remove('hidden'); signOutItem.classList.add('hidden'); }}function todaySlug() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;}function timeStr(iso) { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });}function relDay(dateStr) { const d = dateStr.slice(0, 10); if (d === todaySlug()) return 'Today'; const y = new Date(); y.setDate(y.getDate() - 1); if (d === y.toISOString().slice(0, 10)) return 'Yesterday'; return new Date(d + 'T12:00:00').toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });}async function fetchJSON(url, opts = {}) { const res = await authFetch(url, opts); if (!res.ok) throw new Error(res.status); return res.json();}async function getEntries() { try { const listing = await fetchJSON(POD_ROOT + DATA_PATH); const items = listing['ldp:contains'] || listing.contains || []; const arr = Array.isArray(items) ? items : [items]; const entries = []; for (const item of arr) { const url = typeof item === 'string' ? item : (item['@id'] || ''); if (!url.endsWith('.jsonld')) continue; try { const data = await fetchJSON(url); entries.push({ data, url }); } catch {} } entries.sort((a, b) => (fieldOf(b.data, 'created') || '').localeCompare(fieldOf(a.data, 'created') || '')); return entries; } catch { return []; }}function esc(s) { if (!s) return ''; const d = document.createElement('span'); d.textContent = s; return d.innerHTML;}function toast(msg) { let t = document.querySelector('.toast'); if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('show'); clearTimeout(t._tid); t._tid = setTimeout(() => t.classList.remove('show'), 2200);}function groupByDate(entries) { const groups = {}; for (const e of entries) { const day = (fieldOf(e.data, 'created') || '').slice(0, 10) || 'unknown'; if (!groups[day]) groups[day] = []; groups[day].push(e); } return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0]));}function renderTimeline(entries) { const main = $('#timeline'); const count = $('#entryCount'); count.textContent = entries.length ? `${entries.length}` : ''; if (entries.length === 0) { main.innerHTML = `<div class="empty"> <div class="empty-art"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <path d="M4 19.5A2.5 2.5 0 016.5 17H20"/> <path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/> <path d="M8 7h8M8 11h5"/> </svg> </div> <h2>Your journal awaits</h2> <p>Tap the + button to write your first entry</p> </div>`; return; } const groups = groupByDate(entries); main.innerHTML = groups.map(([day, dayEntries]) => { const cards = dayEntries.map((e, i) => renderEntry(e, i)).join(''); return `<div class="day-group"> <div class="day-header"> <span class="day-dot"></span> <span class="day-label">${esc(relDay(day))}</span> <span class="day-count">${dayEntries.length}</span> </div> ${cards} </div>`; }).join(''); $$('.entry').forEach(card => { card.addEventListener('click', (ev) => { if (ev.target.closest('.entry-del')) return; openSheetForEdit(card.dataset.url); }); }); $$('.entry-del').forEach(btn => { btn.addEventListener('click', async (ev) => { ev.stopPropagation(); const card = btn.closest('.entry'); card.style.opacity = '0'; card.style.transform = 'translateX(40px)'; card.style.transition = 'all 0.25s ease'; await new Promise(r => setTimeout(r, 250)); try { await authFetch(card.dataset.url, { method: 'DELETE' }); loadAndRender(); toast('Entry deleted'); } catch { card.style.opacity = '1'; card.style.transform = ''; toast('Delete failed'); } }); });}function renderEntry({ data, url }, idx) { const title = fieldOf(data, 'title') || ''; const body = fieldOf(data, 'text') || ''; const created = fieldOf(data, 'created') || ''; const mood = fieldOf(data, 'mood') || ''; const tags = fieldOf(data, 'keywords') || []; const tagArr = Array.isArray(tags) ? tags : [tags].filter(Boolean); const time = created ? timeStr(created) : ''; const tagsHtml = [ mood ? `<span class="tag mood">${esc(mood)}</span>` : '', ...tagArr.map(t => `<span class="tag">${esc(t)}</span>`) ].filter(Boolean).join(''); return `<div class="entry" data-url="${esc(url)}" style="animation-delay:${idx * 0.04}s"> <button class="entry-del" title="Delete"> <svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> </button> <div class="entry-top"> ${title ? `<div class="entry-title">${esc(title)}</div>` : ''} <div class="entry-meta"> ${time ? `<span class="entry-time">${esc(time)}</span>` : ''} <a class="entry-link" href="${esc(url)}" target="_blank" title="Open JSON-LD"><svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M10.5 3.5h2v2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M7 7l5.5-3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M12.5 6V11.5a1 1 0 01-1 1H2.5a1 1 0 01-1-1V2.5a1 1 0 011-1H8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>jsonld</a> </div> </div> ${body ? `<div class="entry-body">${esc(body)}</div>` : ''} ${tagsHtml ? `<div class="entry-tags">${tagsHtml}</div>` : ''} </div>`;}function openSheet() { editingUrl = null; $('#backdrop').classList.add('open'); $('#sheet').classList.add('open'); document.body.style.overflow = 'hidden'; $('#editorTitle').value = ''; $('#editorBody').value = ''; $('#editorMood').value = ''; $('#editorTags').value = ''; $('#btnSave').textContent = 'Save entry'; setTimeout(() => $('#editorTitle').focus(), 350);}async function openSheetForEdit(url) { try { const data = await fetchJSON(url); editingUrl = url; $('#backdrop').classList.add('open'); $('#sheet').classList.add('open'); document.body.style.overflow = 'hidden'; $('#editorTitle').value = fieldOf(data, 'title') || ''; $('#editorBody').value = fieldOf(data, 'text') || ''; const mood = fieldOf(data, 'mood') || ''; const tags = fieldOf(data, 'keywords') || []; const tagArr = Array.isArray(tags) ? tags : [tags].filter(Boolean); $('#editorMood').value = mood; $('#editorTags').value = tagArr.join(', '); $('#btnSave').textContent = 'Update entry'; setTimeout(() => $('#editorBody').focus(), 350); } catch { toast('Could not load entry'); }}function closeSheet() { editingUrl = null; $('#backdrop').classList.remove('open'); $('#sheet').classList.remove('open'); document.body.style.overflow = '';}function toggleMenu() { const menu = $('#userMenu'); const bd = $('#menuBackdrop'); const isOpen = menu.classList.contains('open'); if (isOpen) { menu.classList.remove('open'); bd.classList.remove('open'); } else { menu.classList.add('open'); bd.classList.add('open'); }}function closeMenu() { $('#userMenu').classList.remove('open'); $('#menuBackdrop').classList.remove('open');}function doAuth() { return new Promise((resolve) => { const modal = $('#authModal'); const errEl = $('#authError'); modal.classList.add('open'); errEl.textContent = ''; errEl.classList.remove('show'); $('#authUser').value = getUser(); $('#authPass').value = ''; setTimeout(() => { if ($('#authUser').value) $('#authPass').focus(); else $('#authUser').focus(); }, 100); function cleanup() { modal.classList.remove('open'); $('#authSubmit').onclick = null; $('#authCancel').onclick = null; $('#authForm').onsubmit = null; } async function submit() { const u = $('#authUser').value.trim(); const p = $('#authPass').value; if (!u || !p) { errEl.textContent = 'Username and password are required'; errEl.classList.add('show'); return; } $('#authSubmit').disabled = true; $('#authSubmit').textContent = 'Signing in\u2026'; try { const res = await fetch('/idp/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) }); const data = await res.json(); if (data.access_token) { setToken(data.access_token); setUser(u); updateUI(); cleanup(); toast('Signed in as ' + u); resolve(true); } else { errEl.textContent = 'Invalid username or password'; errEl.classList.add('show'); } } catch { errEl.textContent = 'Connection failed'; errEl.classList.add('show'); } finally { $('#authSubmit').disabled = false; $('#authSubmit').textContent = 'Sign in'; } } $('#authSubmit').onclick = submit; $('#authCancel').onclick = () => { cleanup(); resolve(false); }; $('#authForm').onsubmit = (e) => { e.preventDefault(); submit(); }; });}function startSignIn(){ if (window.xlogin && typeof window.xlogin.login === 'function') window.xlogin.login(); else doAuth(); }async function ensureSignedIn(){ if (isLoggedIn()) return true; if (window.xlogin && typeof window.xlogin.login === 'function'){ window.xlogin.login(); toast('Sign in, then tap Save'); return false; } return await doAuth(); }function doLogout() { if (window.xlogin && typeof window.xlogin.logout === 'function') window.xlogin.logout(); setToken(''); setUser(''); updateUI(); closeMenu(); toast('Signed out');}async function saveEntry() { const title = $('#editorTitle').value.trim(); const body = $('#editorBody').value.trim(); const mood = $('#editorMood').value.trim(); const tagsRaw = $('#editorTags').value.trim(); const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; if (!body && !title) return; const entry = { '@context': { 'dc': 'http://purl.org/dc/elements/1.1/', 'dcterms': 'http://purl.org/dc/terms/', 'schema': 'https://schema.org/' }, '@id': '#this', '@type': 'schema:NoteDigitalDocument', 'schema:text': body, 'dcterms:created': new Date().toISOString() }; if (title) entry['dc:title'] = title; if (mood) entry['schema:mood'] = mood; if (tags.length) entry['schema:keywords'] = tags; const isEditing = !!editingUrl; const saveBtn = $('#btnSave'); saveBtn.disabled = true; saveBtn.textContent = isEditing ? 'Updating\u2026' : 'Saving\u2026'; try { if (!isLoggedIn()) { const ok = await ensureSignedIn(); if (!ok) return; } if (isEditing) { const orig = await fetchJSON(editingUrl); entry['dcterms:created'] = fieldOf(orig, 'created') || entry['dcterms:created']; } if(!editingUrl)await ensureJournalContainer();const slug = todaySlug() + '-' + new Date().toTimeString().slice(0, 8).replace(/:/g, ''); const targetUrl = editingUrl || (POD_ROOT + DATA_PATH); const method = editingUrl ? 'PUT' : 'POST'; const extraHeaders = editingUrl ? { 'Content-Type': 'application/ld+json' } : { 'Content-Type': 'application/ld+json', 'Slug': slug + '.jsonld' }; let res = await authFetch(targetUrl, { method, headers: { ...extraHeaders }, body: JSON.stringify(entry) }); if (res.status === 401) { const wasX = !!(window.xlogin && window.xlogin.id); if (!wasX) setToken(''); updateUI(); const ok = await ensureSignedIn(); if (!ok) return; res = await authFetch(targetUrl, { method, headers: { ...extraHeaders }, body: JSON.stringify(entry) }); } if (!res.ok && res.status !== 201) throw new Error(res.status); closeSheet(); toast(isEditing ? 'Entry updated' : 'Entry saved'); loadAndRender(); } catch { toast(isEditing ? 'Update failed' : 'Save failed'); } finally { saveBtn.disabled = false; saveBtn.textContent = 'Save entry'; }}async function loadAndRender() { const entries = await getEntries(); renderTimeline(entries);}$('#btnNew').addEventListener('click', openSheet);$('#btnCancel').addEventListener('click', closeSheet);$('#btnSave').addEventListener('click', saveEntry);$('#backdrop').addEventListener('click', closeSheet);$('#avatarBtn').addEventListener('click', toggleMenu);$('#menuBackdrop').addEventListener('click', closeMenu);$('#menuSignIn').addEventListener('click', () => { closeMenu(); startSignIn(); });$('#menuSignOut').addEventListener('click', doLogout);document.addEventListener('xlogin', () => { updateUI(); loadAndRender(); });document.addEventListener('xlogout', () => { updateUI(); loadAndRender(); });document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSheet(); closeMenu(); } if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && $('#sheet').classList.contains('open')) saveEntry();});(async () => { try { if (window.xlogin && window.xlogin.ready) await window.xlogin.ready; } catch {} updateUI(); loadAndRender(); })();