358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
/**
|
|
* folk-inbox-client — Collaborative email client.
|
|
*
|
|
* Shows mailbox list, thread inbox, thread detail with comments,
|
|
* and approval workflow interface.
|
|
*/
|
|
|
|
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";
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
this.loadMailboxes();
|
|
}
|
|
|
|
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 */ }
|
|
this.render();
|
|
}
|
|
|
|
private async loadThreads(slug: string) {
|
|
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) {
|
|
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() {
|
|
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 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 { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; align-items: center; }
|
|
.nav-btn { padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; }
|
|
.nav-btn.active { background: #6366f1; color: white; border-color: #6366f1; }
|
|
.back-btn { padding: 0.4rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; margin-right: 0.5rem; }
|
|
.back-btn:hover { background: rgba(51,65,85,0.5); }
|
|
.card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; cursor: pointer; transition: border-color 0.2s; }
|
|
.card:hover { border-color: #6366f1; }
|
|
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; }
|
|
.card-title { font-weight: 600; font-size: 0.95rem; }
|
|
.card-meta { font-size: 0.75rem; color: #64748b; }
|
|
.card-desc { font-size: 0.85rem; color: #94a3b8; }
|
|
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; 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; }
|
|
.thread-row { display: flex; gap: 1rem; align-items: flex-start; padding: 0.75rem 1rem; border-bottom: 1px solid #1e293b; cursor: pointer; }
|
|
.thread-row:hover { background: rgba(51,65,85,0.3); }
|
|
.thread-row.unread { font-weight: 600; }
|
|
.thread-from { width: 180px; font-size: 0.85rem; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.thread-subject { flex: 1; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.thread-time { font-size: 0.75rem; color: #64748b; flex-shrink: 0; }
|
|
.thread-indicators { display: flex; gap: 0.25rem; flex-shrink: 0; }
|
|
.star { color: #fbbf24; font-size: 0.75rem; }
|
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: #6366f1; flex-shrink: 0; margin-top: 0.35rem; }
|
|
.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; }
|
|
.filter-btn.active { background: rgba(99,102,241,0.15); color: #818cf8; }
|
|
.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; }
|
|
.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; }
|
|
.comment-author { font-size: 0.8rem; font-weight: 600; color: #818cf8; }
|
|
.comment-body { font-size: 0.85rem; color: #cbd5e1; margin-top: 0.25rem; }
|
|
.comment-time { font-size: 0.7rem; color: #475569; margin-top: 0.25rem; }
|
|
.empty { text-align: center; padding: 3rem; color: #64748b; }
|
|
.approval-card { padding: 1rem; margin-bottom: 0.75rem; }
|
|
.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; }
|
|
.btn-reject { padding: 0.4rem 1rem; border-radius: 6px; border: none; background: #ef4444; color: white; cursor: pointer; font-size: 0.8rem; }
|
|
</style>
|
|
<div class="container">
|
|
${this.renderNav()}
|
|
${this.renderView()}
|
|
</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="nav">
|
|
${this.view !== "mailboxes" ? `<button class="back-btn" data-action="back">←</button>` : ""}
|
|
${items.map((i) => `<button class="nav-btn ${this.view === i.id ? "active" : ""}" data-nav="${i.id}">${i.label}</button>`).join("")}
|
|
</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:2rem;margin-bottom:1rem">📨</p><p>No mailboxes yet</p><p style="font-size:0.8rem;color:#475569;margin-top:0.5rem">Create a shared mailbox to get started</p></div>`;
|
|
}
|
|
return this.mailboxes.map((m) => `
|
|
<div class="card" data-mailbox="${m.slug}">
|
|
<div class="card-header">
|
|
<div class="card-title">${m.name}</div>
|
|
<span class="card-meta">${m.email}</span>
|
|
</div>
|
|
<div class="card-desc">${m.description || "Shared mailbox"}</div>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
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">No threads</div>`
|
|
: this.threads.map((t) => `
|
|
<div class="thread-row ${t.is_read ? "" : "unread"}" data-thread="${t.id}">
|
|
${t.is_read ? "" : `<span class="dot"></span>`}
|
|
<span class="thread-from">${t.from_name || t.from_address || "Unknown"}</span>
|
|
<span class="thread-subject">${t.subject} ${t.comment_count > 0 ? `<span style="color:#64748b;font-weight:400">(${t.comment_count})</span>` : ""}</span>
|
|
<span class="thread-indicators">
|
|
${t.is_starred ? `<span class="star">★</span>` : ""}
|
|
<span class="badge badge-${t.status}">${t.status}</span>
|
|
</span>
|
|
<span class="thread-time">${this.timeAgo(t.received_at)}</span>
|
|
</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">
|
|
From: ${t.from_name || ""} <${t.from_address || "unknown"}>
|
|
· ${this.timeAgo(t.received_at)}
|
|
· <span class="badge badge-${t.status}">${t.status}</span>
|
|
</div>
|
|
</div>
|
|
<div class="detail-body">${t.body_text || t.body_html || "(no content)"}</div>
|
|
<div class="comments-section">
|
|
<div class="comments-title">Comments (${comments.length})</div>
|
|
${comments.map((cm: any) => `
|
|
<div class="comment">
|
|
<span class="comment-author">${cm.username || "Anonymous"}</span>
|
|
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
|
|
<div class="comment-body">${cm.body}</div>
|
|
</div>
|
|
`).join("")}
|
|
${comments.length === 0 ? `<div style="font-size:0.8rem;color:#475569">No comments yet</div>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderApprovals(): string {
|
|
if (this.approvals.length === 0) {
|
|
return `<div class="empty"><p style="font-size:2rem;margin-bottom:1rem">✅</p><p>No pending approvals</p></div>`;
|
|
}
|
|
return this.approvals.map((a) => `
|
|
<div class="card approval-card">
|
|
<div class="card-header">
|
|
<div class="card-title">${a.subject}</div>
|
|
<span class="badge badge-${a.status.toLowerCase()}">${a.status}</span>
|
|
</div>
|
|
<div class="card-meta">
|
|
${a.signature_count || 0} / ${a.required_signatures} signatures
|
|
· ${this.timeAgo(a.created_at)}
|
|
</div>
|
|
${a.status === "PENDING" ? `
|
|
<div class="approval-actions">
|
|
<button class="btn-approve" data-approve="${a.id}">Approve</button>
|
|
<button class="btn-reject" data-reject="${a.id}">Reject</button>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// Approval actions
|
|
this.shadow.querySelectorAll("[data-approve]").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
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 () => {
|
|
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);
|