diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4559383 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# MySQL +MYSQL_ROOT_PASSWORD=changeme_root +MYSQL_DATABASE=bourbonacci +MYSQL_USER=bourbonacci +MYSQL_PASSWORD=changeme_db + +# Backend +DATABASE_URL=mysql+aiomysql://bourbonacci:changeme_db@db:3306/bourbonacci +SECRET_KEY=changeme_generate_a_long_random_string_here +ACCESS_TOKEN_EXPIRE_MINUTES=480 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12587bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +*.pyo +.DS_Store +*.egg-info/ +dist/ +build/ +.venv/ +venv/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0b9158f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..1f706ae --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str + secret_key: str + access_token_expire_minutes: int = 480 + algorithm: str = "HS256" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..672f336 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,20 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + + +engine = create_async_engine(settings.database_url, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def init_db() -> None: + # Import models so their tables are registered on Base.metadata before create_all + from app.models import user, entry # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..1d19483 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,35 @@ +from typing import AsyncGenerator + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import AsyncSessionLocal +from app.utils.security import decode_token + +bearer_scheme = HTTPBearer() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + yield session + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + db: AsyncSession = Depends(get_db), +): + from app.models.user import User + + token = credentials.credentials + user_id = decode_token(token) + if user_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5a89124 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,28 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.database import init_db +from app.routers import auth, users, entries, public + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(title="Bourbonacci", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(entries.router) +app.include_router(public.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/entry.py b/backend/app/models/entry.py new file mode 100644 index 0000000..bc0bc6e --- /dev/null +++ b/backend/app/models/entry.py @@ -0,0 +1,28 @@ +from datetime import datetime, date +from typing import Optional +from sqlalchemy import String, Text, Float, Date, DateTime, ForeignKey, Enum, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +import enum + +from app.database import Base + + +class EntryType(str, enum.Enum): + add = "add" + remove = "remove" + + +class Entry(Base): + __tablename__ = "entries" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + entry_type: Mapped[EntryType] = mapped_column(Enum(EntryType), nullable=False) + date: Mapped[date] = mapped_column(Date, nullable=False) + bourbon_name: Mapped[Optional[str]] = mapped_column(String(255)) + proof: Mapped[Optional[float]] = mapped_column(Float) + amount_shots: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + notes: Mapped[Optional[str]] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + user: Mapped["User"] = relationship("User", back_populates="entries") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..f80f7a1 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[Optional[str]] = mapped_column(String(100)) + timezone: Mapped[str] = mapped_column(String(50), default="UTC") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + entries: Mapped[list["Entry"]] = relationship("Entry", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..795e62f --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.dependencies import get_db +from app.models.user import User +from app.schemas.user import UserCreate, Token, LoginRequest +from app.utils.security import hash_password, verify_password, create_token + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED) +async def register(body: UserCreate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == body.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") + + user = User( + email=body.email, + password_hash=hash_password(body.password), + display_name=body.display_name or body.email.split("@")[0], + ) + db.add(user) + await db.commit() + await db.refresh(user) + + return Token(access_token=create_token(user.id)) + + +@router.post("/login", response_model=Token) +async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == body.email)) + user = result.scalar_one_or_none() + + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + return Token(access_token=create_token(user.id)) diff --git a/backend/app/routers/entries.py b/backend/app/routers/entries.py new file mode 100644 index 0000000..0ec3b2a --- /dev/null +++ b/backend/app/routers/entries.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.dependencies import get_db, get_current_user +from app.models.user import User +from app.models.entry import Entry, EntryType +from app.schemas.entry import EntryCreate, EntryResponse, BottleStats + +router = APIRouter(prefix="/api/entries", tags=["entries"]) + + +def _calc_stats(entries: list[Entry]) -> BottleStats: + adds = [e for e in entries if e.entry_type == EntryType.add] + removes = [e for e in entries if e.entry_type == EntryType.remove] + + total_add_shots = sum(e.amount_shots for e in adds) + total_remove_shots = sum(e.amount_shots for e in removes) + current_total = total_add_shots - total_remove_shots + + # Weighted average proof across all add entries + weighted_proof_sum = sum(e.proof * e.amount_shots for e in adds if e.proof is not None) + proof_shot_total = sum(e.amount_shots for e in adds if e.proof is not None) + estimated_proof = (weighted_proof_sum / proof_shot_total) if proof_shot_total > 0 else None + + return BottleStats( + total_add_entries=len(adds), + current_total_shots=round(current_total, 2), + estimated_proof=round(estimated_proof, 1) if estimated_proof is not None else None, + ) + + +@router.get("", response_model=list[EntryResponse]) +async def list_entries( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Entry) + .where(Entry.user_id == current_user.id) + .order_by(Entry.date.desc(), Entry.created_at.desc()) + ) + return result.scalars().all() + + +@router.post("", response_model=EntryResponse, status_code=status.HTTP_201_CREATED) +async def create_entry( + body: EntryCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if body.entry_type == EntryType.add and not body.bourbon_name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bourbon_name is required for add entries") + + entry = Entry( + user_id=current_user.id, + entry_type=body.entry_type, + date=body.date, + bourbon_name=body.bourbon_name, + proof=body.proof, + amount_shots=body.amount_shots, + notes=body.notes, + ) + async with db.begin(): + db.add(entry) + + await db.refresh(entry) + return entry + + +@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_entry( + entry_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Entry).where(Entry.id == entry_id, Entry.user_id == current_user.id) + ) + entry = result.scalar_one_or_none() + if not entry: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found") + + async with db.begin(): + await db.delete(entry) + + +@router.get("/stats", response_model=BottleStats) +async def get_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute(select(Entry).where(Entry.user_id == current_user.id)) + entries = result.scalars().all() + return _calc_stats(entries) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py new file mode 100644 index 0000000..ed90136 --- /dev/null +++ b/backend/app/routers/public.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.dependencies import get_db +from app.models.user import User +from app.models.entry import Entry, EntryType +from app.schemas.entry import PublicUserStats + +router = APIRouter(prefix="/api/public", tags=["public"]) + + +@router.get("/stats", response_model=list[PublicUserStats]) +async def public_stats(db: AsyncSession = Depends(get_db)): + users_result = await db.execute(select(User)) + users = users_result.scalars().all() + + stats: list[PublicUserStats] = [] + for user in users: + entries_result = await db.execute(select(Entry).where(Entry.user_id == user.id)) + entries = entries_result.scalars().all() + + adds = [e for e in entries if e.entry_type == EntryType.add] + removes = [e for e in entries if e.entry_type == EntryType.remove] + + total_add_shots = sum(e.amount_shots for e in adds) + total_remove_shots = sum(e.amount_shots for e in removes) + current_total = total_add_shots - total_remove_shots + + weighted_proof_sum = sum(e.proof * e.amount_shots for e in adds if e.proof is not None) + proof_shot_total = sum(e.amount_shots for e in adds if e.proof is not None) + estimated_proof = round(weighted_proof_sum / proof_shot_total, 1) if proof_shot_total > 0 else None + + stats.append(PublicUserStats( + display_name=user.display_name or user.email.split("@")[0], + total_add_entries=len(adds), + current_total_shots=round(current_total, 2), + estimated_proof=estimated_proof, + )) + + return stats diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..749e113 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db, get_current_user +from app.models.user import User +from app.schemas.user import UserResponse, UserUpdate, PasswordChange +from app.utils.security import verify_password, hash_password + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.put("/me", response_model=UserResponse) +async def update_me( + body: UserUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if body.display_name is not None: + current_user.display_name = body.display_name + if body.timezone is not None: + current_user.timezone = body.timezone + + async with db.begin(): + db.add(current_user) + + await db.refresh(current_user) + return current_user + + +@router.put("/me/password", status_code=status.HTTP_204_NO_CONTENT) +async def change_password( + body: PasswordChange, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if not verify_password(body.current_password, current_user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect") + + current_user.password_hash = hash_password(body.new_password) + + async with db.begin(): + db.add(current_user) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/entry.py b/backend/app/schemas/entry.py new file mode 100644 index 0000000..fa9c01f --- /dev/null +++ b/backend/app/schemas/entry.py @@ -0,0 +1,40 @@ +from datetime import datetime, date +from typing import Optional +from pydantic import BaseModel + +from app.models.entry import EntryType + + +class EntryCreate(BaseModel): + entry_type: EntryType + date: date + bourbon_name: Optional[str] = None + proof: Optional[float] = None + amount_shots: float = 1.0 + notes: Optional[str] = None + + +class EntryResponse(BaseModel): + id: int + entry_type: EntryType + date: date + bourbon_name: Optional[str] + proof: Optional[float] + amount_shots: float + notes: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + +class BottleStats(BaseModel): + total_add_entries: int + current_total_shots: float + estimated_proof: Optional[float] + + +class PublicUserStats(BaseModel): + display_name: str + total_add_entries: int + current_total_shots: float + estimated_proof: Optional[float] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..853ff7c --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + password: str + display_name: Optional[str] = None + + +class UserUpdate(BaseModel): + display_name: Optional[str] = None + timezone: Optional[str] = None + + +class PasswordChange(BaseModel): + current_password: str + new_password: str + + +class UserResponse(BaseModel): + id: int + email: str + display_name: Optional[str] + timezone: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class LoginRequest(BaseModel): + email: EmailStr + password: str diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..42a0e86 --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1,34 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import settings + +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_token(user_id: int) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = {"sub": str(user_id), "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def decode_token(token: str) -> Optional[int]: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + user_id = payload.get("sub") + if user_id is None: + return None + return int(user_id) + except JWTError: + return None diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..299b7f8 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +sqlalchemy[asyncio]==2.0.36 +aiomysql==0.2.0 +pydantic-settings==2.7.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.20 +pytz==2024.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8ffdb9a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + nginx: + image: nginx:alpine + ports: + - "8057:80" + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + restart: unless-stopped + + backend: + build: ./backend + environment: + - DATABASE_URL=${DATABASE_URL} + - SECRET_KEY=${SECRET_KEY} + - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-480} + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: mysql:8 + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + +volumes: + mysql_data: diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..93a7919 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,417 @@ +/* ============================================================ + Bourbonacci — Bourbon-themed dark UI + ============================================================ */ + +:root { + --bg: #0d0800; + --bg-card: #1c1100; + --bg-card-2: #261800; + --border: #3d2b00; + --amber: #c8860a; + --amber-light: #e6a020; + --amber-dim: #7a5206; + --cream: #f5e6c8; + --cream-dim: #b89d74; + --danger: #c0392b; + --danger-dim: #7d2318; + --success: #27ae60; + --radius: 8px; + --shadow: 0 4px 24px rgba(0,0,0,.6); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--cream); + font-family: 'Georgia', serif; + min-height: 100vh; + line-height: 1.6; +} + +/* ---- Nav ---- */ +nav { + background: var(--bg-card); + border-bottom: 1px solid var(--border); + padding: 0 2rem; + display: flex; + align-items: center; + justify-content: space-between; + height: 60px; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-brand { + font-size: 1.4rem; + font-weight: bold; + color: var(--amber); + text-decoration: none; + letter-spacing: .05em; +} + +.nav-links { + display: flex; + gap: 1.5rem; + align-items: center; +} + +.nav-links a { + color: var(--cream-dim); + text-decoration: none; + font-size: .95rem; + transition: color .2s; +} + +.nav-links a:hover, +.nav-links a.active { + color: var(--amber-light); +} + +.nav-user { + color: var(--amber); + cursor: pointer; + font-size: .95rem; + text-decoration: none; +} + +.nav-user:hover { color: var(--amber-light); } + +/* ---- Layout ---- */ +main { + max-width: 1100px; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +/* ---- Cards ---- */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: var(--shadow); +} + +.card + .card { margin-top: 1.5rem; } + +.card-title { + font-size: 1.1rem; + color: var(--amber); + margin-bottom: 1rem; + letter-spacing: .04em; + text-transform: uppercase; + font-size: .85rem; +} + +/* ---- Stats grid ---- */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-box { + background: var(--bg-card-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.2rem 1rem; + text-align: center; +} + +.stat-value { + font-size: 2rem; + font-weight: bold; + color: var(--amber-light); + display: block; +} + +.stat-label { + font-size: .75rem; + color: var(--cream-dim); + text-transform: uppercase; + letter-spacing: .08em; + margin-top: .25rem; +} + +/* ---- Page title ---- */ +.page-title { + font-size: 1.8rem; + color: var(--amber-light); + margin-bottom: 1.5rem; +} + +.page-subtitle { + color: var(--cream-dim); + margin-top: -.75rem; + margin-bottom: 1.5rem; + font-size: .9rem; +} + +/* ---- Forms ---- */ +.form-group { + margin-bottom: 1.1rem; +} + +label { + display: block; + font-size: .85rem; + color: var(--cream-dim); + margin-bottom: .4rem; + text-transform: uppercase; + letter-spacing: .06em; +} + +input, select, textarea { + width: 100%; + background: var(--bg-card-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--cream); + padding: .65rem .9rem; + font-size: .95rem; + font-family: inherit; + transition: border-color .2s; + outline: none; +} + +input:focus, select:focus, textarea:focus { + border-color: var(--amber); +} + +textarea { resize: vertical; min-height: 80px; } + +select option { background: var(--bg-card); } + +/* ---- Buttons ---- */ +.btn { + display: inline-block; + padding: .6rem 1.4rem; + border-radius: var(--radius); + border: none; + cursor: pointer; + font-family: inherit; + font-size: .95rem; + font-weight: bold; + text-decoration: none; + transition: opacity .2s, background .2s; +} + +.btn-primary { + background: var(--amber); + color: #0d0800; +} + +.btn-primary:hover { background: var(--amber-light); } + +.btn-danger { + background: var(--danger-dim); + color: var(--cream); +} + +.btn-danger:hover { background: var(--danger); } + +.btn-ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--cream-dim); +} + +.btn-ghost:hover { border-color: var(--amber); color: var(--amber); } + +.btn-sm { + padding: .3rem .8rem; + font-size: .82rem; +} + +.btn:disabled { opacity: .5; cursor: not-allowed; } + +/* ---- Table ---- */ +.table-wrap { overflow-x: auto; } + +table { + width: 100%; + border-collapse: collapse; + font-size: .9rem; +} + +thead th { + text-align: left; + padding: .65rem .75rem; + color: var(--amber); + font-size: .75rem; + text-transform: uppercase; + letter-spacing: .07em; + border-bottom: 1px solid var(--border); + font-weight: normal; +} + +tbody tr { + border-bottom: 1px solid var(--border); + transition: background .15s; +} + +tbody tr:hover { background: var(--bg-card-2); } + +tbody td { + padding: .65rem .75rem; + color: var(--cream); + vertical-align: top; +} + +.badge { + display: inline-block; + padding: .2rem .55rem; + border-radius: 20px; + font-size: .72rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: .05em; +} + +.badge-add { background: rgba(200,134,10,.2); color: var(--amber-light); border: 1px solid var(--amber-dim); } +.badge-remove { background: rgba(192,57,43,.2); color: #e07060; border: 1px solid var(--danger-dim); } + +/* ---- Alert ---- */ +.alert { + padding: .75rem 1rem; + border-radius: var(--radius); + font-size: .9rem; + margin-bottom: 1rem; +} + +.alert-error { background: rgba(192,57,43,.2); border: 1px solid var(--danger-dim); color: #e07060; } +.alert-success { background: rgba(39,174,96,.15); border: 1px solid #1e6b3d; color: #5dd490; } + +/* ---- Auth pages ---- */ +.auth-wrap { + max-width: 420px; + margin: 4rem auto; + padding: 0 1rem; +} + +.auth-logo { + text-align: center; + margin-bottom: 2rem; +} + +.auth-logo h1 { color: var(--amber-light); font-size: 2rem; } +.auth-logo p { color: var(--cream-dim); font-size: .9rem; margin-top: .3rem; } + +/* ---- Public dashboard user cards ---- */ +.user-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} + +.user-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + transition: border-color .2s; +} + +.user-card:hover { border-color: var(--amber-dim); } + +.user-card-name { + font-size: 1.1rem; + color: var(--amber-light); + margin-bottom: 1rem; +} + +/* ---- Bottle visual ---- */ +.bottle-bar-wrap { + background: var(--bg-card-2); + border: 1px solid var(--border); + border-radius: 4px; + height: 10px; + overflow: hidden; + margin: .5rem 0 .25rem; +} + +.bottle-bar { + height: 100%; + background: linear-gradient(90deg, var(--amber-dim), var(--amber-light)); + transition: width .4s ease; + border-radius: 4px; +} + +.bottle-label { + font-size: .72rem; + color: var(--cream-dim); + text-align: right; +} + +/* ---- Landing hero ---- */ +.hero { + text-align: center; + padding: 3rem 1rem 2rem; +} + +.hero h1 { font-size: 2.8rem; color: var(--amber-light); } +.hero p { color: var(--cream-dim); max-width: 540px; margin: .75rem auto 1.5rem; font-size: 1rem; } + +/* ---- Section header ---- */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.section-header h2 { + font-size: 1.1rem; + color: var(--amber); + text-transform: uppercase; + letter-spacing: .06em; + font-size: .85rem; +} + +/* ---- Divider ---- */ +.divider { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +/* ---- Empty state ---- */ +.empty { + text-align: center; + padding: 3rem 1rem; + color: var(--cream-dim); +} + +.empty-icon { font-size: 2.5rem; margin-bottom: .75rem; } + +/* ---- Tabs ---- */ +.tabs { + display: flex; + gap: .5rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1.5rem; +} + +.tab { + padding: .6rem 1.2rem; + cursor: pointer; + color: var(--cream-dim); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-size: .9rem; + transition: color .2s, border-color .2s; +} + +.tab.active, .tab:hover { color: var(--amber-light); } +.tab.active { border-bottom-color: var(--amber); } + +/* ---- Responsive ---- */ +@media (max-width: 640px) { + .hero h1 { font-size: 2rem; } + .stats-grid { grid-template-columns: 1fr 1fr; } + nav { padding: 0 1rem; } + main { padding: 1.25rem 1rem; } +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..7adbec2 --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,149 @@ + + + + + + My Bottle — Bourbonacci + + + + + + +
+

