feat: add finance widget and CSS/template updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
finance.b64
Normal file
1
finance.b64
Normal file
File diff suppressed because one or more lines are too long
5
finance.csv
Normal file
5
finance.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
Account,Balance,Currency
|
||||
Checking,4250.00,USD
|
||||
Savings,12400.00,USD
|
||||
Credit Card,-850.50,USD
|
||||
Investments,34800.00,USD
|
||||
|
16
finance.html
Normal file
16
finance.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{- define "widget-content" }}
|
||||
<ul class="list list-gap-10">
|
||||
{{- range .Data.items }}
|
||||
<li class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-10">
|
||||
<div class="color-highlight size-h3">{{ index . 0 }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="size-h3 text-very-compact color-positive">{{ index . 1 }} <span class="color-subdue size-h5">{{ index . 2 }}</span></div>
|
||||
</div>
|
||||
</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- end }}
|
||||
178
finance.js
Normal file
178
finance.js
Normal file
@@ -0,0 +1,178 @@
|
||||
window.initFinanceApp = function() {
|
||||
window.financeApp = {
|
||||
data: null,
|
||||
charts: {},
|
||||
async load() {
|
||||
try {
|
||||
let res = await fetch('/api/finance');
|
||||
let json = await res.json();
|
||||
this.data = json;
|
||||
if (!this.data.monthly || !this.data.monthly.labels) {
|
||||
this.data.monthly = {labels:['Jan'], income:[0], expenses:[0], spend:[0]};
|
||||
}
|
||||
if (!this.data.accounts) this.data.accounts = [];
|
||||
this.renderCharts();
|
||||
this.renderTable();
|
||||
} catch (e) {
|
||||
console.error("Finance App Load Error:", e);
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
await fetch('/api/finance', { method: 'POST', body: JSON.stringify(this.data), headers: {'Content-Type': 'application/json'} });
|
||||
},
|
||||
renderCharts() {
|
||||
const resolveColor = (cssVar, fallback) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.color = fallback;
|
||||
div.style.color = `var(${cssVar})`;
|
||||
div.style.display = 'none';
|
||||
document.body.appendChild(div);
|
||||
const color = getComputedStyle(div).color;
|
||||
document.body.removeChild(div);
|
||||
return color;
|
||||
};
|
||||
|
||||
const cPos = resolveColor('--color-positive', 'hsl(150, 60%, 50%)');
|
||||
const cNeg = resolveColor('--color-negative', 'hsl(0, 70%, 65%)');
|
||||
const cPri = resolveColor('--color-primary', 'hsl(12, 70%, 60%)');
|
||||
const cText = resolveColor('--color-text-subdue', 'rgba(255,255,255,0.6)');
|
||||
|
||||
const resolveGridColor = () => {
|
||||
const div = document.createElement('div');
|
||||
div.style.borderColor = 'rgba(255,255,255,0.05)';
|
||||
div.style.borderColor = 'var(--color-widget-content-border)';
|
||||
div.style.display = 'none';
|
||||
document.body.appendChild(div);
|
||||
const color = getComputedStyle(div).borderColor;
|
||||
document.body.removeChild(div);
|
||||
return color;
|
||||
};
|
||||
const cGrid = resolveGridColor();
|
||||
|
||||
const addAlpha = (rgbStr, alpha) => {
|
||||
let m = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (m) {
|
||||
return `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${alpha})`;
|
||||
}
|
||||
return rgbStr;
|
||||
};
|
||||
|
||||
const cPosAlpha = addAlpha(cPos, 0.6);
|
||||
const cNegAlpha = addAlpha(cNeg, 0.6);
|
||||
const cPriAlpha = addAlpha(cPri, 0.2);
|
||||
|
||||
Chart.defaults.color = cText;
|
||||
Chart.defaults.font.family = 'Outfit, -apple-system, sans-serif';
|
||||
|
||||
const c1 = document.getElementById('incomeExpenseChart');
|
||||
if(c1) {
|
||||
if(this.charts.incomeExpense) this.charts.incomeExpense.destroy();
|
||||
this.charts.incomeExpense = new Chart(c1.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: this.data.monthly.labels,
|
||||
datasets: [
|
||||
{ label: 'Income', data: this.data.monthly.income, backgroundColor: cPosAlpha, borderColor: cPos, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 },
|
||||
{ label: 'Expenses', data: this.data.monthly.expenses, backgroundColor: cNegAlpha, borderColor: cNeg, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
layout: { padding: 10 },
|
||||
scales: {
|
||||
y: { display: false, beginAtZero: true },
|
||||
x: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const c2 = document.getElementById('spendChart');
|
||||
if(c2) {
|
||||
if(this.charts.spend) this.charts.spend.destroy();
|
||||
this.charts.spend = new Chart(c2.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: this.data.monthly.labels,
|
||||
datasets: [{
|
||||
label: 'Spend',
|
||||
data: this.data.monthly.spend,
|
||||
borderColor: cPri,
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
layout: { padding: 10 },
|
||||
scales: {
|
||||
y: { display: false, beginAtZero: true },
|
||||
x: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
renderTable() {
|
||||
const t = document.getElementById('finance-tbody');
|
||||
if(!t) return;
|
||||
t.innerHTML = '';
|
||||
if(!this.data.accounts || this.data.accounts.length === 0) {
|
||||
t.innerHTML = `<tr><td colspan='4' style='text-align:center; padding: 2rem;' class='color-subdue'>No accounts.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this.data.accounts.forEach((acc, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid var(--color-widget-content-border)';
|
||||
const inStyle = `width: 100%; background: transparent; border: none; color: var(--color-text-highlight); padding: 1rem; font-family: inherit; font-size: 1.15rem; outline: none; transition: background 0.2s;`;
|
||||
|
||||
const td1 = document.createElement('td');
|
||||
td1.style.padding = '0';
|
||||
td1.innerHTML = `<input style="${inStyle}" value="${acc.name}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="financeApp.update(${i},'name',this.value)">`;
|
||||
|
||||
const td2 = document.createElement('td');
|
||||
td2.style.padding = '0';
|
||||
let amountCol = acc.balance < 0 ? 'color-negative' : 'color-positive';
|
||||
td2.innerHTML = `<input type="number" step="0.01" style="${inStyle}" class="${amountCol}" value="${acc.balance}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="financeApp.update(${i},'balance',parseFloat(this.value))">`;
|
||||
|
||||
const td3 = document.createElement('td');
|
||||
td3.style.padding = '0';
|
||||
td3.innerHTML = `<input style="${inStyle}" value="${acc.currency}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="financeApp.update(${i},'currency',this.value)">`;
|
||||
|
||||
const td4 = document.createElement('td');
|
||||
td4.style.textAlign = 'right';
|
||||
td4.style.padding = '1rem';
|
||||
td4.innerHTML = `<button onclick="financeApp.delete(${i})" style="padding: 0.4rem 0.8rem; border-radius: 6px; border: none; background: rgba(255, 50, 50, 0.15); color: var(--color-negative); font-weight: 600; cursor: pointer;">Delete</button>`;
|
||||
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tr.appendChild(td3);
|
||||
tr.appendChild(td4);
|
||||
t.appendChild(tr);
|
||||
});
|
||||
},
|
||||
update(i, key, val) { this.data.accounts[i][key] = val; this.renderTable(); this.save(); },
|
||||
addAccount() { this.data.accounts.push({name:'New Account',balance:0,currency:'USD'}); this.renderTable(); this.save(); },
|
||||
delete(i) { this.data.accounts.splice(i, 1); this.renderTable(); this.save(); }
|
||||
};
|
||||
window.financeApp.load();
|
||||
};
|
||||
|
||||
if (!window.financeAppLoaded) {
|
||||
window.financeAppLoaded = true;
|
||||
if (!window.Chart) {
|
||||
let script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
|
||||
script.onload = () => window.initFinanceApp();
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
window.initFinanceApp();
|
||||
}
|
||||
}
|
||||
14
finance.json
Normal file
14
finance.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"accounts": [
|
||||
{"name": "Checking", "balance": 4250.00, "currency": "USD"},
|
||||
{"name": "Savings", "balance": 12400.00, "currency": "USD"},
|
||||
{"name": "Credit Card", "balance": -850.50, "currency": "USD"},
|
||||
{"name": "Investments", "balance": 34800.00, "currency": "USD"}
|
||||
],
|
||||
"monthly": {
|
||||
"labels": ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"],
|
||||
"income": [4000, 4000, 5200, 4000, 4000, 4100],
|
||||
"expenses": [2100, 2400, 3100, 1800, 2000, 2200],
|
||||
"spend": [800, 1100, 1600, 600, 700, 900]
|
||||
}
|
||||
}
|
||||
47
internal/glance/finance.go
Normal file
47
internal/glance/finance.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func handleFinanceGet(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := os.Open("finance.json")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if os.IsNotExist(err) {
|
||||
w.Write([]byte(`{"accounts":[],"monthly":{"labels":[],"income":[],"expenses":[],"spend":[]}}`))
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.Copy(w, f)
|
||||
}
|
||||
|
||||
func handleFinancePost(w http.ResponseWriter, r *http.Request) {
|
||||
var data interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Create("finance.json")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := json.NewEncoder(f).Encode(data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -450,6 +450,9 @@ func (a *application) server() (func() error, func() error) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/finance", handleFinanceGet)
|
||||
mux.HandleFunc("POST /api/finance", handleFinancePost)
|
||||
|
||||
if a.RequiresAuth {
|
||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
font-size: 10px;
|
||||
|
||||
--scheme: ;
|
||||
--bgh: 240;
|
||||
--bgs: 8%;
|
||||
--bgl: 9%;
|
||||
--bgh: 220;
|
||||
--bgs: 12%;
|
||||
--bgl: 10%;
|
||||
--bghs: var(--bgh), var(--bgs);
|
||||
--cm: 1;
|
||||
--tsm: 1;
|
||||
@@ -25,9 +25,12 @@
|
||||
--border-radius: 5px;
|
||||
--mobile-navigation-height: 50px;
|
||||
|
||||
--color-primary: hsl(43, 50%, 70%);
|
||||
--color-positive: var(--color-primary);
|
||||
--color-negative: hsl(0, 70%, 70%);
|
||||
--primary-h: 12;
|
||||
--primary-s: 70%;
|
||||
--primary-l: 60%;
|
||||
--color-primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l));
|
||||
--color-positive: hsl(150, 60%, 50%);
|
||||
--color-negative: hsl(0, 70%, 65%);
|
||||
--color-background: hsl(var(--bghs), var(--bgl));
|
||||
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
|
||||
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
||||
|
||||
@@ -43,6 +43,11 @@ button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
color: inherit;
|
||||
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -110,6 +115,13 @@ html, body, .body-content {
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h3, h4, h5 {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -124,7 +136,7 @@ ul {
|
||||
|
||||
body {
|
||||
font-size: 1.3rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
font-variant-ligatures: none;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-base);
|
||||
@@ -132,6 +144,18 @@ body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background: radial-gradient(circle at 10% 0%, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.07), transparent 50%),
|
||||
radial-gradient(circle at 90% 100%, hsla(var(--primary-h), var(--primary-s), calc(var(--primary-l) + 10%), 0.06), transparent 50%);
|
||||
}
|
||||
|
||||
.page-column-small {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
@@ -301,11 +325,15 @@ kbd:active {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .3s, border-color .3s;
|
||||
transition: color .3s, border-color .3s, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
font-size: var(--font-size-h3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.nav-item:not(.nav-item-current):hover {
|
||||
border-bottom-color: var(--color-text-subdue);
|
||||
color: var(--color-text-highlight);
|
||||
|
||||
@@ -59,19 +59,29 @@
|
||||
}
|
||||
|
||||
.widget-content:not(.widget-content-frameless), .widget-content-frame {
|
||||
background: var(--color-widget-background);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--color-widget-content-border);
|
||||
box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%));
|
||||
background: linear-gradient(180deg, hsl(var(--color-widget-background-hsl-values)) 0%, hsla(var(--bghs), calc(var(--bgl) - 2%), 0.95) 100%);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: calc(var(--border-radius) * 1.5);
|
||||
border: none;
|
||||
box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.05) inset, 0 0 0 1px rgba(255, 255, 255, 0.02) inset, 0 8px 16px -4px hsla(var(--bghs), calc(var(--bgl) - 5%), 0.7);
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.widget-content:not(.widget-content-frameless):hover, .widget-content-frame:hover {
|
||||
box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.08) inset, 0 0 0 1px rgba(255, 255, 255, 0.04) inset, 0 12px 24px -8px hsla(var(--bghs), calc(var(--bgl) - 5%), 0.9), 0 0 40px -10px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.12);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
|
||||
font-size: var(--font-size-h4);
|
||||
margin-bottom: 0.9rem;
|
||||
margin-bottom: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 1.15rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.widget-beta-icon {
|
||||
@@ -91,3 +101,7 @@
|
||||
.widget + .widget {
|
||||
margin-top: var(--widget-gap);
|
||||
}
|
||||
|
||||
.widget-content:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
||||
<head>
|
||||
{{ block "document-head-before" . }}{{ end }}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
||||
const pageData = {
|
||||
|
||||
Reference in New Issue
Block a user