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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user