729 lines
28 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-feeds-dashboard', FolkFeedsDashboard);
|