commit efc950f7bc69c8be705034359445969954f2a929 Author: Maciek "mab122" Bator Date: Mon Jan 26 13:53:17 2026 +0100 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..505e476 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Homebox AI Frontend + +Mobile-first web app for adding items to Homebox with AI-powered image analysis via Ollama. + +## Prerequisites + +- Python 3.11+ +- Nginx +- Ollama with a vision model +- Running Homebox instance + +## Quick Start + +### 1. Pull an Ollama vision model + +For development (16GB RAM, iGPU): +```bash +ollama pull moondream +``` + +For production (6GB+ VRAM): +```bash +ollama pull llava:13b-v1.6-q4_0 +``` + +### 2. Generate SSL certificate + +```bash +cd nginx +chmod +x generate-cert.sh +./generate-cert.sh +``` + +### 3. Set up Python environment + +```bash +cd backend +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +### 4. Configure environment + +Create `backend/.env`: +```bash +OLLAMA_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=moondream +HOMEBOX_URL=http://127.0.0.1:3100 +# Optional: pre-configured token (skip login) +# HOMEBOX_TOKEN=your_token_here +``` + +### 5. Configure and start Nginx + +Copy or symlink the config: +```bash +# Option A: Use the config directly (adjust paths as needed) +sudo cp nginx/nginx.conf /etc/nginx/nginx.conf + +# Edit paths in nginx.conf: +# - ssl_certificate: point to your certs +# - root: point to your frontend folder + +sudo nginx -t # Test config +sudo systemctl restart nginx +``` + +Or for development, run nginx with custom config: +```bash +sudo nginx -c $(pwd)/nginx/nginx.conf +``` + +### 6. Start the backend + +```bash +cd backend +source venv/bin/activate +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +### 7. Access the app + +Open https://localhost in your browser. Accept the self-signed certificate warning. + +For mobile access on the same network, use your computer's IP: +```bash +# Find your IP +ip addr show | grep inet +# or +hostname -I +``` + +Then access `https://YOUR_IP` on your phone. + +## Project Structure + +``` +homebox_ai_frontend/ +├── frontend/ +│ ├── index.html # Single page app +│ ├── styles.css # Mobile-first styles +│ └── app.js # Camera, form, API calls +├── backend/ +│ ├── main.py # FastAPI app +│ ├── config.py # Environment config +│ ├── requirements.txt +│ └── routers/ +│ ├── analyze.py # Ollama integration +│ └── homebox.py # Homebox API proxy +├── nginx/ +│ ├── nginx.conf # HTTPS proxy config +│ ├── generate-cert.sh +│ └── certs/ # SSL certificates +└── README.md +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Health check | +| POST | `/api/login` | Login to Homebox | +| POST | `/api/analyze` | Analyze image with AI | +| GET | `/api/locations` | Get Homebox locations | +| GET | `/api/labels` | Get Homebox labels/tags | +| POST | `/api/items` | Create item in Homebox | +| POST | `/api/items/{id}/attachments` | Upload photo | + +## Usage + +1. Login with your Homebox credentials +2. Point camera at an item and tap **Capture** +3. Wait for AI analysis (suggestions populate automatically) +4. Edit name, description, location, and tags as needed +5. Tap **Save to Homebox** + +## Troubleshooting + +### Camera not working +- Ensure HTTPS is being used (required for camera API) +- Check browser permissions for camera access +- On iOS Safari, camera access requires user interaction + +### AI analysis slow/failing +- Check Ollama is running: `curl http://localhost:11434/api/tags` +- Try a smaller model like `moondream` +- Increase timeout in nginx.conf if needed + +### Homebox connection issues +- Verify Homebox URL in `.env` +- Check Homebox is accessible: `curl http://localhost:3100/api/v1/status` +- Ensure correct credentials + +## Development + +Run backend with auto-reload: +```bash +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +Frontend changes take effect immediately (just refresh browser). + +## License + +MIT diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1f7e136 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +*.pyo +.env +.venv +venv +.git +.gitignore +*.md diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..636e9b2 --- /dev/null +++ b/backend/.env @@ -0,0 +1,5 @@ +OLLAMA_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=ministral-3:3b +HOMEBOX_URL=http://127.0.0.1:3100 +# Optional: pre-configured Homebox API token (skip login) +# HOMEBOX_TOKEN= diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cc2a105 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +OLLAMA_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=moondream +HOMEBOX_URL=http://127.0.0.1:3100 +# Optional: pre-configured Homebox API token (skip login) +# HOMEBOX_TOKEN= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b3a66cf --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/__pycache__/config.cpython-314.pyc b/backend/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..9688609 Binary files /dev/null and b/backend/__pycache__/config.cpython-314.pyc differ diff --git a/backend/__pycache__/main.cpython-314.pyc b/backend/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..e8fb2f3 Binary files /dev/null and b/backend/__pycache__/main.cpython-314.pyc differ diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..349bd3d --- /dev/null +++ b/backend/config.py @@ -0,0 +1,18 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + ollama_url: str = "http://127.0.0.1:11434" + ollama_model: str = "moondream" + homebox_url: str = "http://127.0.0.1:3100" + homebox_token: str = "" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e11b580 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from routers import analyze, homebox + +app = FastAPI( + title="Homebox AI Frontend API", + description="Backend for AI-powered Homebox item addition", + version="1.0.0", +) + +# CORS middleware for development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(analyze.router, prefix="/api", tags=["analyze"]) +app.include_router(homebox.router, prefix="/api", tags=["homebox"]) + + +@app.get("/api/health") +async def health_check(): + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..02beb50 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +httpx>=0.26.0 +python-multipart>=0.0.6 +python-dotenv>=1.0.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..873f7bb --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# Routers package diff --git a/backend/routers/__pycache__/__init__.cpython-314.pyc b/backend/routers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..eb5b504 Binary files /dev/null and b/backend/routers/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/routers/__pycache__/analyze.cpython-314.pyc b/backend/routers/__pycache__/analyze.cpython-314.pyc new file mode 100644 index 0000000..243d113 Binary files /dev/null and b/backend/routers/__pycache__/analyze.cpython-314.pyc differ diff --git a/backend/routers/__pycache__/homebox.cpython-314.pyc b/backend/routers/__pycache__/homebox.cpython-314.pyc new file mode 100644 index 0000000..afa352a Binary files /dev/null and b/backend/routers/__pycache__/homebox.cpython-314.pyc differ diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py new file mode 100644 index 0000000..1622124 --- /dev/null +++ b/backend/routers/analyze.py @@ -0,0 +1,173 @@ +import base64 +import json +from typing import Optional + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from config import get_settings + +router = APIRouter() + + +class LocationOption(BaseModel): + id: str + name: str + + +class LabelOption(BaseModel): + id: str + name: str + + +class AnalyzeRequest(BaseModel): + image: str # Base64 encoded image data + locations: list[LocationOption] = [] + labels: list[LabelOption] = [] + + +class AnalyzeResponse(BaseModel): + name: str + description: str + existing_labels: list[str] # Labels that exist in Homebox + new_labels: list[str] # AI-suggested labels that don't exist yet + suggested_location: str # Location name from available locations + raw_response: Optional[str] = None + + +def build_prompt(locations: list[LocationOption], labels: list[LabelOption]) -> str: + """Build prompt with actual available locations and labels.""" + location_names = [loc.name for loc in locations] if locations else ["Storage"] + label_names = [lbl.name for lbl in labels] if labels else [] + + prompt = """Look at this image and identify the item for a home inventory system. + +Respond with ONLY a JSON object (no other text) with these fields: + +- name: Short descriptive name for the item (e.g. "Red Handled Scissors", "DeWalt Cordless Drill") +- description: Brief description - color, brand, condition, notable features +- labels: Array of 1-4 relevant category labels for this item +- location: Best storage location from the AVAILABLE LOCATIONS below (use exact name) + +AVAILABLE LOCATIONS (you MUST pick one exactly as written): +""" + prompt += "\n".join(f"- {name}" for name in location_names) + + if label_names: + prompt += "\n\nEXISTING LABELS (prefer these if they fit, use exact names):\n" + prompt += "\n".join(f"- {name}" for name in label_names) + prompt += "\n\nFor labels: Use existing labels above if they match. If the item needs a label that doesn't exist, suggest a new short label name (lowercase, single word or hyphenated like 'power-tools')." + else: + prompt += "\n\nNo labels exist yet. Suggest 1-3 short label names (lowercase, single word or hyphenated like 'electronics' or 'hand-tools')." + + prompt += "\n\nRespond with ONLY the JSON object." + return prompt + + +def parse_response(response_text: str, locations: list[LocationOption], labels: list[LabelOption]) -> dict: + """Parse AI response and separate existing vs new labels.""" + # Extract JSON from response + data = {} + try: + start = response_text.find('{') + end = response_text.rfind('}') + if start != -1 and end != -1: + data = json.loads(response_text[start:end + 1]) + except json.JSONDecodeError: + pass + + # Get name and description + name = str(data.get("name", "Unknown Item")) + description = str(data.get("description", "")) + + # Validate location against available options + location_names = {loc.name.lower(): loc.name for loc in locations} + suggested_loc = str(data.get("location", "")) + if suggested_loc.lower() in location_names: + location = location_names[suggested_loc.lower()] + elif locations: + location = locations[0].name + else: + location = "" + + # Separate existing labels from new suggestions + existing_label_map = {lbl.name.lower(): lbl.name for lbl in labels} + existing_labels = [] + new_labels = [] + + raw_labels = data.get("labels", data.get("tags", [])) + if isinstance(raw_labels, list): + for lbl in raw_labels: + lbl_str = str(lbl).strip() + if not lbl_str: + continue + lbl_lower = lbl_str.lower() + if lbl_lower in existing_label_map: + existing_labels.append(existing_label_map[lbl_lower]) + else: + # Clean up new label suggestion + new_label = lbl_lower.replace(' ', '-') + if new_label and new_label not in [l.lower() for l in new_labels]: + new_labels.append(new_label) + + return { + "name": name, + "description": description, + "existing_labels": existing_labels, + "new_labels": new_labels, + "location": location, + } + + +@router.post("/analyze", response_model=AnalyzeResponse) +async def analyze_image(request: AnalyzeRequest): + """Send image to Ollama for analysis and return suggestions.""" + settings = get_settings() + + # Strip data URL prefix if present + image_data = request.image + if "," in image_data: + image_data = image_data.split(",", 1)[1] + + # Validate base64 + try: + base64.b64decode(image_data) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 image data") + + # Build prompt with available options + prompt = build_prompt(request.locations, request.labels) + + # Call Ollama API + ollama_url = f"{settings.ollama_url}/api/generate" + payload = { + "model": settings.ollama_model, + "prompt": prompt, + "images": [image_data], + "stream": False, + } + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post(ollama_url, json=payload) + response.raise_for_status() + result = response.json() + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="Ollama request timed out") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Failed to connect to Ollama: {str(e)}") + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=502, detail=f"Ollama error: {e.response.text}") + + raw_response = result.get("response", "") + parsed = parse_response(raw_response, request.locations, request.labels) + + return AnalyzeResponse( + name=parsed["name"], + description=parsed["description"], + existing_labels=parsed["existing_labels"], + new_labels=parsed["new_labels"], + suggested_location=parsed["location"], + raw_response=raw_response, + ) diff --git a/backend/routers/homebox.py b/backend/routers/homebox.py new file mode 100644 index 0000000..5d12d96 --- /dev/null +++ b/backend/routers/homebox.py @@ -0,0 +1,239 @@ +import base64 +from typing import Optional + +import httpx +from fastapi import APIRouter, HTTPException, Depends, Header +from pydantic import BaseModel + +from config import get_settings, Settings + +router = APIRouter() + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + token: str + expires_at: Optional[str] = None + + +class ItemCreate(BaseModel): + name: str + description: str = "" + location_id: str # Required by Homebox + label_ids: list[str] = [] + quantity: int = 1 + serial_number: str = "" + model_number: str = "" + manufacturer: str = "" + purchase_price: float = 0 + purchase_from: str = "" + notes: str = "" + insured: bool = False + + +class ItemResponse(BaseModel): + id: str + name: str + + +class AttachmentUpload(BaseModel): + image: str # Base64 encoded + filename: str = "photo.jpg" + + +class LabelCreate(BaseModel): + name: str + + +class LabelResponse(BaseModel): + id: str + name: str + + +def get_homebox_client(settings: Settings = Depends(get_settings)): + """Create httpx client for Homebox API.""" + return httpx.AsyncClient( + base_url=settings.homebox_url, + timeout=30.0, + ) + + +def get_auth_header( + authorization: Optional[str] = Header(None), + settings: Settings = Depends(get_settings), +) -> dict: + """Get authorization header from request or config.""" + if authorization: + return {"Authorization": authorization} + if settings.homebox_token: + return {"Authorization": f"Bearer {settings.homebox_token}"} + return {} + + +@router.post("/login", response_model=LoginResponse) +async def login(request: LoginRequest, settings: Settings = Depends(get_settings)): + """Login to Homebox and get auth token.""" + async with httpx.AsyncClient(base_url=settings.homebox_url, timeout=30.0) as client: + try: + response = await client.post( + "/api/v1/users/login", + json={ + "username": request.username, + "password": request.password, + }, + ) + response.raise_for_status() + data = response.json() + token = data.get("token", "") + # Homebox returns "Bearer ..." prefix, strip it + if token.startswith("Bearer "): + token = token[7:] + return LoginResponse( + token=token, + expires_at=data.get("expiresAt"), + ) + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, + detail="Login failed - check credentials", + ) + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Cannot connect to Homebox: {str(e)}") + + +@router.get("/locations") +async def get_locations( + settings: Settings = Depends(get_settings), + auth: dict = Depends(get_auth_header), +): + """Get all locations from Homebox.""" + async with httpx.AsyncClient(base_url=settings.homebox_url, timeout=30.0) as client: + try: + response = await client.get("/api/v1/locations", headers=auth) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail="Failed to fetch locations") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Cannot connect to Homebox: {str(e)}") + + +@router.get("/labels") +async def get_labels( + settings: Settings = Depends(get_settings), + auth: dict = Depends(get_auth_header), +): + """Get all labels/tags from Homebox.""" + async with httpx.AsyncClient(base_url=settings.homebox_url, timeout=30.0) as client: + try: + response = await client.get("/api/v1/labels", headers=auth) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail="Failed to fetch labels") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Cannot connect to Homebox: {str(e)}") + + +@router.post("/labels", response_model=LabelResponse) +async def create_label( + label: LabelCreate, + settings: Settings = Depends(get_settings), + auth: dict = Depends(get_auth_header), +): + """Create a new label in Homebox.""" + async with httpx.AsyncClient(base_url=settings.homebox_url, timeout=30.0) as client: + try: + response = await client.post( + "/api/v1/labels", + json={"name": label.name}, + headers=auth, + ) + response.raise_for_status() + data = response.json() + return LabelResponse(id=data["id"], name=data["name"]) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=f"Failed to create label: {e.response.text}") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Cannot connect to Homebox: {str(e)}") + + +@router.post("/items", response_model=ItemResponse) +async def create_item( + item: ItemCreate, + settings: Settings = Depends(get_settings), + auth: dict = Depends(get_auth_header), +): + """Create a new item in Homebox.""" + async with httpx.AsyncClient(base_url=settings.homebox_url, timeout=30.0) as client: + try: + payload = { + "name": item.name, + "description": item.description, + "locationId": item.location_id, + "quantity": item.quantity, + "serialNumber": item.serial_number, + "modelNumber": item.model_number, + "manufacturer": item.manufacturer, + "purchasePrice": item.purchase_price, + "purchaseFrom": item.purchase_from, + "notes": item.notes, + "insured": item.insured, + } + if item.label_ids: + payload["labelIds"] = item.label_ids + + response = await client.post("/api/v1/items", json=payload, headers=auth) + response.raise_for_status() + data = response.json() + return ItemResponse(id=data["id"], name=data["name"]) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=f"Failed to create item: {e.response.text}") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Cannot connect to Homebox: {str(e)}") + + +@router.post("/items/{item_id}/attachments") +async def upload_attachment( + item_id: str, + attachment: AttachmentUpload, + settings: Settings = Depends(get_settings), + auth: dict = Depends(get_auth_header), +): + """Upload an image attachment to an item.""" + # Strip data URL prefix if present + image_data = attachment.image + if "," in image_data: + image_data = image_data.split(",", 1)[1] + + try: + image_bytes = base64.b64decode(image_data) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 image data") + + async with httpx.AsyncClient(base_url=settings.homebox_url, timeout=60.0) as client: + try: + files = { + "file": (attachment.filename, image_bytes, "image/jpeg"), + } + data = { + "name": attachment.filename, + "type": "photo", + "primary": "true", + } + response = await client.post( + f"/api/v1/items/{item_id}/attachments", + files=files, + data=data, + headers=auth, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=f"Failed to upload attachment: {e.response.text}") + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail=f"Cannot connect to Homebox: {str(e)}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..18e9576 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - OLLAMA_URL=http://host.docker.internal:11434 + - OLLAMA_MODEL=${OLLAMA_MODEL:-ministral-3:3b} + - HOMEBOX_URL=http://host.docker.internal:3100 + - HOMEBOX_TOKEN=${HOMEBOX_TOKEN:-} + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + # Uncomment to expose backend directly for debugging + # ports: + # - "8000:8000" + + nginx: + image: nginx:alpine + ports: + - "443:443" + - "80:80" + volumes: + - ./nginx/nginx.docker.conf:/etc/nginx/nginx.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro + - ./frontend:/usr/share/nginx/html:ro + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..cdcfd85 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,493 @@ +// State +const state = { + token: localStorage.getItem('homebox_token') || '', + locations: [], + labels: [], + selectedLabels: [], // [{id, name, isNew}] + capturedImage: null, + stream: null, +}; + +// DOM Elements +const elements = { + // Screens + loginScreen: document.getElementById('login-screen'), + mainScreen: document.getElementById('main-screen'), + + // Login + loginForm: document.getElementById('login-form'), + username: document.getElementById('username'), + password: document.getElementById('password'), + loginError: document.getElementById('login-error'), + + // Camera + cameraPreview: document.getElementById('camera-preview'), + photoCanvas: document.getElementById('photo-canvas'), + photoPreview: document.getElementById('photo-preview'), + captureBtn: document.getElementById('capture-btn'), + retakeBtn: document.getElementById('retake-btn'), + + // Status + statusSection: document.getElementById('status-section'), + statusText: document.getElementById('status-text'), + + // Form + formSection: document.getElementById('form-section'), + itemName: document.getElementById('item-name'), + itemDescription: document.getElementById('item-description'), + itemLocation: document.getElementById('item-location'), + itemQuantity: document.getElementById('item-quantity'), + selectedTags: document.getElementById('selected-tags'), + tagInput: document.getElementById('tag-input'), + tagSuggestions: document.getElementById('tag-suggestions'), + // More details + itemManufacturer: document.getElementById('item-manufacturer'), + itemModel: document.getElementById('item-model'), + itemSerial: document.getElementById('item-serial'), + itemPrice: document.getElementById('item-price'), + itemPurchaseFrom: document.getElementById('item-purchase-from'), + itemNotes: document.getElementById('item-notes'), + itemInsured: document.getElementById('item-insured'), + saveBtn: document.getElementById('save-btn'), + + // Success/Error + successSection: document.getElementById('success-section'), + newItemBtn: document.getElementById('new-item-btn'), + errorSection: document.getElementById('error-section'), + errorText: document.getElementById('error-text'), + retryBtn: document.getElementById('retry-btn'), +}; + +// API calls +async function api(endpoint, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...(state.token && { Authorization: `Bearer ${state.token}` }), + ...options.headers, + }; + + const response = await fetch(`/api${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Request failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return response.json(); +} + +// Login +async function login(username, password) { + const data = await api('/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); + state.token = data.token; + localStorage.setItem('homebox_token', data.token); + return data; +} + +// Fetch locations and labels +async function fetchMetadata() { + const [locations, labels] = await Promise.all([ + api('/locations'), + api('/labels'), + ]); + state.locations = locations; + state.labels = labels; + populateLocationDropdown(); +} + +function populateLocationDropdown() { + elements.itemLocation.innerHTML = ''; + state.locations.forEach(loc => { + const option = document.createElement('option'); + option.value = loc.id; + option.textContent = loc.name; + elements.itemLocation.appendChild(option); + }); +} + +// Camera functions +async function startCamera() { + try { + state.stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: false, + }); + elements.cameraPreview.srcObject = state.stream; + } catch (err) { + showError('Camera access denied. Please enable camera permissions.'); + } +} + +function stopCamera() { + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } +} + +function capturePhoto() { + const video = elements.cameraPreview; + const canvas = elements.photoCanvas; + + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0); + + state.capturedImage = canvas.toDataURL('image/jpeg', 0.85); + elements.photoPreview.src = state.capturedImage; + + // Switch to preview mode + elements.cameraPreview.classList.add('hidden'); + elements.photoPreview.classList.remove('hidden'); + elements.captureBtn.classList.add('hidden'); + elements.retakeBtn.classList.remove('hidden'); + + stopCamera(); + analyzeImage(); +} + +function retakePhoto() { + state.capturedImage = null; + elements.photoPreview.classList.add('hidden'); + elements.cameraPreview.classList.remove('hidden'); + elements.retakeBtn.classList.add('hidden'); + elements.captureBtn.classList.remove('hidden'); + elements.formSection.classList.add('hidden'); + elements.statusSection.classList.add('hidden'); + startCamera(); +} + +// AI Analysis +async function analyzeImage() { + showStatus('Analyzing image...'); + + try { + // Send image with available locations and labels for AI to choose from + const result = await api('/analyze', { + method: 'POST', + body: JSON.stringify({ + image: state.capturedImage, + locations: state.locations.map(l => ({ id: l.id, name: l.name })), + labels: state.labels.map(l => ({ id: l.id, name: l.name })), + }), + }); + + // Populate form + elements.itemName.value = result.name; + elements.itemDescription.value = result.description; + + // Set location (AI returns exact name from our list) + const matchedLocation = state.locations.find( + loc => loc.name.toLowerCase() === result.suggested_location.toLowerCase() + ); + if (matchedLocation) { + elements.itemLocation.value = matchedLocation.id; + } else if (state.locations.length > 0) { + elements.itemLocation.value = state.locations[0].id; + } + + // Add labels - existing ones from Homebox + state.selectedLabels = []; + (result.existing_labels || []).forEach(labelName => { + const label = state.labels.find(l => l.name.toLowerCase() === labelName.toLowerCase()); + if (label) { + addLabel({ id: label.id, name: label.name, isNew: false }); + } + }); + // Add new label suggestions from AI + (result.new_labels || []).forEach(labelName => { + addLabel({ id: null, name: labelName, isNew: true }); + }); + + hideStatus(); + elements.formSection.classList.remove('hidden'); + } catch (err) { + hideStatus(); + showError(`Analysis failed: ${err.message}`); + } +} + +// Labels +function addLabel(label) { + const name = label.name.trim().toLowerCase(); + if (!name || state.selectedLabels.some(l => l.name.toLowerCase() === name)) return; + + state.selectedLabels.push({ + id: label.id || null, + name: label.name, + isNew: label.isNew || !label.id, + }); + renderLabels(); +} + +function removeLabel(labelName) { + state.selectedLabels = state.selectedLabels.filter(l => l.name.toLowerCase() !== labelName.toLowerCase()); + renderLabels(); +} + +function renderLabels() { + elements.selectedTags.innerHTML = state.selectedLabels.map(label => ` + + ${label.name}${label.isNew ? ' (new)' : ''} + × + + `).join(''); +} + +function showTagSuggestions(query) { + if (!query) { + elements.tagSuggestions.classList.add('hidden'); + return; + } + + const selectedNames = state.selectedLabels.map(l => l.name.toLowerCase()); + const matches = state.labels.filter(label => + label.name.toLowerCase().includes(query.toLowerCase()) && + !selectedNames.includes(label.name.toLowerCase()) + ).slice(0, 5); + + if (matches.length === 0) { + elements.tagSuggestions.classList.add('hidden'); + return; + } + + elements.tagSuggestions.innerHTML = matches.map(label => ` +
+ ${label.name} +
+ `).join(''); + elements.tagSuggestions.classList.remove('hidden'); +} + +// Save item +async function saveItem() { + hideError(); + + // Validate required fields + if (!elements.itemName.value.trim()) { + showError('Please enter an item name'); + return; + } + if (!elements.itemLocation.value) { + showError('Please select a location'); + return; + } + + elements.saveBtn.disabled = true; + showStatus('Saving to Homebox...'); + + try { + // Create any new labels first + const newLabels = state.selectedLabels.filter(l => l.isNew); + for (const label of newLabels) { + showStatus(`Creating label: ${label.name}...`); + const created = await api('/labels', { + method: 'POST', + body: JSON.stringify({ name: label.name }), + }); + // Update the label with the new ID + label.id = created.id; + label.isNew = false; + // Add to state.labels so it's available for future items + state.labels.push({ id: created.id, name: created.name }); + } + + // Collect all label IDs + const labelIds = state.selectedLabels.map(l => l.id).filter(Boolean); + + showStatus('Creating item...'); + // Create item + const item = await api('/items', { + method: 'POST', + body: JSON.stringify({ + name: elements.itemName.value.trim(), + description: elements.itemDescription.value.trim(), + location_id: elements.itemLocation.value, + label_ids: labelIds, + quantity: parseInt(elements.itemQuantity.value) || 1, + serial_number: elements.itemSerial.value.trim(), + model_number: elements.itemModel.value.trim(), + manufacturer: elements.itemManufacturer.value.trim(), + purchase_price: parseFloat(elements.itemPrice.value) || 0, + purchase_from: elements.itemPurchaseFrom.value.trim(), + notes: elements.itemNotes.value.trim(), + insured: elements.itemInsured.checked, + }), + }); + + // Upload photo + if (state.capturedImage) { + showStatus('Uploading photo...'); + await api(`/items/${item.id}/attachments`, { + method: 'POST', + body: JSON.stringify({ + image: state.capturedImage, + filename: 'photo.jpg', + }), + }); + } + + hideStatus(); + showSuccess(); + } catch (err) { + hideStatus(); + showError(`Failed to save: ${err.message}`); + elements.saveBtn.disabled = false; + } +} + +// UI helpers +function showStatus(text) { + elements.statusText.textContent = text; + elements.statusSection.classList.remove('hidden'); +} + +function hideStatus() { + elements.statusSection.classList.add('hidden'); +} + +function showError(message) { + elements.errorText.textContent = message; + elements.errorSection.classList.remove('hidden'); +} + +function hideError() { + elements.errorSection.classList.add('hidden'); +} + +function showSuccess() { + elements.formSection.classList.add('hidden'); + elements.successSection.classList.remove('hidden'); +} + +function resetToCapture() { + state.capturedImage = null; + state.selectedLabels = []; + + // Reset main fields + elements.itemName.value = ''; + elements.itemDescription.value = ''; + elements.itemLocation.value = ''; + elements.itemQuantity.value = '1'; + renderLabels(); + + // Reset additional fields + elements.itemManufacturer.value = ''; + elements.itemModel.value = ''; + elements.itemSerial.value = ''; + elements.itemPrice.value = ''; + elements.itemPurchaseFrom.value = ''; + elements.itemNotes.value = ''; + elements.itemInsured.checked = false; + + elements.successSection.classList.add('hidden'); + elements.errorSection.classList.add('hidden'); + elements.formSection.classList.add('hidden'); + elements.photoPreview.classList.add('hidden'); + elements.cameraPreview.classList.remove('hidden'); + elements.retakeBtn.classList.add('hidden'); + elements.captureBtn.classList.remove('hidden'); + elements.saveBtn.disabled = false; + + startCamera(); +} + +// Initialize +async function init() { + // Check for existing token + if (state.token) { + try { + await fetchMetadata(); + elements.loginScreen.classList.add('hidden'); + elements.mainScreen.classList.remove('hidden'); + startCamera(); + } catch { + // Token expired, show login + state.token = ''; + localStorage.removeItem('homebox_token'); + } + } + + // Event listeners + elements.loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + elements.loginError.classList.add('hidden'); + + try { + await login(elements.username.value, elements.password.value); + await fetchMetadata(); + elements.loginScreen.classList.add('hidden'); + elements.mainScreen.classList.remove('hidden'); + startCamera(); + } catch (err) { + elements.loginError.textContent = err.message; + elements.loginError.classList.remove('hidden'); + } + }); + + elements.captureBtn.addEventListener('click', capturePhoto); + elements.retakeBtn.addEventListener('click', retakePhoto); + elements.saveBtn.addEventListener('click', saveItem); + elements.newItemBtn.addEventListener('click', resetToCapture); + elements.retryBtn.addEventListener('click', () => { + hideError(); + if (state.capturedImage) { + analyzeImage(); + } else { + resetToCapture(); + } + }); + + // Label input + elements.tagInput.addEventListener('input', (e) => { + showTagSuggestions(e.target.value); + }); + + elements.tagInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const value = e.target.value.trim(); + if (value) { + // Check if it matches an existing label + const existing = state.labels.find(l => l.name.toLowerCase() === value.toLowerCase()); + if (existing) { + addLabel({ id: existing.id, name: existing.name, isNew: false }); + } else { + // Create as new label + addLabel({ id: null, name: value, isNew: true }); + } + } + e.target.value = ''; + elements.tagSuggestions.classList.add('hidden'); + } + }); + + elements.tagSuggestions.addEventListener('click', (e) => { + const suggestion = e.target.closest('.tag-suggestion'); + if (suggestion) { + addLabel({ id: suggestion.dataset.id, name: suggestion.dataset.name, isNew: false }); + elements.tagInput.value = ''; + elements.tagSuggestions.classList.add('hidden'); + } + }); + + elements.selectedTags.addEventListener('click', (e) => { + if (e.target.classList.contains('remove-tag')) { + removeLabel(e.target.dataset.tag); + } + }); +} + +init(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9e68e2d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,143 @@ + + + + + + + + Homebox AI + + + +
+ +
+

Homebox AI

+
+ + + +
+ +
+ + + +
+ + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..1f4ad63 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,407 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #1a1a2e; + color: #eee; +} + +#app { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.screen { + flex: 1; + display: flex; + flex-direction: column; + padding: 16px; + max-width: 500px; + margin: 0 auto; + width: 100%; +} + +.hidden { + display: none !important; +} + +/* Login Screen */ +#login-screen { + justify-content: center; + gap: 24px; +} + +#login-screen h1 { + text-align: center; + font-size: 28px; + color: #6c63ff; +} + +#login-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Form elements */ +input, textarea, select { + width: 100%; + padding: 14px 16px; + border: 1px solid #333; + border-radius: 8px; + background: #16213e; + color: #eee; + font-size: 16px; + outline: none; + transition: border-color 0.2s; +} + +input:focus, textarea:focus, select:focus { + border-color: #6c63ff; +} + +textarea { + resize: vertical; + min-height: 60px; +} + +select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} + +/* Buttons */ +button { + padding: 14px 24px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.1s, opacity 0.2s; +} + +button:active { + transform: scale(0.98); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.primary-btn { + background: #6c63ff; + color: white; +} + +.primary-btn:hover:not(:disabled) { + background: #5a52d5; +} + +.secondary-btn { + background: #333; + color: #eee; +} + +.secondary-btn:hover:not(:disabled) { + background: #444; +} + +/* Camera Section */ +#camera-section { + position: relative; + background: #000; + border-radius: 12px; + overflow: hidden; + aspect-ratio: 4/3; + margin-bottom: 16px; +} + +#camera-preview, #photo-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +#photo-canvas { + position: absolute; + top: 0; + left: 0; +} + +#camera-controls { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 12px; +} + +#capture-btn { + width: 64px; + height: 64px; + border-radius: 50%; + background: white; + border: 4px solid #6c63ff; + padding: 0; +} + +#capture-btn:active { + background: #ddd; +} + +/* Status Section */ +#status-section { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 20px; + background: #16213e; + border-radius: 8px; + margin-bottom: 16px; +} + +.spinner { + width: 24px; + height: 24px; + border: 3px solid #333; + border-top-color: #6c63ff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Form Section */ +#form-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.form-group label { + font-size: 14px; + color: #888; + font-weight: 500; +} + +.form-row { + display: flex; + gap: 12px; +} + +.form-row .form-group:last-child { + flex: 0 0 80px; +} + +/* Collapsible Details */ +.more-details { + background: #16213e; + border-radius: 8px; + padding: 12px; +} + +.more-details summary { + cursor: pointer; + color: #6c63ff; + font-weight: 500; + font-size: 14px; + user-select: none; +} + +.more-details[open] summary { + margin-bottom: 16px; +} + +.more-details .form-group { + margin-bottom: 12px; +} + +.more-details .form-group:last-child { + margin-bottom: 0; +} + +.more-details .form-row { + margin-bottom: 12px; +} + +.more-details .form-row .form-group { + margin-bottom: 0; + flex: 1; +} + +.more-details .form-row .form-group:last-child { + flex: 1; +} + +/* Checkbox */ +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: #eee; +} + +.checkbox-group input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: #6c63ff; +} + +/* Tags */ +#tags-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +#selected-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #6c63ff; + color: white; + border-radius: 16px; + font-size: 14px; +} + +.tag-chip .remove-tag { + cursor: pointer; + font-size: 16px; + line-height: 1; + opacity: 0.7; +} + +.tag-chip .remove-tag:hover { + opacity: 1; +} + +.tag-chip.tag-new { + background: transparent; + border: 2px dashed #6c63ff; + color: #6c63ff; +} + +#tag-input-wrapper { + position: relative; +} + +#tag-input { + width: 100%; +} + +#tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #16213e; + border: 1px solid #333; + border-radius: 8px; + margin-top: 4px; + max-height: 150px; + overflow-y: auto; + z-index: 10; +} + +.tag-suggestion { + padding: 10px 14px; + cursor: pointer; +} + +.tag-suggestion:hover { + background: #1f3460; +} + +/* Success Section */ +#success-section { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 40px; + text-align: center; +} + +.success-icon { + width: 64px; + height: 64px; + background: #4caf50; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; +} + +/* Error Section */ +#error-section { + background: #ff5252; + color: white; + padding: 16px; + border-radius: 8px; + text-align: center; +} + +#error-section p { + margin-bottom: 12px; +} + +.error { + color: #ff5252; + font-size: 14px; + text-align: center; +} + +/* Responsive adjustments */ +@media (min-width: 600px) { + .screen { + padding: 24px; + } + + #camera-section { + aspect-ratio: 16/9; + } +} + +/* Safe area for notched phones */ +@supports (padding-top: env(safe-area-inset-top)) { + #app { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} diff --git a/nginx/certs/cert.pem b/nginx/certs/cert.pem new file mode 100644 index 0000000..f528f0a --- /dev/null +++ b/nginx/certs/cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIUBed6Tv0l8js/MNc7255Gwd1bhSgwDQYJKoZIhvcNAQEL +BQAwSzELMAkGA1UEBhMCVVMxDDAKBgNVBAgMA0RldjEMMAoGA1UEBwwDRGV2MQww +CgYDVQQKDANEZXYxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjAxMjYxMjA4MzFa +Fw0yNzAxMjYxMjA4MzFaMEsxCzAJBgNVBAYTAlVTMQwwCgYDVQQIDANEZXYxDDAK +BgNVBAcMA0RldjEMMAoGA1UECgwDRGV2MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeATt8IprWUjDwkzok126AGk1q +pvSDgraZzFdpQGLwu3kosQQXameDTTymrZIWt+Duo+vVn4af+VIYJFDe10SG6YmW +Ly/PPXUg6orG4lgGJUizzx5WAiW9FBe3xBmppqHwd0tnNG5HmUOTWTxKn2x5DU0v +K7bvyAP/dC0htBwa6AEs2JDuRgX1tSWagbLZiwomJ1QWA9QyXpLqidBT04dAqWkX +I4bldX/NX7rl6nlBxDGeyBq/JH4Z8pcTSZuIFK57nT43qMqT7VJ947A5pdhGJuD7 +Htz/tCk6w/pvHQs9+735/y3KSIAvJ3kKtAub/fCKPrvYqZUGHC3nXKf7g14JAgMB +AAGjbzBtMB0GA1UdDgQWBBQElmtlcsn/97FlrmY6SQ+Ef1qQezAfBgNVHSMEGDAW +gBQElmtlcsn/97FlrmY6SQ+Ef1qQezAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT +MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAikvYMwsVtaEN +2PKbNvEd97+4jbKkXRBZ52QLXw/8P15Iy2TM0oZ/9s/1y+OCpuCCBX5T9PnOsGMZ +HzkTCQ/aQlsBa09qK+iH3wUvvsRp1HN1pPt6mHdvEykZja+p0CO5T50h9I5HiCWE +vYaC+L6LIJfRrgWnh4M3AEnS+EG9lIc9dwY2GMMqrCo3yrUMbM/LlpPyIwrEW2ai +bax0MaXc8QXblEuYG8Ltp6Axpi/PmXCOcibXeMWb4bWkj2uD7lm0fJzCVFCRVCM6 +dXLIgOYDCuwBZ8d1leY5Ffw32exyPhzA6R6pvoQEKo1ddYiHe/pnSEg5wqxfx9Y3 +chH6UqopaA== +-----END CERTIFICATE----- diff --git a/nginx/certs/key.pem b/nginx/certs/key.pem new file mode 100644 index 0000000..3f7b807 --- /dev/null +++ b/nginx/certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDeATt8IprWUjDw +kzok126AGk1qpvSDgraZzFdpQGLwu3kosQQXameDTTymrZIWt+Duo+vVn4af+VIY +JFDe10SG6YmWLy/PPXUg6orG4lgGJUizzx5WAiW9FBe3xBmppqHwd0tnNG5HmUOT +WTxKn2x5DU0vK7bvyAP/dC0htBwa6AEs2JDuRgX1tSWagbLZiwomJ1QWA9QyXpLq +idBT04dAqWkXI4bldX/NX7rl6nlBxDGeyBq/JH4Z8pcTSZuIFK57nT43qMqT7VJ9 +47A5pdhGJuD7Htz/tCk6w/pvHQs9+735/y3KSIAvJ3kKtAub/fCKPrvYqZUGHC3n +XKf7g14JAgMBAAECggEACwhypVuu6EHTbtFSTWxrcHBeMpOziSmg24ndOjzZ7e7T +OaN6eZNvK4ZWIk5z+S8QPavl14sDDMY+Pzbm0fxOyommrdEQhjMetBET7ohnKX8G +2vDLKCkZKQlrmFVllRnT6Hh1Rd7EMEi8pAEGTwsXP/jaQiLhB4+gKzzh1U/3KZsQ +Xm4JUoK6I3Mc8a1EbfW897WhKxX2EFMJ1uArFCA5G0iwQ77OaR23uzGSRgLNGuIr +JzKSV0dhn7NBzHVO9UNGC2DFCXXgdT0Rr4CJCaF0ajGXnA5Iz1a9cqxF63R1gel4 +CqexvmbXNF5/YngbQJ/ym80pLFHQ+0niEoYPQKVj5QKBgQD9ccYHgLgudxP1eoco +bD9MsZDHVzr3j5ubaVDjryCo2fGLvlS3/rrrr71y/8c47gA4v9f3sq/CIojjjU38 +i1Mav8KP/ZGOzfNnu6xTRxOiH+LElXaUJ2sQ7hSWZhax//ZP8QVN6a6+aTNN47jk +TEtOgKxCGaiOS0gEOKLXpDYp8wKBgQDgPk1te2I/E1IqArkPAdYpBgeWCtvzK+kT +Lb+l9XnJdCzHOemZOT+pB8JkOk/3LOpnluIusXWHJBEolMBS77C7iGLIdhG852sb +XfvmK4Rz9OedQAQtrcxfosYjXm7jtMmecWCm4oUJmNxfREKfbuLf/m3Zmat8YrqW +54sQgG/7EwKBgQDu8kFd+YGsbSAoJGkhN01GpXJ2Pkud84slrtlQkGAeUbxloACH +qGXap0naDkRp7BnRZy3anmEOizi8MREBtOmZIonw74Q4OvvtJQeHTE+6Xo2SAchW +TgOnZo+KbJ2hHE3BeN8jYdoaM1znZnd+5l9pl+7QMxizb05qjGsbG/rk+QKBgQCO +uDhxWNkDyhk+MbN0kLesLnE/lrO8hhL2qORUDPMxO2aQehRp99zDiPO49MAWUhjy +Sz6zdzCVDWh6SopJftId2UT9zt+lOiiaJ2v+Z24KmzGajLbcF8R+jcvLkPvEozi7 +Re+852jC3e5MF9bLQDjXVUi2+K4DIZceGkoQ+53JzwKBgQD1BJm5FdRo8KgIOllf +6N6bJgNXR8xVbTFSUZRFS6lVKBbf+lqX767wt+uuET2Ejcxxa8Wcf+jWRg/vWsA/ +nI7n3k2k5wXXZsEhg8mrM6IL+K1C/f572+gjouCosxoTiUV4QmwpG7eUuSvcRjLl +pl49j9FkYS7ZLF0Tp4nivDQsDg== +-----END PRIVATE KEY----- diff --git a/nginx/generate-cert.sh b/nginx/generate-cert.sh new file mode 100755 index 0000000..a79bf03 --- /dev/null +++ b/nginx/generate-cert.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Generate self-signed SSL certificate for development + +CERT_DIR="$(dirname "$0")/certs" +mkdir -p "$CERT_DIR" + +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$CERT_DIR/key.pem" \ + -out "$CERT_DIR/cert.pem" \ + -subj "/C=US/ST=Dev/L=Dev/O=Dev/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + +echo "Certificate generated in $CERT_DIR" +echo " - cert.pem (certificate)" +echo " - key.pem (private key)" diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..f93ae7e --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,67 @@ +# Homebox AI Frontend - Nginx Configuration +# +# SETUP: Update the paths below to match your installation: +# 1. ssl_certificate / ssl_certificate_key - path to generated certs +# 2. root - path to frontend folder +# +# Run: sudo nginx -c /full/path/to/this/nginx.conf + +worker_processes 1; +pid /tmp/nginx.pid; +error_log /tmp/nginx_error.log; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + access_log /tmp/nginx_access.log; + + # Increase max body size for image uploads + client_max_body_size 50M; + + server { + listen 443 ssl; + server_name localhost; + + # UPDATE THESE PATHS to your project location + ssl_certificate /home/mab122/devel/homebox_ai_frontend/nginx/certs/cert.pem; + ssl_certificate_key /home/mab122/devel/homebox_ai_frontend/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Frontend static files - UPDATE THIS PATH + location / { + root /home/mab122/devel/homebox_ai_frontend/frontend; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Backend API proxy + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + 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_set_header X-Forwarded-Proto $scheme; + + # Timeout settings for slow AI responses + proxy_connect_timeout 60s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + } + + # Redirect HTTP to HTTPS + server { + listen 80; + server_name localhost; + return 301 https://$host$request_uri; + } +} diff --git a/nginx/nginx.docker.conf b/nginx/nginx.docker.conf new file mode 100644 index 0000000..818a88c --- /dev/null +++ b/nginx/nginx.docker.conf @@ -0,0 +1,58 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + access_log /var/log/nginx/access.log; + + # Increase max body size for image uploads + client_max_body_size 50M; + + server { + listen 443 ssl; + server_name localhost; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Frontend static files + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Backend API proxy - uses docker service name + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + 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_set_header X-Forwarded-Proto $scheme; + + # Timeout settings for slow AI responses + proxy_connect_timeout 60s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + } + + # Redirect HTTP to HTTPS + server { + listen 80; + server_name localhost; + return 301 https://$host$request_uri; + } +}