Spaces:
Running
Running
You are an experienced frontend engineer and creative technologist. Your task is to design and implement a fully client-side web application that generates a dynamic calendar wallpaper image. The project must be suitable for hosting on GitHub Pages (static hosting only, no backend, no server-side code). The application must rely исключительно on HTML, CSS, and JavaScript running in the browser.
5ba9e29 verified | /** | |
| * ChronoCanvas — Calendar Wallpaper Generator | |
| * Shared utilities and index.html logic | |
| */ | |
| // ========================================== | |
| // Device Detection & Presets | |
| // ========================================== | |
| const DEVICE_PRESETS = { | |
| // iPhone | |
| iphone15pm: { width: 1290, height: 2796, platform: 'ios', name: 'iPhone 15 Pro Max' }, | |
| iphone15p: { width: 1179, height: 2556, platform: 'ios', name: 'iPhone 15 Pro' }, | |
| iphone15: { width: 1179, height: 2556, platform: 'ios', name: 'iPhone 15' }, | |
| iphone14pm: { width: 1290, height: 2796, platform: 'ios', name: 'iPhone 14 Pro Max' }, | |
| iphone14p: { width: 1179, height: 2556, platform: 'ios', name: 'iPhone 14 Pro' }, | |
| iphone13: { width: 1170, height: 2532, platform: 'ios', name: 'iPhone 13/14' }, | |
| iphonese: { width: 750, height: 1334, platform: 'ios', name: 'iPhone SE' }, | |
| // Android | |
| pixel8p: { width: 1344, height: 2992, platform: 'android', name: 'Pixel 8 Pro' }, | |
| pixel8: { width: 1080, height: 2400, platform: 'android', name: 'Pixel 8' }, | |
| s24u: { width: 1440, height: 3120, platform: 'android', name: 'Galaxy S24 Ultra' }, | |
| s24: { width: 1080, height: 2340, platform: 'android', name: 'Galaxy S24' }, | |
| // Desktop | |
| macbook14: { width: 3024, height: 1964, platform: 'desktop', name: 'MacBook 14"' }, | |
| macbook16: { width: 3456, height: 2234, platform: 'desktop', name: 'MacBook 16"' }, | |
| imac: { width: 5120, height: 2880, platform: 'desktop', name: 'iMac 5K' }, | |
| fhd: { width: 1920, height: 1080, platform: 'desktop', name: 'Full HD' }, | |
| '4k': { width: 3840, height: 2160, platform: 'desktop', name: '4K UHD' } | |
| }; | |
| const ACCENT_COLORS = { | |
| primary: { 500: '#0ea5e9', 600: '#0284c7' }, | |
| secondary: { 500: '#d946ef', 600: '#c026d3' }, | |
| emerald: { 500: '#10b981', 600: '#059669' }, | |
| amber: { 500: '#f59e0b', 600: '#d97706' }, | |
| rose: { 500: '#f43f5e', 600: '#e11d48' }, | |
| violet: { 500: '#8b5cf6', 600: '#7c3aed' } | |
| }; | |
| // ========================================== | |
| // Utility Functions | |
| // ========================================== | |
| function detectPlatform() { | |
| const ua = navigator.userAgent.toLowerCase(); | |
| if (/iphone|ipad|ipod/.test(ua)) return 'ios'; | |
| if (/android/.test(ua)) return 'android'; | |
| return 'desktop'; | |
| } | |
| function detectResolution() { | |
| const dpr = window.devicePixelRatio || 1; | |
| // Use screen dimensions for wallpaper generation | |
| // For mobile, we want the native resolution | |
| const width = Math.round(window.screen.width * dpr); | |
| const height = Math.round(window.screen.height * dpr); | |
| return { width, height, dpr }; | |
| } | |
| function findBestPreset(width, height, platform) { | |
| // Try to find matching preset | |
| for (const [key, preset] of Object.entries(DEVICE_PRESETS)) { | |
| if (preset.platform === platform && | |
| Math.abs(preset.width - width) < 100 && | |
| Math.abs(preset.height - height) < 100) { | |
| return { key, ...preset }; | |
| } | |
| } | |
| return null; | |
| } | |
| function getMonthData(year, month) { | |
| const firstDay = new Date(year, month, 1); | |
| const lastDay = new Date(year, month + 1, 0); | |
| const daysInMonth = lastDay.getDate(); | |
| const startDayOfWeek = firstDay.getDay(); // 0 = Sunday | |
| const weeks = []; | |
| let currentWeek = []; | |
| // Fill leading empty days | |
| for (let i = 0; i < startDayOfWeek; i++) { | |
| currentWeek.push(null); | |
| } | |
| // Fill days | |
| for (let day = 1; day <= daysInMonth; day++) { | |
| currentWeek.push(day); | |
| if (currentWeek.length === 7) { | |
| weeks.push(currentWeek); | |
| currentWeek = []; | |
| } | |
| } | |
| // Fill trailing empty days | |
| if (currentWeek.length > 0) { | |
| while (currentWeek.length < 7) { | |
| currentWeek.push(null); | |
| } | |
| weeks.push(currentWeek); | |
| } | |
| return { weeks, daysInMonth, startDayOfWeek }; | |
| } | |
| // ========================================== | |
| // Canvas Rendering Engine | |
| // ========================================== | |
| function renderCalendarWallpaper(canvas, options) { | |
| const { | |
| width, | |
| height, | |
| theme = 'dark', | |
| accent = 'primary', | |
| showSeconds = false, | |
| showWeekday = true, | |
| showYear = true, | |
| date = new Date() | |
| } = options; | |
| const ctx = canvas.getContext('2d'); | |
| const colors = ACCENT_COLORS[accent] || ACCENT_COLORS.primary; | |
| // Set canvas size | |
| canvas.width = width; | |
| canvas.height = height; | |
| // Theme colors | |
| const isDark = theme === 'dark'; | |
| const bgColor = isDark ? '#0f172a' : '#ffffff'; | |
| const textColor = isDark ? '#f8fafc' : '#1e293b'; | |
| const mutedColor = isDark ? '#94a3b8' : '#64748b'; | |
| const surfaceColor = isDark ? 'rgba(30, 41, 59, 0.6)' : 'rgba(241, 245, 249, 0.8)'; | |
| const gridColor = isDark ? 'rgba(148, 163, 184, 0.1)' : 'rgba(100, 116, 139, 0.1)'; | |
| // Background | |
| ctx.fillStyle = bgColor; | |
| ctx.fillRect(0, 0, width, height); | |
| // Subtle gradient overlay | |
| const gradient = ctx.createLinearGradient(0, 0, width, height); | |
| if (isDark) { | |
| gradient.addColorStop(0, 'rgba(14, 165, 233, 0.08)'); | |
| gradient.addColorStop(0.5, 'transparent'); | |
| gradient.addColorStop(1, 'rgba(217, 70, 239, 0.05)'); | |
| } else { | |
| gradient.addColorStop(0, 'rgba(14, 165, 233, 0.05)'); | |
| gradient.addColorStop(0.5, 'transparent'); | |
| gradient.addColorStop(1, 'rgba(217, 70, 239, 0.03)'); | |
| } | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, width, height); | |
| // Calculate layout based on aspect ratio | |
| const isPortrait = height > width; | |
| const padding = Math.min(width, height) * 0.08; | |
| const availableWidth = width - padding * 2; | |
| const availableHeight = height - padding * 2; | |
| // Scale factor based on resolution | |
| const baseScale = Math.min(width, height) / 1000; | |
| const scale = Math.max(baseScale, 1); | |
| let currentY = padding; | |
| // Date display | |
| const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', | |
| 'July', 'August', 'September', 'October', 'November', 'December']; | |
| const weekdayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; | |
| const currentMonth = date.getMonth(); | |
| const currentYear = date.getFullYear(); | |
| const currentDay = date.getDate(); | |
| const currentWeekday = date.getDay(); | |
| // Header section | |
| if (showWeekday) { | |
| ctx.font = `300 ${24 * scale}px system-ui, -apple-system, sans-serif`; | |
| ctx.fillStyle = colors[500]; | |
| ctx.textAlign = 'left'; | |
| ctx.fillText(weekdayNames[currentWeekday], padding, currentY + 24 * scale); | |
| currentY += 40 * scale; | |
| } | |
| // Large date | |
| ctx.font = `200 ${120 * scale}px system-ui, -apple-system, sans-serif`; | |
| ctx.fillStyle = textColor; | |
| ctx.fillText(String(currentDay).padStart(2, '0'), padding, currentY + 100 * scale); | |
| currentY += 130 * scale; | |
| // Month and year | |
| ctx.font = `400 ${28 * scale}px system-ui, -apple-system, sans-serif`; | |
| ctx.fillStyle = mutedColor; | |
| const yearText = showYear ? ` ${currentYear}` : ''; | |
| ctx.fillText(`${monthNames[currentMonth]}${yearText}`, padding, currentY); | |
| currentY += 60 * scale; | |
| // Time (optional) | |
| const hours = date.getHours(); | |
| const minutes = date.getMinutes(); | |
| const seconds = date.getSeconds(); | |
| const ampm = hours >= 12 ? 'PM' : 'AM'; | |
| const displayHours = hours % 12 || 12; | |
| const timeString = `${displayHours}:${String(minutes).padStart(2, '0')}${showSeconds ? ':' + String(seconds).padStart(2, '0') : ''} ${ampm}`; | |
| ctx.font = `300 ${20 * scale}px system-ui, -apple-system, sans-serif`; | |
| ctx.fillStyle = mutedColor; | |
| ctx.fillText(timeString, padding, currentY); | |
| currentY += 50 * scale; | |
| // Calendar grid | |
| const { weeks } = getMonthData(currentYear, currentMonth); | |
| const dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; | |
| // Calendar container | |
| const calendarTop = currentY; | |
| const cellSize = Math.min(availableWidth / 7, (availableHeight - currentY + padding) / (weeks.length + 2)); | |
| const calendarWidth = cellSize * 7; | |
| const startX = (width - calendarWidth) / 2; | |
| // Day headers | |
| ctx.font = `500 ${14 * scale}px system-ui, -apple-system, sans-serif`; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| dayNames.forEach((day, i) => { | |
| const x = startX + i * cellSize + cellSize / 2; | |
| const y = calendarTop + cellSize / 2; | |
| ctx.fillStyle = i === 0 || i === 6 ? colors[500] : mutedColor; | |
| ctx.fillText(day, x, y); | |
| }); | |
| // Calendar days | |
| const today = new Date(); | |
| const isCurrentMonth = today.getMonth() === currentMonth && today.getFullYear() === currentYear; | |
| weeks.forEach((week, weekIndex) => { | |
| const rowY = calendarTop + (weekIndex + 1.5) * cellSize; | |
| week.forEach((day, dayIndex) => { | |
| if (!day) return; | |
| const x = startX + dayIndex * cellSize + cellSize / 2; | |
| const y = rowY; | |
| const isToday = isCurrentMonth && day === currentDay; | |
| // Today highlight | |
| if (isToday) { | |
| ctx.beginPath(); | |
| ctx.arc(x, y, cellSize * 0.35, 0, Math.PI * 2); | |
| ctx.fillStyle = colors[500]; | |
| ctx.fill(); | |
| // Glow effect | |
| ctx.beginPath(); | |
| ctx.arc(x, y, cellSize * 0.45, 0, Math.PI * 2); | |
| ctx.fillStyle = colors[500] + '20'; | |
| ctx.fill(); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = `600 ${16 * scale}px system-ui, -apple-system, sans-serif`; | |
| } else { | |
| ctx.fillStyle = textColor; | |
| ctx.font = `400 ${16 * scale}px system-ui, -apple-system, sans-serif`; | |
| } | |
| ctx.fillText(String(day), x, y); | |
| }); | |
| }); | |
| // Decorative elements | |
| // Subtle grid lines | |
| ctx.strokeStyle = gridColor; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 7; i++) { | |
| const x = startX + i * cellSize; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, calendarTop + cellSize); | |
| ctx.lineTo(x, calendarTop + (weeks.length + 1) * cellSize); | |
| ctx.stroke(); | |
| } | |
| for (let i = 0; i <= weeks.length + 1; i++) { | |
| const y = calendarTop + i * cellSize; | |
| ctx.beginPath(); | |
| ctx.moveTo(startX, y); | |
| ctx.lineTo(startX + calendarWidth, y); | |
| ctx.stroke(); | |
| } | |
| // Bottom branding (subtle) | |
| ctx.font = `300 ${12 * scale}px system-ui, -apple-system, sans-serif`; | |
| ctx.fillStyle = isDark ? 'rgba(148, 163, 184, 0.3)' : 'rgba(100, 116, 139, 0.3)'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('ChronoCanvas', width / 2, height - padding / 2); | |
| } | |
| // ========================================== | |
| // URL Parameter Handling | |
| // ========================================== | |
| function buildRenderUrl(params) { | |
| const url = new URL('render.html', window.location.href); | |
| Object.entries(params).forEach(([key, value]) => { | |
| if (value !== undefined && value !== null && value !== '') { | |
| url.searchParams.set(key, String(value)); | |
| } | |
| }); | |
| return url.toString(); | |
| } | |
| function parseRenderUrl(urlString) { | |
| const url = new URL(urlString); | |
| const params = {}; | |
| url.searchParams.forEach((value, key) => { | |
| params[key] = value; | |
| }); | |
| return params; | |
| } | |
| // ========================================== | |
| // Index Page Logic | |
| // ========================================== | |
| function initIndexPage() { | |
| // DOM Elements | |
| const platformInput = document.getElementById('platform'); | |
| const widthInput = document.getElementById('width'); | |
| const heightInput = document.getElementById('height'); | |
| const themeInput = document.getElementById('theme'); | |
| const accentInput = document.getElementById('accent'); | |
| const presetSelect = document.getElementById('preset'); | |
| const deviceLabelInput = document.getElementById('device-label'); | |
| const showSecondsInput = document.getElementById('show-seconds'); | |
| const showWeekdayInput = document.getElementById('show-weekday'); | |
| const showYearInput = document.getElementById('show-year'); | |
| const previewCanvas = document.getElementById('preview-canvas'); | |
| const previewLoading = document.getElementById('preview-loading'); | |
| const generatedUrlInput = document.getElementById('generated-url'); | |
| const generateBtn = document.getElementById('generate-btn'); | |
| const copyBtn = document.getElementById('copy-btn'); | |
| const openRenderBtn = document.getElementById('open-render'); | |
| const detectedDeviceEl = document.getElementById('detected-device'); | |
| const toast = document.getElementById('toast'); | |
| const toastMessage = document.getElementById('toast-message'); | |
| // Platform buttons | |
| const platformBtns = document.querySelectorAll('.platform-btn'); | |
| const themeBtns = document.querySelectorAll('.theme-btn'); | |
| const accentBtns = document.querySelectorAll('.accent-btn'); | |
| // State | |
| let currentParams = {}; | |
| // Auto-detect device | |
| function autoDetect() { | |
| const platform = detectPlatform(); | |
| const { width, height, dpr } = detectResolution(); | |
| // Find best preset | |
| const preset = findBestPreset(width, height, platform); | |
| // Update UI | |
| platformInput.value = platform; | |
| updatePlatformButtons(); | |
| if (preset) { | |
| widthInput.value = preset.width; | |
| heightInput.value = preset.height; | |
| presetSelect.value = preset.key; | |
| deviceLabelInput.value = preset.name; | |
| detectedDeviceEl.textContent = `${preset.name} (${width}×${height} @ ${dpr}x)`; | |
| } else { | |
| // Use detected resolution directly | |
| widthInput.value = width; | |
| heightInput.value = height; | |
| presetSelect.value = ''; | |
| deviceLabelInput.value = ''; | |
| detectedDeviceEl.textContent = `${platform} (${width}×${height} @ ${dpr}x)`; | |
| } | |
| updatePreview(); | |
| } | |
| function updatePlatformButtons() { | |
| const value = platformInput.value; | |
| platformBtns.forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.value === value); | |
| }); | |
| } | |
| function updateThemeButtons() { | |
| const value = themeInput.value; | |
| themeBtns.forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.value === value); | |
| }); | |
| } | |
| function updateAccentButtons() { | |
| const value = accentInput.value; | |
| accentBtns.forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.value === value); | |
| }); | |
| } | |
| function getCurrentParams() { | |
| return { | |
| width: parseInt(widthInput.value) || 1179, | |
| height: parseInt(heightInput.value) || 2556, | |
| platform: platformInput.value, | |
| theme: themeInput.value, | |
| accent: accentInput.value, | |
| label: deviceLabelInput.value, | |
| seconds: showSecondsInput.checked ? '1' : '0', | |
| weekday: showWeekdayInput.checked ? '1' : '0', | |
| year: showYearInput.checked ? '1' : '0' | |
| }; | |
| } | |
| function updatePreview() { | |
| const params = getCurrentParams(); | |
| currentParams = params; | |
| // Generate preview at smaller size | |
| const previewWidth = 400; | |
| const aspectRatio = params.height / params.width; | |
| const previewHeight = Math.round(previewWidth * aspectRatio); | |
| previewCanvas.width = previewWidth; | |
| previewCanvas.height = previewHeight; | |
| renderCalendarWallpaper(previewCanvas, { | |
| width: previewWidth, | |
| height: previewHeight, | |
| theme: params.theme, | |
| accent: params.accent, | |
| showSeconds: params.seconds === '1', | |
| showWeekday: params.weekday === '1', | |
| showYear: params.year === '1', | |
| date: new Date() | |
| }); | |
| previewLoading.classList.add('hidden'); | |
| // Update URL | |
| const renderUrl = buildRenderUrl(params); | |
| generatedUrlInput.value = renderUrl; | |
| openRenderBtn.href = renderUrl; | |
| } | |
| function showToast(message) { | |
| toastMessage.textContent = message; | |
| toast.classList.remove('translate-y-20', 'opacity-0'); | |
| setTimeout(() => { | |
| toast.classList.add('translate-y-20', 'opacity-0'); | |
| }, 2000); | |
| } | |
| // Event Listeners | |
| platformBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| platformInput.value = btn.dataset.value; | |
| updatePlatformButtons(); | |
| updatePreview(); | |
| }); | |
| }); | |
| themeBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| themeInput.value = btn.dataset.value; | |
| updateThemeButtons(); | |
| updatePreview(); | |
| }); | |
| }); | |
| accentBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| accentInput.value = btn.dataset.value; | |
| updateAccentButtons(); | |
| updatePreview(); | |
| }); | |
| }); | |
| presetSelect.addEventListener('change', () => { | |
| const key = presetSelect.value; | |
| if (key && DEVICE_PRESETS[key]) { | |
| const preset = DEVICE_PRESETS[key]; | |
| widthInput.value = preset.width; | |
| heightInput.value = preset.height; | |
| platformInput.value = preset.platform; | |
| deviceLabelInput.value = preset.name; | |
| updatePlatformButtons(); | |
| } | |
| updatePreview(); | |
| }); | |
| [widthInput, heightInput, deviceLabelInput].forEach(el => { | |
| el.addEventListener('input', updatePreview); | |
| }); | |
| [showSecondsInput, showWeekdayInput, showYearInput].forEach(el => { | |
| el.addEventListener('change', updatePreview); | |
| }); | |
| generateBtn.addEventListener('click', updatePreview); | |
| copyBtn.addEventListener('click', async () => { | |
| try { | |
| await navigator.clipboard.writeText(generatedUrlInput.value); | |
| showToast('Copied to clipboard!'); | |
| } catch (err) { | |
| // Fallback | |
| generatedUrlInput.select(); | |
| document.execCommand('copy'); | |
| showToast('Copied to clipboard!'); | |
| } | |
| }); | |
| // Initialize | |
| updateThemeButtons(); | |
| updateAccentButtons(); | |
| autoDetect(); | |
| } | |
| // ========================================== | |
| // Render Page Logic | |
| // ========================================== | |
| function initRenderPage() { | |
| const canvas = document.getElementById('wallpaper-canvas'); | |
| const loading = document.getElementById('loading'); | |
| // Parse URL parameters | |
| const urlParams = new URLSearchParams(window.location.search); | |
| // Get parameters with fallbacks | |
| const getParam = (name, defaultValue, parser = String) => { | |
| const value = urlParams.get(name); | |
| return value !== null ? parser(value) : defaultValue; | |
| }; | |
| // Auto-detect if needed | |
| const { width: detectedWidth, height: detectedHeight } = detectResolution(); | |
| const detectedPlatform = detectPlatform(); | |
| const params = { | |
| width: getParam('width', detectedWidth, parseInt), | |
| height: getParam('height', detectedHeight, parseInt), | |
| theme: getParam('theme', 'dark'), | |
| accent: getParam('accent', 'primary'), | |
| showSeconds: getParam('seconds', '0') === '1', | |
| showWeekday: getParam('weekday', '1') === '1', | |
| showYear: getParam('year', '1') === '1' | |
| }; | |
| // Validate dimensions | |
| params.width = Math.max(320, Math.min(8192, params.width)); | |
| params.height = Math.max(320, Math.min(8192, params.height)); | |
| // Render | |
| function render() { | |
| renderCalendarWallpaper(canvas, { | |
| width: params.width, | |
| height: params.height, | |
| theme: params.theme, | |
| accent: params.accent, | |
| showSeconds: params.showSeconds, | |
| showWeekday: params.showWeekday, | |
| showYear: params.showYear, | |
| date: new Date() | |
| }); | |
| // Hide loading | |
| loading.classList.add('hidden'); | |
| // For automation: update title with timestamp | |
| document.title = `ChronoCanvas — ${new Date().toLocaleString()}`; | |
| } | |
| // Initial render | |
| render(); | |
| // Auto-refresh for clock if showing seconds | |
| if (params.showSeconds) { | |
| setInterval(render, 1000); | |
| } else { | |
| // Refresh every minute to update time | |
| setInterval(render, 60000); | |
| } | |
| } | |
| // ========================================== | |
| // Initialize | |
| // ========================================== | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Detect which page we're on | |
| const isRenderPage = document.getElementById('wallpaper-canvas') !== null; | |
| if (isRenderPage) { | |
| initRenderPage(); | |
| } else { | |
| initIndexPage(); | |
| } | |
| }); |