Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Studio.io XML Parts Builder</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <!-- Google Fonts --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| /* Color Palette */ | |
| --primary: #6366f1; | |
| /* Indigo 500 */ | |
| --primary-hover: #4f46e5; | |
| /* Indigo 600 */ | |
| --bg-body: #f1f5f9; | |
| /* Slate 100 */ | |
| --bg-card: #ffffff; | |
| --text-main: #1e293b; | |
| /* Slate 800 */ | |
| --text-muted: #64748b; | |
| /* Slate 500 */ | |
| --border: #e2e8f0; | |
| /* Slate 200 */ | |
| --danger: #ef4444; | |
| /* Red 500 */ | |
| --success: #22c55e; | |
| /* Green 500 */ | |
| /* Spacing & Shadows */ | |
| --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); | |
| --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); | |
| --radius: 0.5rem; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| background-color: var(--bg-body); | |
| color: var(--text-main); | |
| line-height: 1.5; | |
| -webkit-font-smoothing: antialiased; | |
| padding-bottom: 4rem; | |
| } | |
| .container { | |
| max-width: 1000px; | |
| margin: 0 auto; | |
| padding: 2rem 1rem; | |
| } | |
| /* Header */ | |
| header { | |
| margin-bottom: 2rem; | |
| text-align: center; | |
| } | |
| h1 { | |
| font-size: 1.875rem; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| margin-bottom: 0.5rem; | |
| letter-spacing: -0.025em; | |
| } | |
| p.subtitle { | |
| color: var(--text-muted); | |
| font-size: 1rem; | |
| } | |
| /* Card */ | |
| .card { | |
| background-color: var(--bg-card); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow-md); | |
| padding: 1.5rem; | |
| margin-bottom: 2rem; | |
| border: 1px solid var(--border); | |
| } | |
| /* Form Layout */ | |
| .form-row { | |
| display: flex; | |
| gap: 1rem; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .form-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.375rem; | |
| flex: 1; | |
| } | |
| .form-group label { | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| color: var(--text-main); | |
| } | |
| input[type="text"], | |
| input[type="file"] { | |
| width: 100%; | |
| padding: 0.625rem 0.875rem; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| font-size: 0.95rem; | |
| color: var(--text-main); | |
| transition: border-color 0.15s ease, box-shadow 0.15s ease; | |
| } | |
| input[type="text"]:focus, | |
| input[type="file"]:focus { | |
| outline: none; | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); | |
| } | |
| /* Buttons */ | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 0.625rem 1.25rem; | |
| border: none; | |
| border-radius: var(--radius); | |
| font-weight: 500; | |
| font-size: 0.95rem; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| gap: 0.5rem; | |
| white-space: nowrap; | |
| } | |
| .btn-primary { | |
| background-color: var(--primary); | |
| color: white; | |
| box-shadow: 0 1px 2px 0 rgba(79, 70, 229, 0.4); | |
| } | |
| .btn-primary:hover { | |
| background-color: var(--primary-hover); | |
| } | |
| .btn-secondary { | |
| background-color: white; | |
| color: var(--text-main); | |
| border: 1px solid var(--border); | |
| } | |
| .btn-secondary:hover { | |
| background-color: #f8fafc; | |
| border-color: #cbd5e1; | |
| } | |
| .btn:disabled { | |
| opacity: 0.7; | |
| cursor: not-allowed; | |
| } | |
| /* Divider */ | |
| .divider { | |
| height: 1px; | |
| background-color: var(--border); | |
| margin: 1.5rem 0; | |
| width: 100%; | |
| } | |
| /* Table */ | |
| .table-container { | |
| overflow-x: auto; | |
| border-radius: var(--radius); | |
| border: 1px solid var(--border); | |
| background: white; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| text-align: left; | |
| } | |
| thead { | |
| background-color: #f8fafc; | |
| } | |
| th { | |
| padding: 0.75rem 1rem; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| color: var(--text-muted); | |
| letter-spacing: 0.05em; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| td { | |
| padding: 1rem; | |
| border-bottom: 1px solid var(--border); | |
| vertical-align: middle; | |
| color: var(--text-main); | |
| } | |
| tr:last-child td { | |
| border-bottom: none; | |
| } | |
| /* Stats */ | |
| .stats-bar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 1rem; | |
| } | |
| #count { | |
| font-weight: 600; | |
| color: var(--text-main); | |
| background: #e0e7ff; | |
| color: var(--primary); | |
| padding: 0.25rem 0.75rem; | |
| border-radius: 1rem; | |
| font-size: 0.875rem; | |
| } | |
| /* Notifications (Toast) */ | |
| .toast-container { | |
| position: fixed; | |
| bottom: 2rem; | |
| right: 2rem; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| z-index: 100; | |
| } | |
| .toast { | |
| background: white; | |
| border-left: 4px solid var(--primary); | |
| padding: 1rem 1.25rem; | |
| border-radius: 4px; | |
| box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); | |
| font-size: 0.9rem; | |
| min-width: 250px; | |
| animation: slideIn 0.3s ease-out; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .toast.error { | |
| border-left-color: var(--danger); | |
| } | |
| .toast.success { | |
| border-left-color: var(--success); | |
| } | |
| @keyframes slideIn { | |
| from { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| @keyframes fadeOut { | |
| from { | |
| opacity: 1; | |
| } | |
| to { | |
| opacity: 0; | |
| } | |
| } | |
| /* Spinner */ | |
| .spinner { | |
| width: 1em; | |
| height: 1em; | |
| border: 2px solid currentcolor; | |
| border-right-color: transparent; | |
| border-radius: 50%; | |
| animation: spin 0.75s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| /* Responsive */ | |
| @media (max-width: 640px) { | |
| .form-row { | |
| flex-direction: column; | |
| align-items: stretch; | |
| } | |
| .btn { | |
| width: 100%; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <h1>Studio.io XML Parts Builder</h1> | |
| <p class="subtitle">Quickly build and export lists for your Lego creations</p> | |
| </header> | |
| <main> | |
| <!-- Input Section --> | |
| <section class="card"> | |
| <div class="form-row"> | |
| <div class="form-group" style="flex: 2;"> | |
| <label for="partInput">Add Part</label> | |
| <div style="display: flex; gap: 0.5rem;"> | |
| <input id="partInput" type="text" placeholder="Enter Part ID (e.g. 3001)" /> | |
| <button id="addBtn" class="btn btn-primary"> | |
| <span>Add</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="xmlFileInput">Import XML</label> | |
| <div style="display: flex; gap: 0.5rem;"> | |
| <input id="xmlFileInput" type="file" accept=".xml" /> | |
| <button id="loadXmlBtn" class="btn btn-secondary">Load</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Table Action Bar --> | |
| <div class="stats-bar"> | |
| <span id="count">0 Parts</span> | |
| <!-- Could add clear button here later --> | |
| </div> | |
| <!-- Table Section --> | |
| <div class="card" style="padding: 0; overflow: hidden;"> | |
| <div class="table-container"> | |
| <table id="partsTable" aria-label="Parts list"> | |
| <thead> | |
| <tr> | |
| <th style="width: 150px;">Part ID</th> | |
| <th>Preview</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <!-- Rows will be injected here --> | |
| <tr> | |
| <td colspan="2" style="text-align: center; color: var(--text-muted); padding: 2rem;"> | |
| No parts added yet. Start by entering an ID above. | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Export Section --> | |
| <section class="card"> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="xmlFilename">XML Filename</label> | |
| <input id="xmlFilename" type="text" value="studio_inventory.xml" /> | |
| </div> | |
| <div class="form-group"> | |
| <label for="pdfFilename">PDF Filename</label> | |
| <input id="pdfFilename" type="text" value="studio_inventory.pdf" /> | |
| </div> | |
| </div> | |
| <div class="divider"></div> | |
| <div class="form-row" style="justify-content: flex-end;"> | |
| <button id="saveXmlBtn" class="btn btn-secondary">Export XML</button> | |
| <button id="savePdfBtn" class="btn btn-secondary">Export PDF</button> | |
| </div> | |
| </section> | |
| </main> | |
| </div> | |
| <div class="toast-container" id="toastContainer"></div> | |
| <!-- jsPDF for PDF export --> | |
| <script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.25/jspdf.plugin.autotable.min.js"></script> | |
| <script> | |
| (function () { | |
| // Data State | |
| const state = { | |
| parts: [] | |
| }; | |
| // DOM Elements | |
| const els = { | |
| partInput: document.getElementById('partInput'), | |
| addBtn: document.getElementById('addBtn'), | |
| xmlFileInput: document.getElementById('xmlFileInput'), | |
| loadXmlBtn: document.getElementById('loadXmlBtn'), | |
| countEl: document.getElementById('count'), | |
| tbody: document.querySelector('#partsTable tbody'), | |
| xmlFilename: document.getElementById('xmlFilename'), | |
| pdfFilename: document.getElementById('pdfFilename'), | |
| saveXmlBtn: document.getElementById('saveXmlBtn'), | |
| savePdfBtn: document.getElementById('savePdfBtn'), | |
| toastContainer: document.getElementById('toastContainer') | |
| }; | |
| // --- UI Helpers --- | |
| function showToast(message, type = 'info') { | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.textContent = message; | |
| els.toastContainer.appendChild(toast); | |
| // Remove after 3 seconds | |
| setTimeout(() => { | |
| toast.style.animation = 'fadeOut 0.3s ease-out forwards'; | |
| toast.addEventListener('animationend', () => toast.remove()); | |
| }, 3000); | |
| } | |
| function setLoading(btn, isLoading, originalText = '') { | |
| if (isLoading) { | |
| btn.disabled = true; | |
| btn.innerHTML = '<div class="spinner"></div>'; | |
| } else { | |
| btn.disabled = false; | |
| btn.innerHTML = originalText; | |
| } | |
| } | |
| function renderTable() { | |
| // Sort numerically | |
| state.parts.sort((a, b) => parseInt(a.itemid) - parseInt(b.itemid)); | |
| els.tbody.innerHTML = ''; | |
| if (state.parts.length === 0) { | |
| els.tbody.innerHTML = ` | |
| <tr> | |
| <td colspan="2" style="text-align: center; color: var(--text-muted); padding: 2rem;"> | |
| No parts added yet. Start by entering an ID above. | |
| </td> | |
| </tr>`; | |
| els.countEl.textContent = '0 Parts'; | |
| return; | |
| } | |
| for (const p of state.parts) { | |
| const tr = document.createElement('tr'); | |
| // ID | |
| const tdId = document.createElement('td'); | |
| tdId.textContent = p.itemid; | |
| tdId.style.fontWeight = '600'; // Make ID pop a bit | |
| tr.appendChild(tdId); | |
| // Image | |
| const tdImg = document.createElement('td'); | |
| if (p.imageUrl) { | |
| const img = document.createElement('img'); | |
| img.src = p.imageDataUrl || p.imageUrl; | |
| img.alt = `Part ${p.itemid}`; | |
| img.style.maxHeight = '50px'; | |
| img.style.objectFit = 'contain'; | |
| tdImg.appendChild(img); | |
| } else { | |
| tdImg.textContent = 'No Image'; | |
| tdImg.style.color = 'var(--text-muted)'; | |
| tdImg.style.fontSize = '0.8rem'; | |
| } | |
| tr.appendChild(tdImg); | |
| els.tbody.appendChild(tr); | |
| } | |
| els.countEl.textContent = `${state.parts.length} Parts`; | |
| } | |
| // --- Logic --- | |
| function buildXml(partsList) { | |
| const lines = []; | |
| lines.push('<?xml version="1.0" encoding="UTF-8"?>'); | |
| lines.push('<INVENTORY>'); | |
| for (const p of partsList) { | |
| lines.push(' <ITEM>'); | |
| lines.push(' <ITEMTYPE>P</ITEMTYPE>'); | |
| lines.push(` <ITEMID>${p.itemid}</ITEMID>`); | |
| lines.push(' <COLOR>1</COLOR>'); | |
| lines.push(' <MAXPRICE>-1.0000</MAXPRICE>'); | |
| lines.push(' <MINQTY>1</MINQTY>'); | |
| lines.push(' <CONDITION>X</CONDITION>'); | |
| lines.push(' <NOTIFY>N</NOTIFY>'); | |
| lines.push(' </ITEM>'); | |
| } | |
| lines.push('</INVENTORY>'); | |
| return lines.join('\n'); | |
| } | |
| async function addPart(itemid) { | |
| if (state.parts.find(p => p.itemid === itemid)) { | |
| showToast(`Part ${itemid} already added.`, 'info'); | |
| return; | |
| } | |
| const directUrl = `https://brickarchitect.com/content/parts-large/${itemid}.png`; | |
| // Use CORS proxy to allow fetching binary data for PDF | |
| const proxyUrl = `https://corsproxy.io/?` + encodeURIComponent(directUrl); | |
| let imageDataUrl = null; | |
| try { | |
| // Try fetch via proxy | |
| const resp = await fetch(proxyUrl); | |
| if (resp.ok) { | |
| const blob = await resp.blob(); | |
| imageDataUrl = await new Promise((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = () => resolve(null); | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| } catch (e) { | |
| console.warn('Image fetch via proxy failed', e); | |
| } | |
| // fallback: if proxy failed, we still want to show the part. | |
| // The <img> tag can load the directUrl even if fetch fails (usually). | |
| state.parts.push({ itemid, imageUrl: directUrl, imageDataUrl }); | |
| renderTable(); | |
| showToast(`Added part ${itemid}`, 'success'); | |
| } | |
| // --- Event Listeners --- | |
| els.addBtn.addEventListener('click', async () => { | |
| const itemid = els.partInput.value.trim(); | |
| if (!itemid) { | |
| showToast('Please enter a Part ID.', 'error'); | |
| els.partInput.focus(); | |
| return; | |
| } | |
| const originalText = els.addBtn.innerHTML; | |
| setLoading(els.addBtn, true); | |
| await addPart(itemid); | |
| setLoading(els.addBtn, false, originalText); | |
| els.partInput.value = ''; | |
| els.partInput.focus(); | |
| }); | |
| // Allow Enter key in input | |
| els.partInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') els.addBtn.click(); | |
| }); | |
| els.loadXmlBtn.addEventListener('click', async () => { | |
| const file = els.xmlFileInput.files[0]; | |
| if (!file) { | |
| showToast('Please select an XML file first.', 'error'); | |
| return; | |
| } | |
| const originalText = els.loadXmlBtn.innerHTML; | |
| setLoading(els.loadXmlBtn, true); | |
| try { | |
| const text = await file.text(); | |
| const parser = new DOMParser(); | |
| const xmlDoc = parser.parseFromString(text, 'text/xml'); | |
| const items = xmlDoc.getElementsByTagName('ITEM'); | |
| let count = 0; | |
| for (let i = 0; i < items.length; i++) { | |
| const itemidEl = items[i].getElementsByTagName('ITEMID')[0]; | |
| if (itemidEl) { | |
| const itemid = itemidEl.textContent.trim(); | |
| if (itemid && !state.parts.find(p => p.itemid === itemid)) { | |
| await addPart(itemid); | |
| count++; | |
| } | |
| } | |
| } | |
| showToast(`Imported ${count} new parts from XML.`, 'success'); | |
| } catch (e) { | |
| showToast('Failed to parse XML file.', 'error'); | |
| console.error(e); | |
| } finally { | |
| setLoading(els.loadXmlBtn, false, originalText); | |
| els.xmlFileInput.value = ''; | |
| } | |
| }); | |
| els.saveXmlBtn.addEventListener('click', () => { | |
| if (state.parts.length === 0) { | |
| showToast('No parts to export.', 'error'); | |
| return; | |
| } | |
| const filename = els.xmlFilename.value.trim() || 'studio_inventory.xml'; | |
| const xml = buildXml(state.parts); | |
| const blob = new Blob([xml], { type: 'application/xml' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| showToast('XML exported successfully.', 'success'); | |
| }); | |
| els.savePdfBtn.addEventListener('click', async () => { | |
| if (state.parts.length === 0) { | |
| showToast('No parts to export.', 'error'); | |
| return; | |
| } | |
| const originalText = els.savePdfBtn.innerHTML; | |
| setLoading(els.savePdfBtn, true); | |
| try { | |
| // Ensure images are loaded as DataURLs (if added via XML they might not have it yet if we didn't refetch) | |
| // The addPart logic now fetches immediately, but let's be safe for any future bulk logic | |
| const updated = await Promise.all(state.parts.map(async (p) => { | |
| if (p.imageDataUrl) return p; | |
| // Retry fetch if needed | |
| try { | |
| const proxyUrl = `https://corsproxy.io/?` + encodeURIComponent(p.imageUrl); | |
| const resp = await fetch(proxyUrl); | |
| if (resp.ok) { | |
| const blob = await resp.blob(); | |
| const dataUrl = await new Promise((resolve) => { | |
| const r = new FileReader(); | |
| r.onload = () => resolve(r.result); | |
| r.onerror = () => resolve(null); | |
| r.readAsDataURL(blob); | |
| }); | |
| return { ...p, imageDataUrl: dataUrl }; | |
| } | |
| } catch (e) { } | |
| return p; | |
| })); | |
| const { jsPDF } = window.jspdf; | |
| const doc = new jsPDF(); | |
| const pageH = doc.internal.pageSize.getHeight(); | |
| let y = 20; | |
| // Brand Header | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(22); | |
| doc.setTextColor(30, 41, 59); // Slate 800 | |
| doc.text('Studio Inventory List', 14, y); | |
| y += 10; | |
| doc.setFont("helvetica", "normal"); | |
| doc.setFontSize(10); | |
| doc.setTextColor(100, 116, 139); // Slate 500 | |
| doc.text(`Generated on ${new Date().toLocaleDateString()}`, 14, y); | |
| y += 15; | |
| // Table Header | |
| doc.setFillColor(241, 245, 249); // Slate 100 | |
| doc.rect(14, y, 182, 10, 'F'); | |
| doc.setFontSize(10); | |
| doc.setTextColor(71, 85, 105); // Slate 600 | |
| doc.text('ITEM ID', 18, y + 7); | |
| doc.text('PREVIEW', 110, y + 7); | |
| y += 12; | |
| for (let i = 0; i < updated.length; i++) { | |
| const p = updated[i]; | |
| // Row lines | |
| doc.setDrawColor(226, 232, 240); // Slate 200 | |
| doc.line(14, y + 24, 196, y + 24); | |
| doc.setTextColor(30, 41, 59); | |
| doc.setFontSize(12); | |
| doc.text(p.itemid, 18, y + 12); | |
| if (p.imageDataUrl) { | |
| try { | |
| doc.addImage(p.imageDataUrl, 'PNG', 110, y + 2, 20, 20, undefined, 'FAST'); | |
| } catch (e) { } | |
| } | |
| y += 28; | |
| // Page Break | |
| if (y > pageH - 30) { | |
| doc.addPage(); | |
| y = 20; | |
| } | |
| } | |
| const filename = els.pdfFilename.value.trim() || 'studio_inventory.pdf'; | |
| doc.save(filename); | |
| showToast('PDF exported successfully.', 'success'); | |
| } catch (e) { | |
| console.error(e); | |
| showToast('Failed to export PDF.', 'error'); | |
| } finally { | |
| setLoading(els.savePdfBtn, false, originalText); | |
| } | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |