rspace-online/modules/rpast/components/rpast-viewer.ts

156 lines
5.6 KiB
TypeScript

/**
* <rpast-viewer> — Chronicle-of-self timeline, rendered as a canvas shape.
*
* Hits the server-rendered `/:space/rpast/render` endpoint via iframe for
* the actual timeline/calendar visualization. The chip bar drives query
* params; the iframe reloads on every change.
*
* Attribute: space — the space slug
*/
import '../../../shared/components/rstack-markwhen-view';
type ViewMode = 'timeline' | 'calendar';
interface ModuleInfo { module: string; label: string; icon: string; color?: string; }
export class RpastViewer extends HTMLElement {
static get observedAttributes() { return ['space']; }
#space = '';
#selected: Set<string> | null = null; // null = "all"
#mode: ViewMode = 'timeline';
#modules: ModuleInfo[] = [];
#shadow: ShadowRoot;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
this.#shadow.innerHTML = `
<style>
:host { display: grid; grid-template-rows: auto 1fr auto; height: 100%; width: 100%; }
.bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
padding: 6px 10px; background: #0f172a; border-bottom: 1px solid #1e293b;
font: 12px system-ui; color: #cbd5e1; }
.chip { padding: 2px 10px; border-radius: 999px; background: #1e293b;
cursor: pointer; user-select: none; white-space: nowrap; }
.chip[aria-pressed="true"] { background: #2563eb; color: white; }
.spacer { flex: 1; }
.toggle { display: flex; gap: 4px; }
.toggle button { border: 0; background: #1e293b; color: #cbd5e1; padding: 2px 10px;
border-radius: 4px; font: 12px system-ui; cursor: pointer; }
.toggle button[aria-pressed="true"] { background: #2563eb; color: white; }
.foot { display: flex; gap: 16px; padding: 4px 10px; font: 11px system-ui;
color: #94a3b8; background: #0f172a; border-top: 1px solid #1e293b; }
.foot a { color: #60a5fa; text-decoration: none; }
.foot a:hover { text-decoration: underline; }
</style>
<div class="bar"></div>
<rstack-markwhen-view></rstack-markwhen-view>
<div class="foot"><span class="count">Loading…</span><span class="spacer"></span><a class="download" href="#">Download .mw</a></div>
`;
}
set space(v: string) { this.#space = v; void this.#init(); }
get space() { return this.#space; }
attributeChangedCallback(name: string, _old: string | null, next: string | null) {
if (name === 'space') this.space = next ?? '';
}
connectedCallback() { void this.#init(); }
async #init() {
if (!this.#space) return;
try {
// The modules endpoint is space-agnostic; any space path works.
const res = await fetch(`/${this.#space}/rpast/api/modules`);
this.#modules = await res.json();
} catch (err) {
console.error('[rpast-viewer] failed to load module list', err);
this.#modules = [];
}
this.#paintBar();
await this.#reload();
}
#paintBar() {
const bar = this.#shadow.querySelector('.bar')!;
bar.innerHTML = '';
const allChip = document.createElement('span');
allChip.className = 'chip';
allChip.setAttribute('aria-pressed', String(this.#selected === null));
allChip.textContent = '✨ All';
allChip.addEventListener('click', () => { this.#selected = null; this.#paintBar(); void this.#reload(); });
bar.appendChild(allChip);
for (const m of this.#modules) {
const chip = document.createElement('span');
chip.className = 'chip';
const active = this.#selected !== null && this.#selected.has(m.module);
chip.setAttribute('aria-pressed', String(active));
chip.textContent = `${m.icon} ${m.label}`;
chip.addEventListener('click', () => this.#toggle(m.module));
bar.appendChild(chip);
}
const spacer = document.createElement('div');
spacer.className = 'spacer';
bar.appendChild(spacer);
const toggle = document.createElement('div');
toggle.className = 'toggle';
for (const mode of ['timeline', 'calendar'] as const) {
const btn = document.createElement('button');
btn.textContent = mode[0].toUpperCase() + mode.slice(1);
btn.setAttribute('aria-pressed', String(this.#mode === mode));
btn.addEventListener('click', () => { this.#mode = mode; this.#paintBar(); void this.#reload(); });
toggle.appendChild(btn);
}
bar.appendChild(toggle);
}
#toggle(module: string) {
if (this.#selected === null) this.#selected = new Set([module]);
else if (this.#selected.has(module)) {
this.#selected.delete(module);
if (this.#selected.size === 0) this.#selected = null;
} else this.#selected.add(module);
this.#paintBar();
void this.#reload();
}
#buildUrl(base: string): string {
const params = new URLSearchParams();
if (this.#selected) params.set('modules', [...this.#selected].join(','));
params.set('view', this.#mode);
return `/${this.#space}/rpast/${base}?${params.toString()}`;
}
async #reload() {
const view = this.#shadow.querySelector('rstack-markwhen-view') as HTMLElement & {
src?: string; view?: ViewMode;
};
view.view = this.#mode;
view.src = this.#buildUrl('render');
const dl = this.#shadow.querySelector('.download') as HTMLAnchorElement;
dl.href = this.#buildUrl('api/chronicle.mw');
dl.download = `${this.#space}-rpast.mw`;
try {
const res = await fetch(this.#buildUrl('api/chronicle'));
const data = await res.json() as { count: number; sections: { id: string; count: number }[] };
const foot = this.#shadow.querySelector('.count') as HTMLElement;
foot.textContent = `${data.count.toLocaleString()} creation${data.count === 1 ? '' : 's'} across ${data.sections.length} module${data.sections.length === 1 ? '' : 's'}`;
} catch (err) {
console.error('[rpast-viewer] count fetch failed', err);
}
}
}
if (!customElements.get('rpast-viewer')) {
customElements.define('rpast-viewer', RpastViewer);
}