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

498 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>📜</span>
<span class="name">Token Ledger</span>
<span class="symbol"></span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</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 ? '✉' : 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,
};
}
}