Add initial project files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 19:11:00 -07:00
parent bfab8f71fb
commit 72b23c18aa
32 changed files with 1870 additions and 0 deletions

10
.env.example Normal file
View File

@@ -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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
*.pyo
.DS_Store
*.egg-info/
dist/
build/
.venv/
venv/

10
backend/Dockerfile Normal file
View File

@@ -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"]

14
backend/app/config.py Normal file
View File

@@ -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()

20
backend/app/database.py Normal file
View File

@@ -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)

View File

@@ -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

28
backend/app/main.py Normal file
View File

@@ -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)

View File

View File

@@ -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")

View File

@@ -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")

View File

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

View File

@@ -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]

View File

@@ -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

View File

View File

@@ -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

9
backend/requirements.txt Normal file
View File

@@ -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

41
docker-compose.yml Normal file
View File

@@ -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:

417
frontend/css/style.css Normal file
View File

@@ -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; }
}

149
frontend/dashboard.html Normal file
View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Bottle — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main>
<h1 class="page-title" id="page-title">My Infinity Bottle</h1>
<!-- Stats -->
<div class="stats-grid" id="stats-grid">
<div class="stat-box"><span class="stat-value"></span><span class="stat-label">Bourbons Added</span></div>
<div class="stat-box"><span class="stat-value"></span><span class="stat-label">Est. Proof</span></div>
<div class="stat-box"><span class="stat-value"></span><span class="stat-label">Shots Remaining</span></div>
<div class="stat-box"><span class="stat-value"></span><span class="stat-label">Total Poured In</span></div>
</div>
<!-- Entries -->
<div class="card">
<div class="section-header">
<h2>Entry Log</h2>
<a href="/log.html" class="btn btn-primary btn-sm">+ Add Entry</a>
</div>
<div id="entries-wrap">
<div class="empty"><div class="empty-icon"></div><p>Loading…</p></div>
</div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fmtDate(d) {
// d is YYYY-MM-DD
const [y,m,day] = d.split('-');
return `${m}/${day}/${y}`;
}
document.addEventListener('DOMContentLoaded', async () => {
if (!Auth.requireAuth()) return;
const user = Auth.getUser();
await Auth.renderNav('dashboard');
if (user) {
document.getElementById('page-title').textContent = `${user.display_name || 'My'} Infinity Bottle`;
}
await Promise.all([loadStats(), loadEntries()]);
});
async function loadStats() {
try {
const s = await API.entries.stats();
const grid = document.getElementById('stats-grid');
grid.innerHTML = `
<div class="stat-box">
<span class="stat-value">${s.total_add_entries}</span>
<span class="stat-label">Bourbons Added</span>
</div>
<div class="stat-box">
<span class="stat-value">${s.estimated_proof != null ? s.estimated_proof : '—'}</span>
<span class="stat-label">Est. Proof</span>
</div>
<div class="stat-box">
<span class="stat-value">${s.current_total_shots}</span>
<span class="stat-label">Shots Remaining</span>
</div>
<div class="stat-box">
<span class="stat-value">${(s.total_add_entries > 0 ? s.current_total_shots : 0)}</span>
<span class="stat-label">Net Volume (shots)</span>
</div>
`;
} catch (_) {}
}
async function loadEntries() {
const wrap = document.getElementById('entries-wrap');
try {
const entries = await API.entries.list();
if (entries.length === 0) {
wrap.innerHTML = `<div class="empty"><div class="empty-icon">🥃</div><p>No entries yet. <a href="/log.html" style="color:var(--amber)">Add your first pour.</a></p></div>`;
return;
}
wrap.innerHTML = `
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Type</th>
<th>Date</th>
<th>Bourbon</th>
<th>Proof</th>
<th>Shots</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="entries-body"></tbody>
</table>
</div>`;
const tbody = document.getElementById('entries-body');
tbody.innerHTML = entries.map(e => `
<tr>
<td><span class="badge badge-${e.entry_type}">${e.entry_type}</span></td>
<td>${fmtDate(e.date)}</td>
<td>${escHtml(e.bourbon_name ?? '—')}</td>
<td>${e.proof != null ? e.proof : '—'}</td>
<td>${e.amount_shots}</td>
<td style="max-width:200px;white-space:pre-wrap;word-break:break-word">${escHtml(e.notes ?? '')}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
</td>
</tr>`).join('');
} catch (err) {
wrap.innerHTML = `<div class="alert alert-error">Failed to load entries: ${err.message}</div>`;
}
}
async function deleteEntry(id) {
if (!confirm('Delete this entry?')) return;
try {
await API.entries.delete(id);
await Promise.all([loadStats(), loadEntries()]);
} catch (err) {
alert('Delete failed: ' + err.message);
}
}
</script>
</body>
</html>

