stl-storage/public/index.html
kris 3dff6b00d4 Initial commit: STL Storage Application
- Complete web-based STL file storage and 3D viewer
- Express.js backend with SQLite database
- Interactive Three.js 3D viewer with orbit controls
- File upload with drag-and-drop support
- Security features: rate limiting, input validation, helmet
- Container deployment with Docker/Podman
- Production-ready configuration management
- Comprehensive logging and monitoring

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-07 16:18:58 +00:00

689 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STL Storage - 3D File Manager</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.152.0/build/three.min.js"></script>
<script src="js/three-setup.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
h1 {
font-size: 2.5rem;
font-weight: 600;
margin-bottom: 10px;
}
.upload-section {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.upload-area {
border: 2px dashed #007aff;
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-area:hover {
border-color: #0056cc;
background: #f0f8ff;
}
.upload-area.dragover {
border-color: #0056cc;
background: #f0f8ff;
}
.files-section {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.search-bar {
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
margin-bottom: 20px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.file-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
background: #fafafa;
}
.file-info {
margin-bottom: 10px;
}
.file-name {
font-weight: 600;
margin-bottom: 5px;
word-break: break-word;
}
.file-meta {
font-size: 0.875rem;
color: #6b7280;
}
.file-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
button {
background: #007aff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #0056cc;
}
.btn-danger {
background: #ff3b30;
}
.btn-danger:hover {
background: #d70015;
}
.loading {
text-align: center;
padding: 20px;
color: #6b7280;
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #c00;
padding: 10px;
border-radius: 6px;
margin: 10px 0;
}
.success {
background: #efe;
border: 1px solid #cfc;
color: #060;
padding: 10px;
border-radius: 6px;
margin: 10px 0;
}
#fileInput {
display: none;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
}
.modal-content {
position: relative;
background-color: white;
margin: 2% auto;
width: 90%;
height: 90%;
border-radius: 12px;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
flex: 1;
display: flex;
overflow: hidden;
}
.viewer-container {
flex: 1;
position: relative;
background: #f5f5f7;
}
.viewer-controls {
width: 250px;
padding: 20px;
background: #fafafa;
border-left: 1px solid #e5e7eb;
overflow-y: auto;
}
.control-group {
margin-bottom: 20px;
}
.control-group h4 {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.control-item {
margin-bottom: 10px;
}
.control-item label {
display: block;
margin-bottom: 5px;
font-size: 12px;
color: #6b7280;
}
.control-item input,
.control-item select {
width: 100%;
padding: 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
}
.close {
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: #000;
}
.btn-view {
background: #10b981;
}
.btn-view:hover {
background: #059669;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(245, 245, 247, 0.8);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #6b7280;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>STL Storage</h1>
<p>Upload and manage your 3D printing files</p>
</header>
<div class="upload-section">
<div class="upload-area" onclick="document.getElementById('fileInput').click()">
<h3>Drop STL files here or click to upload</h3>
<p>Maximum file size: 100MB</p>
<input type="file" id="fileInput" accept=".stl" multiple>
</div>
<div id="uploadStatus"></div>
</div>
<div class="files-section">
<input type="text" class="search-bar" id="searchInput" placeholder="Search files...">
<div id="filesList">
<div class="loading">Loading files...</div>
</div>
</div>
</div>
<!-- 3D Viewer Modal -->
<div id="viewerModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">3D Viewer</h2>
<span class="close" onclick="closeViewer()">&times;</span>
</div>
<div class="modal-body">
<div class="viewer-container">
<div id="stlViewer"></div>
<div id="loadingOverlay" class="loading-overlay" style="display: none;">
Loading 3D model...
</div>
</div>
<div class="viewer-controls">
<div class="control-group">
<h4>View Controls</h4>
<div class="control-item">
<button onclick="resetView()" style="width: 100%;">Reset View</button>
</div>
<div class="control-item">
<button onclick="takeScreenshot()" style="width: 100%;">Screenshot</button>
</div>
</div>
<div class="control-group">
<h4>Display Options</h4>
<div class="control-item">
<label>
<input type="checkbox" id="wireframeToggle" onchange="toggleWireframe()">
Wireframe Mode
</label>
</div>
</div>
<div class="control-group">
<h4>Model Color</h4>
<div class="control-item">
<select id="colorSelect" onchange="changeColor()">
<option value="0x00aa88">Teal (Default)</option>
<option value="0xff6b6b">Red</option>
<option value="0x4ecdc4">Cyan</option>
<option value="0x45b7d1">Blue</option>
<option value="0xf9ca24">Yellow</option>
<option value="0x6c5ce7">Purple</option>
<option value="0xa0a0a0">Gray</option>
<option value="0xffffff">White</option>
</select>
</div>
<div class="control-item">
<label>Custom Color:</label>
<input type="color" id="customColor" onchange="changeCustomColor()" value="#00aa88">
</div>
</div>
<div class="control-group">
<h4>Model Info</h4>
<div id="modelInfo" style="font-size: 12px; color: #6b7280;">
No model loaded
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let files = [];
async function uploadFile(file) {
const formData = new FormData();
formData.append('stlFile', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showMessage('File uploaded successfully!', 'success');
loadFiles();
} else {
showMessage(result.error || 'Upload failed', 'error');
}
} catch (error) {
showMessage('Upload failed: ' + error.message, 'error');
}
}
async function loadFiles(search = '') {
try {
const url = search ? `/api/files?search=${encodeURIComponent(search)}` : '/api/files';
const response = await fetch(url);
const result = await response.json();
if (result.success) {
files = result.files;
renderFiles();
} else {
showMessage('Failed to load files', 'error');
}
} catch (error) {
showMessage('Failed to load files: ' + error.message, 'error');
}
}
async function deleteFile(id) {
if (!confirm('Are you sure you want to delete this file?')) return;
try {
const response = await fetch(`/api/files/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
showMessage('File deleted successfully!', 'success');
loadFiles();
} else {
showMessage(result.error || 'Delete failed', 'error');
}
} catch (error) {
showMessage('Delete failed: ' + error.message, 'error');
}
}
function downloadFile(id, filename) {
window.open(`/api/files/${id}/download`, '_blank');
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function renderFiles() {
const filesList = document.getElementById('filesList');
if (files.length === 0) {
filesList.innerHTML = '<div class="loading">No files found</div>';
return;
}
const fileGrid = files.map(file => `
<div class="file-card">
<div class="file-info">
<div class="file-name">${file.original_name}</div>
<div class="file-meta">
Size: ${formatFileSize(file.file_size)}<br>
Uploaded: ${new Date(file.upload_date).toLocaleDateString()}
${file.description ? `<br>Description: ${file.description}` : ''}
${file.tags ? `<br>Tags: ${file.tags}` : ''}
</div>
</div>
<div class="file-actions">
<button class="btn-view" onclick="viewSTL(${file.id}, '${file.original_name}', '${file.filename}')">View 3D</button>
<button onclick="downloadFile(${file.id}, '${file.original_name}')">Download</button>
<button class="btn-danger" onclick="deleteFile(${file.id})">Delete</button>
</div>
</div>
`).join('');
filesList.innerHTML = `<div class="file-grid">${fileGrid}</div>`;
}
function showMessage(message, type) {
const status = document.getElementById('uploadStatus');
status.innerHTML = `<div class="${type}">${message}</div>`;
setTimeout(() => {
status.innerHTML = '';
}, 5000);
}
// Event listeners
document.getElementById('fileInput').addEventListener('change', (e) => {
Array.from(e.target.files).forEach(uploadFile);
});
document.getElementById('searchInput').addEventListener('input', (e) => {
const search = e.target.value.trim();
if (search.length >= 2 || search.length === 0) {
loadFiles(search);
}
});
// Drag and drop
const uploadArea = document.querySelector('.upload-area');
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
Array.from(e.dataTransfer.files).forEach(uploadFile);
});
// 3D Viewer functionality
let stlViewer = null;
let currentFile = null;
function viewSTL(fileId, originalName, filename) {
console.log('viewSTL called with:', { fileId, originalName, filename });
console.log('THREE available:', typeof THREE !== 'undefined');
console.log('THREE.STLLoader available:', typeof THREE.STLLoader !== 'undefined');
console.log('THREE.OrbitControls available:', typeof THREE.OrbitControls !== 'undefined');
currentFile = { id: fileId, originalName, filename };
const modal = document.getElementById('viewerModal');
const modalTitle = document.getElementById('modalTitle');
const loadingOverlay = document.getElementById('loadingOverlay');
modalTitle.textContent = `3D Viewer - ${originalName}`;
modal.style.display = 'block';
loadingOverlay.style.display = 'flex';
loadingOverlay.innerHTML = 'Initializing 3D viewer...';
// Check if Three.js is loaded
if (typeof THREE === 'undefined') {
loadingOverlay.innerHTML = 'Error: Three.js not loaded';
console.error('Three.js not loaded');
return;
}
if (typeof THREE.STLLoader === 'undefined') {
loadingOverlay.innerHTML = 'Error: STLLoader not available';
console.error('STLLoader not available');
return;
}
// Initialize viewer if not already done
try {
if (!stlViewer) {
console.log('Creating new STLViewer...');
stlViewer = new STLViewer('stlViewer');
console.log('STLViewer created successfully');
}
// Load the STL file
const stlUrl = `/uploads/stl/${filename}`;
console.log('Loading STL from URL:', stlUrl);
loadingOverlay.innerHTML = 'Loading 3D model...';
stlViewer.loadSTL(stlUrl)
.then(() => {
console.log('STL loaded successfully');
loadingOverlay.style.display = 'none';
updateModelInfo();
})
.catch((error) => {
console.error('Failed to load STL:', error);
loadingOverlay.innerHTML = `Error loading model: ${error.message}`;
});
} catch (error) {
console.error('Error initializing viewer:', error);
loadingOverlay.innerHTML = `Error initializing viewer: ${error.message}`;
}
}
function closeViewer() {
const modal = document.getElementById('viewerModal');
modal.style.display = 'none';
if (stlViewer) {
stlViewer.dispose();
stlViewer = null;
}
}
function resetView() {
if (stlViewer) {
stlViewer.resetView();
}
}
function toggleWireframe() {
const wireframeToggle = document.getElementById('wireframeToggle');
if (stlViewer) {
stlViewer.setWireframe(wireframeToggle.checked);
}
}
function changeColor() {
const colorSelect = document.getElementById('colorSelect');
const customColor = document.getElementById('customColor');
if (stlViewer) {
const colorValue = parseInt(colorSelect.value);
stlViewer.setColor(colorValue);
// Update custom color picker to match
const hexColor = '#' + colorValue.toString(16).padStart(6, '0');
customColor.value = hexColor;
}
}
function changeCustomColor() {
const customColor = document.getElementById('customColor');
const colorSelect = document.getElementById('colorSelect');
if (stlViewer) {
const hexColor = customColor.value.replace('#', '');
const colorValue = parseInt(hexColor, 16);
stlViewer.setColor(colorValue);
// Reset select to show it's using custom color
colorSelect.selectedIndex = -1;
}
}
function takeScreenshot() {
if (stlViewer && currentFile) {
const dataUrl = stlViewer.screenshot();
if (dataUrl) {
// Create download link
const link = document.createElement('a');
link.download = `${currentFile.originalName}_screenshot.png`;
link.href = dataUrl;
link.click();
}
}
}
function updateModelInfo() {
const modelInfo = document.getElementById('modelInfo');
if (stlViewer && stlViewer.mesh && currentFile) {
const geometry = stlViewer.mesh.geometry;
const box = new THREE.Box3().setFromObject(stlViewer.mesh);
const size = box.getSize(new THREE.Vector3());
const vertices = geometry.attributes.position.count;
const faces = vertices / 3;
modelInfo.innerHTML = `
<strong>Geometry:</strong><br>
Vertices: ${vertices.toLocaleString()}<br>
Faces: ${Math.floor(faces).toLocaleString()}<br>
<br>
<strong>Dimensions:</strong><br>
Width: ${size.x.toFixed(2)}<br>
Height: ${size.y.toFixed(2)}<br>
Depth: ${size.z.toFixed(2)}<br>
`;
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('viewerModal');
if (event.target === modal) {
closeViewer();
}
}
// Handle escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const modal = document.getElementById('viewerModal');
if (modal.style.display === 'block') {
closeViewer();
}
}
});
// Load files on page load
loadFiles();
</script>
<script src="js/stl-viewer.js"></script>
</body>
</html>