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