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,12 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
SECRET_KEY = "change-me-in-production-use-a-long-random-string"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode["exp"] = expire
|
||||||
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./btc_portfolio.db")
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .auth import decode_token
|
||||||
|
from .database import get_db
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> models.User:
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
user = db.query(models.User).filter(models.User.username == username).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .database import engine, Base
|
||||||
|
from .routes import users, purchases, stats
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="BTC Portfolio API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(purchases.router)
|
||||||
|
app.include_router(stats.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return {"message": "BTC Portfolio API"}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
password = Column(String, nullable=False)
|
||||||
|
|
||||||
|
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
|
||||||
|
|
||||||
|
|
||||||
|
class Purchase(Base):
|
||||||
|
__tablename__ = "purchases"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
amount_eur = Column(Float, nullable=False)
|
||||||
|
price_eur = Column(Float, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
|
owner = relationship("User", back_populates="purchases")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseCreate(BaseModel):
|
||||||
|
amount_eur: float
|
||||||
|
price_eur: float
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
amount_eur: float
|
||||||
|
price_eur: float
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/purchases", response_model=List[PurchaseOut])
|
||||||
|
def list_purchases(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purchases", response_model=PurchaseOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def add_purchase(
|
||||||
|
purchase_in: PurchaseCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
purchase = models.Purchase(
|
||||||
|
amount_eur=purchase_in.amount_eur,
|
||||||
|
price_eur=purchase_in.price_eur,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(purchase)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(purchase)
|
||||||
|
return purchase
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/purchases/{purchase_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_purchase(
|
||||||
|
purchase_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
purchase = db.query(models.Purchase).filter(
|
||||||
|
models.Purchase.id == purchase_id,
|
||||||
|
models.Purchase.user_id == current_user.id,
|
||||||
|
).first()
|
||||||
|
if not purchase:
|
||||||
|
raise HTTPException(status_code=404, detail="Purchase not found")
|
||||||
|
db.delete(purchase)
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
from ..services.btc import get_btc_price_eur
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
|
||||||
|
|
||||||
|
total_invested = sum(p.amount_eur for p in purchases)
|
||||||
|
total_btc = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
|
||||||
|
average_price = total_invested / total_btc if total_btc > 0 else 0.0
|
||||||
|
current_price = get_btc_price_eur()
|
||||||
|
portfolio_value = total_btc * current_price
|
||||||
|
profit_loss = portfolio_value - total_invested
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_invested": round(total_invested, 2),
|
||||||
|
"total_btc": round(total_btc, 8),
|
||||||
|
"average_price": round(average_price, 2),
|
||||||
|
"current_price": round(current_price, 2),
|
||||||
|
"portfolio_value": round(portfolio_value, 2),
|
||||||
|
"profit_loss": round(profit_loss, 2),
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
from ..auth import hash_password, verify_password, create_access_token
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||||
|
def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
existing = db.query(models.User).filter(models.User.username == user_in.username).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already taken")
|
||||||
|
user = models.User(
|
||||||
|
username=user_in.username,
|
||||||
|
password=hash_password(user_in.password),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "User created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(user_in: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(models.User).filter(models.User.username == user_in.username).first()
|
||||||
|
if not user or not verify_password(user_in.password, user.password):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
token = create_access_token({"sub": user.username})
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def get_btc_price_eur() -> float:
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.coingecko.com/api/v3/simple/price",
|
||||||
|
params={"ids": "bitcoin", "vs_currencies": "eur"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return float(resp.json()["bitcoin"]["eur"])
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
sqlalchemy
|
||||||
|
passlib[bcrypt]
|
||||||
|
bcrypt==3.2.2
|
||||||
|
python-jose[cryptography]
|
||||||
|
requests
|
||||||
|
python-multipart
|
||||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- REACT_APP_API_URL=http://localhost:8000
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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