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:
@@ -0,0 +1,75 @@
|
||||
# Static Site Deployment
|
||||
|
||||
The Yoto Up Covers web app is a **100% static application** that can be deployed to any static site hosting service.
|
||||
|
||||
## Required Files for Deployment
|
||||
|
||||
Only these two files are needed for the web app to function:
|
||||
|
||||
```
|
||||
web_app/
|
||||
├── index.html # Main application
|
||||
└── app.js # Application logic
|
||||
```
|
||||
|
||||
## Hosting Options
|
||||
|
||||
### 1. GitHub Pages (Free)
|
||||
```bash
|
||||
# Create a new repository
|
||||
git init
|
||||
git add index.html app.js
|
||||
git commit -m "Initial commit"
|
||||
git branch -M main
|
||||
git remote add origin https://github.com/yourusername/yoto-covers.git
|
||||
git push -u origin main
|
||||
|
||||
# Enable GitHub Pages in repository settings
|
||||
# Your app will be available at: https://yourusername.github.io/yoto-covers/
|
||||
```
|
||||
|
||||
### 2. Netlify (Free tier available)
|
||||
```bash
|
||||
# Install Netlify CLI
|
||||
npm install -g netlify-cli
|
||||
|
||||
# Deploy
|
||||
netlify deploy --prod --dir web_app --public
|
||||
```
|
||||
|
||||
### 3. Vercel (Free tier available)
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
npm install -g vercel
|
||||
|
||||
# Deploy
|
||||
cd web_app
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### 4. Manual Upload
|
||||
Simply upload `index.html` and `app.js` to any web hosting service:
|
||||
- AWS S3 + CloudFront
|
||||
- Firebase Hosting
|
||||
- DigitalOcean Spaces
|
||||
- Any traditional web host
|
||||
|
||||
## Features That Work Offline
|
||||
|
||||
- ✅ Template customization
|
||||
- ✅ Color picker
|
||||
- ✅ Image upload and editing
|
||||
- ✅ Live preview generation
|
||||
- ✅ HTML export
|
||||
- ✅ Print functionality
|
||||
|
||||
## Browser Requirements
|
||||
|
||||
- Modern browser with HTML5 support
|
||||
- File API (for image uploads)
|
||||
- Blob URLs (for previews)
|
||||
- No server-side dependencies
|
||||
|
||||
## Demo
|
||||
|
||||
A live demo can be viewed by opening `index.html` in any modern web browser.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Yoto Up Covers Web App
|
||||
|
||||
A standalone web application for creating and customizing Yoto card covers. This web app generates printable sheets of Yoto card covers with full customization options.
|
||||
|
||||
## Features
|
||||
|
||||
### Print Layout Configuration
|
||||
- **Cards per Page**: Configure grid layout (1-4 columns, 1-5 rows)
|
||||
- **Page Margins**: Adjustable margins in millimeters
|
||||
- **Card Spacing**: Control spacing between cards on the sheet
|
||||
- **A4 Print Optimization**: Designed for standard A4 paper printing
|
||||
|
||||
### Template Customization
|
||||
- **Multiple Templates**: Classic, Frozen, Minimal, and Vintage cover styles
|
||||
- **Title Styles**: Classic, Large, Small, Folded, and Condensed title effects
|
||||
- **Color Customization**: Customize accent colors and title colors
|
||||
- **Full Bleed Option**: Choose whether images cover the full card background
|
||||
- **Title Shadows**: Add depth with optional title shadows
|
||||
|
||||
### Card Variations
|
||||
- **Individual Card Customization**: Set different titles, footers, and images for each card position
|
||||
- **No Duplicate Cards**: Only generates the number of cards you specify (no automatic duplication)
|
||||
- **Single Card Mode**: If no variations are defined, generates a single card for testing
|
||||
|
||||
### Print Layout Configuration
|
||||
- **Cards per Page**: Configure grid layout (1-4 columns, 1-5 rows)
|
||||
- **Page Margins**: Adjustable margins in millimeters
|
||||
- **Card Spacing**: Control spacing between cards on the sheet
|
||||
- **Multi-Page Support**: Automatically creates multiple pages if needed
|
||||
- **A4 Print Optimization**: Designed for standard A4 paper printing
|
||||
|
||||
### Image Management
|
||||
- **Drag & Drop Upload**: Easily upload multiple images
|
||||
- **Image Editing**: Adjust fit modes (scale, crop, resize)
|
||||
- **Crop Positioning**: Fine-tune crop positions and offsets
|
||||
- **Text Overlays**: Add customizable text overlays on images
|
||||
|
||||
### Preview & Export
|
||||
- **Live Preview**: Real-time preview of your print sheet design
|
||||
- **Zoom Controls**: Zoom in/out and fit-to-screen options
|
||||
- **HTML Export**: Download your print sheet as an HTML file
|
||||
- **Print Preview**: Print directly from the browser with faithful reproduction
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Open the Web App**: Open `index.html` in your web browser
|
||||
2. **Configure Print Layout**: Set cards per row/column, margins, and spacing
|
||||
3. **Customize Template**: Select a template and adjust global settings
|
||||
4. **Add Images**: Upload images via drag & drop or file selection
|
||||
5. **Create Variations**: Add individual card customizations if needed
|
||||
6. **Edit Images**: Select images to adjust fit, crop, and add text overlays
|
||||
7. **Preview**: Click "Generate Preview" to see your print sheet
|
||||
8. **Export**: Download as HTML or print directly
|
||||
|
||||
## Print Layout Options
|
||||
|
||||
### Grid Configuration
|
||||
- **Cards per Row**: Number of cards horizontally (1-4)
|
||||
- **Cards per Column**: Number of cards vertically (1-5)
|
||||
- **Total Cards**: Up to 20 cards per A4 sheet
|
||||
|
||||
### Spacing & Margins
|
||||
- **Page Margin**: Space around the entire sheet (default: 10mm)
|
||||
- **Card Spacing**: Gap between individual cards (default: 5mm)
|
||||
|
||||
### Print Optimization
|
||||
- **A4 Paper Size**: Optimized for standard A4 printing
|
||||
- **300 DPI Resolution**: High-quality output for printing
|
||||
- **CSS Print Media**: Separate styles for screen and print
|
||||
|
||||
## Template Styles
|
||||
|
||||
### Classic
|
||||
- Traditional card layout with centered title and footer
|
||||
- Hero image area with rounded corners
|
||||
- Clean, professional appearance
|
||||
|
||||
### Frozen
|
||||
- Icy, frosted aesthetic
|
||||
- Large uppercase title
|
||||
- Subtle shadow effects
|
||||
|
||||
### Minimal
|
||||
- Clean, minimalist design
|
||||
- Focus on typography and negative space
|
||||
- Subtle accent elements
|
||||
|
||||
### Vintage
|
||||
- Retro-inspired design
|
||||
- Classic typography treatments
|
||||
- Warm color palettes
|
||||
|
||||
## Title Effects
|
||||
|
||||
### Folded (Default)
|
||||
- 3D paper folding effect using CSS pseudo-elements
|
||||
- Creates depth with highlight and shadow layers
|
||||
- Responsive to title size changes
|
||||
|
||||
### Classic
|
||||
- Standard centered title
|
||||
- Clean, readable typography
|
||||
|
||||
### Large/Small
|
||||
- Scaled title sizes for emphasis or subtlety
|
||||
|
||||
### Condensed
|
||||
- Narrow letter spacing for compact titles
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Card Dimensions
|
||||
- **Fixed Size**: Each card is exactly 54mm × 85.6mm (standard NFC card dimensions)
|
||||
- **No Scaling**: Cards maintain exact proportions for printing
|
||||
- **Grid Layout**: Cards are arranged in a CSS Grid for precise positioning
|
||||
|
||||
### Print Sheet Specifications
|
||||
- **Paper Size**: A4 (210mm × 297mm)
|
||||
- **Resolution**: 300 DPI for high-quality printing
|
||||
- **Layout**: CSS Grid with configurable rows/columns
|
||||
- **Multi-Page**: Automatic page breaks for large numbers of cards
|
||||
- **Print CSS**: Separate styles for screen and print
|
||||
|
||||
### Browser Compatibility
|
||||
- Modern browsers with CSS Grid and Flexbox support
|
||||
- HTML5 File API for image uploads
|
||||
- CSS Transforms for title effects
|
||||
- Print media queries for faithful reproduction
|
||||
|
||||
### File Formats
|
||||
- Input: PNG, JPG, JPEG, GIF, WebP
|
||||
- Output: HTML with embedded images (base64)
|
||||
- Print: Direct browser printing or PDF export
|
||||
|
||||
## Usage Tips
|
||||
|
||||
1. **Card Variations**: Add card variations to create multiple unique cards. If no variations are added, a single card is generated for testing.
|
||||
2. **Print Layout**: Configure the grid layout to fit your needs. Cards will be arranged automatically across multiple pages if necessary.
|
||||
3. **Fixed Dimensions**: Each card is exactly 54mm × 85.6mm, ensuring compatibility with NFC card sleeves.
|
||||
4. **Image Quality**: Use high-resolution images (at least 500×800px) for best results when printing.
|
||||
5. **Print Testing**: Always test print output before final production. Use the "Print Preview" button for browser printing.
|
||||
|
||||
## Development
|
||||
|
||||
This web app is built with:
|
||||
- **HTML5**: Semantic markup and structure
|
||||
- **CSS3**: Modern styling with Grid, Flexbox, and Transforms
|
||||
- **Vanilla JavaScript**: No external dependencies for maximum compatibility
|
||||
|
||||
### File Structure
|
||||
```
|
||||
web_app/
|
||||
├── index.html # Main application interface
|
||||
├── app.js # Application logic and functionality
|
||||
└── README.md # This documentation
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This web app replicates the covers functionality from the Yoto Up desktop application. For bug reports or feature requests related to the original desktop app, please refer to the main Yoto Up repository.
|
||||
|
||||
## License
|
||||
|
||||
This web app is part of the Yoto Up project and follows the same licensing terms.
|
||||
Binary file not shown.
+748
@@ -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();
|
||||
+771
@@ -0,0 +1,771 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Yoto Up - Covers</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
.header p {
|
||||
opacity: 0.9;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.print-layout-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.print-layout-panel .section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.print-layout-panel .form-group {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr 300px;
|
||||
gap: 20px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image-panel {
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card-features-panel {
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.card-list-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.card-list-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-group.inline {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.image-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.image-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.image-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 10px 0;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 60px;
|
||||
height: 40px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f8f9fa;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border: 2px dashed #3498db;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
background: #e8f4fd;
|
||||
border-color: #2980b9;
|
||||
}
|
||||
|
||||
.file-input-label.dragover {
|
||||
background: #e8f4fd;
|
||||
border-color: #27ae60;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.template-option {
|
||||
padding: 10px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.template-option:hover {
|
||||
border-color: #3498db;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.template-option.selected {
|
||||
border-color: #3498db;
|
||||
background: #e8f4fd;
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.title-style-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.title-style-option {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.title-style-option:hover {
|
||||
border-color: #3498db;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.title-style-option.selected {
|
||||
border-color: #3498db;
|
||||
background: #e8f4fd;
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.card-variation-item {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.main-content {
|
||||
grid-template-columns: 250px 1fr;
|
||||
}
|
||||
|
||||
.card-features-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-toggle {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Yoto Up - Covers</h1>
|
||||
<p>Create and customize Yoto card covers with templates, images, and text overlays</p>
|
||||
</div>
|
||||
|
||||
<!-- Print Layout at the top -->
|
||||
<div class="print-layout-panel panel">
|
||||
<div class="section">
|
||||
<h3>Print Layout</h3>
|
||||
<div class="form-group">
|
||||
<label for="cards-per-row">Cards per row</label>
|
||||
<select id="cards-per-row">
|
||||
<option value="1">1</option>
|
||||
<option value="2" selected>2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cards-per-column">Cards per column</label>
|
||||
<select id="cards-per-column">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5" selected>5</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="page-margin">Page margin (mm)</label>
|
||||
<input type="number" id="page-margin" value="10" min="0" max="50">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-spacing">Card spacing (mm)</label>
|
||||
<input type="number" id="card-spacing" value="5" min="0" max="20">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- Image Management Panel (Left) -->
|
||||
<div class="panel image-panel">
|
||||
<div class="section">
|
||||
<h3>Image Management</h3>
|
||||
<div class="form-group">
|
||||
<label>Upload Images</label>
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="image-upload" class="file-input" multiple accept="image/*">
|
||||
<div class="file-input-label">
|
||||
<span>Drop images here or click to browse</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-list" id="image-list">
|
||||
<!-- Images will be added here dynamically -->
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-danger" id="clear-images">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel (Center) -->
|
||||
<div class="panel preview-panel">
|
||||
<h3 style="margin-bottom: 20px;">Preview</h3>
|
||||
|
||||
<div class="preview-container">
|
||||
<div class="preview-placeholder" id="preview-placeholder">
|
||||
Click "Generate Preview" to see your cover
|
||||
</div>
|
||||
<iframe id="preview-iframe" class="preview-iframe" style="display: none;"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" id="open-preview">Open in New Tab</button>
|
||||
<button class="btn btn-success" id="print-preview">Print Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Features Panel (Right) -->
|
||||
<div class="panel card-features-panel">
|
||||
<div class="section">
|
||||
<h3>Active Card</h3>
|
||||
<div class="form-group">
|
||||
<label for="active-card-select">Select Card</label>
|
||||
<select id="active-card-select">
|
||||
<option value="-1">No card selected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="card-editor" style="display: none;">
|
||||
<div class="section">
|
||||
<h3 id="card-editor-title">Edit Card</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-template-preset">Template Preset</label>
|
||||
<select id="card-template-preset">
|
||||
<option value="">Custom</option>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="frozen">Frozen</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="vintage">Vintage</option>
|
||||
<option value="comic">Comic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-background-style">Background Style</label>
|
||||
<select id="card-background-style">
|
||||
<option value="solid">Solid</option>
|
||||
<option value="gradient">Gradient</option>
|
||||
<option value="pattern">Pattern</option>
|
||||
<option value="comic">Comic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-font-family">Font Family</label>
|
||||
<select id="card-font-family">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-font-weight">Font Weight</label>
|
||||
<select id="card-font-weight">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="bold">Bold</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-footer-color">Footer Color</label>
|
||||
<div class="color-picker">
|
||||
<input type="color" id="card-footer-color" value="#111111" class="color-input">
|
||||
<input type="text" id="card-footer-color-text" value="#111111" class="color-value" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-title-input">Title</label>
|
||||
<input type="text" id="card-title-input" placeholder="Enter card title">
|
||||
<div class="checkbox-group inline">
|
||||
<input type="checkbox" id="card-show-title" checked>
|
||||
<label for="card-show-title">Show title</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-footer-input">Footer</label>
|
||||
<input type="text" id="card-footer-input" placeholder="Enter footer text">
|
||||
<div class="checkbox-group inline">
|
||||
<input type="checkbox" id="card-show-footer" checked>
|
||||
<label for="card-show-footer">Show footer</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Title Style</label>
|
||||
<div class="title-style-grid">
|
||||
<div class="title-style-option" data-style="classic">Classic</div>
|
||||
<div class="title-style-option" data-style="large">Large</div>
|
||||
<div class="title-style-option" data-style="small">Small</div>
|
||||
<div class="title-style-option selected" data-style="folded">Folded</div>
|
||||
<div class="title-style-option" data-style="condensed">Condensed</div>
|
||||
<div class="title-style-option" data-style="italic">Italic</div>
|
||||
<div class="title-style-option" data-style="bold">Bold</div>
|
||||
<div class="title-style-option" data-style="outlined">Outlined</div>
|
||||
<div class="title-style-option" data-style="shadow">Shadow</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="global-title-font-size">Title Font Size (px)</label>
|
||||
<input type="number" id="global-title-font-size" value="20" min="10" max="200" step="5">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-accent-color">Accent Color</label>
|
||||
<div class="color-picker">
|
||||
<input type="color" id="card-accent-color" value="#f1c40f" class="color-input">
|
||||
<input type="text" id="card-accent-color-text" value="#f1c40f" class="color-value" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-title-color">Title Color</label>
|
||||
<div class="color-picker">
|
||||
<input type="color" id="card-title-color" value="#111111" class="color-input">
|
||||
<input type="text" id="card-title-color-text" value="#111111" class="color-value" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="card-full-bleed" checked>
|
||||
<label for="card-full-bleed">Full bleed background</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="card-title-shadow">
|
||||
<label for="card-title-shadow">Title shadow</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Image</label>
|
||||
<select id="card-image-select">
|
||||
<option value="-1">Use default image</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" style="margin-top: 15px;">
|
||||
<button class="btn btn-primary" id="update-card">Update Card</button>
|
||||
<button class="btn btn-danger" id="delete-card">Delete Card</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>All Cards</h3>
|
||||
<p style="color: #666; font-size: 14px; margin-bottom: 15px;">Manage your cards. Click on a card to edit it.</p>
|
||||
<div id="card-list">
|
||||
<!-- Cards will be listed here -->
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top: 15px;">
|
||||
<button class="btn btn-secondary" id="add-card">Add Card</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Actions</h3>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" id="generate-preview">Generate Preview</button>
|
||||
<button class="btn btn-success" id="download-html">Download HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="status-message" style="display: none;"></div>
|
||||
|
||||
<!-- Mobile toggle buttons -->
|
||||
<div class="mobile-toggle">
|
||||
<button class="btn btn-primary" id="toggle-settings">Settings</button>
|
||||
<button class="btn btn-primary" id="toggle-editor">Editor</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "yoto-up-covers-web",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone web application for creating Yoto card covers",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
"start": "python3 server.py",
|
||||
"serve": "python3 -m http.server 8000",
|
||||
"build": "echo 'No build step required - this is a static web app'"
|
||||
},
|
||||
"keywords": [
|
||||
"yoto",
|
||||
"covers",
|
||||
"cards",
|
||||
"web-app",
|
||||
"html",
|
||||
"css",
|
||||
"javascript"
|
||||
],
|
||||
"author": "Yoto Up Team",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/xkjq/yoto-up"
|
||||
},
|
||||
"homepage": "https://github.com/xkjq/yoto-up",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"> 1%",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Yoto Cover - My Yoto Card</title>
|
||||
<style>
|
||||
@font-face { font-family: 'DejaVuSans'; src: local('DejaVu Sans'); }
|
||||
html,body { margin:0; padding:0; width:100%; height:100%; }
|
||||
.card {
|
||||
box-sizing:border-box;
|
||||
width:100%;
|
||||
height:100%;
|
||||
font-family: 'DejaVuSans', serif;
|
||||
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==') no-repeat center/cover;
|
||||
position:relative;
|
||||
transform: scale(1.0);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.title {
|
||||
position:absolute;
|
||||
top:6%;
|
||||
left:6%;
|
||||
right:6%;
|
||||
text-align:center;
|
||||
font-size:calc(108px + 0.5rem);
|
||||
color:#2c3e50;
|
||||
font-weight:900;
|
||||
text-transform:uppercase;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
.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:#ff6b6b;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-weight:700;
|
||||
color:#000;
|
||||
border-radius:6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="title folded" data-heading="My Yoto Card">My Yoto Card</div>
|
||||
|
||||
<div class="overlay"></div>
|
||||
<div class="footer">Created with Yoto Up</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple HTTP server to serve the Yoto Up Covers web app.
|
||||
Run this script and open http://localhost:8000 in your browser.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Change to the current directory (where the files are)
|
||||
web_app_dir = Path(__file__).parent
|
||||
os.chdir(web_app_dir)
|
||||
|
||||
# Set up the server
|
||||
PORT = 8000
|
||||
|
||||
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
# Add CORS headers to allow cross-origin requests
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Suppress server logs for cleaner output
|
||||
return
|
||||
|
||||
def main():
|
||||
print("Yoto Up Covers Web App Server")
|
||||
print("=" * 40)
|
||||
print(f"Serving from: {web_app_dir.absolute()}")
|
||||
print(f"Open your browser to: http://localhost:{PORT}")
|
||||
print("Press Ctrl+C to stop the server")
|
||||
print()
|
||||
|
||||
try:
|
||||
with socketserver.TCPServer(("", PORT), CustomHTTPRequestHandler) as httpd:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
except OSError as e:
|
||||
if e.errno == 48: # Address already in use
|
||||
print(f"Error: Port {PORT} is already in use. Try a different port or stop the other server.")
|
||||
else:
|
||||
print(f"Error starting server: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user