makerstash/client/app-features.js

805 lines
27 KiB
JavaScript

// Additional Features for MakerStash
// This file contains all the new feature implementations
// State for bulk selection
let selectedModels = new Set();
let cachedModels = []; // Store models for later reference
// Filter and Sort Functions
function applyFilters() {
const fileType = document.getElementById('filterFileType').value;
const hasSupports = document.getElementById('filterSupports').checked;
const sortBy = document.getElementById('sortBy').value;
const sortOrder = document.getElementById('sortOrder').value;
const searchTerm = document.getElementById('searchInput').value;
loadAllModels(searchTerm, '', '', fileType, hasSupports, sortBy, sortOrder);
}
function clearFilters() {
document.getElementById('filterFileType').value = '';
document.getElementById('filterSupports').checked = false;
document.getElementById('sortBy').value = 'created_at';
document.getElementById('sortOrder').value = 'DESC';
document.getElementById('searchInput').value = '';
loadAllModels();
}
// Update loadAllModels to accept filter parameters
const originalLoadAllModels = window.loadAllModels;
window.loadAllModels = async function(search = '', tag = '', collection = '', fileType = '', hasSupports = false, sortBy = 'created_at', sortOrder = 'DESC') {
const grid = document.getElementById('modelsGrid');
grid.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i><p>Loading models...</p></div>';
try {
let url = `${API_BASE}/models?`;
if (search) url += `search=${encodeURIComponent(search)}&`;
if (tag) url += `tag=${encodeURIComponent(tag)}&`;
if (collection) url += `collection=${encodeURIComponent(collection)}&`;
if (fileType) url += `fileType=${encodeURIComponent(fileType)}&`;
if (hasSupports) url += `hasSupports=true&`;
url += `sortBy=${sortBy}&sortOrder=${sortOrder}`;
const response = await fetch(url, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
});
const data = await response.json();
if (!response.ok) {
if (response.status === 401) {
grid.innerHTML = '<div class="loading-spinner"><p>Please log in to view models</p></div>';
return;
}
throw new Error(data.error || 'Failed to load models');
}
if (data.models && data.models.length > 0) {
displayModelsWithSelection(data.models);
} else {
grid.innerHTML = '<div class="loading-spinner"><p>No models found</p></div>';
}
} catch (error) {
grid.innerHTML = `<div class="loading-spinner"><p>Error loading models: ${error.message}</p></div>`;
console.error('Error loading models:', error);
}
};
// Display models with selection checkboxes
function displayModelsWithSelection(models) {
cachedModels = models; // Cache the models
const grid = document.getElementById('modelsGrid');
grid.innerHTML = '';
models.forEach(model => {
const card = document.createElement('div');
card.className = 'model-card';
if (selectedModels.has(model.id)) {
card.classList.add('selected');
}
card.onclick = (e) => {
if (!e.target.classList.contains('model-checkbox')) {
showModelDetails(model.id);
}
};
const fileIcon = getFileIcon(model.file_type);
const fileSize = formatFileSize(model.file_size);
const createdDate = new Date(model.created_at).toLocaleDateString();
const previewContent = model.preview_image
? `<img src="/${model.preview_image}" alt="${escapeHtml(model.name)}" />`
: `<i class="fas ${fileIcon}"></i>`;
const has3DViewer = ['.stl', '.obj', '.3mf'].includes(model.file_type);
card.innerHTML = `
<input type="checkbox" class="model-checkbox" ${selectedModels.has(model.id) ? 'checked' : ''}
onchange="toggleModelSelection(${model.id})" onclick="event.stopPropagation()">
<div class="model-preview ${model.preview_image ? 'has-thumbnail' : ''}">
${previewContent}
${has3DViewer ? `
<button class="btn-3d-preview" onclick="event.stopPropagation(); open3DViewer(${model.id})" title="View in 3D">
<i class="fas fa-cube"></i>
</button>
` : ''}
</div>
<div class="model-info">
<h3>${escapeHtml(model.name)}</h3>
<p>${escapeHtml(model.description || 'No description')}</p>
<div class="model-meta">
<span><i class="fas fa-file"></i> ${fileSize}</span>
<span><i class="fas fa-calendar"></i> ${createdDate}</span>
</div>
${model.tags && model.tags.length > 0 ? `
<div class="model-tags">
${model.tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join('')}
</div>
` : ''}
</div>
`;
grid.appendChild(card);
});
}
// Bulk Selection Functions
function toggleModelSelection(modelId) {
if (selectedModels.has(modelId)) {
selectedModels.delete(modelId);
} else {
selectedModels.add(modelId);
}
updateBulkToolbar();
updateSelectedCards();
}
function updateBulkToolbar() {
const toolbar = document.getElementById('bulkToolbar');
const count = document.getElementById('bulkSelectionCount');
if (selectedModels.size > 0) {
toolbar.style.display = 'block';
count.textContent = `${selectedModels.size} selected`;
} else {
toolbar.style.display = 'none';
}
}
function updateSelectedCards() {
document.querySelectorAll('.model-card').forEach(card => {
const checkbox = card.querySelector('.model-checkbox');
if (checkbox && checkbox.checked) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
}
function clearSelection() {
selectedModels.clear();
document.querySelectorAll('.model-checkbox').forEach(cb => cb.checked = false);
updateBulkToolbar();
updateSelectedCards();
}
// Bulk Operations
async function bulkAddTags() {
if (selectedModels.size === 0) return;
// Populate and show modal
showModal('bulkTagsModal');
}
async function handleBulkAddTags(event) {
event.preventDefault();
const tagsInput = document.getElementById('bulkTagsInput').value;
const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t);
if (tags.length === 0) {
showNotification('Please enter at least one tag', 'error');
return;
}
try {
const response = await fetch(`${API_BASE}/bulk/tag`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({
modelIds: Array.from(selectedModels),
tags: tags
})
});
if (response.ok) {
closeModal('bulkTagsModal');
showNotification('Tags added successfully!', 'success');
document.getElementById('bulkTagsInput').value = '';
clearSelection();
loadAllModels();
loadTags();
} else {
const data = await response.json();
showNotification(data.error || 'Failed to add tags', 'error');
}
} catch (error) {
showNotification('Failed to add tags: ' + error.message, 'error');
}
}
async function bulkMoveToCollection() {
if (selectedModels.size === 0) return;
// Populate collection dropdown
const select = document.getElementById('bulkMoveCollection');
select.innerHTML = '<option value="">None (Remove from collection)</option>' +
allCollections.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
showModal('bulkMoveModal');
}
async function handleBulkMove(event) {
event.preventDefault();
const collectionId = document.getElementById('bulkMoveCollection').value || null;
try {
const response = await fetch(`${API_BASE}/bulk/move`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({
modelIds: Array.from(selectedModels),
collectionId: collectionId
})
});
if (response.ok) {
closeModal('bulkMoveModal');
showNotification('Models moved successfully!', 'success');
clearSelection();
loadAllModels();
} else {
const data = await response.json();
showNotification(data.error || 'Failed to move models', 'error');
}
} catch (error) {
showNotification('Failed to move models: ' + error.message, 'error');
}
}
async function bulkDelete() {
if (selectedModels.size === 0) return;
if (!confirm(`Are you sure you want to delete ${selectedModels.size} models? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`${API_BASE}/bulk/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({
modelIds: Array.from(selectedModels)
})
});
if (response.ok) {
showNotification('Models deleted successfully!', 'success');
clearSelection();
loadAllModels();
} else {
const data = await response.json();
showNotification(data.error || 'Failed to delete models', 'error');
}
} catch (error) {
showNotification('Failed to delete models: ' + error.message, 'error');
}
}
// Print Queue Functions
async function loadPrintQueue() {
try {
const response = await fetch(`${API_BASE}/print-queue`, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const data = await response.json();
const countBadge = document.getElementById('queueCount');
if (countBadge) {
countBadge.textContent = data.queue.length;
}
return data.queue;
} catch (error) {
console.error('Error loading print queue:', error);
return [];
}
}
async function showPrintQueueModal() {
showModal('printQueueModal');
const queueList = document.getElementById('printQueueList');
queueList.innerHTML = '<div class="loading-spinner">Loading queue...</div>';
const queue = await loadPrintQueue();
if (queue.length === 0) {
queueList.innerHTML = '<p style="text-align: center; color: var(--text-secondary);">Queue is empty</p>';
return;
}
queueList.innerHTML = queue.map(item => `
<div class="queue-item" data-id="${item.id}">
<div class="queue-item-info">
<h4>${escapeHtml(item.model_name)}</h4>
<div class="queue-details-grid">
<div class="queue-detail">
<label>Quantity:</label>
<span>${item.quantity || 1}</span>
</div>
<div class="queue-detail">
<label>Filament:</label>
<span>${item.filament_type ? `${item.filament_type.toUpperCase()} (${item.color})` : 'Not specified'}</span>
</div>
<div class="queue-detail">
<label>Temperatures:</label>
<span>${item.print_temp ? item.print_temp + '°C' : '—'} / ${item.bed_temp ? item.bed_temp + '°C' : '—'}</span>
</div>
<div class="queue-detail">
<label>Speed:</label>
<span>${item.print_speed ? item.print_speed.charAt(0).toUpperCase() + item.print_speed.slice(1) : 'Normal'}</span>
</div>
<div class="queue-detail">
<label>Support:</label>
<span>${item.support_structure ? item.support_structure.charAt(0).toUpperCase() + item.support_structure.slice(1) : 'None'}</span>
</div>
<div class="queue-detail">
<label>Infill:</label>
<span>${item.infill_density || 20}%</span>
</div>
<div class="queue-detail">
<label>Layer Height:</label>
<span>${item.layer_height || 0.2}mm</span>
</div>
<div class="queue-detail">
<label>Priority:</label>
<span>${getPriorityLabel(item.priority)}</span>
</div>
</div>
${item.special_instructions ? `
<div class="queue-notes">
<strong>Notes:</strong> ${escapeHtml(item.special_instructions)}
</div>
` : ''}
<small style="color: var(--text-secondary);">Added: ${new Date(item.added_at).toLocaleDateString()}</small>
</div>
<div class="queue-item-actions">
<button class="btn btn-sm btn-secondary" onclick="editPrintJob(${item.id})">
<i class="fas fa-edit"></i> Edit
</button>
<button class="btn btn-sm btn-success" onclick="completeQueueItem(${item.id})">
<i class="fas fa-check"></i> Done
</button>
<button class="btn btn-sm btn-danger" onclick="removeFromQueue(${item.id})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`).join('');
}
function getPriorityLabel(priority) {
switch(parseInt(priority)) {
case 20: return 'Urgent';
case 10: return 'High';
case 5: return 'Medium';
default: return 'Low';
}
}
let currentEditPrintJobId = null;
async function editPrintJob(jobId) {
try {
// Get the queue item data
const response = await fetch(`${API_BASE}/print-queue`, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const data = await response.json();
const job = data.queue.find(q => q.id === jobId);
if (!job) {
showNotification('Job not found', 'error');
return;
}
currentEditPrintJobId = jobId;
// Populate the form
document.getElementById('editPrintModelName').value = job.model_name;
document.getElementById('editPrintQuantity').value = job.quantity || 1;
document.getElementById('editPrintFilamentType').value = job.filament_type || '';
document.getElementById('editPrintColor').value = job.color || '';
document.getElementById('editPrintTemp').value = job.print_temp || '';
document.getElementById('editPrintBedTemp').value = job.bed_temp || '';
document.getElementById('editPrintSpeed').value = job.print_speed || 'normal';
document.getElementById('editPrintSupport').value = job.support_structure || 'none';
document.getElementById('editPrintInfill').value = job.infill_density || 20;
document.getElementById('editPrintLayerHeight').value = job.layer_height || 0.2;
document.getElementById('editPrintNotes').value = job.special_instructions || '';
document.getElementById('editPrintPriority').value = job.priority || 0;
showModal('editPrintJobModal');
} catch (error) {
showNotification('Failed to load job details: ' + error.message, 'error');
console.error('Error:', error);
}
}
async function saveEditPrintJob(event) {
event.preventDefault();
if (!currentEditPrintJobId) {
showNotification('Job ID not set', 'error');
return;
}
const printData = {
quantity: parseInt(document.getElementById('editPrintQuantity').value),
filament_type: document.getElementById('editPrintFilamentType').value,
color: document.getElementById('editPrintColor').value,
print_temp: document.getElementById('editPrintTemp').value ? parseInt(document.getElementById('editPrintTemp').value) : null,
bed_temp: document.getElementById('editPrintBedTemp').value ? parseInt(document.getElementById('editPrintBedTemp').value) : null,
print_speed: document.getElementById('editPrintSpeed').value,
support_structure: document.getElementById('editPrintSupport').value,
infill_density: parseInt(document.getElementById('editPrintInfill').value),
layer_height: parseFloat(document.getElementById('editPrintLayerHeight').value),
special_instructions: document.getElementById('editPrintNotes').value,
priority: parseInt(document.getElementById('editPrintPriority').value)
};
try {
const response = await fetch(`${API_BASE}/print-queue/${currentEditPrintJobId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(printData)
});
const data = await response.json();
if (!response.ok) {
showNotification('Error saving changes: ' + (data.error || 'Unknown error'), 'error');
return;
}
showNotification('Print job updated successfully!', 'success');
closeModal('editPrintJobModal');
showPrintQueueModal(); // Refresh the queue display
} catch (error) {
showNotification('Failed to save changes: ' + error.message, 'error');
console.error('Error:', error);
}
}
async function removePrintJob() {
if (!currentEditPrintJobId) return;
if (!confirm('Are you sure you want to delete this print job?')) return;
try {
const response = await fetch(`${API_BASE}/print-queue/${currentEditPrintJobId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (!response.ok) {
showNotification('Error deleting job', 'error');
return;
}
showNotification('Print job deleted', 'success');
closeModal('editPrintJobModal');
showPrintQueueModal(); // Refresh the queue display
} catch (error) {
showNotification('Failed to delete job: ' + error.message, 'error');
}
}
async function bulkAddToQueue() {
if (selectedModels.size === 0) return;
// If only one model, show detailed form
if (selectedModels.size === 1) {
const modelId = Array.from(selectedModels)[0];
// Find the model in cached models
const model = cachedModels.find(m => m.id === modelId);
const modelName = model ? model.name : `Model ${modelId}`;
showPrintDetailsModal(modelId, modelName);
return;
}
// For multiple models, add them quickly with basic settings
try {
const promises = Array.from(selectedModels).map(modelId =>
fetch(`${API_BASE}/print-queue`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ modelId, priority: 0 })
})
);
await Promise.all(promises);
showNotification('Models added to print queue!', 'success');
clearSelection();
loadPrintQueue();
} catch (error) {
showNotification('Failed to add to queue: ' + error.message, 'error');
}
}
async function updateQueuePriority(queueId, priority) {
try {
await fetch(`${API_BASE}/print-queue/${queueId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ priority: parseInt(priority) })
});
} catch (error) {
console.error('Error updating priority:', error);
}
}
async function completeQueueItem(queueId) {
try {
await fetch(`${API_BASE}/print-queue/${queueId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ status: 'completed' })
});
showNotification('Marked as completed!', 'success');
showPrintQueueModal();
loadPrintQueue();
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function removeFromQueue(queueId) {
try {
await fetch(`${API_BASE}/print-queue/${queueId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authToken}` }
});
showNotification('Removed from queue', 'success');
showPrintQueueModal();
loadPrintQueue();
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
// Export/Import Functions
async function exportAllModels() {
try {
const response = await fetch(`${API_BASE}/export/all`, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `makerstash-export-${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showNotification('Export started! Check your downloads.', 'success');
} else {
showNotification('Export failed', 'error');
}
} catch (error) {
showNotification('Export error: ' + error.message, 'error');
}
}
async function bulkExport() {
if (selectedModels.size === 0) return;
try {
const response = await fetch(`${API_BASE}/export/models`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ modelIds: Array.from(selectedModels) })
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `makerstash-models-${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showNotification('Export started!', 'success');
} else {
showNotification('Export failed', 'error');
}
} catch (error) {
showNotification('Export error: ' + error.message, 'error');
}
}
function showImportModal() {
// Populate collection dropdown
const select = document.getElementById('importCollection');
select.innerHTML = '<option value="">None</option>' +
allCollections.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
showModal('importModal');
}
// Quick add function - creates a reference without file
async function handleQuickImport(event) {
event.preventDefault();
const url = document.getElementById('importUrl').value;
const name = document.getElementById('importName').value;
const description = document.getElementById('importDescription').value;
const creator = document.getElementById('importCreator').value;
const collectionId = document.getElementById('importCollection').value;
const tagsInput = document.getElementById('importTags').value;
const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t) : [];
// Create a placeholder file entry (we'll create a dummy text file)
const dummyContent = `This is a reference to a model from ${url}\n\nDownload the actual file from the URL above and upload it to replace this reference.`;
const blob = new Blob([dummyContent], { type: 'text/plain' });
const file = new File([blob], 'reference.txt', { type: 'text/plain' });
const formData = new FormData();
formData.append('files', file);
formData.append('name', name);
formData.append('description', description || '');
formData.append('creator', creator || '');
formData.append('source_url', url);
formData.append('collection_id', collectionId || '');
formData.append('is_supported', false);
formData.append('notes', `Reference from ${url}`);
if (tags.length > 0) {
formData.append('tags', JSON.stringify(tags));
}
try {
const response = await fetch(`${API_BASE}/models`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${authToken}` },
body: formData
});
if (response.ok) {
closeModal('importModal');
showNotification('Reference saved! You can now download the file from the URL and upload it.', 'success');
// Clear form
document.getElementById('importUrl').value = '';
document.getElementById('importName').value = '';
document.getElementById('importDescription').value = '';
document.getElementById('importCreator').value = '';
document.getElementById('importTags').value = '';
loadAllModels();
} else {
const data = await response.json();
showNotification(data.error || 'Failed to save reference', 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
// Update auth function to show new sections
const originalUpdateUIForAuth = window.updateUIForAuth;
window.updateUIForAuth = function() {
if (originalUpdateUIForAuth) {
originalUpdateUIForAuth();
}
document.getElementById('loginBtn').style.display = 'none';
document.getElementById('registerBtn').style.display = 'none';
document.getElementById('userMenu').style.display = 'flex';
document.getElementById('username').textContent = currentUser.username;
document.getElementById('uploadBtn').style.display = 'block';
document.getElementById('createCollectionBtn').style.display = 'block';
document.getElementById('printQueueSection').style.display = 'block';
document.getElementById('exportSection').style.display = 'block';
// Load print queue
if (authToken) {
loadPrintQueue();
}
};
// Print Details Modal Functions
let currentPrintModelId = null;
function showPrintDetailsModal(modelId, modelName) {
currentPrintModelId = modelId;
document.getElementById('printModelName').value = modelName;
document.getElementById('printQuantity').value = 1;
document.getElementById('printFilamentType').value = '';
document.getElementById('printColor').value = '';
document.getElementById('printTemp').value = '';
document.getElementById('printBedTemp').value = '';
document.getElementById('printSpeed').value = 'normal';
document.getElementById('printSupport').value = 'none';
document.getElementById('printInfill').value = 20;
document.getElementById('printLayerHeight').value = 0.2;
document.getElementById('printNotes').value = '';
document.getElementById('printPriority').value = 0;
showModal('printDetailsModal');
}
async function savePrintDetails(event) {
event.preventDefault();
if (!currentPrintModelId) {
showNotification('Model ID not set', 'error');
return;
}
const printData = {
modelId: currentPrintModelId,
quantity: parseInt(document.getElementById('printQuantity').value),
filament_type: document.getElementById('printFilamentType').value,
color: document.getElementById('printColor').value,
print_temp: document.getElementById('printTemp').value ? parseInt(document.getElementById('printTemp').value) : null,
bed_temp: document.getElementById('printBedTemp').value ? parseInt(document.getElementById('printBedTemp').value) : null,
print_speed: document.getElementById('printSpeed').value,
support_structure: document.getElementById('printSupport').value,
infill_density: parseInt(document.getElementById('printInfill').value),
layer_height: parseFloat(document.getElementById('printLayerHeight').value),
special_instructions: document.getElementById('printNotes').value,
priority: parseInt(document.getElementById('printPriority').value)
};
try {
const response = await fetch(`${API_BASE}/print-queue`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(printData)
});
const data = await response.json();
if (!response.ok) {
showNotification('Error adding to queue: ' + (data.error || 'Unknown error'), 'error');
return;
}
showNotification('Model added to print queue with specifications!', 'success');
closeModal('printDetailsModal');
loadPrintQueue();
clearSelection();
} catch (error) {
showNotification('Failed to add to queue: ' + error.message, 'error');
console.error('Error:', error);
}
}
console.log('Additional features loaded');