765 lines
42 KiB
TypeScript
765 lines
42 KiB
TypeScript
/**
|
|
* folk-inbox-client — Collaborative email client.
|
|
*
|
|
* Shows mailbox list, thread inbox, thread detail with comments,
|
|
* and approval workflow interface. Includes a help/guide popout.
|
|
*/
|
|
|
|
class FolkInboxClient extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = "demo";
|
|
private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes";
|
|
private mailboxes: any[] = [];
|
|
private threads: any[] = [];
|
|
private currentMailbox: any = null;
|
|
private currentThread: any = null;
|
|
private approvals: any[] = [];
|
|
private filter: "all" | "open" | "snoozed" | "closed" = "all";
|
|
private helpOpen = false;
|
|
private composeOpen = false;
|
|
private showingSampleData = false;
|
|
private demoApprovals: any[] = [
|
|
{
|
|
id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING",
|
|
to_addresses: ["finance@example.org"], required_signatures: 3, signature_count: 1,
|
|
body_text: "Approved. Please proceed with the cloud infrastructure upgrade as outlined. Budget: $4,200/quarter.",
|
|
created_at: new Date(Date.now() - 4 * 3600000).toISOString(),
|
|
signers: [
|
|
{ name: "Alice Chen", vote: "APPROVE", signed_at: new Date(Date.now() - 3 * 3600000).toISOString() },
|
|
{ name: "Bob Martinez", vote: null, signed_at: null },
|
|
{ name: "Carol Wu", vote: null, signed_at: null },
|
|
],
|
|
},
|
|
{
|
|
id: "a2", subject: "Partnership agreement with Acme Corp", status: "PENDING",
|
|
to_addresses: ["legal@acmecorp.com", "partnerships@example.org"], required_signatures: 2, signature_count: 1,
|
|
body_text: "We've reviewed the terms and are ready to proceed. Please find the signed MOU attached.",
|
|
created_at: new Date(Date.now() - 12 * 3600000).toISOString(),
|
|
signers: [
|
|
{ name: "Dave Park", vote: "APPROVE", signed_at: new Date(Date.now() - 10 * 3600000).toISOString() },
|
|
{ name: "Alice Chen", vote: null, signed_at: null },
|
|
],
|
|
},
|
|
{
|
|
id: "a3", subject: "Re: Security incident disclosure", status: "APPROVED",
|
|
to_addresses: ["security@example.com"], required_signatures: 2, signature_count: 2,
|
|
body_text: "Disclosure notice prepared and co-signed. Sending to affected parties.",
|
|
created_at: new Date(Date.now() - 48 * 3600000).toISOString(),
|
|
signers: [
|
|
{ name: "Bob Martinez", vote: "APPROVE", signed_at: new Date(Date.now() - 46 * 3600000).toISOString() },
|
|
{ name: "Carol Wu", vote: "APPROVE", signed_at: new Date(Date.now() - 44 * 3600000).toISOString() },
|
|
],
|
|
},
|
|
];
|
|
private demoThreads: Record<string, any[]> = {
|
|
team: [
|
|
{ id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] },
|
|
{ id: "t2", from_name: "Bob Martinez", from_address: "bob@example.com", subject: "Deploy checklist for v2.1", status: "open", is_read: false, is_starred: false, comment_count: 1, received_at: new Date(Date.now() - 5 * 3600000).toISOString(), body_text: "Here is the deploy checklist for v2.1. Please review before we cut the release.\n\n- [ ] Run full test suite\n- [ ] Update changelog\n- [ ] Tag release in Gitea\n- [ ] Deploy to staging\n- [ ] Smoke test all modules", comments: [{ username: "Alice Chen", body: "I can handle the changelog update.", created_at: new Date(Date.now() - 4 * 3600000).toISOString() }] },
|
|
{ id: "t3", from_name: "Carol Wu", from_address: "carol@example.com", subject: "Design system color tokens", status: "snoozed", is_read: true, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 24 * 3600000).toISOString(), body_text: "I've been working on standardizing our color tokens across all modules. The current approach of inline hex values is getting unwieldy. Proposal attached.", comments: [] },
|
|
{ id: "t4", from_name: "Dave Park", from_address: "dave@example.com", subject: "Q1 Retrospective summary", status: "closed", is_read: true, is_starred: false, comment_count: 5, received_at: new Date(Date.now() - 72 * 3600000).toISOString(), body_text: "Summary of our Q1 retrospective:\n\nWhat went well: Local-first architecture, community engagement, rapid prototyping.\nWhat to improve: Documentation, test coverage, onboarding flow.", comments: [{ username: "Alice Chen", body: "Great summary, Dave.", created_at: new Date(Date.now() - 70 * 3600000).toISOString() }, { username: "Bob Martinez", body: "+1 on improving docs.", created_at: new Date(Date.now() - 69 * 3600000).toISOString() }, { username: "Carol Wu", body: "I can lead the onboarding redesign.", created_at: new Date(Date.now() - 68 * 3600000).toISOString() }, { username: "Dave Park", body: "Sounds good, let's schedule a kickoff.", created_at: new Date(Date.now() - 67 * 3600000).toISOString() }, { username: "Alice Chen", body: "Added to next sprint.", created_at: new Date(Date.now() - 66 * 3600000).toISOString() }] },
|
|
],
|
|
support: [
|
|
{ id: "t5", from_name: "New User", from_address: "newuser@example.com", subject: "How do I create a space?", status: "open", is_read: false, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 1 * 3600000).toISOString(), body_text: "Hi, I just signed up and I'm not sure how to create my own space. The docs mention a space switcher but I can't find it. Could you point me in the right direction?", comments: [] },
|
|
{ id: "t6", from_name: "Partner Org", from_address: "partner@example.org", subject: "Integration API access request", status: "open", is_read: true, is_starred: true, comment_count: 2, received_at: new Date(Date.now() - 8 * 3600000).toISOString(), body_text: "We'd like to integrate our platform with rSpace modules via the API. Could you provide API documentation and access credentials for our staging environment?", comments: [{ username: "Team Bot", body: "Request logged. Assigning to API team.", created_at: new Date(Date.now() - 7 * 3600000).toISOString() }, { username: "Bob Martinez", body: "I'll send over the API docs today.", created_at: new Date(Date.now() - 6 * 3600000).toISOString() }] },
|
|
{ id: "t7", from_name: "Community Member", from_address: "member@example.com", subject: "Feature request: dark mode", status: "closed", is_read: true, is_starred: false, comment_count: 4, received_at: new Date(Date.now() - 96 * 3600000).toISOString(), body_text: "Would love to see a proper dark mode toggle. The current theme is close but some panels still have bright backgrounds.", comments: [{ username: "Carol Wu", body: "This is on our roadmap! Targeting next release.", created_at: new Date(Date.now() - 90 * 3600000).toISOString() }, { username: "Community Member", body: "Awesome, looking forward to it.", created_at: new Date(Date.now() - 88 * 3600000).toISOString() }, { username: "Carol Wu", body: "Dark mode shipped in v2.0!", created_at: new Date(Date.now() - 48 * 3600000).toISOString() }, { username: "Community Member", body: "Looks great, thanks!", created_at: new Date(Date.now() - 46 * 3600000).toISOString() }] },
|
|
],
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
|
this.loadMailboxes();
|
|
}
|
|
|
|
private loadDemoData() {
|
|
this.mailboxes = [
|
|
{ slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 },
|
|
{ slug: "support", name: "Support", email: "support@rspace.online", description: "Community support requests with multi-sig approval workflows", thread_count: 3, unread_count: 1 },
|
|
];
|
|
this.render();
|
|
}
|
|
|
|
private async loadMailboxes() {
|
|
try {
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
const resp = await fetch(`${base}/api/mailboxes`);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this.mailboxes = data.mailboxes || [];
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
if (this.mailboxes.length === 0) {
|
|
this.showingSampleData = true;
|
|
this.loadDemoData();
|
|
return;
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
private async loadThreads(slug: string) {
|
|
if (this.space === "demo" || this.showingSampleData) {
|
|
this.threads = this.demoThreads[slug] || [];
|
|
if (this.filter !== "all") this.threads = this.threads.filter(t => t.status === this.filter);
|
|
this.render();
|
|
return;
|
|
}
|
|
try {
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
const status = this.filter === "all" ? "" : `?status=${this.filter}`;
|
|
const resp = await fetch(`${base}/api/mailboxes/${slug}/threads${status}`);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this.threads = data.threads || [];
|
|
}
|
|
} catch { /* ignore */ }
|
|
this.render();
|
|
}
|
|
|
|
private async loadThread(id: string) {
|
|
if (this.space === "demo" || this.showingSampleData) {
|
|
const all = [...(this.demoThreads.team || []), ...(this.demoThreads.support || [])];
|
|
this.currentThread = all.find(t => t.id === id) || null;
|
|
if (this.currentThread) {
|
|
this.currentThread.comments = this.currentThread.comments || [{ username: "Team Bot", body: "Thread noted.", created_at: new Date().toISOString() }];
|
|
}
|
|
this.render();
|
|
return;
|
|
}
|
|
try {
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
const resp = await fetch(`${base}/api/threads/${id}`);
|
|
if (resp.ok) {
|
|
this.currentThread = await resp.json();
|
|
}
|
|
} catch { /* ignore */ }
|
|
this.render();
|
|
}
|
|
|
|
private async loadApprovals() {
|
|
if (this.space === "demo" || this.showingSampleData) { this.approvals = this.demoApprovals; this.render(); return; }
|
|
try {
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : "";
|
|
const resp = await fetch(`${base}/api/approvals${q}`);
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
this.approvals = data.approvals || [];
|
|
}
|
|
} catch { /* ignore */ }
|
|
this.render();
|
|
}
|
|
|
|
private timeAgo(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
private snippet(text: string, len = 80): string {
|
|
if (!text) return "";
|
|
const s = text.replace(/\n+/g, " ").trim();
|
|
return s.length > len ? s.slice(0, len) + "..." : s;
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: #e2e8f0; }
|
|
.container { max-width: 1000px; margin: 0 auto; }
|
|
|
|
/* Nav */
|
|
.rapp-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; min-height: 36px; }
|
|
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; }
|
|
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
|
.nav-btn { padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; }
|
|
.nav-btn.active { background: #6366f1; color: white; border-color: #6366f1; }
|
|
.nav-btn:hover:not(.active) { border-color: #475569; color: #e2e8f0; }
|
|
.nav-spacer { flex: 1; }
|
|
.help-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(99,102,241,0.3); background: rgba(99,102,241,0.1); color: #818cf8; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; }
|
|
.help-btn:hover { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.5); }
|
|
|
|
/* Mailbox cards */
|
|
.mailbox-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 0.75rem; }
|
|
.mailbox-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; cursor: pointer; transition: all 0.2s; }
|
|
.mailbox-card:hover { border-color: #6366f1; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99,102,241,0.15); }
|
|
.mailbox-icon { font-size: 1.5rem; margin-bottom: 0.75rem; }
|
|
.mailbox-name { font-weight: 600; font-size: 1rem; margin-bottom: 0.25rem; }
|
|
.mailbox-email { font-size: 0.8rem; color: #6366f1; margin-bottom: 0.5rem; font-family: monospace; }
|
|
.mailbox-desc { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; margin-bottom: 0.75rem; }
|
|
.mailbox-stats { display: flex; gap: 1rem; font-size: 0.75rem; color: #64748b; }
|
|
.mailbox-stats .unread { color: #818cf8; font-weight: 600; }
|
|
|
|
/* Thread list */
|
|
.inbox-list { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; overflow: hidden; }
|
|
.filter-bar { display: flex; gap: 0.25rem; padding: 0.75rem 1rem; border-bottom: 1px solid #1e293b; }
|
|
.filter-btn { padding: 0.25rem 0.75rem; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 0.75rem; transition: all 0.15s; }
|
|
.filter-btn:hover { color: #94a3b8; }
|
|
.filter-btn.active { background: rgba(99,102,241,0.15); color: #818cf8; }
|
|
|
|
.thread-row { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.85rem 1rem; border-bottom: 1px solid rgba(30,41,59,0.5); cursor: pointer; transition: background 0.15s; }
|
|
.thread-row:last-child { border-bottom: none; }
|
|
.thread-row:hover { background: rgba(51,65,85,0.25); }
|
|
.thread-row.unread .thread-subject { font-weight: 600; color: #f1f5f9; }
|
|
.thread-dot { width: 8px; height: 8px; border-radius: 50%; background: #6366f1; flex-shrink: 0; margin-top: 0.45rem; }
|
|
.thread-dot.read { background: transparent; }
|
|
.thread-content { flex: 1; min-width: 0; }
|
|
.thread-top { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; margin-bottom: 0.15rem; }
|
|
.thread-from { font-size: 0.85rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.thread-time { font-size: 0.7rem; color: #64748b; flex-shrink: 0; }
|
|
.thread-subject { font-size: 0.85rem; color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 0.15rem; }
|
|
.thread-snippet { font-size: 0.75rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.thread-tags { display: flex; gap: 0.35rem; margin-top: 0.35rem; align-items: center; flex-wrap: wrap; }
|
|
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 4px; font-size: 0.65rem; font-weight: 600; }
|
|
.badge-open { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-snoozed { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
|
.badge-closed { background: rgba(100,116,139,0.15); color: #94a3b8; }
|
|
.badge-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
|
.badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; }
|
|
.badge-comments { background: rgba(99,102,241,0.15); color: #818cf8; }
|
|
.star { color: #fbbf24; font-size: 0.7rem; }
|
|
|
|
/* Thread detail */
|
|
.detail-panel { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.5rem; }
|
|
.detail-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #1e293b; }
|
|
.detail-subject { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
|
.detail-meta { font-size: 0.8rem; color: #64748b; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
|
.detail-body { font-size: 0.9rem; line-height: 1.6; color: #cbd5e1; margin-bottom: 1.5rem; white-space: pre-wrap; }
|
|
.comments-section { border-top: 1px solid #1e293b; padding-top: 1rem; }
|
|
.comments-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: #94a3b8; }
|
|
.comment { padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 0.5rem; border-left: 3px solid #334155; }
|
|
.comment-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.25rem; }
|
|
.comment-author { font-size: 0.8rem; font-weight: 600; color: #818cf8; }
|
|
.comment-body { font-size: 0.85rem; color: #cbd5e1; line-height: 1.5; }
|
|
.comment-time { font-size: 0.7rem; color: #475569; }
|
|
.empty { text-align: center; padding: 3rem; color: #64748b; }
|
|
|
|
/* Approval cards */
|
|
.approval-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; border-left: 4px solid #334155; transition: border-color 0.2s; }
|
|
.approval-card.status-pending { border-left-color: #fbbf24; }
|
|
.approval-card.status-approved { border-left-color: #22c55e; }
|
|
.approval-card.status-rejected { border-left-color: #ef4444; }
|
|
.approval-to { font-size: 0.8rem; color: #64748b; margin-bottom: 0.5rem; }
|
|
.approval-to code { font-size: 0.75rem; color: #818cf8; background: rgba(99,102,241,0.1); padding: 0.1rem 0.4rem; border-radius: 4px; }
|
|
.approval-body-preview { font-size: 0.8rem; color: #94a3b8; line-height: 1.5; padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin: 0.5rem 0; border-left: 3px solid #334155; max-height: 4.5em; overflow: hidden; }
|
|
.approval-progress { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; }
|
|
.progress-bar { flex: 1; height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; }
|
|
.progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
|
|
.progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
|
|
.progress-fill.approved { background: linear-gradient(90deg, #22c55e, #16a34a); }
|
|
.progress-text { font-size: 0.75rem; color: #94a3b8; flex-shrink: 0; }
|
|
.signer-list { margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
|
.signer-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
|
|
.signer-avatar { width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 700; color: white; flex-shrink: 0; }
|
|
.signer-avatar.signed { background: #22c55e; }
|
|
.signer-avatar.waiting { background: #334155; }
|
|
.signer-name { flex: 1; }
|
|
.signer-name.signed { color: #e2e8f0; }
|
|
.signer-name.waiting { color: #64748b; }
|
|
.signer-status { font-size: 0.7rem; font-weight: 600; }
|
|
.signer-status.signed { color: #4ade80; }
|
|
.signer-status.waiting { color: #64748b; }
|
|
.approval-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
|
.btn-approve { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #22c55e; color: white; cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: background 0.15s; }
|
|
.btn-approve:hover { background: #16a34a; }
|
|
.btn-reject { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #ef4444; color: white; cursor: pointer; font-size: 0.8rem; font-weight: 500; transition: background 0.15s; }
|
|
.btn-reject:hover { background: #dc2626; }
|
|
.btn-compose { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #6366f1; background: rgba(99,102,241,0.1); color: #818cf8; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.15s; margin-bottom: 1rem; }
|
|
.btn-compose:hover { background: rgba(99,102,241,0.2); border-color: #818cf8; }
|
|
|
|
/* Compose form */
|
|
.compose-panel { background: rgba(15,23,42,0.5); border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
|
|
.compose-panel h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
|
|
.compose-field { margin-bottom: 0.75rem; }
|
|
.compose-field label { display: block; font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.25rem; font-weight: 500; }
|
|
.compose-field input, .compose-field textarea { width: 100%; background: rgba(0,0,0,0.3); border: 1px solid #334155; border-radius: 8px; padding: 0.5rem 0.75rem; color: #e2e8f0; font-size: 0.85rem; font-family: inherit; outline: none; transition: border-color 0.15s; box-sizing: border-box; }
|
|
.compose-field input:focus, .compose-field textarea:focus { border-color: #6366f1; }
|
|
.compose-field textarea { resize: vertical; min-height: 100px; }
|
|
.compose-threshold { font-size: 0.8rem; color: #94a3b8; padding: 0.6rem 0.75rem; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; margin-bottom: 0.75rem; }
|
|
.compose-actions { display: flex; gap: 0.5rem; }
|
|
.btn-submit { padding: 0.5rem 1.25rem; border-radius: 8px; border: none; background: linear-gradient(135deg, #6366f1, #0891b2); color: white; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: opacity 0.15s; }
|
|
.btn-submit:hover { opacity: 0.9; }
|
|
.btn-cancel { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; }
|
|
.btn-cancel:hover { border-color: #475569; color: #e2e8f0; }
|
|
|
|
/* Help panel */
|
|
.help-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
|
.help-panel { background: #0f172a; border: 1px solid #334155; border-radius: 16px; max-width: 640px; width: 100%; max-height: 80vh; overflow-y: auto; padding: 2rem; position: relative; }
|
|
.help-close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; color: #64748b; cursor: pointer; font-size: 1.25rem; padding: 4px 8px; border-radius: 6px; }
|
|
.help-close:hover { color: #e2e8f0; background: rgba(255,255,255,0.05); }
|
|
.help-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; }
|
|
.help-subtitle { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
.help-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1.5rem; }
|
|
.help-card { background: rgba(30,41,59,0.5); border: 1px solid #334155; border-radius: 10px; padding: 1rem; }
|
|
.help-card-icon { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
|
.help-card h4 { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.25rem; }
|
|
.help-card p { font-size: 0.75rem; color: #94a3b8; line-height: 1.4; }
|
|
.help-section { margin-bottom: 1.25rem; }
|
|
.help-section h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; color: #e2e8f0; }
|
|
.help-steps { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.help-step { display: flex; gap: 0.75rem; align-items: flex-start; }
|
|
.help-step-num { width: 24px; height: 24px; border-radius: 50%; background: #6366f1; color: white; font-size: 0.7rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
.help-step-text { font-size: 0.8rem; color: #cbd5e1; line-height: 1.5; }
|
|
.help-cta { display: inline-block; padding: 0.6rem 1.5rem; background: linear-gradient(135deg, #6366f1, #0891b2); color: white; border-radius: 8px; text-decoration: none; font-size: 0.85rem; font-weight: 600; }
|
|
|
|
.sample-banner { padding: 8px 16px; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); border-radius: 8px; color: #a5b4fc; font-size: 13px; text-align: center; margin-bottom: 12px; }
|
|
|
|
@media (max-width: 600px) {
|
|
.mailbox-grid { grid-template-columns: 1fr; }
|
|
.thread-from { max-width: 100px; }
|
|
.thread-row { flex-wrap: wrap; gap: 4px; }
|
|
.help-grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
<div class="container">
|
|
${this.renderNav()}
|
|
${this.showingSampleData ? '<div class="sample-banner">Showing sample data — create a mailbox to get started</div>' : ''}
|
|
${this.renderView()}
|
|
${this.helpOpen ? this.renderHelp() : ""}
|
|
</div>
|
|
`;
|
|
this.bindEvents();
|
|
}
|
|
|
|
private renderNav(): string {
|
|
const items = [
|
|
{ id: "mailboxes", label: "Mailboxes" },
|
|
{ id: "approvals", label: "Approvals" },
|
|
];
|
|
if (this.currentMailbox) {
|
|
items.unshift({ id: "threads", label: this.currentMailbox.name });
|
|
}
|
|
return `
|
|
<div class="rapp-nav">
|
|
${this.view !== "mailboxes" ? `<button class="rapp-nav__back" data-action="back">←</button>` : ""}
|
|
${items.map((i) => `<button class="nav-btn ${this.view === i.id ? "active" : ""}" data-nav="${i.id}">${i.label}</button>`).join("")}
|
|
<span class="nav-spacer"></span>
|
|
<button class="help-btn" data-action="help">? Guide</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderView(): string {
|
|
switch (this.view) {
|
|
case "mailboxes": return this.renderMailboxes();
|
|
case "threads": return this.renderThreads();
|
|
case "thread": return this.renderThreadDetail();
|
|
case "approvals": return this.renderApprovals();
|
|
default: return "";
|
|
}
|
|
}
|
|
|
|
private renderMailboxes(): string {
|
|
if (this.mailboxes.length === 0) {
|
|
return `
|
|
<div class="empty">
|
|
<p style="font-size:2.5rem;margin-bottom:1rem">📨</p>
|
|
<p style="font-size:1.1rem;color:#e2e8f0;font-weight:500;margin-bottom:0.5rem">No mailboxes yet</p>
|
|
<p style="font-size:0.85rem;color:#64748b;max-width:400px;margin:0 auto 1.5rem;line-height:1.5">
|
|
Create a shared mailbox for your team. Members can triage, comment on, and co-sign outgoing emails.
|
|
</p>
|
|
<button class="help-btn" data-action="help" style="font-size:0.85rem;padding:0.5rem 1.25rem">Learn how rInbox works</button>
|
|
</div>`;
|
|
}
|
|
return `<div class="mailbox-grid">${this.mailboxes.map((m) => {
|
|
const demoThreads = this.demoThreads[m.slug] || [];
|
|
const threadCount = m.thread_count ?? demoThreads.length;
|
|
const unreadCount = m.unread_count ?? demoThreads.filter((t: any) => !t.is_read).length;
|
|
return `
|
|
<div class="mailbox-card" data-mailbox="${m.slug}">
|
|
<div class="mailbox-icon">📨</div>
|
|
<div class="mailbox-name">${m.name}</div>
|
|
<div class="mailbox-email">${m.email}</div>
|
|
<div class="mailbox-desc">${m.description || "Shared mailbox"}</div>
|
|
<div class="mailbox-stats">
|
|
<span>${threadCount} thread${threadCount !== 1 ? "s" : ""}</span>
|
|
${unreadCount > 0 ? `<span class="unread">${unreadCount} unread</span>` : ""}
|
|
</div>
|
|
</div>`;
|
|
}).join("")}</div>`;
|
|
}
|
|
|
|
private renderThreads(): string {
|
|
const filters = ["all", "open", "snoozed", "closed"];
|
|
return `
|
|
<div class="inbox-list">
|
|
<div class="filter-bar">
|
|
${filters.map((f) => `<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${f.charAt(0).toUpperCase() + f.slice(1)}</button>`).join("")}
|
|
</div>
|
|
${this.threads.length === 0
|
|
? `<div class="empty"><p>No threads${this.filter !== "all" ? ` with status "${this.filter}"` : ""}</p></div>`
|
|
: this.threads.map((t) => `
|
|
<div class="thread-row ${t.is_read ? "" : "unread"}" data-thread="${t.id}">
|
|
<span class="thread-dot ${t.is_read ? "read" : ""}"></span>
|
|
<div class="thread-content">
|
|
<div class="thread-top">
|
|
<span class="thread-from">${t.from_name || t.from_address || "Unknown"}</span>
|
|
<span class="thread-time">${this.timeAgo(t.received_at)}</span>
|
|
</div>
|
|
<div class="thread-subject">${t.subject}</div>
|
|
<div class="thread-snippet">${this.snippet(t.body_text)}</div>
|
|
<div class="thread-tags">
|
|
${t.is_starred ? `<span class="star">★</span>` : ""}
|
|
<span class="badge badge-${t.status}">${t.status}</span>
|
|
${t.comment_count > 0 ? `<span class="badge badge-comments">💬 ${t.comment_count}</span>` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderThreadDetail(): string {
|
|
if (!this.currentThread) return `<div class="empty">Loading...</div>`;
|
|
const t = this.currentThread;
|
|
const comments = t.comments || [];
|
|
return `
|
|
<div class="detail-panel">
|
|
<div class="detail-header">
|
|
<div class="detail-subject">${t.subject}</div>
|
|
<div class="detail-meta">
|
|
<span>From: <strong style="color:#e2e8f0">${t.from_name || ""}</strong> <${t.from_address || "unknown"}></span>
|
|
<span>·</span>
|
|
<span>${this.timeAgo(t.received_at)}</span>
|
|
<span>·</span>
|
|
<span class="badge badge-${t.status}">${t.status}</span>
|
|
${t.is_starred ? `<span class="star">★</span>` : ""}
|
|
</div>
|
|
</div>
|
|
<div class="detail-body">${t.body_text || t.body_html || "(no content)"}</div>
|
|
<div class="comments-section">
|
|
<div class="comments-title">💬 Internal Comments (${comments.length})</div>
|
|
${comments.map((cm: any) => `
|
|
<div class="comment">
|
|
<div class="comment-top">
|
|
<span class="comment-author">${cm.username || "Anonymous"}</span>
|
|
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
|
|
</div>
|
|
<div class="comment-body">${cm.body}</div>
|
|
</div>
|
|
`).join("")}
|
|
${comments.length === 0 ? `<div style="font-size:0.8rem;color:#475569;padding:0.75rem">No comments yet — discuss this thread with your team before replying.</div>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderApprovals(): string {
|
|
const composeForm = this.composeOpen ? `
|
|
<div class="compose-panel">
|
|
<h3>✎ Draft for Multi-Sig Approval</h3>
|
|
<div class="compose-field">
|
|
<label>To</label>
|
|
<input id="compose-to" type="email" placeholder="recipient@example.com" />
|
|
</div>
|
|
<div class="compose-field">
|
|
<label>Subject</label>
|
|
<input id="compose-subject" type="text" placeholder="Email subject" />
|
|
</div>
|
|
<div class="compose-field">
|
|
<label>Body</label>
|
|
<textarea id="compose-body" placeholder="Write the email body. This will be reviewed and co-signed by your team before sending."></textarea>
|
|
</div>
|
|
<div class="compose-threshold">
|
|
🔒 This email requires <strong style="color:#818cf8">2 of 3</strong> team member signatures before it will be sent.
|
|
</div>
|
|
<div class="compose-actions">
|
|
<button class="btn-submit" data-action="submit-compose">Submit for Approval</button>
|
|
<button class="btn-cancel" data-action="cancel-compose">Cancel</button>
|
|
</div>
|
|
</div>
|
|
` : "";
|
|
|
|
if (this.approvals.length === 0 && !this.composeOpen) {
|
|
return `
|
|
<button class="btn-compose" data-action="compose">✎ Compose for Approval</button>
|
|
<div class="empty">
|
|
<p style="font-size:2rem;margin-bottom:1rem">✅</p>
|
|
<p style="font-size:1rem;color:#e2e8f0;font-weight:500;margin-bottom:0.5rem">No pending approvals</p>
|
|
<p style="font-size:0.8rem;color:#64748b;max-width:380px;margin:0 auto 1.5rem;line-height:1.5">
|
|
When a team member drafts an outgoing email, it appears here for multi-sig approval.
|
|
Collect the required signatures before it sends.
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
const cards = this.approvals.map((a) => {
|
|
const pct = a.required_signatures > 0 ? Math.min(100, Math.round((a.signature_count / a.required_signatures) * 100)) : 0;
|
|
const statusClass = a.status.toLowerCase();
|
|
const signers = (a.signers || []).map((s: any) => {
|
|
const initials = (s.name || "?").split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase();
|
|
const isSigned = s.vote === "APPROVE";
|
|
return `
|
|
<div class="signer-row">
|
|
<div class="signer-avatar ${isSigned ? "signed" : "waiting"}">${initials}</div>
|
|
<span class="signer-name ${isSigned ? "signed" : "waiting"}">${s.name}</span>
|
|
<span class="signer-status ${isSigned ? "signed" : "waiting"}">
|
|
${isSigned ? "✓ Signed " + this.timeAgo(s.signed_at) : "Awaiting signature"}
|
|
</span>
|
|
</div>`;
|
|
}).join("");
|
|
|
|
return `
|
|
<div class="approval-card status-${statusClass}">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.25rem">
|
|
<div style="font-weight:600;font-size:0.95rem;flex:1;margin-right:0.5rem">${a.subject}</div>
|
|
<span class="badge badge-${statusClass}">${a.status}</span>
|
|
</div>
|
|
<div class="approval-to">To: ${(a.to_addresses || []).map((e: string) => `<code>${e}</code>`).join(", ")}</div>
|
|
<div style="font-size:0.75rem;color:#475569;margin-bottom:0.35rem">${this.timeAgo(a.created_at)}</div>
|
|
${a.body_text ? `<div class="approval-body-preview">${this.snippet(a.body_text, 200)}</div>` : ""}
|
|
<div class="approval-progress">
|
|
<div class="progress-bar"><div class="progress-fill ${statusClass}" style="width:${pct}%"></div></div>
|
|
<span class="progress-text">${a.signature_count || 0} / ${a.required_signatures} signatures</span>
|
|
</div>
|
|
${signers ? `<div class="signer-list">${signers}</div>` : ""}
|
|
${a.status === "PENDING" ? `
|
|
<div class="approval-actions">
|
|
<button class="btn-approve" data-approve="${a.id}">✓ Approve & Sign</button>
|
|
<button class="btn-reject" data-reject="${a.id}">✗ Reject</button>
|
|
</div>
|
|
` : ""}
|
|
</div>`;
|
|
}).join("");
|
|
|
|
return `
|
|
<button class="btn-compose" data-action="compose">✎ Compose for Approval</button>
|
|
${composeForm}
|
|
${cards}
|
|
`;
|
|
}
|
|
|
|
private renderHelp(): string {
|
|
return `
|
|
<div class="help-overlay" data-action="close-help-overlay">
|
|
<div class="help-panel" data-help-panel>
|
|
<button class="help-close" data-action="close-help">×</button>
|
|
<div class="help-title">📨 rInbox Guide</div>
|
|
<div class="help-subtitle">
|
|
Collaborative email with multi-sig approval workflows.
|
|
Shared mailboxes for your team where outgoing emails require collective sign-off.
|
|
</div>
|
|
|
|
<div class="help-grid">
|
|
<div class="help-card">
|
|
<div class="help-card-icon">📨</div>
|
|
<h4>Shared Mailboxes</h4>
|
|
<p>Create team mailboxes that multiple members can read, triage, and respond to with role-based access.</p>
|
|
</div>
|
|
<div class="help-card">
|
|
<div class="help-card-icon">💬</div>
|
|
<h4>Threaded Comments</h4>
|
|
<p>Discuss emails internally before replying. @mention teammates and coordinate responses.</p>
|
|
</div>
|
|
<div class="help-card">
|
|
<div class="help-card-icon">✅</div>
|
|
<h4>Multi-Sig Approval</h4>
|
|
<p>Outgoing emails require M-of-N signatures. Board votes and treasury approvals built into email.</p>
|
|
</div>
|
|
<div class="help-card">
|
|
<div class="help-card-icon">🔄</div>
|
|
<h4>IMAP Sync</h4>
|
|
<p>Connect any IMAP mailbox. Server-side polling syncs emails automatically every 30 seconds.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h3>How It Works</h3>
|
|
<div class="help-steps">
|
|
<div class="help-step">
|
|
<span class="help-step-num">1</span>
|
|
<span class="help-step-text"><strong>Create a mailbox</strong> — set up a shared inbox for your team with an email address and invite members.</span>
|
|
</div>
|
|
<div class="help-step">
|
|
<span class="help-step-num">2</span>
|
|
<span class="help-step-text"><strong>Triage & discuss</strong> — incoming emails sync via IMAP. Team members triage threads, leave comments, and star important messages.</span>
|
|
</div>
|
|
<div class="help-step">
|
|
<span class="help-step-num">3</span>
|
|
<span class="help-step-text"><strong>Approve & send</strong> — draft a reply, then collect the required signatures. Once the threshold is met, the email sends.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h3>Use Cases</h3>
|
|
<div class="help-steps">
|
|
<div class="help-step">
|
|
<span class="help-step-num" style="background:#22d3ee">⚖</span>
|
|
<span class="help-step-text"><strong>Governance</strong> — board decisions require 3-of-5 signers before the email sends.</span>
|
|
</div>
|
|
<div class="help-step">
|
|
<span class="help-step-num" style="background:#22c55e">💰</span>
|
|
<span class="help-step-text"><strong>Treasury</strong> — 2-of-3 finance team co-sign payment authorizations. Bridges email to on-chain wallets.</span>
|
|
</div>
|
|
<div class="help-step">
|
|
<span class="help-step-num" style="background:#fbbf24">🔎</span>
|
|
<span class="help-step-text"><strong>Audit Trails</strong> — every email is co-signed with cryptographic proof of who approved what.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="text-align:center;margin-top:1.5rem">
|
|
<a href="/${this.space}/rinbox/about" class="help-cta">View Full Feature Page →</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private bindEvents() {
|
|
// Navigation
|
|
this.shadow.querySelectorAll("[data-nav]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const nav = (btn as HTMLElement).dataset.nav as any;
|
|
if (nav === "approvals") {
|
|
this.view = "approvals";
|
|
this.loadApprovals();
|
|
} else if (nav === "mailboxes") {
|
|
this.view = "mailboxes";
|
|
this.currentMailbox = null;
|
|
this.render();
|
|
} else if (nav === "threads") {
|
|
this.view = "threads";
|
|
this.render();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Back
|
|
const backBtn = this.shadow.querySelector("[data-action='back']");
|
|
if (backBtn) {
|
|
backBtn.addEventListener("click", () => {
|
|
if (this.view === "thread") {
|
|
this.view = "threads";
|
|
this.render();
|
|
} else if (this.view === "threads" || this.view === "approvals") {
|
|
this.view = "mailboxes";
|
|
this.currentMailbox = null;
|
|
this.render();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Help
|
|
this.shadow.querySelectorAll("[data-action='help']").forEach((btn) => {
|
|
btn.addEventListener("click", () => { this.helpOpen = true; this.render(); });
|
|
});
|
|
const helpOverlay = this.shadow.querySelector("[data-action='close-help-overlay']");
|
|
if (helpOverlay) {
|
|
helpOverlay.addEventListener("click", (e) => {
|
|
if ((e.target as HTMLElement).dataset.action === "close-help-overlay") {
|
|
this.helpOpen = false; this.render();
|
|
}
|
|
});
|
|
}
|
|
const helpClose = this.shadow.querySelector("[data-action='close-help']");
|
|
if (helpClose) {
|
|
helpClose.addEventListener("click", () => { this.helpOpen = false; this.render(); });
|
|
}
|
|
|
|
// Mailbox click
|
|
this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => {
|
|
card.addEventListener("click", () => {
|
|
const slug = (card as HTMLElement).dataset.mailbox!;
|
|
this.currentMailbox = this.mailboxes.find((m) => m.slug === slug);
|
|
this.view = "threads";
|
|
this.loadThreads(slug);
|
|
});
|
|
});
|
|
|
|
// Thread click
|
|
this.shadow.querySelectorAll("[data-thread]").forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
const id = (row as HTMLElement).dataset.thread!;
|
|
this.view = "thread";
|
|
this.loadThread(id);
|
|
});
|
|
});
|
|
|
|
// Filter
|
|
this.shadow.querySelectorAll("[data-filter]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
this.filter = (btn as HTMLElement).dataset.filter as any;
|
|
if (this.currentMailbox) this.loadThreads(this.currentMailbox.slug);
|
|
});
|
|
});
|
|
|
|
// Compose
|
|
const composeBtn = this.shadow.querySelector("[data-action='compose']");
|
|
if (composeBtn) {
|
|
composeBtn.addEventListener("click", () => { this.composeOpen = !this.composeOpen; this.render(); });
|
|
}
|
|
const cancelCompose = this.shadow.querySelector("[data-action='cancel-compose']");
|
|
if (cancelCompose) {
|
|
cancelCompose.addEventListener("click", () => { this.composeOpen = false; this.render(); });
|
|
}
|
|
const submitCompose = this.shadow.querySelector("[data-action='submit-compose']");
|
|
if (submitCompose) {
|
|
submitCompose.addEventListener("click", async () => {
|
|
if (this.space === "demo" || this.showingSampleData) {
|
|
alert("Compose is disabled in demo mode. In a live space, this would submit the email for team approval.");
|
|
return;
|
|
}
|
|
const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim();
|
|
const subject = (this.shadow.getElementById("compose-subject") as HTMLInputElement)?.value.trim();
|
|
const body = (this.shadow.getElementById("compose-body") as HTMLTextAreaElement)?.value.trim();
|
|
if (!to || !subject || !body) { alert("Please fill all fields."); return; }
|
|
try {
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
const mailbox = this.currentMailbox?.slug || "";
|
|
await fetch(`${base}/api/approvals`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ to_addresses: [to], subject, body_text: body, mailbox }),
|
|
});
|
|
this.composeOpen = false;
|
|
this.loadApprovals();
|
|
} catch { alert("Failed to submit. Please try again."); }
|
|
});
|
|
}
|
|
|
|
// Approval actions
|
|
this.shadow.querySelectorAll("[data-approve]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
if (this.space === "demo" || this.showingSampleData) { alert("Approvals are disabled in demo mode."); return; }
|
|
const id = (btn as HTMLElement).dataset.approve!;
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
await fetch(`${base}/api/approvals/${id}/sign`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ vote: "APPROVE" }),
|
|
});
|
|
this.loadApprovals();
|
|
});
|
|
});
|
|
this.shadow.querySelectorAll("[data-reject]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
if (this.space === "demo" || this.showingSampleData) { alert("Approvals are disabled in demo mode."); return; }
|
|
const id = (btn as HTMLElement).dataset.reject!;
|
|
const base = window.location.pathname.replace(/\/$/, "");
|
|
await fetch(`${base}/api/approvals/${id}/sign`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ vote: "REJECT" }),
|
|
});
|
|
this.loadApprovals();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-inbox-client", FolkInboxClient);
|