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.
This commit is contained in:
Ross
2026-01-04 20:43:57 +00:00
commit cda15991e3
9 changed files with 2992 additions and 0 deletions
+748
View File
@@ -0,0 +1,748 @@
// 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();