rspace-online/modules/rpast/mod.ts

104 lines
3.7 KiB
TypeScript

/**
* rPast — chronicle-of-self timeline.
*
* Projects every dated CRDT record across every rApp into a single
* timeline/calendar. Zero writes to source modules. The `.mw` text is
* regenerated on every request; saved "chronicles" are just filter configs.
*/
import { Hono } from 'hono';
import type { RSpaceModule } from '../../shared/module';
import {
enumerateCreations, listCreationEnumerators, renderMarkwhen,
} from '../../shared/markwhen';
import { renderMarkwhenHtml } from '../../shared/markwhen/html-render';
import { renderShell } from '../../server/shell';
import { getModuleInfoList } from '../../shared/module';
import { pastSchema } from './schemas';
import { renderLanding } from './landing';
const routes = new Hono();
// GET / — marketing landing on bare domain, interactive viewer in a space.
routes.get('/', c => {
const space = c.req.param('space') || 'demo';
if (!space || space === 'rpast.online') {
return c.html(renderLanding());
}
return c.html(renderShell({
title: `${space} — rPast | rSpace`,
moduleId: 'rpast',
spaceSlug: space,
modules: getModuleInfoList(),
theme: 'dark',
body: `<rpast-viewer space="${space}" style="height:calc(100vh - 52px);display:block"></rpast-viewer>`,
scripts: `<script type="module" src="/modules/rpast/rpast-viewer.js?v=1"></script>`,
}));
});
routes.get('/api/modules', c => {
return c.json(listCreationEnumerators().map(e => ({
module: e.module, label: e.label, icon: e.icon, color: e.color,
})));
});
async function buildProjection(c: any) {
const space = c.req.param('space');
const modules = c.req.query('modules')?.split(',').filter(Boolean);
const from = c.req.query('from') ? Number(c.req.query('from')) : undefined;
const to = c.req.query('to') ? Number(c.req.query('to')) : undefined;
const view = (c.req.query('view') as 'timeline' | 'calendar') ?? 'timeline';
// Derive the public base URL from the request so in-timeline "Open in
// rApp" links resolve against the same origin the user arrived from.
// rSpace production is always behind Cloudflare/Traefik TLS, so for any
// non-loopback host we always emit https — forwarded-proto headers can
// be `http` (internal Traefik→container hop) even though the real client
// request was https.
const host = c.req.header('x-forwarded-host') ?? c.req.header('host');
const isLocal = !host || /^(localhost|127\.|\[::1\])/i.test(host);
const proto = isLocal ? 'http' : 'https';
const baseUrl = host ? `${proto}://${host}` : undefined;
const sources = await enumerateCreations(space, { modules, from, to });
const projection = renderMarkwhen(sources, {
view, title: `${space} — rPast`, baseUrl,
});
return { space, sources, projection, view };
}
routes.get('/api/chronicle', async c => {
const { sources, projection } = await buildProjection(c);
return c.json({
text: projection.text,
count: projection.count,
sections: sources.map(s => ({ id: s.id, label: s.label, count: s.events.length })),
});
});
routes.get('/api/chronicle.mw', async c => {
const { projection } = await buildProjection(c);
return c.text(projection.text, 200, { 'content-type': 'text/plain; charset=utf-8' });
});
routes.get('/render', async c => {
const { projection, view } = await buildProjection(c);
const html = renderMarkwhenHtml(projection.text, view);
return c.html(html);
});
export const rpastModule: RSpaceModule = {
id: 'rpast',
name: 'rPast',
icon: '🕰️',
description: 'Chronicle-of-self — every rApp creation, ever, on one timeline.',
routes,
landingPage: renderLanding,
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{
pattern: '{space}:rpast:chronicles',
description: 'Saved chronicle configs (module selection + filters + mode)',
init: pastSchema.init,
}],
};