LegoStudio / index.html
JameslineUK's picture
Update index.html
4c67899 verified
Raw
History Blame Contribute Delete
25.8 kB
<!doctype html>
<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>