homebox_ai_fronted/frontend/app.js

546 lines
18 KiB
JavaScript

// State
const state = {
token: localStorage.getItem('homebox_token') || '',
locations: [],
labels: [],
selectedLabels: [], // [{id, name, isNew}]
capturedImage: null,
stream: null,
lastItemUrl: 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'),
itemLink: document.getElementById('item-link'),
copyLinkBtn: document.getElementById('copy-link-btn'),
copyFeedback: document.getElementById('copy-feedback'),
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 main form fields
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;
}
// Set quantity
elements.itemQuantity.value = result.quantity || 1;
// 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 });
});
// Populate additional fields from AI (More Details section)
elements.itemManufacturer.value = result.manufacturer || '';
elements.itemModel.value = result.model_number || '';
elements.itemSerial.value = result.serial_number || '';
elements.itemNotes.value = result.notes || '';
if (result.purchase_price !== null && result.purchase_price !== undefined) {
elements.itemPrice.value = result.purchase_price;
} else {
elements.itemPrice.value = '';
}
hideStatus();
elements.formSection.classList.remove('hidden');
} catch (err) {
hideStatus();
showError(`Analysis failed: ${err.message}`);
}
}
// Labels
function addLabel(label) {
const name = label.name.trim().toLowerCase();
if (!name || state.selectedLabels.some(l => l.name.toLowerCase() === name)) return;
state.selectedLabels.push({
id: label.id || null,
name: label.name,
isNew: label.isNew || !label.id,
});
renderLabels();
}
function removeLabel(labelName) {
state.selectedLabels = state.selectedLabels.filter(l => l.name.toLowerCase() !== labelName.toLowerCase());
renderLabels();
}
function renderLabels() {
elements.selectedTags.innerHTML = state.selectedLabels.map(label => `
<span class="tag-chip ${label.isNew ? 'tag-new' : ''}">
${label.name}${label.isNew ? ' (new)' : ''}
<span class="remove-tag" data-tag="${label.name}">&times;</span>
</span>
`).join('');
}
function showTagSuggestions(query) {
if (!query) {
elements.tagSuggestions.classList.add('hidden');
return;
}
const selectedNames = state.selectedLabels.map(l => l.name.toLowerCase());
const matches = state.labels.filter(label =>
label.name.toLowerCase().includes(query.toLowerCase()) &&
!selectedNames.includes(label.name.toLowerCase())
).slice(0, 5);
if (matches.length === 0) {
elements.tagSuggestions.classList.add('hidden');
return;
}
elements.tagSuggestions.innerHTML = matches.map(label => `
<div class="tag-suggestion" data-id="${label.id}" data-name="${label.name}">
${label.name}
</div>
`).join('');
elements.tagSuggestions.classList.remove('hidden');
}
// Save item
async function saveItem() {
hideError();
// Validate required fields
if (!elements.itemName.value.trim()) {
showError('Please enter an item name');
return;
}
if (!elements.itemLocation.value) {
showError('Please select a location');
return;
}
elements.saveBtn.disabled = true;
showStatus('Saving to Homebox...');
try {
// Create any new labels first
const newLabels = state.selectedLabels.filter(l => l.isNew);
for (const label of newLabels) {
showStatus(`Creating label: ${label.name}...`);
const created = await api('/labels', {
method: 'POST',
body: JSON.stringify({ name: label.name }),
});
// Update the label with the new ID
label.id = created.id;
label.isNew = false;
// Add to state.labels so it's available for future items
state.labels.push({ id: created.id, name: created.name });
}
// Collect all label IDs
const labelIds = state.selectedLabels.map(l => l.id).filter(Boolean);
showStatus('Creating item...');
// Create item
const item = await api('/items', {
method: 'POST',
body: JSON.stringify({
name: elements.itemName.value.trim(),
description: elements.itemDescription.value.trim(),
location_id: elements.itemLocation.value,
label_ids: labelIds,
quantity: parseInt(elements.itemQuantity.value) || 1,
serial_number: elements.itemSerial.value.trim(),
model_number: elements.itemModel.value.trim(),
manufacturer: elements.itemManufacturer.value.trim(),
purchase_price: parseFloat(elements.itemPrice.value) || 0,
purchase_from: elements.itemPurchaseFrom.value.trim(),
notes: elements.itemNotes.value.trim(),
insured: elements.itemInsured.checked,
}),
});
// Upload photo
if (state.capturedImage) {
showStatus('Uploading photo...');
await api(`/items/${item.id}/attachments`, {
method: 'POST',
body: JSON.stringify({
image: state.capturedImage,
filename: 'photo.jpg',
}),
});
}
hideStatus();
showSuccess(item.url);
} 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(itemUrl) {
elements.formSection.classList.add('hidden');
elements.itemLink.href = itemUrl;
elements.itemLink.textContent = 'Open in Homebox';
elements.copyFeedback.classList.add('hidden');
elements.successSection.classList.remove('hidden');
// Store URL for copy functionality
state.lastItemUrl = itemUrl;
}
async function copyItemLink() {
if (!state.lastItemUrl) return;
try {
await navigator.clipboard.writeText(state.lastItemUrl);
elements.copyFeedback.classList.remove('hidden');
setTimeout(() => {
elements.copyFeedback.classList.add('hidden');
}, 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = state.lastItemUrl;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
elements.copyFeedback.classList.remove('hidden');
setTimeout(() => {
elements.copyFeedback.classList.add('hidden');
}, 2000);
}
}
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.copyLinkBtn.addEventListener('click', copyItemLink);
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();