initial commit
This commit is contained in:
commit
efc950f7bc
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
|
|
@ -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=
|
||||
|
|
@ -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=
|
||||
|
|
@ -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.
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Routers package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -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
|
||||
|
|
@ -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}">×</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();
|
||||
|
|
@ -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">✓</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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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-----
|
||||
|
|
@ -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-----
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue