Add initial project files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal 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
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal 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
14
backend/app/config.py
Normal 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
20
backend/app/database.py
Normal 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)
|
||||
35
backend/app/dependencies.py
Normal file
35
backend/app/dependencies.py
Normal 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
28
backend/app/main.py
Normal 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)
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
28
backend/app/models/entry.py
Normal file
28
backend/app/models/entry.py
Normal 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")
|
||||
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal 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")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
39
backend/app/routers/auth.py
Normal file
39
backend/app/routers/auth.py
Normal 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))
|
||||
95
backend/app/routers/entries.py
Normal file
95
backend/app/routers/entries.py
Normal 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)
|
||||
41
backend/app/routers/public.py
Normal file
41
backend/app/routers/public.py
Normal 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
|
||||
47
backend/app/routers/users.py
Normal file
47
backend/app/routers/users.py
Normal 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)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
40
backend/app/schemas/entry.py
Normal file
40
backend/app/schemas/entry.py
Normal 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]
|
||||
39
backend/app/schemas/user.py
Normal file
39
backend/app/schemas/user.py
Normal 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
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
34
backend/app/utils/security.py
Normal file
34
backend/app/utils/security.py
Normal 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
9
backend/requirements.txt
Normal 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
41
docker-compose.yml
Normal 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
417
frontend/css/style.css
Normal 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
149
frontend/dashboard.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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
91
frontend/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
59
frontend/js/api.js
Normal file
59
frontend/js/api.js
Normal 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
72
frontend/js/auth.js
Normal 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
162
frontend/log.html
Normal 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
80
frontend/login.html
Normal 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
162
frontend/profile.html
Normal 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
101
frontend/register.html
Normal 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
18
nginx/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user