rspace-online/lib/folk-token-ledger.ts

498 lines
11 KiB
TypeScript

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`
<div class="header">
<span class="header-title">
<span>\uD83D\uDCDC</span>
<span class="name">Token Ledger</span>
<span class="symbol"></span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="ledger-body">
<div class="summary"></div>
<div class="holder-list"></div>
</div>
<div class="issue-form">
<div class="issue-row">
<input type="text" placeholder="Email or DID..." class="holder-input" />
<input type="number" placeholder="Amt" class="amount-input" min="1" />
</div>
<div class="issue-row">
<input type="text" placeholder="Memo (optional)" class="memo-input" />
<button class="issue-btn">Issue</button>
</div>
</div>
`;
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 = `
<div class="summary-row">
<span class="label">Total Issued</span>
<span class="value">${totalIssued.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)}</span>
</div>
${mintInfo.totalSupply > 0 ? `
<div class="summary-row">
<span class="label">Remaining</span>
<span class="value">${remaining.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)}</span>
</div>
` : ""}
`;
// Aggregate balances by holder
const balances = new Map<string, { label: string; total: number; isEscrow: boolean; lastMemo: string }>();
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 = '<div class="empty-state">No tokens issued yet</div>';
} else {
this.#listEl.innerHTML = sorted
.map(([holder, info]) => `
<div class="holder-item">
<div class="holder-info">
<div class="holder-icon" style="background: ${info.isEscrow ? '#f59e0b' : this.#holderColor(holder)}">
${info.isEscrow ? '\u2709' : info.label.charAt(0).toUpperCase()}
</div>
<div>
<div class="holder-name">
${this.#escapeHtml(info.label)}${info.isEscrow ? '<span class="escrow-badge">escrow</span>' : ''}
</div>
<div class="holder-memo">${this.#escapeHtml(info.lastMemo)}</div>
</div>
</div>
<span class="holder-amount">${info.total.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)}</span>
</div>
`)
.join("");
}
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-token-ledger",
mintId: this.#mintId,
entries: this.#entries,
};
}
}