Compare commits
12 Commits
c88fd526e5
...
additions
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab88b3758 | ||
|
|
492e0ec47c | ||
|
|
c8b7322f13 | ||
|
|
6c5b7a3f4c | ||
|
|
36d5ae023f | ||
|
|
478c08f6a7 | ||
|
|
cae90d16ba | ||
|
|
fbc07bd142 | ||
|
|
4a4d3e1755 | ||
|
|
f243a4938f | ||
|
|
9416de1497 | ||
|
|
283a5fcfd0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
/playground
|
/playground
|
||||||
/.idea
|
/.idea
|
||||||
/glance*.yml
|
/glance*.yml
|
||||||
|
glance-bin
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,7 +1,18 @@
|
|||||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
<p align="center"><img src="docs/logo.png"></p>
|
||||||
<h1 align="center">Glance</h1>
|
<h1 align="center">Glance</h1>
|
||||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md#configuring-glance">Configuration</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> • <a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
|
<p align="center">
|
||||||
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a></p>
|
<a href="#installation">Install</a> •
|
||||||
|
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
|
||||||
|
<a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> •
|
||||||
|
<a href="https://github.com/sponsors/glanceapp">Sponsor</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/glanceapp/community-widgets">Community widgets</a> •
|
||||||
|
<a href="docs/preconfigured-pages.md">Preconfigured pages</a> •
|
||||||
|
<a href="docs/themes.md">Themes</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">A lightweight, highly customizable dashboard that displays<br> your feeds in a beautiful, streamlined interface</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -149,14 +149,14 @@ pages:
|
|||||||
columns:
|
columns:
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
$include: rss.yml
|
- $include: rss.yml
|
||||||
- name: News
|
- name: News
|
||||||
columns:
|
columns:
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
$include: rss.yml
|
- $include: rss.yml
|
||||||
- type: reddit
|
- type: reddit
|
||||||
subreddit: news
|
subreddit: news
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pages:
|
|||||||
channels:
|
channels:
|
||||||
- theprimeagen
|
- theprimeagen
|
||||||
- j_blow
|
- j_blow
|
||||||
- piratesoftware
|
- giantwaffle
|
||||||
- cohhcarnage
|
- cohhcarnage
|
||||||
- christitustech
|
- christitustech
|
||||||
- EJ_SA
|
- EJ_SA
|
||||||
|
|||||||
BIN
docs/images/themes/neon-pink.png
Normal file
BIN
docs/images/themes/neon-pink.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/images/themes/shades-of-purple.png
Normal file
BIN
docs/images/themes/shades-of-purple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
BIN
docs/logo.png
BIN
docs/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -86,92 +86,92 @@ Pull requests with your page configurations are welcome!
|
|||||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Markets
|
- name: Markets
|
||||||
columns:
|
columns:
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
- type: markets
|
- type: markets
|
||||||
title: Indices
|
title: Indices
|
||||||
markets:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: SPY
|
||||||
name: S&P 500
|
name: S&P 500
|
||||||
- symbol: DX-Y.NYB
|
- symbol: DX-Y.NYB
|
||||||
name: Dollar Index
|
name: Dollar Index
|
||||||
|
|
||||||
- type: markets
|
- type: markets
|
||||||
title: Crypto
|
title: Crypto
|
||||||
markets:
|
markets:
|
||||||
- symbol: BTC-USD
|
- symbol: BTC-USD
|
||||||
name: Bitcoin
|
name: Bitcoin
|
||||||
- symbol: ETH-USD
|
- symbol: ETH-USD
|
||||||
name: Ethereum
|
name: Ethereum
|
||||||
|
|
||||||
- type: markets
|
- type: markets
|
||||||
title: Stocks
|
title: Stocks
|
||||||
sort-by: absolute-change
|
sort-by: absolute-change
|
||||||
markets:
|
markets:
|
||||||
- symbol: NVDA
|
- symbol: NVDA
|
||||||
name: NVIDIA
|
name: NVIDIA
|
||||||
- symbol: AAPL
|
- symbol: AAPL
|
||||||
name: Apple
|
name: Apple
|
||||||
- symbol: MSFT
|
- symbol: MSFT
|
||||||
name: Microsoft
|
name: Microsoft
|
||||||
- symbol: GOOGL
|
- symbol: GOOGL
|
||||||
name: Google
|
name: Google
|
||||||
- symbol: AMD
|
- symbol: AMD
|
||||||
name: AMD
|
name: AMD
|
||||||
- symbol: RDDT
|
- symbol: RDDT
|
||||||
name: Reddit
|
name: Reddit
|
||||||
- symbol: AMZN
|
- symbol: AMZN
|
||||||
name: Amazon
|
name: Amazon
|
||||||
- symbol: TSLA
|
- symbol: TSLA
|
||||||
name: Tesla
|
name: Tesla
|
||||||
- symbol: INTC
|
- symbol: INTC
|
||||||
name: Intel
|
name: Intel
|
||||||
- symbol: META
|
- symbol: META
|
||||||
name: Meta
|
name: Meta
|
||||||
|
|
||||||
- size: full
|
- size: full
|
||||||
widgets:
|
widgets:
|
||||||
- type: rss
|
- type: rss
|
||||||
title: News
|
title: News
|
||||||
style: horizontal-cards
|
style: horizontal-cards
|
||||||
feeds:
|
feeds:
|
||||||
- url: https://feeds.bloomberg.com/markets/news.rss
|
- url: https://feeds.bloomberg.com/markets/news.rss
|
||||||
title: Bloomberg
|
title: Bloomberg
|
||||||
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
|
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
|
||||||
title: Fox Business
|
title: Fox Business
|
||||||
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
|
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
|
||||||
title: Fox Business
|
title: Fox Business
|
||||||
|
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
- type: reddit
|
- type: reddit
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
subreddit: technology
|
subreddit: technology
|
||||||
- type: reddit
|
- type: reddit
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
subreddit: wallstreetbets
|
subreddit: wallstreetbets
|
||||||
|
|
||||||
- type: videos
|
- type: videos
|
||||||
style: grid-cards
|
style: grid-cards
|
||||||
collapse-after-rows: 3
|
collapse-after-rows: 3
|
||||||
channels:
|
channels:
|
||||||
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
|
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
|
||||||
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
|
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
|
||||||
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
|
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
|
||||||
|
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
- type: rss
|
- type: rss
|
||||||
title: News
|
title: News
|
||||||
limit: 30
|
limit: 30
|
||||||
collapse-after: 13
|
collapse-after: 13
|
||||||
feeds:
|
feeds:
|
||||||
- url: https://www.ft.com/technology?format=rss
|
- url: https://www.ft.com/technology?format=rss
|
||||||
title: Financial Times
|
title: Financial Times
|
||||||
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
||||||
title: Wall Street Journal
|
title: Wall Street Journal
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,28 @@ theme:
|
|||||||
negative-color: 0 100 67
|
negative-color: 0 100 67
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Shades of Purple
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 243 33 25
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 50 100 49
|
||||||
|
positive-color: 98 82 71
|
||||||
|
negative-color: 12 77 52
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neon Pink
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 240 27 11
|
||||||
|
contrast-multiplier: 1.5
|
||||||
|
primary-color: 321 100 71
|
||||||
|
positive-color: 165 78 51
|
||||||
|
negative-color: 360 100 71
|
||||||
|
```
|
||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
### Catppuccin Latte
|
### Catppuccin Latte
|
||||||
|
|||||||
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,10 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/finance", handleFinanceGet)
|
||||||
|
mux.HandleFunc("POST /api/finance", handleFinancePost)
|
||||||
|
mux.HandleFunc("POST /api/ai-chat", handleAIChatProxy)
|
||||||
|
|
||||||
if a.RequiresAuth {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
||||||
--scheme: ;
|
--scheme: ;
|
||||||
--bgh: 240;
|
--bgh: 220;
|
||||||
--bgs: 8%;
|
--bgs: 12%;
|
||||||
--bgl: 9%;
|
--bgl: 10%;
|
||||||
--bghs: var(--bgh), var(--bgs);
|
--bghs: var(--bgh), var(--bgs);
|
||||||
--cm: 1;
|
--cm: 1;
|
||||||
--tsm: 1;
|
--tsm: 1;
|
||||||
@@ -25,9 +25,12 @@
|
|||||||
--border-radius: 5px;
|
--border-radius: 5px;
|
||||||
--mobile-navigation-height: 50px;
|
--mobile-navigation-height: 50px;
|
||||||
|
|
||||||
--color-primary: hsl(43, 50%, 70%);
|
--primary-h: 12;
|
||||||
--color-positive: var(--color-primary);
|
--primary-s: 70%;
|
||||||
--color-negative: hsl(0, 70%, 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-background: hsl(var(--bghs), var(--bgl));
|
||||||
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
|
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
|
||||||
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
color: inherit;
|
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 {
|
::selection {
|
||||||
@@ -110,6 +115,13 @@ html, body, .body-content {
|
|||||||
|
|
||||||
h1, h2, h3, h4, h5 {
|
h1, h2, h3, h4, h5 {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3, h4, h5 {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -124,7 +136,7 @@ ul {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-size: 1.3rem;
|
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;
|
font-variant-ligatures: none;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--color-text-base);
|
color: var(--color-text-base);
|
||||||
@@ -132,6 +144,18 @@ body {
|
|||||||
overflow-y: scroll;
|
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 {
|
.page-column-small {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -301,11 +325,15 @@ kbd:active {
|
|||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-bottom: 2px solid transparent;
|
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);
|
font-size: var(--font-size-h3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-item:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item:not(.nav-item-current):hover {
|
.nav-item:not(.nav-item-current):hover {
|
||||||
border-bottom-color: var(--color-text-subdue);
|
border-bottom-color: var(--color-text-subdue);
|
||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
|
|||||||
117
internal/glance/static/css/widget-ai-chat.css
Normal file
117
internal/glance/static/css/widget-ai-chat.css
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
.ai-chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.1) transparent;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-messages:empty::before {
|
||||||
|
content: 'Start a conversation';
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
display: block;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-msg {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-width: 88%;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-msg-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-background);
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-msg-assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-msg-loading::after {
|
||||||
|
content: '▋';
|
||||||
|
animation: ai-chat-blink 0.8s step-end infinite;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-chat-blink {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
min-height: 36px;
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-input::placeholder {
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-background);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat-send:not(:disabled):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
@import "widget-videos.css";
|
@import "widget-videos.css";
|
||||||
@import "widget-weather.css";
|
@import "widget-weather.css";
|
||||||
@import "widget-todo.css";
|
@import "widget-todo.css";
|
||||||
|
@import "widget-ai-chat.css";
|
||||||
|
|
||||||
@import "forum-posts.css";
|
@import "forum-posts.css";
|
||||||
|
|
||||||
@@ -59,19 +60,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.widget-content:not(.widget-content-frameless), .widget-content-frame {
|
.widget-content:not(.widget-content-frameless), .widget-content-frame {
|
||||||
background: var(--color-widget-background);
|
background: linear-gradient(180deg, hsl(var(--color-widget-background-hsl-values)) 0%, hsla(var(--bghs), calc(var(--bgl) - 2%), 0.95) 100%);
|
||||||
border-radius: var(--border-radius);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--color-widget-content-border);
|
border-radius: calc(var(--border-radius) * 1.5);
|
||||||
box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.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 {
|
.widget-header {
|
||||||
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
|
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
|
||||||
font-size: var(--font-size-h4);
|
margin-bottom: 1.2rem;
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-beta-icon {
|
.widget-beta-icon {
|
||||||
@@ -91,3 +102,7 @@
|
|||||||
.widget + .widget {
|
.widget + .widget {
|
||||||
margin-top: var(--widget-gap);
|
margin-top: var(--widget-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-content:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|||||||
97
internal/glance/static/js/ai-chat.js
Normal file
97
internal/glance/static/js/ai-chat.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
export default function setupAIChat(el) {
|
||||||
|
const model = el.dataset.model || 'llama3.2';
|
||||||
|
const ollamaURL = el.dataset.ollamaUrl || 'http://localhost:11434';
|
||||||
|
|
||||||
|
const messagesEl = el.querySelector('.ai-chat-messages');
|
||||||
|
const inputEl = el.querySelector('.ai-chat-input');
|
||||||
|
const sendBtn = el.querySelector('.ai-chat-send');
|
||||||
|
|
||||||
|
const history = [];
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function appendMessage(role, text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `ai-chat-msg ai-chat-msg-${role}`;
|
||||||
|
div.textContent = text;
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = inputEl.value.trim();
|
||||||
|
if (!text || busy) return;
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
inputEl.value = '';
|
||||||
|
inputEl.style.height = 'auto';
|
||||||
|
|
||||||
|
history.push({ role: 'user', content: text });
|
||||||
|
appendMessage('user', text);
|
||||||
|
|
||||||
|
const assistantEl = appendMessage('assistant', '');
|
||||||
|
assistantEl.classList.add('ai-chat-msg-loading');
|
||||||
|
|
||||||
|
let reply = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ollama_url: ollamaURL, model, messages: history }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
assistantEl.textContent = `Error: ${res.statusText}`;
|
||||||
|
assistantEl.classList.remove('ai-chat-msg-loading');
|
||||||
|
history.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const lines = decoder.decode(value, { stream: true }).split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(line);
|
||||||
|
if (chunk.message && chunk.message.content) {
|
||||||
|
reply += chunk.message.content;
|
||||||
|
assistantEl.textContent = reply;
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
assistantEl.textContent = 'Failed to reach Ollama.';
|
||||||
|
} finally {
|
||||||
|
assistantEl.classList.remove('ai-chat-msg-loading');
|
||||||
|
if (reply) history.push({ role: 'assistant', content: reply });
|
||||||
|
busy = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
|
||||||
|
inputEl.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-grow textarea
|
||||||
|
inputEl.addEventListener('input', () => {
|
||||||
|
inputEl.style.height = 'auto';
|
||||||
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
134
internal/glance/static/js/finance.js
Normal file
134
internal/glance/static/js/finance.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export default async function setupFinance(el) {
|
||||||
|
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 addAlpha = (rgb, a) => {
|
||||||
|
const m = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||||
|
return m ? `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${a})` : rgb;
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/finance');
|
||||||
|
data = await res.json();
|
||||||
|
if (!data.monthly || !data.monthly.labels) {
|
||||||
|
data.monthly = { labels: ['Jan'], income: [0], expenses: [0], spend: [0] };
|
||||||
|
}
|
||||||
|
if (!data.accounts) data.accounts = [];
|
||||||
|
renderCharts();
|
||||||
|
renderTable();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Finance widget error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await fetch('/api/finance', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts() {
|
||||||
|
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)');
|
||||||
|
|
||||||
|
Chart.defaults.color = cText;
|
||||||
|
Chart.defaults.font.family = 'Outfit, -apple-system, sans-serif';
|
||||||
|
|
||||||
|
const c1 = el.querySelector('#incomeExpenseChart');
|
||||||
|
if (c1) {
|
||||||
|
if (charts.incomeExpense) charts.incomeExpense.destroy();
|
||||||
|
charts.incomeExpense = new Chart(c1.getContext('2d'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.monthly.labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Income', data: data.monthly.income, backgroundColor: addAlpha(cPos, 0.6), borderColor: cPos, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 },
|
||||||
|
{ label: 'Expenses', data: data.monthly.expenses, backgroundColor: addAlpha(cNeg, 0.6), 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 = el.querySelector('#spendChart');
|
||||||
|
if (c2) {
|
||||||
|
if (charts.spend) charts.spend.destroy();
|
||||||
|
charts.spend = new Chart(c2.getContext('2d'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.monthly.labels,
|
||||||
|
datasets: [{ label: 'Spend', data: 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 } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = el.querySelector('#finance-tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (!data.accounts || data.accounts.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:2rem" class="color-subdue">No accounts.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inStyle = `width:100%;background:transparent;border:none;color:var(--color-text-highlight);padding:1rem;font-family:inherit;font-size:1.05rem;outline:none;transition:background 0.2s`;
|
||||||
|
data.accounts.forEach((acc, i) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.style.borderBottom = '1px solid var(--color-widget-content-border)';
|
||||||
|
|
||||||
|
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="this.closest('.widget').financeApp.update(${i},'name',this.value)">`;
|
||||||
|
|
||||||
|
const td2 = document.createElement('td'); td2.style.padding = '0';
|
||||||
|
td2.innerHTML = `<input type="number" step="0.01" style="${inStyle}" class="${acc.balance < 0 ? 'color-negative' : 'color-positive'}" value="${acc.balance}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="this.closest('.widget').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="this.closest('.widget').financeApp.update(${i},'currency',this.value)">`;
|
||||||
|
|
||||||
|
const td4 = document.createElement('td'); td4.style.textAlign = 'right'; td4.style.padding = '1rem';
|
||||||
|
td4.innerHTML = `<button onclick="this.closest('.widget').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);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
update(i, key, val) { data.accounts[i][key] = val; renderTable(); save(); },
|
||||||
|
addAccount() { data.accounts.push({ name: 'New Account', balance: 0, currency: 'USD' }); renderTable(); save(); },
|
||||||
|
delete(i) { data.accounts.splice(i, 1); renderTable(); save(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach app to widget element so inline event handlers can reach it
|
||||||
|
el.financeApp = app;
|
||||||
|
|
||||||
|
// Also expose the add-account button
|
||||||
|
const addBtn = el.querySelector('[data-finance-add]');
|
||||||
|
if (addBtn) addBtn.onclick = () => app.addAccount();
|
||||||
|
|
||||||
|
if (!window.Chart) {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://cdn.jsdelivr.net/npm/chart.js';
|
||||||
|
s.onload = () => load();
|
||||||
|
document.head.appendChild(s);
|
||||||
|
} else {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -653,6 +653,28 @@ async function setupTodos() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setupFinances() {
|
||||||
|
const elems = Array.from(document.getElementsByClassName("personal-finance"));
|
||||||
|
if (elems.length == 0) return;
|
||||||
|
|
||||||
|
const finance = await import('./finance.js');
|
||||||
|
|
||||||
|
for (let i = 0; i < elems.length; i++) {
|
||||||
|
finance.default(elems[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupAIChats() {
|
||||||
|
const elems = Array.from(document.getElementsByClassName("ai-chat"));
|
||||||
|
if (elems.length == 0) return;
|
||||||
|
|
||||||
|
const aiChat = await import('./ai-chat.js');
|
||||||
|
|
||||||
|
for (let i = 0; i < elems.length; i++) {
|
||||||
|
aiChat.default(elems[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupTruncatedElementTitles() {
|
function setupTruncatedElementTitles() {
|
||||||
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
||||||
|
|
||||||
@@ -757,6 +779,8 @@ async function setupPage() {
|
|||||||
setupClocks()
|
setupClocks()
|
||||||
await setupCalendars();
|
await setupCalendars();
|
||||||
await setupTodos();
|
await setupTodos();
|
||||||
|
await setupFinances();
|
||||||
|
await setupAIChats();
|
||||||
setupCarousels();
|
setupCarousels();
|
||||||
setupSearchBoxes();
|
setupSearchBoxes();
|
||||||
setupCollapsibleLists();
|
setupCollapsibleLists();
|
||||||
|
|||||||
15
internal/glance/templates/ai-chat.html
Normal file
15
internal/glance/templates/ai-chat.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="ai-chat" data-model="{{ .Model }}" data-ollama-url="{{ .OllamaURL }}">
|
||||||
|
<div class="ai-chat-messages"></div>
|
||||||
|
<div class="ai-chat-input-row">
|
||||||
|
<textarea class="ai-chat-input" placeholder="Ask anything..." rows="1"></textarea>
|
||||||
|
<button class="ai-chat-send" aria-label="Send">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
|
||||||
|
<path d="M3.105 2.289a.75.75 0 0 0-.826.95l1.414 4.925A1.5 1.5 0 0 0 5.135 9.25h6.115a.75.75 0 0 1 0 1.5H5.135a1.5 1.5 0 0 0-1.442 1.086l-1.414 4.926a.75.75 0 0 0 .826.95 28.896 28.896 0 0 0 15.293-7.154.75.75 0 0 0 0-1.115A28.897 28.897 0 0 0 3.105 2.289Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
||||||
<head>
|
<head>
|
||||||
{{ block "document-head-before" . }}{{ end }}
|
{{ 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>
|
<script>
|
||||||
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
||||||
const pageData = {
|
const pageData = {
|
||||||
|
|||||||
42
internal/glance/templates/personal-finance.html
Normal file
42
internal/glance/templates/personal-finance.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="personal-finance" style="display:flex;flex-direction:column;gap:1.5rem">
|
||||||
|
|
||||||
|
<div style="display:flex;gap:1.5rem;flex-wrap:wrap">
|
||||||
|
<div style="flex:1;min-width:280px">
|
||||||
|
<p class="size-h5 color-subdue uppercase" style="margin-bottom:0.5rem">Income vs Expense</p>
|
||||||
|
<div style="position:relative;height:200px">
|
||||||
|
<canvas id="incomeExpenseChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:280px">
|
||||||
|
<p class="size-h5 color-subdue uppercase" style="margin-bottom:0.5rem">Monthly Discretionary Spend</p>
|
||||||
|
<div style="position:relative;height:200px">
|
||||||
|
<canvas id="spendChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
|
||||||
|
<p class="size-h5 color-subdue uppercase">Account Balances</p>
|
||||||
|
<button data-finance-add style="padding:0.4rem 0.8rem;border-radius:6px;border:none;background:var(--color-primary);color:var(--color-background);font-weight:600;cursor:pointer;text-transform:uppercase;letter-spacing:0.1em;font-size:0.75rem">+ Add Account</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table style="width:100%;border-collapse:collapse;text-align:left">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:2px solid var(--color-widget-content-border)">
|
||||||
|
<th style="padding:0.6rem 1rem" class="size-h5 color-subdue uppercase">Account</th>
|
||||||
|
<th style="padding:0.6rem 1rem" class="size-h5 color-subdue uppercase">Balance</th>
|
||||||
|
<th style="padding:0.6rem 1rem" class="size-h5 color-subdue uppercase">Currency</th>
|
||||||
|
<th style="padding:0.6rem 1rem;text-align:right" class="size-h5 color-subdue uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="finance-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
77
internal/glance/widget-ai-chat.go
Normal file
77
internal/glance/widget-ai-chat.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var aiChatWidgetTemplate = mustParseTemplate("ai-chat.html", "widget-base.html")
|
||||||
|
|
||||||
|
type aiChatWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
OllamaURL string `yaml:"ollama-url"`
|
||||||
|
Model string `yaml:"model"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *aiChatWidget) initialize() error {
|
||||||
|
w.withTitle("AI Assistant").withError(nil)
|
||||||
|
if w.OllamaURL == "" {
|
||||||
|
w.OllamaURL = "http://localhost:11434"
|
||||||
|
}
|
||||||
|
if w.Model == "" {
|
||||||
|
w.Model = "llama3.2"
|
||||||
|
}
|
||||||
|
w.cachedHTML = w.renderTemplate(w, aiChatWidgetTemplate)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *aiChatWidget) Render() template.HTML {
|
||||||
|
return w.cachedHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatProxy(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
OllamaURL string `json:"ollama_url"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(resp, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.OllamaURL == "" {
|
||||||
|
body.OllamaURL = "http://localhost:11434"
|
||||||
|
}
|
||||||
|
|
||||||
|
ollamaReq := map[string]interface{}{
|
||||||
|
"model": body.Model,
|
||||||
|
"messages": body.Messages,
|
||||||
|
"stream": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ollamaBody, err := json.Marshal(ollamaReq)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(resp, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ollamaResp, err := http.Post(body.OllamaURL+"/api/chat", "application/json", bytes.NewReader(ollamaBody))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(resp, "could not reach Ollama: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ollamaResp.Body.Close()
|
||||||
|
|
||||||
|
resp.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
resp.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
io.Copy(resp, ollamaResp.Body)
|
||||||
|
}
|
||||||
20
internal/glance/widget-personal-finance.go
Normal file
20
internal/glance/widget-personal-finance.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
|
var personalFinanceWidgetTemplate = mustParseTemplate("personal-finance.html", "widget-base.html")
|
||||||
|
|
||||||
|
type personalFinanceWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *personalFinanceWidget) initialize() error {
|
||||||
|
widget.withTitle("Personal Finance").withError(nil)
|
||||||
|
widget.cachedHTML = widget.renderTemplate(widget, personalFinanceWidgetTemplate)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *personalFinanceWidget) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -146,9 +146,6 @@ func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate
|
|||||||
if strings.HasPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) {
|
if strings.HasPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix) {
|
||||||
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" +
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" +
|
||||||
strings.TrimPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix)
|
strings.TrimPrefix(channelOrPlaylistIDs[i], videosWidgetPlaylistPrefix)
|
||||||
} else if !includeShorts && strings.HasPrefix(channelOrPlaylistIDs[i], "UC") {
|
|
||||||
playlistId := strings.Replace(channelOrPlaylistIDs[i], "UC", "UULF", 1)
|
|
||||||
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
|
|
||||||
} else {
|
} else {
|
||||||
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelOrPlaylistIDs[i]
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelOrPlaylistIDs[i]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ func newWidget(widgetType string) (widget, error) {
|
|||||||
w = &serverStatsWidget{}
|
w = &serverStatsWidget{}
|
||||||
case "to-do":
|
case "to-do":
|
||||||
w = &todoWidget{}
|
w = &todoWidget{}
|
||||||
|
case "personal-finance":
|
||||||
|
w = &personalFinanceWidget{}
|
||||||
|
case "ai-chat":
|
||||||
|
w = &aiChatWidget{}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user