91
frontend/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bourbonacci — Infinity Bottle Tracker</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main>
<div class="hero">
<h1>The Infinity Bottle</h1>
<p>One pour from every bottle. An ever-evolving blend that grows richer with every addition.</p>
<a href="/login.html" class="btn btn-primary" id="hero-cta">Track Your Bottle</a>
</div>
<div class="section-header">
<h2>Community Bottles</h2>
</div>
<div id="user-cards" class="user-cards">
<div class="empty"><div class="empty-icon"></div><p>Loading...</p></div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
await Auth.renderNav('home');
if (Auth.isLoggedIn()) {
document.getElementById('hero-cta').textContent = 'Go to My Bottle';
document.getElementById('hero-cta').href = '/dashboard.html';
}
const container = document.getElementById('user-cards');
try {
const stats = await API.public.stats();
if (stats.length === 0) {
container.innerHTML = `<div class="empty"><div class="empty-icon">🥃</div><p>No bottles yet — be the first to register!</p></div>`;
return;
}
container.innerHTML = stats.map(u => {
const maxShots = 25;
const pct = Math.min(100, (u.current_total_shots / maxShots) * 100);
const proof = u.estimated_proof != null ? `${u.estimated_proof}` : '—';
return `
<div class="user-card">
<div class="user-card-name">${escHtml(u.display_name)}</div>
<div class="stats-grid" style="margin-bottom:.75rem">
<div class="stat-box">
<span class="stat-value">${u.total_add_entries}</span>
<span class="stat-label">Bourbons</span>
</div>
<div class="stat-box">
<span class="stat-value">${proof}</span>
<span class="stat-label">Est. Proof</span>
</div>
<div class="stat-box">
<span class="stat-value">${u.current_total_shots}</span>
<span class="stat-label">Shots Left</span>
</div>
</div>
<div class="bottle-bar-wrap">
<div class="bottle-bar" style="width:${pct}%"></div>
</div>
<div class="bottle-label">${u.current_total_shots} shots remaining</div>
</div>`;
}).join('');
} catch (err) {
container.innerHTML = `<div class="alert alert-error">Could not load stats: ${err.message}</div>`;
}
});
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>

59
frontend/js/api.js Normal file
View File

@@ -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'),
},
};
})();

72
frontend/js/auth.js Normal file
View File

@@ -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 = `
<a href="/dashboard.html" class="${activePage === 'dashboard' ? 'active' : ''}">My Bottle</a>
<a href="/log.html" class="${activePage === 'log' ? 'active' : ''}">Log Entry</a>
`;
navUserEl.innerHTML = `
<a href="/profile.html" class="nav-user">${user?.display_name || user?.email || 'Account'}</a>
<a href="#" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
`;
document.getElementById('logout-btn')?.addEventListener('click', (e) => {
e.preventDefault();
logout();
});
} else {
navLinksEl.innerHTML = '';
navUserEl.innerHTML = `<a href="/login.html" class="btn btn-primary btn-sm">Login</a>`;
}
}
return { getToken, saveToken, getUser, saveUser, logout, isLoggedIn, requireAuth, redirectIfLoggedIn, renderNav };
})();

