rspace-online/modules/rfeeds/components/folk-feeds-dashboard.ts

729 lines
28 KiB
TypeScript

/**
* <folk-feeds-dashboard> — RSS feed timeline + source management + activity + profiles.
*
* Subscribes to Automerge doc for real-time updates.
* Falls back to REST API when offline runtime unavailable.
*/
import { feedsSchema, feedsDocId } from '../schemas';
import type { FeedsDoc, FeedSource, FeedItem, ManualPost, ActivityModuleConfig } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
interface TimelineEntry {
type: 'item' | 'post' | 'activity';
id: string;
title: string;
url: string;
summary: string;
author: string;
sourceName: string;
sourceColor: string;
publishedAt: number;
reshared: boolean;
moduleId?: string;
itemType?: string;
}
interface UserProfile {
did: string;
displayName: string;
bio: string;
publishModules: string[];
createdAt: number;
updatedAt: number;
}
export class FolkFeedsDashboard extends HTMLElement {
private _space = 'demo';
private _doc: FeedsDoc | null = null;
private _timeline: TimelineEntry[] = [];
private _sources: FeedSource[] = [];
private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = [];
private _tab: 'timeline' | 'sources' | 'modules' | 'profile' = 'timeline';
private _loading = true;
private _activityConfig: Record<string, ActivityModuleConfig> = {};
private _profile: UserProfile | null = null;
static get observedAttributes() { return ['space']; }
connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this._space = this.getAttribute('space') || 'demo';
this.render();
this.subscribeOffline();
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
}
this._subscribedDocIds = [];
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this._space = val;
}
private async subscribeOffline() {
let runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime) {
await new Promise(r => setTimeout(r, 200));
runtime = (window as any).__rspaceOfflineRuntime;
}
if (!runtime) { await this.loadFromAPI(); return; }
if (!runtime.isInitialized && runtime.init) {
try { await runtime.init(); } catch { /* already init'd */ }
}
if (!runtime.isInitialized) { await this.loadFromAPI(); return; }
try {
const docId = feedsDocId(this._space) as DocumentId;
const doc = await runtime.subscribe(docId, feedsSchema);
this._subscribedDocIds.push(docId);
this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFromDoc(updated);
});
} catch {
await this.loadFromAPI();
}
}
private async loadFromAPI() {
try {
const base = (window as any).__rspaceBase || '';
const [tlRes, srcRes, actRes] = await Promise.all([
fetch(`${base}/${this._space}/rfeeds/api/timeline?limit=50`),
fetch(`${base}/${this._space}/rfeeds/api/sources`),
fetch(`${base}/${this._space}/rfeeds/api/activity/config`),
]);
if (tlRes.ok) this._timeline = await tlRes.json();
if (srcRes.ok) this._sources = await srcRes.json();
if (actRes.ok) this._activityConfig = await actRes.json();
} catch { /* offline */ }
// Load profile if authenticated
await this.loadProfile();
this._loading = false;
this.render();
}
private async loadProfile() {
const token = this.getToken();
if (!token) return;
try {
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/profile`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) this._profile = await res.json();
} catch { /* ignore */ }
}
private renderFromDoc(doc: FeedsDoc) {
if (!doc) return;
this._doc = doc;
this._loading = false;
// Build timeline from doc
const entries: TimelineEntry[] = [];
if (doc.items) {
for (const item of Object.values(doc.items)) {
const source = doc.sources?.[item.sourceId];
if (!source?.enabled) continue;
entries.push({
type: 'item',
id: item.id,
title: item.title,
url: item.url,
summary: item.summary,
author: item.author,
sourceName: source?.name || 'Unknown',
sourceColor: source?.color || '#94a3b8',
publishedAt: item.publishedAt,
reshared: item.reshared,
});
}
}
if (doc.posts) {
for (const post of Object.values(doc.posts)) {
entries.push({
type: 'post',
id: post.id,
title: '',
url: '',
summary: post.content,
author: post.authorName || post.authorDid,
sourceName: 'Manual Post',
sourceColor: '#67e8f9',
publishedAt: post.createdAt,
reshared: true,
});
}
}
// Activity cache entries
if (doc.activityCache) {
for (const entry of Object.values(doc.activityCache)) {
const config = doc.settings?.activityModules?.[entry.moduleId];
if (config && !config.enabled) continue;
entries.push({
type: 'activity',
id: entry.id,
title: entry.title,
url: entry.url,
summary: entry.summary,
author: entry.author,
sourceName: config?.label || entry.moduleId,
sourceColor: config?.color || '#94a3b8',
publishedAt: entry.publishedAt,
reshared: entry.reshared,
moduleId: entry.moduleId,
itemType: entry.itemType,
});
}
}
entries.sort((a, b) => b.publishedAt - a.publishedAt);
this._timeline = entries;
this._sources = doc.sources ? Object.values(doc.sources).sort((a, b) => b.addedAt - a.addedAt) : [];
// Extract activity config from doc
if (doc.settings?.activityModules) {
this._activityConfig = { ...doc.settings.activityModules };
}
// Extract profile
if (doc.userProfiles) {
const token = this.getToken();
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const did = payload.did || payload.sub;
if (did && doc.userProfiles[did]) {
this._profile = doc.userProfiles[did] as any;
}
} catch { /* ignore */ }
}
}
this.render();
}
private getToken(): string | null {
try {
const raw = localStorage.getItem('encryptid-session');
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed?.token || parsed?.jwt || null;
} catch { return null; }
}
private async addSource() {
const root = this.shadowRoot!;
const urlInput = root.querySelector<HTMLInputElement>('#add-source-url');
const nameInput = root.querySelector<HTMLInputElement>('#add-source-name');
if (!urlInput?.value) return;
const token = this.getToken();
if (!token) { alert('Sign in to add feeds'); return; }
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ url: urlInput.value, name: nameInput?.value || undefined }),
});
if (res.ok) {
urlInput.value = '';
if (nameInput) nameInput.value = '';
if (!this._doc) await this.loadFromAPI();
} else {
const err = await res.json().catch(() => ({ error: 'Failed' }));
alert(err.error || 'Failed to add source');
}
}
private async deleteSource(id: string) {
if (!confirm('Remove this feed source and all its items?')) return;
const token = this.getToken();
if (!token) return;
const base = (window as any).__rspaceBase || '';
await fetch(`${base}/${this._space}/rfeeds/api/sources/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!this._doc) await this.loadFromAPI();
}
private async syncSource(id: string) {
const token = this.getToken();
if (!token) return;
const base = (window as any).__rspaceBase || '';
const btn = this.shadowRoot?.querySelector<HTMLButtonElement>(`[data-sync="${id}"]`);
if (btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; }
const res = await fetch(`${base}/${this._space}/rfeeds/api/sources/${id}/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
const result = await res.json().catch(() => ({}));
if (btn) {
btn.disabled = false;
btn.textContent = result.added ? `+${result.added}` : 'Sync';
setTimeout(() => { btn.textContent = 'Sync'; }, 2000);
}
if (!this._doc) await this.loadFromAPI();
}
private async toggleReshare(id: string, type: 'item' | 'activity') {
const token = this.getToken();
if (!token) return;
const base = (window as any).__rspaceBase || '';
const endpoint = type === 'activity'
? `${base}/${this._space}/rfeeds/api/activity/${encodeURIComponent(id)}/reshare`
: `${base}/${this._space}/rfeeds/api/items/${id}/reshare`;
await fetch(endpoint, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}` },
});
if (!this._doc) await this.loadFromAPI();
}
private async createPost() {
const root = this.shadowRoot!;
const textarea = root.querySelector<HTMLTextAreaElement>('#post-content');
if (!textarea?.value.trim()) return;
const token = this.getToken();
if (!token) { alert('Sign in to post'); return; }
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: textarea.value }),
});
if (res.ok) {
textarea.value = '';
if (!this._doc) await this.loadFromAPI();
}
}
private async importOPML() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.opml,.xml';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const token = this.getToken();
if (!token) { alert('Sign in to import'); return; }
const opml = await file.text();
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/sources/import-opml`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ opml }),
});
const result = await res.json().catch(() => ({}));
if (result.added) alert(`Imported ${result.added} feeds`);
if (!this._doc) await this.loadFromAPI();
};
input.click();
}
private async syncActivity() {
const token = this.getToken();
if (!token) { alert('Sign in to sync activity'); return; }
const btn = this.shadowRoot?.querySelector<HTMLButtonElement>('#sync-activity-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; }
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/activity/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
const result = await res.json().catch(() => ({}));
if (btn) {
btn.disabled = false;
btn.textContent = result.synced ? `Synced ${result.synced}` : 'Sync Activity';
setTimeout(() => { btn.textContent = 'Sync Activity'; }, 3000);
}
if (!this._doc) await this.loadFromAPI();
}
private async toggleModule(moduleId: string, enabled: boolean) {
const token = this.getToken();
if (!token) return;
const current = this._activityConfig[moduleId] || { enabled: false, label: moduleId, color: '#94a3b8' };
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/activity/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ modules: { [moduleId]: { ...current, enabled } } }),
});
if (res.ok) {
this._activityConfig = await res.json();
this.render();
}
}
private async saveProfile() {
const token = this.getToken();
if (!token) { alert('Sign in to save profile'); return; }
const root = this.shadowRoot!;
const displayName = root.querySelector<HTMLInputElement>('#profile-name')?.value || '';
const bio = root.querySelector<HTMLTextAreaElement>('#profile-bio')?.value || '';
const publishModules: string[] = [];
root.querySelectorAll<HTMLInputElement>('[data-publish-module]').forEach((cb) => {
if (cb.checked) publishModules.push(cb.dataset.publishModule!);
});
const base = (window as any).__rspaceBase || '';
const res = await fetch(`${base}/${this._space}/rfeeds/api/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ displayName, bio, publishModules }),
});
if (res.ok) {
this._profile = await res.json();
this.render();
// Flash save button
const btn = this.shadowRoot?.querySelector<HTMLButtonElement>('#save-profile-btn');
if (btn) { btn.textContent = 'Saved!'; setTimeout(() => { btn.textContent = 'Save Profile'; }, 2000); }
}
}
private timeAgo(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60_000) return 'just now';
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
if (diff < 2592000_000) return `${Math.floor(diff / 86400_000)}d ago`;
return new Date(ts).toLocaleDateString();
}
private render() {
const root = this.shadowRoot!;
const activityCount = this._timeline.filter(e => e.type === 'activity').length;
root.innerHTML = `
<style>
:host { display:block; max-width:720px; margin:0 auto; padding:16px; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
* { box-sizing:border-box; }
.tabs { display:flex; gap:4px; margin-bottom:16px; flex-wrap:wrap; }
.tab { padding:8px 16px; border:1px solid #334155; border-radius:8px; background:transparent; color:#94a3b8; cursor:pointer; font-size:14px; }
.tab.active { background:#1e293b; color:#e2e8f0; border-color:#475569; }
.tab:hover { background:#1e293b; }
.composer { display:flex; gap:8px; margin-bottom:20px; }
.composer textarea { flex:1; background:#0f172a; border:1px solid #334155; border-radius:8px; padding:10px; color:#e2e8f0; resize:none; font-size:14px; min-height:60px; }
.composer textarea:focus { outline:none; border-color:#67e8f9; }
.btn { padding:8px 14px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:500; }
.btn-primary { background:#0891b2; color:#fff; }
.btn-primary:hover { background:#06b6d4; }
.btn-sm { padding:4px 10px; font-size:12px; }
.btn-ghost { background:transparent; border:1px solid #334155; color:#94a3b8; }
.btn-ghost:hover { background:#1e293b; color:#e2e8f0; }
.btn-danger { background:#7f1d1d; color:#fca5a5; }
.btn-danger:hover { background:#991b1b; }
/* Timeline items */
.entry { padding:14px 16px; border:1px solid #1e293b; border-radius:10px; margin-bottom:10px; background:#0f172a; transition:border-color .15s; }
.entry:hover { border-color:#334155; }
.entry-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; gap:8px; }
.source-badge { font-size:11px; padding:2px 8px; border-radius:99px; font-weight:500; white-space:nowrap; }
.entry-title { font-size:15px; font-weight:600; color:#f1f5f9; }
.entry-title a { color:inherit; text-decoration:none; }
.entry-title a:hover { text-decoration:underline; }
.entry-summary { font-size:13px; color:#94a3b8; line-height:1.5; margin-top:4px; }
.entry-meta { display:flex; gap:12px; align-items:center; margin-top:8px; font-size:12px; color:#64748b; }
.reshare-btn { cursor:pointer; padding:2px 8px; border-radius:4px; border:1px solid #334155; background:transparent; color:#64748b; font-size:11px; }
.reshare-btn.active { background:#164e63; border-color:#0891b2; color:#67e8f9; }
.reshare-btn:hover { border-color:#475569; }
.type-badge { font-size:10px; padding:1px 6px; border-radius:4px; background:#1e293b; color:#64748b; text-transform:uppercase; letter-spacing:0.5px; }
/* Sources */
.source-card { padding:14px 16px; border:1px solid #1e293b; border-radius:10px; margin-bottom:10px; background:#0f172a; }
.source-card-header { display:flex; justify-content:space-between; align-items:center; }
.source-name { font-size:14px; font-weight:600; color:#f1f5f9; }
.source-url { font-size:12px; color:#64748b; word-break:break-all; margin-top:2px; }
.source-status { font-size:12px; color:#64748b; margin-top:6px; display:flex; gap:12px; align-items:center; }
.source-error { color:#f87171; font-size:12px; margin-top:4px; }
.source-actions { display:flex; gap:6px; }
.add-source { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.add-source input { flex:1; min-width:200px; background:#0f172a; border:1px solid #334155; border-radius:8px; padding:8px 12px; color:#e2e8f0; font-size:13px; }
.add-source input:focus { outline:none; border-color:#67e8f9; }
.add-source input::placeholder { color:#475569; }
.status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.status-dot.ok { background:#22c55e; }
.status-dot.err { background:#ef4444; }
.status-dot.pending { background:#eab308; }
.empty { text-align:center; color:#475569; padding:40px 0; font-size:14px; }
.loading { text-align:center; color:#475569; padding:40px 0; }
.toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
/* Modules config */
.module-row { display:flex; align-items:center; gap:12px; padding:10px 14px; border:1px solid #1e293b; border-radius:8px; margin-bottom:8px; background:#0f172a; }
.module-row label { flex:1; font-size:14px; color:#e2e8f0; cursor:pointer; display:flex; align-items:center; gap:8px; }
.module-color { width:12px; height:12px; border-radius:50%; }
.toggle { position:relative; width:36px; height:20px; }
.toggle input { opacity:0; width:0; height:0; }
.toggle .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background:#334155; border-radius:10px; transition:.2s; }
.toggle .slider:before { content:""; position:absolute; height:16px; width:16px; left:2px; bottom:2px; background:#94a3b8; border-radius:50%; transition:.2s; }
.toggle input:checked + .slider { background:#0891b2; }
.toggle input:checked + .slider:before { transform:translateX(16px); background:#fff; }
/* Profile */
.profile-form { display:flex; flex-direction:column; gap:12px; }
.profile-form label { font-size:13px; color:#94a3b8; }
.profile-form input, .profile-form textarea { background:#0f172a; border:1px solid #334155; border-radius:8px; padding:8px 12px; color:#e2e8f0; font-size:14px; }
.profile-form input:focus, .profile-form textarea:focus { outline:none; border-color:#67e8f9; }
.profile-form textarea { resize:vertical; min-height:60px; }
.publish-modules { display:flex; flex-wrap:wrap; gap:8px; margin-top:4px; }
.publish-modules label { display:flex; align-items:center; gap:6px; padding:4px 10px; border:1px solid #334155; border-radius:6px; font-size:13px; color:#94a3b8; cursor:pointer; }
.publish-modules label:has(input:checked) { border-color:#0891b2; color:#67e8f9; background:#164e6322; }
.publish-modules input { accent-color:#0891b2; }
.feed-url { margin-top:12px; padding:10px 14px; background:#0f172a; border:1px solid #334155; border-radius:8px; font-size:12px; color:#64748b; word-break:break-all; }
.feed-url a { color:#67e8f9; }
.section-title { font-size:13px; font-weight:600; color:#94a3b8; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:8px; margin-top:16px; }
</style>
<div class="tabs">
<button class="tab ${this._tab === 'timeline' ? 'active' : ''}" data-tab="timeline">Timeline</button>
<button class="tab ${this._tab === 'sources' ? 'active' : ''}" data-tab="sources">Sources (${this._sources.length})</button>
<button class="tab ${this._tab === 'modules' ? 'active' : ''}" data-tab="modules">Modules${activityCount ? ` (${activityCount})` : ''}</button>
<button class="tab ${this._tab === 'profile' ? 'active' : ''}" data-tab="profile">My Profile</button>
</div>
${this._loading ? '<div class="loading">Loading feeds\u2026</div>' :
this._tab === 'timeline' ? this.renderTimeline() :
this._tab === 'sources' ? this.renderSources() :
this._tab === 'modules' ? this.renderModules() :
this.renderProfile()}
`;
// Event listeners
root.querySelectorAll('.tab').forEach((btn) => {
btn.addEventListener('click', () => {
this._tab = (btn as HTMLElement).dataset.tab as any;
this.render();
});
});
const postBtn = root.querySelector('#post-btn');
postBtn?.addEventListener('click', () => this.createPost());
const addBtn = root.querySelector('#add-source-btn');
addBtn?.addEventListener('click', () => this.addSource());
const importBtn = root.querySelector('#import-opml-btn');
importBtn?.addEventListener('click', () => this.importOPML());
const syncActBtn = root.querySelector('#sync-activity-btn');
syncActBtn?.addEventListener('click', () => this.syncActivity());
const saveProfileBtn = root.querySelector('#save-profile-btn');
saveProfileBtn?.addEventListener('click', () => this.saveProfile());
root.querySelectorAll('[data-sync]').forEach((btn) => {
btn.addEventListener('click', () => this.syncSource((btn as HTMLElement).dataset.sync!));
});
root.querySelectorAll('[data-delete-source]').forEach((btn) => {
btn.addEventListener('click', () => this.deleteSource((btn as HTMLElement).dataset.deleteSource!));
});
root.querySelectorAll('[data-reshare]').forEach((btn) => {
const el = btn as HTMLElement;
btn.addEventListener('click', () => this.toggleReshare(el.dataset.reshare!, (el.dataset.reshareType as any) || 'item'));
});
root.querySelectorAll('[data-toggle-module]').forEach((input) => {
input.addEventListener('change', () => {
const el = input as HTMLInputElement;
this.toggleModule(el.dataset.toggleModule!, el.checked);
});
});
}
private renderTimeline(): string {
const composer = `
<div class="composer">
<textarea id="post-content" placeholder="Write a post\u2026" rows="2"></textarea>
<button class="btn btn-primary" id="post-btn">Post</button>
</div>`;
if (!this._timeline.length) {
return composer + '<div class="empty">No feed items yet. Add some sources in the Sources tab!</div>';
}
const items = this._timeline.slice(0, 100).map((e) => `
<div class="entry">
<div class="entry-header">
<span class="source-badge" style="background:${e.sourceColor}22; color:${e.sourceColor}">${this.escHtml(e.sourceName)}</span>
<span style="font-size:12px;color:#64748b">
${e.type === 'activity' && e.itemType ? `<span class="type-badge">${this.escHtml(e.itemType)}</span> ` : ''}
${this.timeAgo(e.publishedAt)}
</span>
</div>
${e.title ? `<div class="entry-title">${e.url ? `<a href="${this.escHtml(e.url)}" target="_blank" rel="noopener">${this.escHtml(e.title)}</a>` : this.escHtml(e.title)}</div>` : ''}
<div class="entry-summary">${this.escHtml(e.summary)}</div>
<div class="entry-meta">
${e.author ? `<span>${this.escHtml(e.author)}</span>` : ''}
${e.type === 'item' ? `<button class="reshare-btn ${e.reshared ? 'active' : ''}" data-reshare="${e.id}" data-reshare-type="item">${e.reshared ? 'Shared' : 'Reshare'}</button>` : ''}
${e.type === 'activity' ? `<button class="reshare-btn ${e.reshared ? 'active' : ''}" data-reshare="${e.id}" data-reshare-type="activity">${e.reshared ? 'Shared' : 'Reshare'}</button>` : ''}
</div>
</div>`).join('');
return composer + items;
}
private renderSources(): string {
const addForm = `
<div class="add-source">
<input type="url" id="add-source-url" placeholder="Feed URL (RSS or Atom)">
<input type="text" id="add-source-name" placeholder="Display name (optional)" style="max-width:180px">
<button class="btn btn-primary" id="add-source-btn">Add Feed</button>
<button class="btn btn-ghost" id="import-opml-btn">Import OPML</button>
</div>`;
if (!this._sources.length) {
return addForm + '<div class="empty">No feed sources yet. Add one above!</div>';
}
const cards = this._sources.map((s) => {
const dotClass = s.lastError ? 'err' : s.lastFetchedAt ? 'ok' : 'pending';
return `
<div class="source-card">
<div class="source-card-header">
<div>
<div class="source-name" style="color:${s.color}">${this.escHtml(s.name)}</div>
<div class="source-url">${this.escHtml(s.url)}</div>
</div>
<div class="source-actions">
<button class="btn btn-sm btn-ghost" data-sync="${s.id}">Sync</button>
<button class="btn btn-sm btn-danger" data-delete-source="${s.id}">\u2715</button>
</div>
</div>
<div class="source-status">
<span class="status-dot ${dotClass}"></span>
<span>${s.itemCount} items</span>
<span>${s.lastFetchedAt ? 'Last sync ' + this.timeAgo(s.lastFetchedAt) : 'Never synced'}</span>
<span>${s.enabled ? 'Active' : 'Paused'}</span>
</div>
${s.lastError ? `<div class="source-error">${this.escHtml(s.lastError)}</div>` : ''}
</div>`;
}).join('');
return addForm + cards;
}
private renderModules(): string {
const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles'];
const defaultConfigs: Record<string, { label: string; color: string }> = {
rcal: { label: 'Calendar', color: '#f59e0b' },
rtasks: { label: 'Tasks', color: '#8b5cf6' },
rdocs: { label: 'Docs', color: '#3b82f6' },
rnotes: { label: 'Vaults', color: '#a78bfa' },
rsocials: { label: 'Socials', color: '#ec4899' },
rwallet: { label: 'Wallet', color: '#10b981' },
rphotos: { label: 'Photos', color: '#f97316' },
rfiles: { label: 'Files', color: '#64748b' },
};
const rows = moduleIds.map((id) => {
const config = this._activityConfig[id] || { enabled: false, ...defaultConfigs[id] };
const label = config.label || defaultConfigs[id]?.label || id;
const color = config.color || defaultConfigs[id]?.color || '#94a3b8';
return `
<div class="module-row">
<label>
<span class="module-color" style="background:${color}"></span>
${this.escHtml(label)}
</label>
<div class="toggle">
<input type="checkbox" data-toggle-module="${id}" ${config.enabled ? 'checked' : ''}>
<span class="slider"></span>
</div>
</div>`;
}).join('');
return `
<div class="toolbar">
<div class="section-title">Activity Modules</div>
<button class="btn btn-sm btn-primary" id="sync-activity-btn">Sync Activity</button>
</div>
<p style="font-size:13px;color:#64748b;margin:0 0 12px">Enable modules to pull their activity into the timeline. Admin only.</p>
${rows}`;
}
private renderProfile(): string {
const token = this.getToken();
if (!token) {
return '<div class="empty">Sign in to manage your feed profile.</div>';
}
const p = this._profile;
const moduleIds = ['rcal', 'rtasks', 'rdocs', 'rnotes', 'rsocials', 'rwallet', 'rphotos', 'rfiles', 'posts'];
const labels: Record<string, string> = {
rcal: 'Calendar', rtasks: 'Tasks', rdocs: 'Docs', rnotes: 'Vaults',
rsocials: 'Socials', rwallet: 'Wallet', rphotos: 'Photos', rfiles: 'Files', posts: 'Manual Posts',
};
const publishSet = new Set(p?.publishModules || []);
const checkboxes = moduleIds.map((id) => `
<label>
<input type="checkbox" data-publish-module="${id}" ${publishSet.has(id) ? 'checked' : ''}>
${labels[id] || id}
</label>`).join('');
// Personal feed URL
let feedUrlHtml = '';
if (p?.did) {
const host = window.location.origin;
const feedUrl = `${host}/${this._space}/rfeeds/user/${encodeURIComponent(p.did)}/feed.xml`;
feedUrlHtml = `
<div class="feed-url">
Your personal feed: <a href="${this.escHtml(feedUrl)}" target="_blank">${this.escHtml(feedUrl)}</a>
<br><span style="color:#475569">Others can subscribe to this URL from any RSS reader or rFeeds space.</span>
</div>`;
}
return `
<div class="profile-form">
<div>
<label>Display Name</label>
<input type="text" id="profile-name" value="${this.escHtml(p?.displayName || '')}" placeholder="Your name">
</div>
<div>
<label>Bio</label>
<textarea id="profile-bio" placeholder="A short bio\u2026" rows="2">${this.escHtml(p?.bio || '')}</textarea>
</div>
<div>
<label>Publish to Personal Feed</label>
<div class="publish-modules">${checkboxes}</div>
</div>
<button class="btn btn-primary" id="save-profile-btn">Save Profile</button>
${feedUrlHtml}
</div>`;
}
private escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}
customElements.define('folk-feeds-dashboard', FolkFeedsDashboard);