My Infinity Bottle

+ + +
+
Bourbons Added
+
Est. Proof
+
Shots Remaining
+
Total Poured In
+
+ + +
+
+

Entry Log

+ + Add Entry +
+ +
+

Loading…

+
+
+
+ + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ffd772a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,91 @@ + + + + + + Bourbonacci — Infinity Bottle Tracker + + + + + + +
+
+

The Infinity Bottle

+

One pour from every bottle. An ever-evolving blend that grows richer with every addition.

+ Track Your Bottle +
+ +
+

Community Bottles

+
+ +
+

Loading...

+
+
+ + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..724a5ec --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,59 @@ +/* Central API client — all fetch calls go through here */ + +const API = (() => { + const base = '/api'; + + function token() { + return localStorage.getItem('bb_token'); + } + + async function request(method, path, body) { + const headers = { 'Content-Type': 'application/json' }; + const tok = token(); + if (tok) headers['Authorization'] = `Bearer ${tok}`; + + const res = await fetch(base + path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (res.status === 204) return null; + + const data = await res.json().catch(() => null); + + if (!res.ok) { + const msg = data?.detail || `HTTP ${res.status}`; + throw new Error(Array.isArray(msg) ? msg.map(e => e.msg).join(', ') : msg); + } + + return data; + } + + return { + get: (path) => request('GET', path), + post: (path, body) => request('POST', path, body), + put: (path, body) => request('PUT', path, body), + delete: (path) => request('DELETE', path), + + auth: { + login: (email, password) => request('POST', '/auth/login', { email, password }), + register: (email, password, display_name) => + request('POST', '/auth/register', { email, password, display_name }), + }, + users: { + me: () => request('GET', '/users/me'), + update: (body) => request('PUT', '/users/me', body), + changePassword: (body) => request('PUT', '/users/me/password', body), + }, + entries: { + list: () => request('GET', '/entries'), + stats: () => request('GET', '/entries/stats'), + create: (body) => request('POST', '/entries', body), + delete: (id) => request('DELETE', `/entries/${id}`), + }, + public: { + stats: () => request('GET', '/public/stats'), + }, + }; +})(); diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..80481d9 --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,72 @@ +/* Auth state helpers shared across all pages */ + +const Auth = (() => { + const KEY = 'bb_token'; + const USER_KEY = 'bb_user'; + + function getToken() { return localStorage.getItem(KEY); } + + function saveToken(token) { localStorage.setItem(KEY, token); } + + function getUser() { + const raw = localStorage.getItem(USER_KEY); + return raw ? JSON.parse(raw) : null; + } + + function saveUser(user) { localStorage.setItem(USER_KEY, JSON.stringify(user)); } + + function logout() { + localStorage.removeItem(KEY); + localStorage.removeItem(USER_KEY); + window.location.href = '/index.html'; + } + + function isLoggedIn() { return !!getToken(); } + + /* Redirect to login if not authenticated */ + function requireAuth() { + if (!isLoggedIn()) { + window.location.href = '/login.html'; + return false; + } + return true; + } + + /* Redirect away from auth pages if already logged in */ + function redirectIfLoggedIn() { + if (isLoggedIn()) { + window.location.href = '/dashboard.html'; + } + } + + /* Render the nav user area; call after DOM ready */ + async function renderNav(activePage) { + const navLinksEl = document.getElementById('nav-links'); + const navUserEl = document.getElementById('nav-user'); + if (!navLinksEl || !navUserEl) return; + + if (isLoggedIn()) { + let user = getUser(); + if (!user) { + try { user = await API.users.me(); saveUser(user); } catch (_) {} + } + navLinksEl.innerHTML = ` + My Bottle + Log Entry + `; + navUserEl.innerHTML = ` + ${user?.display_name || user?.email || 'Account'} + Logout + `; + document.getElementById('logout-btn')?.addEventListener('click', (e) => { + e.preventDefault(); + logout(); + }); + } else { + navLinksEl.innerHTML = ''; + navUserEl.innerHTML = `Login`; + } + } + + return { getToken, saveToken, getUser, saveUser, logout, isLoggedIn, requireAuth, redirectIfLoggedIn, renderNav }; +})(); diff --git a/frontend/log.html b/frontend/log.html new file mode 100644 index 0000000..57ccb5b --- /dev/null +++ b/frontend/log.html @@ -0,0 +1,162 @@ + + + + + + Log Entry — Bourbonacci + + + + + + +
+

Log Entry

+ +
+
Add to Bottle
+
Remove (Drink)
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+ + + +
+ + + + + + diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..0f9740e --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,80 @@ + + + + + + Login — Bourbonacci + + + + + + +
+ + +
+
+
+
+ + +
+
+ + +
+ +
+
+

+ Don't have an account? Register +

+
+
+ + + + + + diff --git a/frontend/profile.html b/frontend/profile.html new file mode 100644 index 0000000..188bda2 --- /dev/null +++ b/frontend/profile.html @@ -0,0 +1,162 @@ + + + + + + Profile — Bourbonacci + + + + + + +
+

Profile Settings

+ + +
+
Account Info
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
Change Password
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
Danger Zone
+

Sign out of your account on this device.

+ +
+
+ + + + + + diff --git a/frontend/register.html b/frontend/register.html new file mode 100644 index 0000000..9d9bd45 --- /dev/null +++ b/frontend/register.html @@ -0,0 +1,101 @@ + + + + + + Register — Bourbonacci + + + + + + +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+

+ Already have an account? Sign in +

+
+
+ + + + + + diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..d1d2329 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 60s; + } +}