546 lines
18 KiB
JavaScript
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}">×</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();
|