initial commit

This commit is contained in:
Maciek "mab122" Bator 2026-01-26 13:53:17 +01:00
commit efc950f7bc
25 changed files with 1930 additions and 0 deletions

166
README.md Normal file
View File

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

9
backend/.dockerignore Normal file
View File

@ -0,0 +1,9 @@
__pycache__
*.pyc
*.pyo
.env
.venv
venv
.git
.gitignore
*.md

5
backend/.env Normal file
View File

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

5
backend/.env.example Normal file
View File

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

12
backend/Dockerfile Normal file
View File

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

Binary file not shown.

Binary file not shown.

18
backend/config.py Normal file
View File

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

33
backend/main.py Normal file
View File

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

7
backend/requirements.txt Normal file
View File

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

View File

@ -0,0 +1 @@
# Routers package

Binary file not shown.

Binary file not shown.

Binary file not shown.

173
backend/routers/analyze.py Normal file
View File

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

239
backend/routers/homebox.py Normal file
View File

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

29
docker-compose.yml Normal file
View File

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

493
frontend/app.js Normal file
View File

@ -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 = '<option value="">Select location...</option>';
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 => `
<span class="tag-chip ${label.isNew ? 'tag-new' : ''}">
${label.name}${label.isNew ? ' (new)' : ''}
<span class="remove-tag" data-tag="${label.name}">&times;</span>
</span>
`).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 => `
<div class="tag-suggestion" data-id="${label.id}" data-name="${label.name}">
${label.name}
</div>
`).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();

143
frontend/index.html Normal file
View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Homebox AI</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen">
<h1>Homebox AI</h1>
<form id="login-form">
<input type="email" id="username" placeholder="Email" required autocomplete="username">
<input type="password" id="password" placeholder="Password" required autocomplete="current-password">
<button type="submit">Login</button>
</form>
<div id="login-error" class="error hidden"></div>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<!-- Camera Section -->
<div id="camera-section">
<video id="camera-preview" autoplay playsinline></video>
<canvas id="photo-canvas" class="hidden"></canvas>
<img id="photo-preview" class="hidden" alt="Captured photo">
<div id="camera-controls">
<button id="capture-btn" class="primary-btn">Capture</button>
<button id="retake-btn" class="secondary-btn hidden">Retake</button>
</div>
</div>
<!-- Status/Loading -->
<div id="status-section" class="hidden">
<div class="spinner"></div>
<span id="status-text">Analyzing...</span>
</div>
<!-- Form Section -->
<div id="form-section" class="hidden">
<div class="form-group">
<label for="item-name">Name *</label>
<input type="text" id="item-name" placeholder="Item name" required>
</div>
<div class="form-group">
<label for="item-description">Description</label>
<textarea id="item-description" placeholder="Description" rows="2"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="item-location">Location *</label>
<select id="item-location">
<option value="">Select location...</option>
</select>
</div>
<div class="form-group">
<label for="item-quantity">Qty</label>
<input type="number" id="item-quantity" value="1" min="1">
</div>
</div>
<div class="form-group">
<label>Labels</label>
<div id="tags-container">
<div id="selected-tags"></div>
<div id="tag-input-wrapper">
<input type="text" id="tag-input" placeholder="Add label...">
<div id="tag-suggestions" class="hidden"></div>
</div>
</div>
</div>
<!-- Collapsible Details -->
<details class="more-details">
<summary>More Details</summary>
<div class="form-row">
<div class="form-group">
<label for="item-manufacturer">Manufacturer</label>
<input type="text" id="item-manufacturer" placeholder="Brand/Manufacturer">
</div>
<div class="form-group">
<label for="item-model">Model</label>
<input type="text" id="item-model" placeholder="Model number">
</div>
</div>
<div class="form-group">
<label for="item-serial">Serial Number</label>
<input type="text" id="item-serial" placeholder="Serial number">
</div>
<div class="form-row">
<div class="form-group">
<label for="item-price">Purchase Price</label>
<input type="number" id="item-price" placeholder="0.00" step="0.01" min="0">
</div>
<div class="form-group">
<label for="item-purchase-from">Purchased From</label>
<input type="text" id="item-purchase-from" placeholder="Store/Seller">
</div>
</div>
<div class="form-group">
<label for="item-notes">Notes</label>
<textarea id="item-notes" placeholder="Additional notes" rows="2"></textarea>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="item-insured">
<span>Insured</span>
</label>
</div>
</details>
<button id="save-btn" class="primary-btn">Save to Homebox</button>
</div>
<!-- Success Message -->
<div id="success-section" class="hidden">
<div class="success-icon">&#10003;</div>
<p>Item saved successfully!</p>
<button id="new-item-btn" class="primary-btn">Add Another Item</button>
</div>
<!-- Error Display -->
<div id="error-section" class="hidden">
<p id="error-text"></p>
<button id="retry-btn" class="secondary-btn">Try Again</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

407
frontend/styles.css Normal file
View File

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

22
nginx/certs/cert.pem Normal file
View File

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

28
nginx/certs/key.pem Normal file
View File

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

15
nginx/generate-cert.sh Executable file
View File

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

67
nginx/nginx.conf Normal file
View File

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

58
nginx/nginx.docker.conf Normal file
View File

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