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