From 39074147426326e07455eb6196e44de8d4ab62ea Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 23 Mar 2026 22:15:40 +0100 Subject: [PATCH] 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 --- btc-portfolio/backend/Dockerfile | 12 +++ btc-portfolio/backend/app/__init__.py | 0 btc-portfolio/backend/app/auth.py | 28 +++++ btc-portfolio/backend/app/database.py | 22 ++++ btc-portfolio/backend/app/dependencies.py | 29 +++++ btc-portfolio/backend/app/main.py | 26 +++++ btc-portfolio/backend/app/models.py | 26 +++++ btc-portfolio/backend/app/routes/__init__.py | 0 btc-portfolio/backend/app/routes/purchases.py | 67 ++++++++++++ btc-portfolio/backend/app/routes/stats.py | 33 ++++++ btc-portfolio/backend/app/routes/users.py | 42 ++++++++ .../backend/app/services/__init__.py | 0 btc-portfolio/backend/app/services/btc.py | 14 +++ btc-portfolio/backend/requirements.txt | 8 ++ btc-portfolio/data/btc_portfolio.db | Bin 0 -> 24576 bytes btc-portfolio/docker-compose.yml | 20 ++++ btc-portfolio/frontend/Dockerfile | 16 +++ btc-portfolio/frontend/package.json | 21 ++++ btc-portfolio/frontend/public/index.html | 15 +++ btc-portfolio/frontend/src/App.js | 22 ++++ .../frontend/src/components/AddPurchase.js | 76 +++++++++++++ .../frontend/src/components/PortfolioChart.js | 100 ++++++++++++++++++ .../frontend/src/components/PurchaseList.js | 58 ++++++++++ btc-portfolio/frontend/src/index.js | 6 ++ btc-portfolio/frontend/src/pages/Dashboard.js | 99 +++++++++++++++++ btc-portfolio/frontend/src/pages/Login.js | 58 ++++++++++ btc-portfolio/frontend/src/pages/Register.js | 61 +++++++++++ 27 files changed, 859 insertions(+) create mode 100644 btc-portfolio/backend/Dockerfile create mode 100644 btc-portfolio/backend/app/__init__.py create mode 100644 btc-portfolio/backend/app/auth.py create mode 100644 btc-portfolio/backend/app/database.py create mode 100644 btc-portfolio/backend/app/dependencies.py create mode 100644 btc-portfolio/backend/app/main.py create mode 100644 btc-portfolio/backend/app/models.py create mode 100644 btc-portfolio/backend/app/routes/__init__.py create mode 100644 btc-portfolio/backend/app/routes/purchases.py create mode 100644 btc-portfolio/backend/app/routes/stats.py create mode 100644 btc-portfolio/backend/app/routes/users.py create mode 100644 btc-portfolio/backend/app/services/__init__.py create mode 100644 btc-portfolio/backend/app/services/btc.py create mode 100644 btc-portfolio/backend/requirements.txt create mode 100644 btc-portfolio/data/btc_portfolio.db create mode 100644 btc-portfolio/docker-compose.yml create mode 100644 btc-portfolio/frontend/Dockerfile create mode 100644 btc-portfolio/frontend/package.json create mode 100644 btc-portfolio/frontend/public/index.html create mode 100644 btc-portfolio/frontend/src/App.js create mode 100644 btc-portfolio/frontend/src/components/AddPurchase.js create mode 100644 btc-portfolio/frontend/src/components/PortfolioChart.js create mode 100644 btc-portfolio/frontend/src/components/PurchaseList.js create mode 100644 btc-portfolio/frontend/src/index.js create mode 100644 btc-portfolio/frontend/src/pages/Dashboard.js create mode 100644 btc-portfolio/frontend/src/pages/Login.js create mode 100644 btc-portfolio/frontend/src/pages/Register.js diff --git a/btc-portfolio/backend/Dockerfile b/btc-portfolio/backend/Dockerfile new file mode 100644 index 0000000..7f2d0b9 --- /dev/null +++ b/btc-portfolio/backend/Dockerfile @@ -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"] diff --git a/btc-portfolio/backend/app/__init__.py b/btc-portfolio/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btc-portfolio/backend/app/auth.py b/btc-portfolio/backend/app/auth.py new file mode 100644 index 0000000..7d1993e --- /dev/null +++ b/btc-portfolio/backend/app/auth.py @@ -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]) diff --git a/btc-portfolio/backend/app/database.py b/btc-portfolio/backend/app/database.py new file mode 100644 index 0000000..10e1e1e --- /dev/null +++ b/btc-portfolio/backend/app/database.py @@ -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() diff --git a/btc-portfolio/backend/app/dependencies.py b/btc-portfolio/backend/app/dependencies.py new file mode 100644 index 0000000..edcebf0 --- /dev/null +++ b/btc-portfolio/backend/app/dependencies.py @@ -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 diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py new file mode 100644 index 0000000..bafeb7e --- /dev/null +++ b/btc-portfolio/backend/app/main.py @@ -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"} diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py new file mode 100644 index 0000000..22fa09a --- /dev/null +++ b/btc-portfolio/backend/app/models.py @@ -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") diff --git a/btc-portfolio/backend/app/routes/__init__.py b/btc-portfolio/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py new file mode 100644 index 0000000..58ebecf --- /dev/null +++ b/btc-portfolio/backend/app/routes/purchases.py @@ -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() diff --git a/btc-portfolio/backend/app/routes/stats.py b/btc-portfolio/backend/app/routes/stats.py new file mode 100644 index 0000000..b619099 --- /dev/null +++ b/btc-portfolio/backend/app/routes/stats.py @@ -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), + } diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py new file mode 100644 index 0000000..970210f --- /dev/null +++ b/btc-portfolio/backend/app/routes/users.py @@ -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"} diff --git a/btc-portfolio/backend/app/services/__init__.py b/btc-portfolio/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py new file mode 100644 index 0000000..e20acdb --- /dev/null +++ b/btc-portfolio/backend/app/services/btc.py @@ -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 diff --git a/btc-portfolio/backend/requirements.txt b/btc-portfolio/backend/requirements.txt new file mode 100644 index 0000000..9c39138 --- /dev/null +++ b/btc-portfolio/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +sqlalchemy +passlib[bcrypt] +bcrypt==3.2.2 +python-jose[cryptography] +requests +python-multipart diff --git a/btc-portfolio/data/btc_portfolio.db b/btc-portfolio/data/btc_portfolio.db new file mode 100644 index 0000000000000000000000000000000000000000..6d9ecdf13a4849bd85b2fea169d44464bce5205f GIT binary patch literal 24576 zcmeI(L66cv6bJC>Lc0Nj?5(E_lgI(0S#@>eRbU6G3#}9o&;u#7hOSbuEm_={Y>1!9 zZ=v7D;~t!LDFRvU2LF@NPUcNt^L}%h0?kIv_AUBx;*JfUz92_L5Xfsv2_e$*!!KbI z`8mGq2Nz+jzpE>elj|=j9u-qxNqU?Psb^6N)Gutyxr4>1rm;*=Bz6Eq*9xGQ#(Y zZwx+LtKh05F7vet#Tr|`Ihn9c{*)H0FrBJei>mEf?FmgLjPYdV_e@qVM{MX?hHsgDzNeORQZMK|dck^OIl=t0rZc6Y24lJ9 zy?Ls$GSit_Voe&1dExA2z8I6_N3zh1NW&O^Sa^+ZQ%2E%@o$z72a^0$7H&3k1aZe0 zTj9LeGE-Ys8tt{Uxl0<}vAc}BWd*2I1#-&xZD5VlJxB! z6^K$G009U<00Izz00bZa0SG_<0uWdU#A1m=@cV!Mr++jEKmY;|fB*y_009U<00Izz n00j0}Ao%_t`~N*&V3Y;{2tWV=5P$##AOHafKmY;|-~xXD2aoc0 literal 0 HcmV?d00001 diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml new file mode 100644 index 0000000..ef03644 --- /dev/null +++ b/btc-portfolio/docker-compose.yml @@ -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 diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile new file mode 100644 index 0000000..4975171 --- /dev/null +++ b/btc-portfolio/frontend/Dockerfile @@ -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"] diff --git a/btc-portfolio/frontend/package.json b/btc-portfolio/frontend/package.json new file mode 100644 index 0000000..77acbe6 --- /dev/null +++ b/btc-portfolio/frontend/package.json @@ -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"] + } +} diff --git a/btc-portfolio/frontend/public/index.html b/btc-portfolio/frontend/public/index.html new file mode 100644 index 0000000..e2bab94 --- /dev/null +++ b/btc-portfolio/frontend/public/index.html @@ -0,0 +1,15 @@ + + + + + + BTC Portfolio + + + +
+ + diff --git a/btc-portfolio/frontend/src/App.js b/btc-portfolio/frontend/src/App.js new file mode 100644 index 0000000..0774d39 --- /dev/null +++ b/btc-portfolio/frontend/src/App.js @@ -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 : ; +} + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + + + ); +} diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js new file mode 100644 index 0000000..6b1e04b --- /dev/null +++ b/btc-portfolio/frontend/src/components/AddPurchase.js @@ -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 ( +
+
Add Purchase
+
+
+ setAmountEur(e.target.value)} + required + /> + setPriceEur(e.target.value)} + required + /> + +
+ {error &&
{error}
} +
+
+ ); +} diff --git a/btc-portfolio/frontend/src/components/PortfolioChart.js b/btc-portfolio/frontend/src/components/PortfolioChart.js new file mode 100644 index 0000000..a0e3e8d --- /dev/null +++ b/btc-portfolio/frontend/src/components/PortfolioChart.js @@ -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 ( +
+
Portfolio Chart
+ +
+ +
+ ); +} diff --git a/btc-portfolio/frontend/src/components/PurchaseList.js b/btc-portfolio/frontend/src/components/PurchaseList.js new file mode 100644 index 0000000..2c82705 --- /dev/null +++ b/btc-portfolio/frontend/src/components/PurchaseList.js @@ -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 ( +
+
Purchases
+ {purchases.length === 0 ? ( +
No purchases yet.
+ ) : ( + + + + + + + + + + + + {purchases.map(p => ( + + + + + + + + ))} + +
DateAmount (€)Price (€/BTC)BTC
{new Date(p.created_at).toLocaleDateString()}€{p.amount_eur.toLocaleString()}€{p.price_eur.toLocaleString()}{(p.amount_eur / p.price_eur).toFixed(6)} + +
+ )} +
+ ); +} diff --git a/btc-portfolio/frontend/src/index.js b/btc-portfolio/frontend/src/index.js new file mode 100644 index 0000000..217069b --- /dev/null +++ b/btc-portfolio/frontend/src/index.js @@ -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(); diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js new file mode 100644 index 0000000..cd13a85 --- /dev/null +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -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 ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+
+
₿ BTC Portfolio
+ +
+ + {stats && ( +
+ + + + + + = 0 ? '+' : ''}€${stats.profit_loss.toLocaleString()}`} + highlight={plHighlight} + /> +
+ )} + + + + +
+ ); +} diff --git a/btc-portfolio/frontend/src/pages/Login.js b/btc-portfolio/frontend/src/pages/Login.js new file mode 100644 index 0000000..25f4cd9 --- /dev/null +++ b/btc-portfolio/frontend/src/pages/Login.js @@ -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 ( +
+
+
₿ BTC Portfolio
+
+ {error &&
{error}
} + setUsername(e.target.value)} required /> + setPassword(e.target.value)} required /> + +
+ No account? Register +
+
+ ); +} diff --git a/btc-portfolio/frontend/src/pages/Register.js b/btc-portfolio/frontend/src/pages/Register.js new file mode 100644 index 0000000..803ff07 --- /dev/null +++ b/btc-portfolio/frontend/src/pages/Register.js @@ -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 ( +
+
+
₿ Create Account
+
+ {error &&
{error}
} + {success &&
{success}
} + setUsername(e.target.value)} required /> + setPassword(e.target.value)} required /> + +
+ Already have an account? Login +
+
+ ); +}