Files
Ross cda15991e3 feat: Initial commit of Yoto Up Covers web application
- Added main HTML file (index.html) for the web app interface.
- Created a sample cover template (sample_cover.html) for Yoto cards.
- Implemented a simple HTTP server (server.py) to serve the web app.
- Configured package.json for project metadata and scripts.
2026-01-04 20:43:57 +00:00

748 lines
27 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Yoto Up Covers Web App
class YotoCoversApp {
constructor() {
this.images = [];
this.selectedImageIndex = -1;
this.zoomLevel = 1.0;
this.currentTemplate = 'classic';
this.currentTitleStyle = 'folded';
this.printLayout = {
cardsPerRow: 2,
cardsPerColumn: 5,
pageMargin: 10,
cardSpacing: 5
};
this.templateSettings = {
title: '',
footer: '',
accentColor: '#f1c40f',
titleColor: '#111111',
fullBleed: true,
titleShadow: false,
titleFontSize: null,
footerFontSize: null
};
this.cardVariations = [];
this.init();
}
init() {
this.bindEvents();
this.updateUI();
this.updateCardVariations();
}
bindEvents() {
// Print layout settings
document.getElementById('cards-per-row').addEventListener('change', (e) => {
this.printLayout.cardsPerRow = parseInt(e.target.value);
this.generatePreview();
});
document.getElementById('cards-per-column').addEventListener('change', (e) => {
this.printLayout.cardsPerColumn = parseInt(e.target.value);
this.generatePreview();
});
document.getElementById('page-margin').addEventListener('input', (e) => {
this.printLayout.pageMargin = parseInt(e.target.value);
this.generatePreview();
});
document.getElementById('card-spacing').addEventListener('input', (e) => {
this.printLayout.cardSpacing = parseInt(e.target.value);
this.generatePreview();
});
// Template settings
document.getElementById('template-select').addEventListener('change', (e) => {
this.currentTemplate = e.target.value;
this.generatePreview();
});
document.getElementById('title-input').addEventListener('input', (e) => {
this.templateSettings.title = e.target.value;
this.generatePreview();
});
document.getElementById('footer-input').addEventListener('input', (e) => {
this.templateSettings.footer = e.target.value;
this.generatePreview();
});
// Title style selection
document.querySelectorAll('.title-style-option').forEach(option => {
option.addEventListener('click', () => {
document.querySelectorAll('.title-style-option').forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
this.currentTitleStyle = option.dataset.style;
this.generatePreview();
});
});
// Color pickers
document.getElementById('accent-color').addEventListener('input', (e) => {
this.templateSettings.accentColor = e.target.value;
document.getElementById('accent-color-text').value = e.target.value;
this.generatePreview();
});
document.getElementById('title-color').addEventListener('input', (e) => {
this.templateSettings.titleColor = e.target.value;
document.getElementById('title-color-text').value = e.target.value;
this.generatePreview();
});
// Checkboxes
document.getElementById('full-bleed').addEventListener('change', (e) => {
this.templateSettings.fullBleed = e.target.checked;
this.generatePreview();
});
document.getElementById('title-shadow').addEventListener('change', (e) => {
this.templateSettings.titleShadow = e.target.checked;
this.generatePreview();
});
// Image upload
const imageUpload = document.getElementById('image-upload');
const fileInputLabel = document.querySelector('.file-input-label');
imageUpload.addEventListener('change', (e) => this.handleImageUpload(e.target.files));
imageUpload.addEventListener('dragover', (e) => {
e.preventDefault();
fileInputLabel.classList.add('dragover');
});
imageUpload.addEventListener('dragleave', (e) => {
e.preventDefault();
fileInputLabel.classList.remove('dragover');
});
imageUpload.addEventListener('drop', (e) => {
e.preventDefault();
fileInputLabel.classList.remove('dragover');
this.handleImageUpload(e.dataTransfer.files);
});
// Image management
document.getElementById('clear-images').addEventListener('click', () => this.clearAllImages());
// Actions
document.getElementById('generate-preview').addEventListener('click', () => this.generatePreview());
document.getElementById('download-html').addEventListener('click', () => this.downloadHTML());
document.getElementById('open-preview').addEventListener('click', () => this.openPreviewInNewTab());
document.getElementById('print-preview').addEventListener('click', () => this.printPreview());
// Zoom controls
document.getElementById('zoom-in').addEventListener('click', () => this.zoomIn());
document.getElementById('zoom-out').addEventListener('click', () => this.zoomOut());
document.getElementById('zoom-reset').addEventListener('click', () => this.resetZoom());
// Image editor
document.getElementById('fit-mode').addEventListener('change', (e) => {
if (this.selectedImageIndex >= 0) {
this.images[this.selectedImageIndex].fitMode = e.target.value;
this.generatePreview();
}
});
document.getElementById('crop-position').addEventListener('change', (e) => {
if (this.selectedImageIndex >= 0) {
this.images[this.selectedImageIndex].cropPosition = e.target.value;
this.generatePreview();
}
});
document.getElementById('crop-offset-x').addEventListener('input', (e) => {
if (this.selectedImageIndex >= 0) {
this.images[this.selectedImageIndex].cropOffsetX = parseFloat(e.target.value);
document.getElementById('crop-offset-x-value').textContent = e.target.value;
this.generatePreview();
}
});
document.getElementById('crop-offset-y').addEventListener('input', (e) => {
if (this.selectedImageIndex >= 0) {
this.images[this.selectedImageIndex].cropOffsetY = parseFloat(e.target.value);
document.getElementById('crop-offset-y-value').textContent = e.target.value;
this.generatePreview();
}
});
document.getElementById('add-text-overlay').addEventListener('click', () => this.addTextOverlay());
document.getElementById('add-card-variation').addEventListener('click', () => this.addCardVariation());
// Mobile toggles
document.getElementById('toggle-settings').addEventListener('click', () => {
const panel = document.querySelector('.settings-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('toggle-editor').addEventListener('click', () => {
const panel = document.querySelector('.edit-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
});
}
async handleImageUpload(files) {
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
const imageData = await this.readFileAsDataURL(file);
const coverImage = new CoverImage(file.name, imageData);
this.images.push(coverImage);
} catch (error) {
this.showStatusMessage(`Error loading ${file.name}: ${error.message}`, 'error');
}
}
}
this.updateImageList();
this.updateCardVariations();
this.generatePreview();
}
readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
updateImageList() {
const imageList = document.getElementById('image-list');
imageList.innerHTML = '';
this.images.forEach((image, index) => {
const item = document.createElement('div');
item.className = 'image-item';
item.innerHTML = `
<div class="image-info">
<div class="image-name">${image.name}</div>
<div class="image-meta">${image.width || 0} × ${image.height || 0}px</div>
</div>
<div class="image-actions">
<button class="btn btn-secondary" onclick="app.selectImage(${index})">Edit</button>
<button class="btn btn-danger" onclick="app.removeImage(${index})">×</button>
</div>
`;
imageList.appendChild(item);
});
}
selectImage(index) {
this.selectedImageIndex = index;
this.updateImageEditor();
}
removeImage(index) {
this.images.splice(index, 1);
if (this.selectedImageIndex === index) {
this.selectedImageIndex = -1;
} else if (this.selectedImageIndex > index) {
this.selectedImageIndex--;
}
this.updateImageList();
this.updateImageEditor();
this.generatePreview();
}
clearAllImages() {
this.images = [];
this.selectedImageIndex = -1;
this.updateImageList();
this.updateImageEditor();
this.updateCardVariations();
this.generatePreview();
}
updateImageEditor() {
const editor = document.getElementById('image-editor');
const noSelection = document.getElementById('no-image-selected');
if (this.selectedImageIndex >= 0) {
const image = this.images[this.selectedImageIndex];
editor.style.display = 'block';
noSelection.style.display = 'none';
document.getElementById('fit-mode').value = image.fitMode;
document.getElementById('crop-position').value = image.cropPosition;
document.getElementById('crop-offset-x').value = image.cropOffsetX;
document.getElementById('crop-offset-x-value').textContent = image.cropOffsetX;
document.getElementById('crop-offset-y').value = image.cropOffsetY;
document.getElementById('crop-offset-y-value').textContent = image.cropOffsetY;
this.updateTextOverlays();
} else {
editor.style.display = 'none';
noSelection.style.display = 'block';
}
}
updateTextOverlays() {
const container = document.getElementById('text-overlays');
container.innerHTML = '';
if (this.selectedImageIndex >= 0) {
const image = this.images[this.selectedImageIndex];
image.textOverlays.forEach((overlay, index) => {
const item = document.createElement('div');
item.className = 'text-overlay-item';
item.innerHTML = `
<input type="text" class="text-overlay-input" value="${overlay.text}" placeholder="Text overlay" oninput="app.updateTextOverlay(${index}, 'text', this.value)">
<div class="text-overlay-controls">
<input type="color" value="${overlay.color}" onchange="app.updateTextOverlay(${index}, 'color', this.value)">
<input type="number" value="${overlay.fontSize}" min="8" max="72" onchange="app.updateTextOverlay(${index}, 'fontSize', this.value)">
<button class="btn btn-danger" onclick="app.removeTextOverlay(${index})">×</button>
</div>
`;
container.appendChild(item);
});
}
}
addTextOverlay() {
if (this.selectedImageIndex >= 0) {
const image = this.images[this.selectedImageIndex];
image.textOverlays.push({
text: 'New Text',
color: '#ffffff',
fontSize: 24,
x: 50,
y: 50
});
this.updateTextOverlays();
this.generatePreview();
}
}
updateTextOverlay(index, property, value) {
if (this.selectedImageIndex >= 0) {
const image = this.images[this.selectedImageIndex];
if (property === 'fontSize') {
value = parseInt(value);
}
image.textOverlays[index][property] = value;
this.generatePreview();
}
}
removeTextOverlay(index) {
if (this.selectedImageIndex >= 0) {
const image = this.images[this.selectedImageIndex];
image.textOverlays.splice(index, 1);
this.updateTextOverlays();
this.generatePreview();
}
}
addCardVariation() {
const variation = {
title: '',
footer: '',
imageIndex: -1
};
this.cardVariations.push(variation);
this.updateCardVariations();
this.generatePreview();
}
updateCardVariation(index, field, value) {
this.cardVariations[index][field] = value;
this.generatePreview();
}
removeCardVariation(index) {
this.cardVariations.splice(index, 1);
this.updateCardVariations();
this.generatePreview();
}
updateCardVariations() {
const container = document.getElementById('card-variations');
container.innerHTML = '';
this.cardVariations.forEach((variation, index) => {
const item = document.createElement('div');
item.className = 'card-variation-item';
item.innerHTML = `
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
<strong>Card ${index + 1}:</strong>
<button class="btn btn-danger" onclick="app.removeCardVariation(${index})" style="font-size: 12px; padding: 4px 8px;">×</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group" style="margin: 0;">
<label>Title</label>
<input type="text" value="${variation.title}" placeholder="Custom title" oninput="app.updateCardVariation(${index}, 'title', this.value)">
</div>
<div class="form-group" style="margin: 0;">
<label>Footer</label>
<input type="text" value="${variation.footer}" placeholder="Custom footer" oninput="app.updateCardVariation(${index}, 'footer', this.value)">
</div>
</div>
<div class="form-group" style="margin: 0; grid-column: span 2;">
<label>Image</label>
<select onchange="app.updateCardVariation(${index}, 'imageIndex', parseInt(this.value))">
<option value="-1">Use default image</option>
${this.images.map((img, imgIndex) => `<option value="${imgIndex}" ${variation.imageIndex === imgIndex ? 'selected' : ''}>${img.name}</option>`).join('')}
</select>
</div>
`;
container.appendChild(item);
});
}
async generatePreview() {
const placeholder = document.getElementById('preview-placeholder');
const iframe = document.getElementById('preview-iframe');
if (this.images.length === 0 && !this.templateSettings.title) {
placeholder.textContent = 'Add an image or enter a title to generate preview';
placeholder.style.display = 'flex';
iframe.style.display = 'none';
return;
}
placeholder.innerHTML = '<div class="loading"></div> Generating preview...';
placeholder.style.display = 'flex';
iframe.style.display = 'none';
try {
const html = await this.generateHTML();
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
iframe.src = url;
iframe.style.display = 'block';
placeholder.style.display = 'none';
// Apply zoom after iframe loads
iframe.onload = () => {
this.applyZoomToPreview();
};
// Clean up the previous blob URL
if (iframe.dataset.previousUrl) {
URL.revokeObjectURL(iframe.dataset.previousUrl);
}
iframe.dataset.previousUrl = url;
} catch (error) {
placeholder.textContent = `Error generating preview: ${error.message}`;
this.showStatusMessage(`Preview generation failed: ${error.message}`, 'error');
}
}
async generateHTML() {
// Determine number of cards to generate
let totalCards;
if (this.cardVariations.length > 0) {
totalCards = this.cardVariations.length;
} else {
totalCards = 1; // Single card if no variations
}
// For print sheet, arrange cards in grid, but don't exceed available space
const cardsPerPage = this.printLayout.cardsPerRow * this.printLayout.cardsPerColumn;
const pagesNeeded = Math.ceil(totalCards / cardsPerPage);
const totalCardsToGenerate = pagesNeeded * cardsPerPage; // Fill pages completely, but we'll hide empty slots
const cards = [];
// Generate cards
for (let i = 0; i < totalCardsToGenerate; i++) {
if (i < totalCards) {
// Use variation or default
const variation = this.cardVariations[i];
const imageIndex = variation && variation.imageIndex >= 0 ? variation.imageIndex : 0;
const imageUrl = this.images.length > 0 ? this.images[imageIndex].dataUrl : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
cards.push({
imageUrl,
title: (variation && variation.title) || this.templateSettings.title || `Card ${i + 1}`,
footer: (variation && variation.footer) || this.templateSettings.footer,
isVisible: true
});
} else {
// Empty card for filling the grid
cards.push({
imageUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
title: '',
footer: '',
isVisible: false
});
}
}
// Fixed card dimensions: 54mm x 85.6mm
const cardWidthMm = 54;
const cardHeightMm = 85.6;
// Generate HTML for print sheet
const html = `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Yoto Covers Print Sheet</title>
<style>
@font-face { font-family: 'DejaVuSans'; src: local('DejaVu Sans'); }
@media screen {
html, body { margin: 0; padding: 20px; background: #f5f5f5; }
.print-page { border: 2px dashed #ccc; margin: 20px auto; background: white; page-break-after: always; }
.print-page:last-child { page-break-after: auto; }
}
@media print {
html, body { margin: 0; padding: 0; background: white; }
.print-page { border: none; margin: 0; page-break-after: always; }
.print-page:last-child { page-break-after: auto; }
@page { margin: 0; size: A4; }
}
.card {
box-sizing: border-box;
position: relative;
}
.empty-card {
background: #f9f9f9;
}
.title {
position: absolute;
top: 6%;
left: 6%;
right: 6%;
text-align: center;
font-size: calc(108px + 0.5rem);
color: ${this.templateSettings.titleColor};
font-weight: 900;
text-transform: uppercase;
${this.templateSettings.titleShadow ? 'text-shadow: 1px 4px 6px rgba(0,0,0,0.3);' : ''}
}
.title.folded {
position: absolute;
left: 6%;
right: 6%;
top: 6%;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Source Code Pro', monospace;
font-weight: 900;
white-space: nowrap;
color: lch(76 39.21 9.23 / 0.5);
text-transform: uppercase;
transform: skew(10deg) rotate(-10deg);
text-shadow: 1px 4px 6px lch(90 2.22 62.5), 0 0 0 lch(28 26.21 12.27), 1px 4px 6px lch(90 2.22 62.5);
}
.title.folded::before {
content: attr(data-heading);
position: absolute;
left: 0;
top: -4.8%;
overflow: hidden;
height: 50%;
color: lch(97 2.19 62.49);
transform: translate(0.15em, 0) skew(-13deg) scale(1, 1.2);
text-shadow: 2px -1px 6px rgba(0,0,0,0.2);
}
.title.folded::after {
content: attr(data-heading);
position: absolute;
left: 0;
color: lch(83 2.26 62.51);
transform: translate(0, 0) skew(13deg) scale(1, 0.8);
clip-path: polygon(0 50%, 100% 50%, 100% 100%, 0 100%);
text-shadow: 2px -1px 6px lch(0 0 0 / 0.3);
}
.hero {
position: absolute;
top: 18%;
left: 6%;
right: 6%;
bottom: 18%;
display: flex;
align-items: center;
justify-content: center;
}
.hero img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.overlay {
position: absolute;
top: 18%;
left: 6%;
right: 6%;
bottom: 18%;
border-radius: 8px;
pointer-events: none;
background: rgba(0,0,0,0);
}
.footer {
position: absolute;
bottom: 4%;
left: 6%;
right: 6%;
height: 10%;
background: ${this.templateSettings.accentColor};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #000;
border-radius: 6px;
}
</style>
</head>
<body>
<div class="print-sheet">
${cards.map((card, index) => `
<div class="card">
<div class="title ${this.currentTitleStyle === 'folded' ? 'folded' : ''}" data-heading="${this.escapeHtml(card.title)}">${this.escapeHtml(card.title)}</div>
${!this.templateSettings.fullBleed ? `<div class="hero"><img src="${card.imageUrl}" alt="cover"/></div>` : ''}
<div class="overlay"></div>
<div class="footer">${this.escapeHtml(card.footer)}</div>
</div>
`).join('')}
</div>
</body>
</html>`;
return html;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
zoomIn() {
this.zoomLevel = Math.min(this.zoomLevel * 1.2, 3.0);
this.updateZoomDisplay();
this.applyZoomToPreview();
}
zoomOut() {
this.zoomLevel = Math.max(this.zoomLevel / 1.2, 0.3);
this.updateZoomDisplay();
this.applyZoomToPreview();
}
resetZoom() {
this.zoomLevel = 1.0;
this.updateZoomDisplay();
this.applyZoomToPreview();
}
updateZoomDisplay() {
const zoomPercent = Math.round(this.zoomLevel * 100);
document.getElementById('zoom-level').textContent = zoomPercent === 100 ? 'Fit' : zoomPercent + '%';
}
applyZoomToPreview() {
const iframe = document.getElementById('preview-iframe');
if (iframe.contentDocument && iframe.contentDocument.body) {
const printSheet = iframe.contentDocument.querySelector('.print-sheet');
if (printSheet) {
printSheet.style.transform = `scale(${this.zoomLevel})`;
printSheet.style.transformOrigin = 'top center';
}
}
}
openPreviewInNewTab() {
const iframe = document.getElementById('preview-iframe');
if (iframe.src) {
window.open(iframe.src, '_blank');
} else {
this.showStatusMessage('No preview available. Generate a preview first.', 'error');
}
}
printPreview() {
const iframe = document.getElementById('preview-iframe');
if (iframe.contentWindow) {
iframe.contentWindow.print();
} else {
this.showStatusMessage('No preview available to print.', 'error');
}
}
downloadHTML() {
if (!this.templateSettings.title && this.images.length === 0) {
this.showStatusMessage('Add content before downloading.', 'error');
return;
}
this.generateHTML().then(html => {
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `yoto-cover-${Date.now()}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showStatusMessage('HTML file downloaded successfully!', 'success');
}).catch(error => {
this.showStatusMessage(`Download failed: ${error.message}`, 'error');
});
}
showStatusMessage(message, type = 'info') {
const statusDiv = document.getElementById('status-message');
statusDiv.textContent = message;
statusDiv.className = `status-message status-${type}`;
statusDiv.style.display = 'block';
setTimeout(() => {
statusDiv.style.display = 'none';
}, 5000);
}
updateUI() {
this.updateZoomDisplay();
// Initialize print layout controls
document.getElementById('cards-per-row').value = this.printLayout.cardsPerRow;
document.getElementById('cards-per-column').value = this.printLayout.cardsPerColumn;
document.getElementById('page-margin').value = this.printLayout.pageMargin;
document.getElementById('card-spacing').value = this.printLayout.cardSpacing;
}
}
class CoverImage {
constructor(name, dataUrl) {
this.name = name;
this.dataUrl = dataUrl;
this.fitMode = 'scale';
this.cropPosition = 'center';
this.cropOffsetX = 0;
this.cropOffsetY = 0;
this.textOverlays = [];
this.width = 0;
this.height = 0;
// Get image dimensions
this.getImageDimensions();
}
async getImageDimensions() {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
this.width = img.naturalWidth;
this.height = img.naturalHeight;
resolve();
};
img.src = this.dataUrl;
});
}
}
// Initialize the app
const app = new YotoCoversApp();