// State const state = { token: localStorage.getItem('homebox_token') || '', locations: [], labels: [], selectedLabels: [], // [{id, name, isNew}] capturedImage: null, stream: null, }; // DOM Elements const elements = { // Screens loginScreen: document.getElementById('login-screen'), mainScreen: document.getElementById('main-screen'), // Login loginForm: document.getElementById('login-form'), username: document.getElementById('username'), password: document.getElementById('password'), loginError: document.getElementById('login-error'), // Camera cameraPreview: document.getElementById('camera-preview'), photoCanvas: document.getElementById('photo-canvas'), photoPreview: document.getElementById('photo-preview'), captureBtn: document.getElementById('capture-btn'), retakeBtn: document.getElementById('retake-btn'), // Status statusSection: document.getElementById('status-section'), statusText: document.getElementById('status-text'), // Form formSection: document.getElementById('form-section'), itemName: document.getElementById('item-name'), itemDescription: document.getElementById('item-description'), itemLocation: document.getElementById('item-location'), itemQuantity: document.getElementById('item-quantity'), selectedTags: document.getElementById('selected-tags'), tagInput: document.getElementById('tag-input'), tagSuggestions: document.getElementById('tag-suggestions'), // More details itemManufacturer: document.getElementById('item-manufacturer'), itemModel: document.getElementById('item-model'), itemSerial: document.getElementById('item-serial'), itemPrice: document.getElementById('item-price'), itemPurchaseFrom: document.getElementById('item-purchase-from'), itemNotes: document.getElementById('item-notes'), itemInsured: document.getElementById('item-insured'), saveBtn: document.getElementById('save-btn'), // Success/Error successSection: document.getElementById('success-section'), newItemBtn: document.getElementById('new-item-btn'), errorSection: document.getElementById('error-section'), errorText: document.getElementById('error-text'), retryBtn: document.getElementById('retry-btn'), }; // API calls async function api(endpoint, options = {}) { const headers = { 'Content-Type': 'application/json', ...(state.token && { Authorization: `Bearer ${state.token}` }), ...options.headers, }; const response = await fetch(`/api${endpoint}`, { ...options, headers, }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Request failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } return response.json(); } // Login async function login(username, password) { const data = await api('/login', { method: 'POST', body: JSON.stringify({ username, password }), }); state.token = data.token; localStorage.setItem('homebox_token', data.token); return data; } // Fetch locations and labels async function fetchMetadata() { const [locations, labels] = await Promise.all([ api('/locations'), api('/labels'), ]); state.locations = locations; state.labels = labels; populateLocationDropdown(); } function populateLocationDropdown() { elements.itemLocation.innerHTML = ''; state.locations.forEach(loc => { const option = document.createElement('option'); option.value = loc.id; option.textContent = loc.name; elements.itemLocation.appendChild(option); }); } // Camera functions async function startCamera() { try { state.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 }, }, audio: false, }); elements.cameraPreview.srcObject = state.stream; } catch (err) { showError('Camera access denied. Please enable camera permissions.'); } } function stopCamera() { if (state.stream) { state.stream.getTracks().forEach(track => track.stop()); state.stream = null; } } function capturePhoto() { const video = elements.cameraPreview; const canvas = elements.photoCanvas; canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0); state.capturedImage = canvas.toDataURL('image/jpeg', 0.85); elements.photoPreview.src = state.capturedImage; // Switch to preview mode elements.cameraPreview.classList.add('hidden'); elements.photoPreview.classList.remove('hidden'); elements.captureBtn.classList.add('hidden'); elements.retakeBtn.classList.remove('hidden'); stopCamera(); analyzeImage(); } function retakePhoto() { state.capturedImage = null; elements.photoPreview.classList.add('hidden'); elements.cameraPreview.classList.remove('hidden'); elements.retakeBtn.classList.add('hidden'); elements.captureBtn.classList.remove('hidden'); elements.formSection.classList.add('hidden'); elements.statusSection.classList.add('hidden'); startCamera(); } // AI Analysis async function analyzeImage() { showStatus('Analyzing image...'); try { // Send image with available locations and labels for AI to choose from const result = await api('/analyze', { method: 'POST', body: JSON.stringify({ image: state.capturedImage, locations: state.locations.map(l => ({ id: l.id, name: l.name })), labels: state.labels.map(l => ({ id: l.id, name: l.name })), }), }); // Populate form elements.itemName.value = result.name; elements.itemDescription.value = result.description; // Set location (AI returns exact name from our list) const matchedLocation = state.locations.find( loc => loc.name.toLowerCase() === result.suggested_location.toLowerCase() ); if (matchedLocation) { elements.itemLocation.value = matchedLocation.id; } else if (state.locations.length > 0) { elements.itemLocation.value = state.locations[0].id; } // Add labels - existing ones from Homebox state.selectedLabels = []; (result.existing_labels || []).forEach(labelName => { const label = state.labels.find(l => l.name.toLowerCase() === labelName.toLowerCase()); if (label) { addLabel({ id: label.id, name: label.name, isNew: false }); } }); // Add new label suggestions from AI (result.new_labels || []).forEach(labelName => { addLabel({ id: null, name: labelName, isNew: true }); }); hideStatus(); elements.formSection.classList.remove('hidden'); } catch (err) { hideStatus(); showError(`Analysis failed: ${err.message}`); } } // Labels function addLabel(label) { const name = label.name.trim().toLowerCase(); if (!name || state.selectedLabels.some(l => l.name.toLowerCase() === name)) return; state.selectedLabels.push({ id: label.id || null, name: label.name, isNew: label.isNew || !label.id, }); renderLabels(); } function removeLabel(labelName) { state.selectedLabels = state.selectedLabels.filter(l => l.name.toLowerCase() !== labelName.toLowerCase()); renderLabels(); } function renderLabels() { elements.selectedTags.innerHTML = state.selectedLabels.map(label => ` ${label.name}${label.isNew ? ' (new)' : ''} × `).join(''); } function showTagSuggestions(query) { if (!query) { elements.tagSuggestions.classList.add('hidden'); return; } const selectedNames = state.selectedLabels.map(l => l.name.toLowerCase()); const matches = state.labels.filter(label => label.name.toLowerCase().includes(query.toLowerCase()) && !selectedNames.includes(label.name.toLowerCase()) ).slice(0, 5); if (matches.length === 0) { elements.tagSuggestions.classList.add('hidden'); return; } elements.tagSuggestions.innerHTML = matches.map(label => `
${label.name}
`).join(''); elements.tagSuggestions.classList.remove('hidden'); } // Save item async function saveItem() { hideError(); // Validate required fields if (!elements.itemName.value.trim()) { showError('Please enter an item name'); return; } if (!elements.itemLocation.value) { showError('Please select a location'); return; } elements.saveBtn.disabled = true; showStatus('Saving to Homebox...'); try { // Create any new labels first const newLabels = state.selectedLabels.filter(l => l.isNew); for (const label of newLabels) { showStatus(`Creating label: ${label.name}...`); const created = await api('/labels', { method: 'POST', body: JSON.stringify({ name: label.name }), }); // Update the label with the new ID label.id = created.id; label.isNew = false; // Add to state.labels so it's available for future items state.labels.push({ id: created.id, name: created.name }); } // Collect all label IDs const labelIds = state.selectedLabels.map(l => l.id).filter(Boolean); showStatus('Creating item...'); // Create item const item = await api('/items', { method: 'POST', body: JSON.stringify({ name: elements.itemName.value.trim(), description: elements.itemDescription.value.trim(), location_id: elements.itemLocation.value, label_ids: labelIds, quantity: parseInt(elements.itemQuantity.value) || 1, serial_number: elements.itemSerial.value.trim(), model_number: elements.itemModel.value.trim(), manufacturer: elements.itemManufacturer.value.trim(), purchase_price: parseFloat(elements.itemPrice.value) || 0, purchase_from: elements.itemPurchaseFrom.value.trim(), notes: elements.itemNotes.value.trim(), insured: elements.itemInsured.checked, }), }); // Upload photo if (state.capturedImage) { showStatus('Uploading photo...'); await api(`/items/${item.id}/attachments`, { method: 'POST', body: JSON.stringify({ image: state.capturedImage, filename: 'photo.jpg', }), }); } hideStatus(); showSuccess(); } catch (err) { hideStatus(); showError(`Failed to save: ${err.message}`); elements.saveBtn.disabled = false; } } // UI helpers function showStatus(text) { elements.statusText.textContent = text; elements.statusSection.classList.remove('hidden'); } function hideStatus() { elements.statusSection.classList.add('hidden'); } function showError(message) { elements.errorText.textContent = message; elements.errorSection.classList.remove('hidden'); } function hideError() { elements.errorSection.classList.add('hidden'); } function showSuccess() { elements.formSection.classList.add('hidden'); elements.successSection.classList.remove('hidden'); } function resetToCapture() { state.capturedImage = null; state.selectedLabels = []; // Reset main fields elements.itemName.value = ''; elements.itemDescription.value = ''; elements.itemLocation.value = ''; elements.itemQuantity.value = '1'; renderLabels(); // Reset additional fields elements.itemManufacturer.value = ''; elements.itemModel.value = ''; elements.itemSerial.value = ''; elements.itemPrice.value = ''; elements.itemPurchaseFrom.value = ''; elements.itemNotes.value = ''; elements.itemInsured.checked = false; elements.successSection.classList.add('hidden'); elements.errorSection.classList.add('hidden'); elements.formSection.classList.add('hidden'); elements.photoPreview.classList.add('hidden'); elements.cameraPreview.classList.remove('hidden'); elements.retakeBtn.classList.add('hidden'); elements.captureBtn.classList.remove('hidden'); elements.saveBtn.disabled = false; startCamera(); } // Initialize async function init() { // Check for existing token if (state.token) { try { await fetchMetadata(); elements.loginScreen.classList.add('hidden'); elements.mainScreen.classList.remove('hidden'); startCamera(); } catch { // Token expired, show login state.token = ''; localStorage.removeItem('homebox_token'); } } // Event listeners elements.loginForm.addEventListener('submit', async (e) => { e.preventDefault(); elements.loginError.classList.add('hidden'); try { await login(elements.username.value, elements.password.value); await fetchMetadata(); elements.loginScreen.classList.add('hidden'); elements.mainScreen.classList.remove('hidden'); startCamera(); } catch (err) { elements.loginError.textContent = err.message; elements.loginError.classList.remove('hidden'); } }); elements.captureBtn.addEventListener('click', capturePhoto); elements.retakeBtn.addEventListener('click', retakePhoto); elements.saveBtn.addEventListener('click', saveItem); elements.newItemBtn.addEventListener('click', resetToCapture); elements.retryBtn.addEventListener('click', () => { hideError(); if (state.capturedImage) { analyzeImage(); } else { resetToCapture(); } }); // Label input elements.tagInput.addEventListener('input', (e) => { showTagSuggestions(e.target.value); }); elements.tagInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const value = e.target.value.trim(); if (value) { // Check if it matches an existing label const existing = state.labels.find(l => l.name.toLowerCase() === value.toLowerCase()); if (existing) { addLabel({ id: existing.id, name: existing.name, isNew: false }); } else { // Create as new label addLabel({ id: null, name: value, isNew: true }); } } e.target.value = ''; elements.tagSuggestions.classList.add('hidden'); } }); elements.tagSuggestions.addEventListener('click', (e) => { const suggestion = e.target.closest('.tag-suggestion'); if (suggestion) { addLabel({ id: suggestion.dataset.id, name: suggestion.dataset.name, isNew: false }); elements.tagInput.value = ''; elements.tagSuggestions.classList.add('hidden'); } }); elements.selectedTags.addEventListener('click', (e) => { if (e.target.classList.contains('remove-tag')) { removeLabel(e.target.dataset.tag); } }); } init();