import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 320px; min-height: 280px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #6d28d9; color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-title .symbol { opacity: 0.8; font-weight: 400; font-size: 11px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .ledger-body { padding: 12px; } .summary-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; } .summary-row .label { color: #64748b; } .summary-row .value { font-weight: 600; color: #1e293b; } .holder-list { max-height: 220px; overflow-y: auto; margin-top: 10px; } .holder-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; font-size: 12px; border-radius: 4px; margin-bottom: 3px; background: #f8fafc; } .holder-item:hover { background: #f1f5f9; } .holder-info { display: flex; align-items: center; gap: 6px; } .holder-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; flex-shrink: 0; } .holder-name { color: #1e293b; font-weight: 500; } .holder-memo { font-size: 9px; color: #94a3b8; } .holder-amount { font-weight: 600; color: #6d28d9; white-space: nowrap; } .escrow-badge { font-size: 9px; background: #fef3c7; color: #92400e; padding: 1px 5px; border-radius: 3px; margin-left: 4px; } .empty-state { text-align: center; padding: 16px; color: #94a3b8; font-size: 12px; } .issue-form { display: flex; flex-direction: column; gap: 4px; padding: 8px 12px; border-top: 1px solid #e2e8f0; } .issue-row { display: flex; gap: 4px; } .issue-form input { flex: 1; padding: 6px 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 12px; outline: none; } .issue-form input:focus { border-color: #6d28d9; } .issue-form input.amount-input { width: 80px; flex: 0; } .issue-btn { padding: 6px 12px; background: #6d28d9; color: white; border: none; border-radius: 4px; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap; } .issue-btn:hover { background: #5b21b6; } .issue-btn:disabled { background: #cbd5e1; cursor: not-allowed; } `; export interface LedgerEntry { id: string; holder: string; holderLabel: string; amount: number; issuedAt: string; issuedBy: string; memo: string; } declare global { interface HTMLElementTagNameMap { "folk-token-ledger": FolkTokenLedger; } } export class FolkTokenLedger extends FolkShape { static override tagName = "folk-token-ledger"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #mintId = ""; #entries: LedgerEntry[] = []; #headerEl: HTMLElement | null = null; #summaryEl: HTMLElement | null = null; #listEl: HTMLElement | null = null; #issueBtnEl: HTMLButtonElement | null = null; get mintId() { return this.#mintId; } set mintId(v: string) { this.#mintId = v; this.#render(); } get entries() { return this.#entries; } set entries(v: LedgerEntry[]) { this.#entries = v; this.#render(); this.#updateLinkedMint(); this.dispatchEvent(new CustomEvent("content-change")); } get totalIssued() { return this.#entries.reduce((sum, e) => sum + e.amount, 0); } addEntry(entry: LedgerEntry) { this.#entries.push(entry); this.#render(); this.#updateLinkedMint(); this.dispatchEvent(new CustomEvent("content-change")); } #getLinkedMint(): HTMLElement | null { if (!this.#mintId) return null; return document.getElementById(this.#mintId); } #getMintInfo(): { name: string; symbol: string; color: string; totalSupply: number } { const mint = this.#getLinkedMint() as any; if (mint) { return { name: mint.tokenName || "Token", symbol: mint.tokenSymbol || "TKN", color: mint.tokenColor || "#6d28d9", totalSupply: mint.totalSupply || 0, }; } return { name: "Token", symbol: "TKN", color: "#6d28d9", totalSupply: 0 }; } #updateLinkedMint() { const mint = this.#getLinkedMint() as any; if (mint && typeof mint.issuedSupply !== "undefined") { mint.issuedSupply = this.totalIssued; } } #isEmail(s: string): boolean { return s.includes("@") && !s.startsWith("did:"); } #holderColor(holder: string): string { let hash = 0; for (let i = 0; i < holder.length; i++) { hash = ((hash << 5) - hash + holder.charCodeAt(i)) | 0; } const hue = Math.abs(hash) % 360; return `hsl(${hue}, 55%, 50%)`; } override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
\uD83D\uDCDC Token Ledger
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#headerEl = wrapper.querySelector(".header"); this.#summaryEl = wrapper.querySelector(".summary"); this.#listEl = wrapper.querySelector(".holder-list"); const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const holderInput = wrapper.querySelector(".holder-input") as HTMLInputElement; const amountInput = wrapper.querySelector(".amount-input") as HTMLInputElement; const memoInput = wrapper.querySelector(".memo-input") as HTMLInputElement; this.#issueBtnEl = wrapper.querySelector(".issue-btn") as HTMLButtonElement; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); const stopProp = (e: Event) => e.stopPropagation(); holderInput.addEventListener("click", stopProp); amountInput.addEventListener("click", stopProp); memoInput.addEventListener("click", stopProp); const issueToken = () => { const holder = holderInput.value.trim(); const amount = Number(amountInput.value); const memo = memoInput.value.trim(); if (!holder || !amount || amount <= 0) return; // Check supply const mintInfo = this.#getMintInfo(); if (mintInfo.totalSupply > 0 && this.totalIssued + amount > mintInfo.totalSupply) { return; // Would exceed supply } const label = this.#isEmail(holder) ? holder.split("@")[0] : holder.length > 16 ? `${holder.slice(0, 8)}...${holder.slice(-4)}` : holder; this.addEntry({ id: `entry-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, holder, holderLabel: label, amount, issuedAt: new Date().toISOString(), issuedBy: "", memo: memo || "Issued", }); holderInput.value = ""; amountInput.value = ""; memoInput.value = ""; }; this.#issueBtnEl.addEventListener("click", (e) => { e.stopPropagation(); issueToken(); }); holderInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.stopPropagation(); issueToken(); } }); amountInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.stopPropagation(); issueToken(); } }); memoInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.stopPropagation(); issueToken(); } }); this.#render(); return root; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #render() { if (!this.#headerEl || !this.#summaryEl || !this.#listEl) return; const mintInfo = this.#getMintInfo(); // Update header this.#headerEl.style.background = mintInfo.color; const nameEl = this.#headerEl.querySelector(".name"); const symbolEl = this.#headerEl.querySelector(".symbol"); if (nameEl) nameEl.textContent = `${mintInfo.name} Ledger`; if (symbolEl) symbolEl.textContent = mintInfo.symbol; // Update issue button color if (this.#issueBtnEl) { this.#issueBtnEl.style.background = mintInfo.color; } const totalIssued = this.totalIssued; const remaining = mintInfo.totalSupply > 0 ? mintInfo.totalSupply - totalIssued : 0; this.#summaryEl.innerHTML = `
Total Issued ${totalIssued.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)}
${mintInfo.totalSupply > 0 ? `
Remaining ${remaining.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)}
` : ""} `; // Aggregate balances by holder const balances = new Map(); for (const entry of this.#entries) { const existing = balances.get(entry.holder); if (existing) { existing.total += entry.amount; existing.lastMemo = entry.memo; } else { balances.set(entry.holder, { label: entry.holderLabel, total: entry.amount, isEscrow: this.#isEmail(entry.holder), lastMemo: entry.memo, }); } } // Sort by balance descending const sorted = [...balances.entries()].sort((a, b) => b[1].total - a[1].total); if (sorted.length === 0) { this.#listEl.innerHTML = '
No tokens issued yet
'; } else { this.#listEl.innerHTML = sorted .map(([holder, info]) => `
${info.isEscrow ? '\u2709' : info.label.charAt(0).toUpperCase()}
${this.#escapeHtml(info.label)}${info.isEscrow ? 'escrow' : ''}
${this.#escapeHtml(info.lastMemo)}
${info.total.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)}
`) .join(""); } } override toJSON() { return { ...super.toJSON(), type: "folk-token-ledger", mintId: this.#mintId, entries: this.#entries, }; } }