mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(mobile): rooms, live and upload capture, and the altitude-band identity
The Now feed mixes sensor, eye, track, number, witness and room cards across Orbit, Sky, Ground and Inside, ordered so neighbours differ, with composited thumbnails and live public-camera frames. Every situation has a room: typed, place- and time-anchored posts, evidence badges, bridging-rated notes and a cited Known / Disputed / Unknown local synthesis, with cross-tab presence over BroadcastChannel. Example posts are labelled as such. Capture adds Live (10 s segments sealed into a hash chain, shown on the globe as a view frustum) and Upload (EXIF placement, Unverified tier), and an attach sheet that files any capture into a nearby situation. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
38 files changed
+9837
-1081
No files matched your search
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Post composer: Capture first, text second (DESIGN.md §11.3 "Reply with
|
||||
* evidence"). Structured kinds, an optional place and time anchor.
|
||||
*/
|
||||
|
||||
import { icon } from './icons.js';
|
||||
import { $, $$, esc, haptic, toast } from './dom.js';
|
||||
import { createSheet } from './sheet.js';
|
||||
import { KINDS, KIND } from './rooms.js';
|
||||
import { llShort, utc } from './bands.js';
|
||||
|
||||
export function createComposer({
|
||||
rooms,
|
||||
getTime,
|
||||
getLocation,
|
||||
getView,
|
||||
onCapture,
|
||||
onPosted,
|
||||
}) {
|
||||
const sheet = createSheet({
|
||||
label: 'New post',
|
||||
detents: ['large'],
|
||||
modal: true,
|
||||
dismissible: true,
|
||||
closeButton: true,
|
||||
className: 'composer-sheet opaque',
|
||||
heights: { large: (H) => Math.round(Math.min(H * 0.9, 820)) },
|
||||
});
|
||||
let ctx = null; // { s, replyTo, kind, place, time }
|
||||
|
||||
function placeOptions() {
|
||||
const s = ctx.s;
|
||||
const opts = [
|
||||
{ id: 'none', label: 'No place' },
|
||||
{
|
||||
id: 'situation',
|
||||
label: 'At the situation',
|
||||
ll: { lat: s.lat, lon: s.lon },
|
||||
},
|
||||
];
|
||||
const me = getLocation();
|
||||
if (me) opts.push({ id: 'me', label: 'Where I am', ll: me });
|
||||
const v = getView();
|
||||
if (
|
||||
v &&
|
||||
Number.isFinite(v.lat) &&
|
||||
Math.abs(v.lat - s.lat) + Math.abs(v.lon - s.lon) > 0.0005
|
||||
)
|
||||
opts.push({ id: 'view', label: 'Globe centre', ll: v });
|
||||
return opts;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const s = ctx.s;
|
||||
const r = ctx.replyTo;
|
||||
sheet.head.innerHTML = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Room<b>·</b>${esc(s.title)}</div>
|
||||
<h2 class="sheet-title">${r ? 'Reply' : 'Post to the room'}</h2>
|
||||
${r ? `<div class="cmp-quote"><b class="tele">${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}</b>${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}</div>` : ''}`;
|
||||
const places = placeOptions();
|
||||
const t = getTime();
|
||||
sheet.body.innerHTML = `
|
||||
<button class="cmp-capture" type="button" data-act="capture">
|
||||
<span class="cc-ic">${icon('viewfinder')}</span>
|
||||
<span class="cc-t"><b>Answer with a sealed capture</b><span>The strongest reply is a photo or a live view from the scene, signed with your phone’s motion and position. It earns the Was there badge.</span></span>
|
||||
${icon('chevronRight', { cls: 'chev' })}
|
||||
</button>
|
||||
<div class="cmp-or tele"><span>or write</span></div>
|
||||
<div class="kind-row" role="radiogroup" aria-label="Kind of post">
|
||||
${KINDS.map((k) => `<button type="button" role="radio" class="kind-chip" data-kind="${k.id}" aria-checked="${k.id === ctx.kind}">${icon(k.icon)}<span>${k.name}</span></button>`).join('')}
|
||||
</div>
|
||||
<p class="kind-hint tele">${esc(KIND[ctx.kind].hint)}</p>
|
||||
<textarea class="cmp-text" rows="4" maxlength="2000" placeholder="${esc(placeholder(ctx.kind))}" data-no-drag aria-label="Post text"></textarea>
|
||||
<div class="cmp-anchor">
|
||||
<div class="ca-h tele">${icon('pin')}Anchor in place</div>
|
||||
<div class="seg-chips" data-anchor="place">${places.map((p) => `<button type="button" data-v="${p.id}" aria-pressed="${p.id === ctx.place}">${esc(p.label)}${p.ll ? `<small>${esc(llShort(p.ll.lat, p.ll.lon, 2))}</small>` : ''}</button>`).join('')}</div>
|
||||
<div class="ca-h tele">${icon('clock')}Anchor in time</div>
|
||||
<div class="seg-chips" data-anchor="time"><button type="button" data-v="none" aria-pressed="${ctx.time === 'none'}">No time</button><button type="button" data-v="now" aria-pressed="${ctx.time === 'now'}">Now<small>${esc(utc(new Date(), { seconds: false }))}</small></button>${t ? `<button type="button" data-v="ribbon" aria-pressed="${ctx.time === 'ribbon'}">Ribbon time<small>${esc(utc(t, { seconds: false }))}</small></button>` : ''}</div>
|
||||
</div>
|
||||
<div class="cmp-foot"><span class="tele">Stored on this device · shared live with your other open tabs</span><button class="pill-btn primary" type="button" data-act="post">Post</button></div>`;
|
||||
}
|
||||
|
||||
function placeholder(kind) {
|
||||
return {
|
||||
witness: 'What did you see or hear, from where, and when?',
|
||||
context: 'Background that helps people read the data…',
|
||||
question: 'What can’t the sensors tell you?',
|
||||
correction: 'What is wrong, and what does the evidence show instead?',
|
||||
prediction: 'What happens next? It stays on the record.',
|
||||
}[kind];
|
||||
}
|
||||
|
||||
function open(s, { replyTo = null, kind } = {}) {
|
||||
ctx = {
|
||||
s,
|
||||
replyTo,
|
||||
kind:
|
||||
kind ||
|
||||
(replyTo
|
||||
? replyTo.kind === 'question'
|
||||
? 'context'
|
||||
: 'correction'
|
||||
: 'witness'),
|
||||
place: 'situation',
|
||||
time: 'now',
|
||||
};
|
||||
render();
|
||||
sheet.open('large');
|
||||
setTimeout(
|
||||
() => $('.cmp-text', sheet.body)?.focus({ preventScroll: true }),
|
||||
380,
|
||||
);
|
||||
}
|
||||
|
||||
sheet.root.addEventListener('click', async (e) => {
|
||||
if (!ctx) return;
|
||||
const k = e.target.closest('[data-kind]');
|
||||
if (k) {
|
||||
ctx.kind = k.dataset.kind;
|
||||
$$('[data-kind]', sheet.body).forEach((b) =>
|
||||
b.setAttribute('aria-checked', String(b === k)),
|
||||
);
|
||||
$('.kind-hint', sheet.body).textContent = KIND[ctx.kind].hint;
|
||||
$('.cmp-text', sheet.body).placeholder = placeholder(ctx.kind);
|
||||
haptic('tick');
|
||||
return;
|
||||
}
|
||||
const chip = e.target.closest('[data-anchor] button');
|
||||
if (chip) {
|
||||
const group = chip.parentElement.dataset.anchor;
|
||||
ctx[group] = chip.dataset.v;
|
||||
$$('button', chip.parentElement).forEach((b) =>
|
||||
b.setAttribute('aria-pressed', String(b === chip)),
|
||||
);
|
||||
haptic('tick');
|
||||
return;
|
||||
}
|
||||
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||
if (act === 'capture') {
|
||||
sheet.close();
|
||||
onCapture(ctx.s, { replyTo: ctx.replyTo });
|
||||
} else if (act === 'post') {
|
||||
const text = $('.cmp-text', sheet.body).value.trim();
|
||||
if (!text) {
|
||||
$('.cmp-text', sheet.body).focus();
|
||||
toast('Write something first');
|
||||
return;
|
||||
}
|
||||
const place = placeOptions().find((p) => p.id === ctx.place)?.ll || null;
|
||||
const t =
|
||||
ctx.time === 'now'
|
||||
? Date.now()
|
||||
: ctx.time === 'ribbon'
|
||||
? +getTime()
|
||||
: null;
|
||||
const anchor =
|
||||
place || t ? { lat: place?.lat, lon: place?.lon, t } : null;
|
||||
const p = await rooms.post(ctx.s, {
|
||||
kind: ctx.kind,
|
||||
text,
|
||||
replyTo: ctx.replyTo?.id || null,
|
||||
anchor,
|
||||
evidence: {},
|
||||
});
|
||||
haptic('success');
|
||||
sheet.close();
|
||||
toast(`Posted to the room · ${KIND[p.kind].name}`, {
|
||||
icon: icon(KIND[p.kind].icon),
|
||||
});
|
||||
onPosted?.(ctx.s, p);
|
||||
}
|
||||
});
|
||||
|
||||
return { open, close: () => sheet.close() };
|
||||
}
|
||||
Reference in new issue
Block a user