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:
Jonathan
2026-03-23 22:15:40 +01:00
parent 84679639ef
commit 3907414742
27 changed files with 859 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
ARG REACT_APP_API_URL=http://localhost:8000
ENV REACT_APP_API_URL=$REACT_APP_API_URL
RUN npm run build
FROM node:18-alpine
RUN npm install -g serve
WORKDIR /app
COPY --from=build /app/build ./build
EXPOSE 3000
CMD ["serve", "-s", "build", "-l", "3000"]
+21
View File
@@ -0,0 +1,21 @@
{
"name": "btc-portfolio-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"react-scripts": "5.0.1",
"chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build"
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
}
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BTC Portfolio</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f0f; color: #e0e0e0; }
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
function PrivateRoute({ children }) {
return localStorage.getItem('token') ? children : <Navigate to="/login" />;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
);
}
@@ -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>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
@@ -0,0 +1,99 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList';
import PortfolioChart from '../components/PortfolioChart';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
app: { maxWidth: '900px', margin: '0 auto', padding: '1.5rem' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem', marginBottom: '1.5rem' },
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
statValue: { fontSize: '1.2rem', fontWeight: 700 },
positive: { color: '#6bff8e' },
negative: { color: '#ff6b6b' },
neutral: { color: '#f7931a' },
};
function StatCard({ label, value, highlight }) {
return (
<div style={styles.statCard}>
<div style={styles.statLabel}>{label}</div>
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
</div>
);
}
export default function Dashboard() {
const [stats, setStats] = useState(null);
const [purchases, setPurchases] = useState([]);
const navigate = useNavigate();
const authHeaders = () => ({
Authorization: `Bearer ${localStorage.getItem('token')}`,
});
const fetchData = useCallback(async () => {
try {
const [statsRes, purchasesRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }),
]);
if (statsRes.status === 401) {
localStorage.removeItem('token');
navigate('/login');
return;
}
setStats(await statsRes.json());
setPurchases(await purchasesRes.json());
} catch {
// silently fail — network may be unavailable
}
}, [navigate]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleLogout = () => {
localStorage.removeItem('token');
navigate('/login');
};
const plHighlight = stats
? stats.profit_loss >= 0 ? 'positive' : 'negative'
: 'neutral';
return (
<div style={styles.app}>
<div style={styles.header}>
<div style={styles.logo}> BTC Portfolio</div>
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
</div>
{stats && (
<div style={styles.statsGrid}>
<StatCard label="Total Invested" value={`${stats.total_invested.toLocaleString()}`} />
<StatCard label="Total BTC" value={`${stats.total_btc}`} highlight="neutral" />
<StatCard label="Avg Buy Price" value={`${stats.average_price.toLocaleString()}`} />
<StatCard label="Current BTC Price" value={`${stats.current_price.toLocaleString()}`} />
<StatCard label="Portfolio Value" value={`${stats.portfolio_value.toLocaleString()}`} />
<StatCard
label="Profit / Loss"
value={`${stats.profit_loss >= 0 ? '+' : ''}${stats.profit_loss.toLocaleString()}`}
highlight={plHighlight}
/>
</div>
)}
<PortfolioChart purchases={purchases} stats={stats} />
<AddPurchase onAdded={fetchData} />
<PurchaseList purchases={purchases} onDeleted={fetchData} />
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
container: { display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' },
card: { background: '#1a1a1a', padding: '2rem', borderRadius: '12px', width: '360px', border: '1px solid #333' },
title: { fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', color: '#f7931a' },
input: { width: '100%', padding: '0.75rem', marginBottom: '1rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
button: { width: '100%', padding: '0.75rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, fontSize: '1rem', cursor: 'pointer' },
error: { color: '#ff6b6b', marginBottom: '1rem', fontSize: '0.9rem' },
link: { display: 'block', textAlign: 'center', marginTop: '1rem', color: '#888', textDecoration: 'none' },
};
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const res = await fetch(`${API}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
setError(data.detail || 'Login failed');
return;
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
navigate('/');
} catch {
setError('Network error');
}
};
return (
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.title}> BTC Portfolio</div>
<form onSubmit={handleSubmit}>
{error && <div style={styles.error}>{error}</div>}
<input style={styles.input} placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input style={styles.input} type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
<button style={styles.button} type="submit">Login</button>
</form>
<Link style={styles.link} to="/register">No account? Register</Link>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
container: { display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' },
card: { background: '#1a1a1a', padding: '2rem', borderRadius: '12px', width: '360px', border: '1px solid #333' },
title: { fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', color: '#f7931a' },
input: { width: '100%', padding: '0.75rem', marginBottom: '1rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
button: { width: '100%', padding: '0.75rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, fontSize: '1rem', cursor: 'pointer' },
error: { color: '#ff6b6b', marginBottom: '1rem', fontSize: '0.9rem' },
success: { color: '#6bff8e', marginBottom: '1rem', fontSize: '0.9rem' },
link: { display: 'block', textAlign: 'center', marginTop: '1rem', color: '#888', textDecoration: 'none' },
};
export default function Register() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
try {
const res = await fetch(`${API}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
setError(data.detail || 'Registration failed');
return;
}
setSuccess('Account created! Redirecting...');
setTimeout(() => navigate('/login'), 1500);
} catch {
setError('Network error');
}
};
return (
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.title}> Create Account</div>
<form onSubmit={handleSubmit}>
{error && <div style={styles.error}>{error}</div>}
{success && <div style={styles.success}>{success}</div>}
<input style={styles.input} placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input style={styles.input} type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
<button style={styles.button} type="submit">Register</button>
</form>
<Link style={styles.link} to="/login">Already have an account? Login</Link>
</div>
</div>
);
}