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: 280px; min-height: 200px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #d97706; 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-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); } .budget-body { padding: 12px; } .budget-summary { margin-bottom: 12px; } .budget-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; } .budget-row .label { color: #64748b; } .budget-row .value { font-weight: 600; color: #1e293b; } .budget-row .remaining { color: #059669; } .budget-row .over { color: #dc2626; } .progress-bar { width: 100%; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; margin: 8px 0 12px; } .progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s ease; } .progress-fill.ok { background: #059669; } .progress-fill.warning { background: #d97706; } .progress-fill.danger { background: #dc2626; } .expense-list { max-height: 200px; overflow-y: auto; } .expense-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; font-size: 12px; border-radius: 4px; margin-bottom: 3px; background: #f8fafc; } .expense-item:hover { background: #f1f5f9; } .expense-desc { color: #1e293b; font-weight: 500; } .expense-cat { font-size: 9px; color: #64748b; } .expense-amount { font-weight: 600; color: #dc2626; } .add-form { display: flex; gap: 4px; padding: 8px 12px; border-top: 1px solid #e2e8f0; } .add-form input { flex: 1; padding: 6px 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 12px; outline: none; } .add-form input:focus { border-color: #d97706; } .add-form input.amount-input { width: 80px; flex: 0; } .empty-state { text-align: center; padding: 16px; color: #94a3b8; font-size: 12px; } `; export interface BudgetExpense { id: string; description: string; amount: number; category: string; date?: string; } declare global { interface HTMLElementTagNameMap { "folk-budget": FolkBudget; } } export class FolkBudget extends FolkShape { static override tagName = "folk-budget"; 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; } #budgetTotal = 0; #currency = "USD"; #expenses: BudgetExpense[] = []; #listEl: HTMLElement | null = null; #summaryEl: HTMLElement | null = null; #progressEl: HTMLElement | null = null; get budgetTotal() { return this.#budgetTotal; } set budgetTotal(v: number) { this.#budgetTotal = v; this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } get currency() { return this.#currency; } set currency(v: string) { this.#currency = v; this.#render(); } get expenses() { return this.#expenses; } set expenses(v: BudgetExpense[]) { this.#expenses = v; this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } get spent() { return this.#expenses.reduce((sum, e) => sum + e.amount, 0); } addExpense(expense: BudgetExpense) { this.#expenses.push(expense); this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
\uD83D\uDCB0 Budget
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#summaryEl = wrapper.querySelector(".budget-summary"); this.#progressEl = wrapper.querySelector(".progress-fill"); this.#listEl = wrapper.querySelector(".expense-list"); const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const descInput = wrapper.querySelector(".desc-input") as HTMLInputElement; const amountInput = wrapper.querySelector(".amount-input") as HTMLInputElement; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); const addExpense = () => { if (descInput.value.trim() && amountInput.value) { this.addExpense({ id: `exp-${Date.now()}`, description: descInput.value.trim(), amount: Number(amountInput.value), category: "OTHER", }); descInput.value = ""; amountInput.value = ""; } }; descInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.stopPropagation(); addExpense(); } }); amountInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.stopPropagation(); addExpense(); } }); descInput.addEventListener("click", (e) => e.stopPropagation()); amountInput.addEventListener("click", (e) => e.stopPropagation()); this.#render(); return root; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #render() { if (!this.#summaryEl || !this.#progressEl || !this.#listEl) return; const spent = this.spent; const remaining = this.#budgetTotal - spent; const pct = this.#budgetTotal > 0 ? (spent / this.#budgetTotal) * 100 : 0; this.#summaryEl.innerHTML = `
Budget ${this.#currency} ${this.#budgetTotal.toLocaleString()}
Spent ${this.#currency} ${spent.toLocaleString()}
Remaining ${this.#currency} ${Math.abs(remaining).toLocaleString()}${remaining < 0 ? ' over' : ''}
`; this.#progressEl.style.width = `${Math.min(pct, 100)}%`; this.#progressEl.className = `progress-fill ${pct > 90 ? 'danger' : pct > 70 ? 'warning' : 'ok'}`; if (this.#expenses.length === 0) { this.#listEl.innerHTML = '
No expenses yet
'; } else { this.#listEl.innerHTML = this.#expenses .slice() .reverse() .map((e) => `
${this.#escapeHtml(e.description)}
${this.#escapeHtml(e.category)}
-${this.#currency} ${e.amount.toLocaleString()}
`) .join(""); } } override toJSON() { return { ...super.toJSON(), type: "folk-budget", budgetTotal: this.#budgetTotal, currency: this.#currency, expenses: this.#expenses, }; } }