162
frontend/log.html Normal file
View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Entry — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main style="max-width:640px">
<h1 class="page-title">Log Entry</h1>
<div class="tabs">
<div class="tab active" id="tab-add" onclick="switchTab('add')">Add to Bottle</div>
<div class="tab" id="tab-remove" onclick="switchTab('remove')">Remove (Drink)</div>
</div>
<!-- ADD FORM -->
<div id="pane-add">
<div class="card">
<div id="alert-add"></div>
<form id="form-add">
<div class="form-group">
<label for="add-date">Date</label>
<input type="date" id="add-date" required />
</div>
<div class="form-group">
<label for="bourbon-name">Bourbon Name</label>
<input type="text" id="bourbon-name" required placeholder="e.g. Buffalo Trace" />
</div>
<div class="form-group">
<label for="proof">Proof</label>
<input type="number" id="proof" min="0" max="200" step="0.1" placeholder="e.g. 90" />
</div>
<div class="form-group">
<label for="add-shots">Amount (shots)</label>
<input type="number" id="add-shots" min="0.25" step="0.25" value="1" required />
</div>
<div class="form-group">
<label for="add-notes">Notes</label>
<textarea id="add-notes" placeholder="Tasting notes, batch info, anything worth remembering…"></textarea>
</div>
<div style="display:flex;gap:1rem;margin-top:.5rem">
<button type="submit" class="btn btn-primary" id="btn-add">Add to Bottle</button>
<a href="/dashboard.html" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
<!-- REMOVE FORM -->
<div id="pane-remove" style="display:none">
<div class="card">
<p style="color:var(--cream-dim);margin-bottom:1rem;font-size:.9rem">
Log shots you've poured and consumed from the infinity bottle.
</p>
<div id="alert-remove"></div>
<form id="form-remove">
<div class="form-group">
<label for="remove-date">Date</label>
<input type="date" id="remove-date" required />
</div>
<div class="form-group">
<label for="remove-shots">Shots Consumed</label>
<input type="number" id="remove-shots" min="0.25" step="0.25" value="1" required />
</div>
<div class="form-group">
<label for="remove-notes">Notes</label>
<textarea id="remove-notes" placeholder="Occasion, tasting notes…"></textarea>
</div>
<div style="display:flex;gap:1rem;margin-top:.5rem">
<button type="submit" class="btn btn-danger" id="btn-remove">Log Removal</button>
<a href="/dashboard.html" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
function today() {
return new Date().toISOString().split('T')[0];
}
document.addEventListener('DOMContentLoaded', () => {
if (!Auth.requireAuth()) return;
Auth.renderNav('log');
document.getElementById('add-date').value = today();
document.getElementById('remove-date').value = today();
});
function switchTab(tab) {
document.getElementById('pane-add').style.display = tab === 'add' ? '' : 'none';
document.getElementById('pane-remove').style.display = tab === 'remove' ? '' : 'none';
document.getElementById('tab-add').className = 'tab' + (tab === 'add' ? ' active' : '');
document.getElementById('tab-remove').className = 'tab' + (tab === 'remove' ? ' active' : '');
}
document.getElementById('form-add').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-add');
const btn = document.getElementById('btn-add');
alert.innerHTML = '';
btn.disabled = true;
try {
await API.entries.create({
entry_type: 'add',
date: document.getElementById('add-date').value,
bourbon_name: document.getElementById('bourbon-name').value.trim(),
proof: parseFloat(document.getElementById('proof').value) || null,
amount_shots: parseFloat(document.getElementById('add-shots').value),
notes: document.getElementById('add-notes').value.trim() || null,
});
alert.innerHTML = `<div class="alert alert-success">Added to the bottle!</div>`;
e.target.reset();
document.getElementById('add-date').value = today();
document.getElementById('add-shots').value = '1';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
document.getElementById('form-remove').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-remove');
const btn = document.getElementById('btn-remove');
alert.innerHTML = '';
btn.disabled = true;
try {
await API.entries.create({
entry_type: 'remove',
date: document.getElementById('remove-date').value,
amount_shots: parseFloat(document.getElementById('remove-shots').value),
notes: document.getElementById('remove-notes').value.trim() || null,
});
alert.innerHTML = `<div class="alert alert-success">Removal logged. Cheers!</div>`;
e.target.reset();
document.getElementById('remove-date').value = today();
document.getElementById('remove-shots').value = '1';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

80
frontend/login.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<div class="auth-wrap">
<div class="auth-logo">
<h1>Welcome Back</h1>
<p>Sign in to manage your infinity bottle</p>
</div>
<div class="card">
<div id="alert"></div>
<form id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required placeholder="you@example.com" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required placeholder="••••••••" />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:.5rem" id="submit-btn">Sign In</button>
</form>
<hr class="divider" />
<p style="text-align:center;color:var(--cream-dim);font-size:.9rem">
Don't have an account? <a href="/register.html" style="color:var(--amber)">Register</a>
</p>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
Auth.redirectIfLoggedIn();
Auth.renderNav();
const form = document.getElementById('login-form');
const alert = document.getElementById('alert');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
alert.innerHTML = '';
btn.disabled = true;
btn.textContent = 'Signing in…';
try {
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const data = await API.auth.login(email, password);
Auth.saveToken(data.access_token);
// Pre-fetch user info so nav renders immediately
const user = await API.users.me();
Auth.saveUser(user);
window.location.href = '/dashboard.html';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
});
</script>
</body>
</html>

162
frontend/profile.html Normal file
View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profile — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<main style="max-width:560px">
<h1 class="page-title">Profile Settings</h1>
<!-- Account info -->
<div class="card">
<div class="card-title">Account Info</div>
<div id="alert-profile"></div>
<form id="form-profile">
<div class="form-group">
<label for="email-display">Email</label>
<input type="text" id="email-display" disabled style="opacity:.5;cursor:not-allowed" />
</div>
<div class="form-group">
<label for="display-name">Display Name</label>
<input type="text" id="display-name" placeholder="How you appear publicly" maxlength="100" />
</div>
<div class="form-group">
<label for="timezone">Time Zone</label>
<select id="timezone"></select>
</div>
<button type="submit" class="btn btn-primary" id="btn-profile">Save Changes</button>
</form>
</div>
<!-- Change password -->
<div class="card">
<div class="card-title">Change Password</div>
<div id="alert-pw"></div>
<form id="form-pw">
<div class="form-group">
<label for="cur-pw">Current Password</label>
<input type="password" id="cur-pw" required autocomplete="current-password" />
</div>
<div class="form-group">
<label for="new-pw">New Password</label>
<input type="password" id="new-pw" required autocomplete="new-password" placeholder="Min 8 characters" />
</div>
<div class="form-group">
<label for="conf-pw">Confirm New Password</label>
<input type="password" id="conf-pw" required autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary" id="btn-pw">Update Password</button>
</form>
</div>
<!-- Danger -->
<div class="card" style="border-color:var(--danger-dim)">
<div class="card-title" style="color:#e07060">Danger Zone</div>
<p style="color:var(--cream-dim);font-size:.9rem;margin-bottom:1rem">Sign out of your account on this device.</p>
<button class="btn btn-danger" onclick="Auth.logout()">Logout</button>
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
const TIMEZONES = [
'UTC',
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
'America/Anchorage','Pacific/Honolulu',
'America/Toronto','America/Vancouver','America/Winnipeg',
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Moscow',
'Asia/Tokyo','Asia/Shanghai','Asia/Kolkata','Asia/Dubai',
'Australia/Sydney','Australia/Perth',
'Pacific/Auckland',
];
document.addEventListener('DOMContentLoaded', async () => {
if (!Auth.requireAuth()) return;
await Auth.renderNav();
// Populate timezone select
const tzSel = document.getElementById('timezone');
TIMEZONES.forEach(tz => {
const opt = document.createElement('option');
opt.value = tz;
opt.textContent = tz.replace('_', ' ');
tzSel.appendChild(opt);
});
try {
const user = await API.users.me();
Auth.saveUser(user);
document.getElementById('email-display').value = user.email;
document.getElementById('display-name').value = user.display_name || '';
tzSel.value = user.timezone || 'UTC';
} catch (err) {
document.getElementById('alert-profile').innerHTML = `<div class="alert alert-error">Failed to load profile: ${err.message}</div>`;
}
});
document.getElementById('form-profile').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-profile');
const btn = document.getElementById('btn-profile');
alert.innerHTML = '';
btn.disabled = true;
try {
const user = await API.users.update({
display_name: document.getElementById('display-name').value.trim() || null,
timezone: document.getElementById('timezone').value,
});
Auth.saveUser(user);
alert.innerHTML = `<div class="alert alert-success">Profile updated.</div>`;
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
document.getElementById('form-pw').addEventListener('submit', async (e) => {
e.preventDefault();
const alert = document.getElementById('alert-pw');
const btn = document.getElementById('btn-pw');
const newPw = document.getElementById('new-pw').value;
const confPw = document.getElementById('conf-pw').value;
alert.innerHTML = '';
if (newPw !== confPw) {
alert.innerHTML = `<div class="alert alert-error">New passwords do not match.</div>`;
return;
}
if (newPw.length < 8) {
alert.innerHTML = `<div class="alert alert-error">Password must be at least 8 characters.</div>`;
return;
}
btn.disabled = true;
try {
await API.users.changePassword({
current_password: document.getElementById('cur-pw').value,
new_password: newPw,
});
alert.innerHTML = `<div class="alert alert-success">Password updated.</div>`;
e.target.reset();
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

101
frontend/register.html Normal file
View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Register — Bourbonacci</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<nav>
<a href="/index.html" class="nav-brand">🥃 Bourbonacci</a>
<div class="nav-links" id="nav-links"></div>
<div id="nav-user"></div>
</nav>
<div class="auth-wrap">
<div class="auth-logo">
<h1>Start Your Bottle</h1>
<p>Create an account to track your infinity bottle</p>
</div>
<div class="card">
<div id="alert"></div>
<form id="register-form">
<div class="form-group">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" placeholder="Your name (public)" autocomplete="nickname" />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" required placeholder="you@example.com" autocomplete="email" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required placeholder="Min 8 characters" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="confirm">Confirm Password</label>
<input type="password" id="confirm" required placeholder="••••••••" autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:.5rem" id="submit-btn">Create Account</button>
</form>
<hr class="divider" />
<p style="text-align:center;color:var(--cream-dim);font-size:.9rem">
Already have an account? <a href="/login.html" style="color:var(--amber)">Sign in</a>
</p>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
Auth.redirectIfLoggedIn();
Auth.renderNav();
const form = document.getElementById('register-form');
const alert = document.getElementById('alert');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
alert.innerHTML = '';
const displayName = document.getElementById('display_name').value.trim();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const confirm = document.getElementById('confirm').value;
if (password !== confirm) {
alert.innerHTML = `<div class="alert alert-error">Passwords do not match.</div>`;
return;
}
if (password.length < 8) {
alert.innerHTML = `<div class="alert alert-error">Password must be at least 8 characters.</div>`;
return;
}
btn.disabled = true;
btn.textContent = 'Creating account…';
try {
const data = await API.auth.register(email, password, displayName || undefined);
Auth.saveToken(data.access_token);
const user = await API.users.me();
Auth.saveUser(user);
window.location.href = '/dashboard.html';
} catch (err) {
alert.innerHTML = `<div class="alert alert-error">${err.message}</div>`;
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
});
</script>
</body>
</html>

18
nginx/default.conf Normal file
View File

@@ -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;
}
}