156 lines
5.6 KiB
TypeScript
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);
|
|
}
|