Add full-stack BTC portfolio web app
Multi-user FastAPI + React app with JWT auth, SQLite storage, and CoinGecko price integration. Dockerized with docker-compose. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const styles = {
|
||||
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
|
||||
input: { flex: 1, minWidth: '140px', padding: '0.65rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
|
||||
button: { padding: '0.65rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
|
||||
error: { color: '#ff6b6b', marginTop: '0.5rem', fontSize: '0.9rem' },
|
||||
};
|
||||
|
||||
export default function AddPurchase({ onAdded }) {
|
||||
const [amountEur, setAmountEur] = useState('');
|
||||
const [priceEur, setPriceEur] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const token = localStorage.getItem('token');
|
||||
try {
|
||||
const res = await fetch(`${API}/purchases`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount_eur: parseFloat(amountEur),
|
||||
price_eur: parseFloat(priceEur),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError('Failed to add purchase');
|
||||
return;
|
||||
}
|
||||
setAmountEur('');
|
||||
setPriceEur('');
|
||||
onAdded();
|
||||
} catch {
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.title}>Add Purchase</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={styles.row}>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="Amount (EUR)"
|
||||
value={amountEur}
|
||||
onChange={e => setAmountEur(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="BTC Price (EUR)"
|
||||
value={priceEur}
|
||||
onChange={e => setPriceEur(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button style={styles.button} type="submit">Add</button>
|
||||
</div>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler);
|
||||
|
||||
const styles = {
|
||||
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||
};
|
||||
|
||||
export default function PortfolioChart({ purchases, stats }) {
|
||||
const chartRef = useRef(null);
|
||||
|
||||
if (!purchases || purchases.length === 0) return null;
|
||||
|
||||
// Build cumulative data points sorted by date
|
||||
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
|
||||
let cumInvested = 0;
|
||||
let cumBtc = 0;
|
||||
const labels = [];
|
||||
const portfolioValues = [];
|
||||
const investedValues = [];
|
||||
|
||||
sorted.forEach((p, i) => {
|
||||
cumInvested += p.amount_eur;
|
||||
cumBtc += p.amount_eur / p.price_eur;
|
||||
const currentVal = cumBtc * (stats?.current_price || p.price_eur);
|
||||
labels.push(new Date(p.created_at).toLocaleDateString());
|
||||
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
|
||||
investedValues.push(parseFloat(cumInvested.toFixed(2)));
|
||||
});
|
||||
|
||||
const currentPrice = stats?.current_price || 0;
|
||||
const breakEvenLine = labels.map(() => stats?.average_price || 0);
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Portfolio Value (€)',
|
||||
data: portfolioValues,
|
||||
borderColor: '#f7931a',
|
||||
backgroundColor: 'rgba(247,147,26,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Total Invested (€)',
|
||||
data: investedValues,
|
||||
borderColor: '#4fc3f7',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [6, 3],
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#aaa' } },
|
||||
tooltip: { mode: 'index', intersect: false },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#666' }, grid: { color: '#2a2a2a' } },
|
||||
y: { ticks: { color: '#666' }, grid: { color: '#2a2a2a' } },
|
||||
},
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
const url = chart.toBase64Image();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'btc-portfolio.png';
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.title}>Portfolio Chart</div>
|
||||
<Line ref={chartRef} data={data} options={options} />
|
||||
<br />
|
||||
<button style={styles.saveBtn} onClick={handleSave}>Save as PNG</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const styles = {
|
||||
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||
table: { width: '100%', borderCollapse: 'collapse' },
|
||||
th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' },
|
||||
td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' },
|
||||
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||
empty: { color: '#555', textAlign: 'center', padding: '1rem' },
|
||||
};
|
||||
|
||||
export default function PurchaseList({ purchases, onDeleted }) {
|
||||
const handleDelete = async (id) => {
|
||||
const token = localStorage.getItem('token');
|
||||
await fetch(`${API}/purchases/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
onDeleted();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.title}>Purchases</div>
|
||||
{purchases.length === 0 ? (
|
||||
<div style={styles.empty}>No purchases yet.</div>
|
||||
) : (
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Date</th>
|
||||
<th style={styles.th}>Amount (€)</th>
|
||||
<th style={styles.th}>Price (€/BTC)</th>
|
||||
<th style={styles.th}>BTC</th>
|
||||
<th style={styles.th}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchases.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||
<td style={styles.td}>€{p.amount_eur.toLocaleString()}</td>
|
||||
<td style={styles.td}>€{p.price_eur.toLocaleString()}</td>
|
||||
<td style={styles.td}>{(p.amount_eur / p.price_eur).toFixed(6)}</td>
|
||||
<td style={styles.td}>
|
||||
<button style={styles.deleteBtn} onClick={() => handleDelete(p.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user