(() => { const fileInput = document.getElementById('file-input'); const cameraInput = document.getElementById('camera-input'); const cameraBtn = document.getElementById('camera-btn'); const libraryBtn = document.getElementById('library-btn'); const dropZone = document.getElementById('drop-zone'); const dropCard = document.getElementById('drop-card'); const editorCard = document.getElementById('editor-card'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const templateRow = document.getElementById('template-row'); const captionInput = document.getElementById('caption-input'); const captionStatus = document.getElementById('caption-status'); const redraftBtn = document.getElementById('redraft-btn'); const newPhotoBtn = document.getElementById('new-photo-btn'); const downloadBtn = document.getElementById('download-btn'); const shareBtn = document.getElementById('share-btn'); const resultPanel = document.getElementById('result-panel'); const source = document.createElement('canvas'); // original photo, cover-cropped into the photo area const sourceCtx = source.getContext('2d'); let originalImg = null; let photoDate = new Date(); let locationLabel = null; // "City, Country" from EXIF GPS + server-side reverse geocode, or null // Slack the fixed 3:2 crop leaves to pan within, in original-photo pixel // space — recomputed every rebuildSource(), read by the drag handler. let cropBounds = { iw: 0, ih: 0, cropW: 0, cropH: 0, outW: 0, outH: 0 }; const panHint = document.getElementById('pan-hint'); const params = { template: 'cream', // Normalized 0-1 position of the crop window within whatever slack the // fixed 3:2 aspect leaves — 0.5/0.5 is centered. cropOffsetX: 0.5, cropOffsetY: 0.5, caption: '', }; // Registered via CSS @font-face but canvas fillText won't wait for a // lazily-loaded webfont on its own — load it explicitly once up front. let fontReady = false; document.fonts.load('700 40px Caveat').catch(() => {}).finally(() => { fontReady = true; if (originalImg) render(); }); // Small seeded PRNG so a template's jagged/faded look stays fixed per // photo instead of jittering every redraw. function mulberry32(seed) { let a = Math.floor(seed * 0xffffffff); return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } let torSeed = Math.random(); async function loadFile(file) { if (!file || !file.type.startsWith('image/')) return; // Read EXIF before the image element even loads — DateTimeOriginal // backdates the caption/postmark to when the photo was actually taken, // not when it happened to be uploaded; GPS (if present) drives the // location line. Both are optional: parse failures/missing tags just // mean "fall back to upload date, no location," per PROPOSAL.md. const exif = window.PostcardExif ? await window.PostcardExif.read(file) : null; photoDate = exif?.date || new Date(); locationLabel = null; const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { originalImg = img; URL.revokeObjectURL(url); dropCard.hidden = true; editorCard.hidden = false; resultPanel.hidden = true; torSeed = Math.random(); params.cropOffsetX = 0.5; params.cropOffsetY = 0.5; rebuildSource(); render(); draftCaption(); if (exif && exif.lat != null && exif.lon != null) lookupLocation(exif.lat, exif.lon); }; img.src = url; } async function lookupLocation(lat, lon) { try { const res = await fetch(`/api/geocode?lat=${lat}&lon=${lon}`); const data = await res.json(); if (data.label) { locationLabel = data.label; render(); } } catch { // No location line — never invents one, per PROPOSAL.md. } } // Builds `source`: the photo cover-cropped to a fixed postcard photo-area // aspect (3:2, classic postcard proportions), capped so a raw phone photo // doesn't choke canvas ops. function rebuildSource() { if (!originalImg) return; const iw = originalImg.naturalWidth; const ih = originalImg.naturalHeight; const targetAspect = 3 / 2; let cropW = iw, cropH = ih, cropX = 0, cropY = 0; const currentAspect = iw / ih; if (currentAspect > targetAspect) { cropW = Math.round(ih * targetAspect); cropX = Math.round((iw - cropW) * params.cropOffsetX); } else { cropH = Math.round(iw / targetAspect); cropY = Math.round((ih - cropH) * params.cropOffsetY); } const maxDim = 1800; let outW = cropW, outH = cropH; if (Math.max(outW, outH) > maxDim) { const scale = maxDim / Math.max(outW, outH); outW = Math.round(outW * scale); outH = Math.round(outH * scale); } source.width = outW; source.height = outH; sourceCtx.clearRect(0, 0, outW, outH); sourceCtx.drawImage(originalImg, cropX, cropY, cropW, cropH, 0, 0, outW, outH); cropBounds = { iw, ih, cropW, cropH, outW, outH }; updatePanAffordance(); // Full card = photo area + a border margin all around, matching a // physical postcard's white/cream frame. const margin = Math.round(outW * 0.06); canvas.width = outW + margin * 2; canvas.height = outH + margin * 2; } function hasPanSlack() { return cropBounds.iw > cropBounds.cropW || cropBounds.ih > cropBounds.cropH; } function updatePanAffordance() { const pannable = hasPanSlack(); canvas.classList.toggle('pannable', pannable); if (panHint) panHint.hidden = !pannable; } // Drag-to-reposition, same trick as latent: convert pointer movement in // CSS pixels to original-photo pixels via crop-width/display-width, // through the outW downscale (canvas's own pixel space === outW/outH, // not cropW/cropH) and the canvas's CSS scaling, so it stays 1:1 // regardless of either. Dragging from anywhere on the canvas is fine // even though the border margin isn't photo -- the gesture still reads // as "drag the photo," and the margin is only ~6% per side. let dragState = null; let panRaf = null; canvas.addEventListener('pointerdown', (e) => { if (!hasPanSlack()) return; const { iw, ih, cropW, cropH } = cropBounds; dragState = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, cropX0: Math.round((iw - cropW) * params.cropOffsetX), cropY0: Math.round((ih - cropH) * params.cropOffsetY), }; canvas.setPointerCapture(e.pointerId); canvas.classList.add('panning'); }); canvas.addEventListener('pointermove', (e) => { if (!dragState || e.pointerId !== dragState.pointerId) return; const rect = canvas.getBoundingClientRect(); const { iw, ih, cropW, cropH, outW, outH } = cropBounds; const cropPxPerCssPxX = (canvas.width / rect.width) * (cropW / outW); const cropPxPerCssPxY = (canvas.height / rect.height) * (cropH / outH); const deltaX = (e.clientX - dragState.startX) * cropPxPerCssPxX; const deltaY = (e.clientY - dragState.startY) * cropPxPerCssPxY; const slackX = iw - cropW; const slackY = ih - cropH; const newCropX = Math.min(Math.max(dragState.cropX0 - deltaX, 0), slackX); const newCropY = Math.min(Math.max(dragState.cropY0 - deltaY, 0), slackY); params.cropOffsetX = slackX > 0 ? newCropX / slackX : 0.5; params.cropOffsetY = slackY > 0 ? newCropY / slackY : 0.5; if (panRaf) return; panRaf = requestAnimationFrame(() => { panRaf = null; rebuildSource(); render(); }); }); function endPan(e) { if (!dragState || (e && e.pointerId !== dragState.pointerId)) return; canvas.classList.remove('panning'); dragState = null; } canvas.addEventListener('pointerup', endPan); canvas.addEventListener('pointercancel', endPan); function render() { if (!originalImg) return; const w = canvas.width; const h = canvas.height; const margin = Math.round(source.width * 0.06); ctx.clearRect(0, 0, w, h); // Border background per template. if (params.template === 'faded') { ctx.fillStyle = '#e9dfc8'; } else { ctx.fillStyle = '#f7f2e6'; } ctx.fillRect(0, 0, w, h); // Photo, with a per-template filter applied at draw time. ctx.save(); ctx.filter = params.template === 'faded' ? 'sepia(0.25) saturate(0.55) contrast(0.85) brightness(1.1)' : 'none'; if (params.template === 'torn') { clipTornRect(margin, margin, source.width, source.height, torSeed); } ctx.drawImage(source, margin, margin); ctx.restore(); if (params.template !== 'torn') { ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.lineWidth = 1; ctx.strokeRect(margin + 0.5, margin + 0.5, source.width - 1, source.height - 1); } drawStampCorner(w, h, margin); drawCaption(w, h, margin); } // Clips the current path to a rectangle with a hand-torn edge — jagged // teeth along each side, seeded so it's stable per photo. function clipTornRect(x, y, w, h, seed) { const rng = mulberry32(seed); const tooth = Math.max(2, Math.round(Math.min(w, h) * 0.006)); const step = tooth * 3; ctx.beginPath(); let px = x, py = y; ctx.moveTo(px, py); for (; px < x + w; px += step) ctx.lineTo(px, y + (rng() - 0.5) * tooth); ctx.lineTo(x + w, y + (rng() - 0.5) * tooth); for (; py < y + h; py += step) ctx.lineTo(x + w + (rng() - 0.5) * tooth, py); ctx.lineTo(x + w + (rng() - 0.5) * tooth, y + h); for (px = x + w; px > x; px -= step) ctx.lineTo(px, y + h + (rng() - 0.5) * tooth); ctx.lineTo(x, y + h + (rng() - 0.5) * tooth); for (py = y + h; py > y; py -= step) ctx.lineTo(x + (rng() - 0.5) * tooth, py); ctx.closePath(); ctx.clip(); } // Postmark stamp — date always, plus a location line only when the photo // had EXIF GPS and reverse geocoding succeeded. Never an invented // location, per PROPOSAL.md's fallback for "no EXIF GPS." function drawStampCorner(w, h, margin) { const size = margin * 1.6; // Right edge of the (rotated) box must stay inside the card, not just // inside the photo area — the border margin is the only safe zone. const x = w - margin - size; const y = margin * 0.15; ctx.save(); ctx.translate(x, y); ctx.rotate((6 * Math.PI) / 180); ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = Math.max(1, size * 0.02); ctx.setLineDash([size * 0.04, size * 0.03]); ctx.strokeRect(0, 0, size, size * 0.8); ctx.setLineDash([]); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.textAlign = 'center'; const dateStr = photoDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); if (locationLabel) { ctx.font = `${Math.round(size * 0.1)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; ctx.fillText(truncate(locationLabel, 18), size / 2, size * 0.3); ctx.font = `${Math.round(size * 0.11)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; ctx.fillText(dateStr, size / 2, size * 0.52); ctx.font = `${Math.round(size * 0.08)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; ctx.fillText('postcard', size / 2, size * 0.7); } else { ctx.font = `${Math.round(size * 0.13)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; ctx.fillText(dateStr, size / 2, size * 0.45); ctx.font = `${Math.round(size * 0.09)}px ui-monospace, 'JetBrains Mono', 'Fira Code', monospace`; ctx.fillText('postcard', size / 2, size * 0.65); } ctx.restore(); } function truncate(str, maxLen) { return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str; } function drawCaption(w, h, margin) { if (!params.caption) return; ctx.save(); ctx.fillStyle = 'rgba(30,25,15,0.85)'; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; // Caveat reads smaller than a sans face at the same px size (thin // connected strokes), so it gets a bigger multiplier and bolder weight // than a plain cursive fallback would need. const fontSize = Math.max(16, Math.round(margin * 0.55)); ctx.font = fontReady ? `700 ${fontSize}px "Caveat", cursive` : `${Math.round(fontSize * 0.75)}px "Segoe Script", "Bradley Hand", cursive`; wrapText(params.caption, margin, h - margin * 0.35, w - margin * 2, fontSize * 1.15); ctx.restore(); } function wrapText(text, x, bottomY, maxWidth, lineHeight) { const words = text.split(/\s+/); const lines = []; let line = ''; for (const word of words) { const test = line ? `${line} ${word}` : word; if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word; } else { line = test; } } if (line) lines.push(line); const clipped = lines.slice(-3); // at most 3 lines, bottom-anchored const startY = bottomY - (clipped.length - 1) * lineHeight; clipped.forEach((l, i) => ctx.fillText(l, x, startY + i * lineHeight)); } async function draftCaption() { captionStatus.textContent = 'drafting…'; try { const dateStr = photoDate.toISOString().slice(0, 10); const res = await fetch(`/api/caption?date=${dateStr}`); const data = await res.json(); captionInput.value = data.caption || ''; params.caption = captionInput.value; captionStatus.textContent = ''; render(); } catch { captionStatus.textContent = ''; } } function showResult(html, isError) { resultPanel.innerHTML = html; resultPanel.classList.toggle('error', !!isError); resultPanel.hidden = false; } function canvasToBlob() { return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92)); } async function upload() { shareBtn.disabled = true; showResult('uploading…'); try { const blob = await canvasToBlob(); const res = await fetch('/api/upload', { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob, }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `upload failed (${res.status})`); } const data = await res.json(); const link = `${location.origin}${data.url}`; showResult(`share link: ${link}`); } catch (err) { showResult(err.message || 'something went wrong', true); } finally { shareBtn.disabled = false; } } fileInput.addEventListener('change', () => loadFile(fileInput.files[0])); cameraInput.addEventListener('change', () => loadFile(cameraInput.files[0])); cameraBtn.addEventListener('click', () => cameraInput.click()); libraryBtn.addEventListener('click', () => fileInput.click()); ['dragover', 'dragenter'].forEach(evt => dropZone.addEventListener(evt, e => { e.preventDefault(); dropZone.classList.add('drag-over'); }) ); ['dragleave', 'dragend', 'drop'].forEach(evt => dropZone.addEventListener(evt, () => dropZone.classList.remove('drag-over')) ); dropZone.addEventListener('drop', e => { e.preventDefault(); loadFile(e.dataTransfer.files[0]); }); [...templateRow.children].forEach(btn => { btn.addEventListener('click', () => { params.template = btn.dataset.template; [...templateRow.children].forEach(b => b.classList.toggle('active', b === btn)); render(); }); }); captionInput.addEventListener('input', () => { params.caption = captionInput.value; render(); }); redraftBtn.addEventListener('click', draftCaption); newPhotoBtn.addEventListener('click', () => { originalImg = null; locationLabel = null; fileInput.value = ''; cameraInput.value = ''; editorCard.hidden = true; dropCard.hidden = false; resultPanel.hidden = true; }); downloadBtn.addEventListener('click', async () => { const blob = await canvasToBlob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `postcard-${Date.now()}.jpg`; a.click(); URL.revokeObjectURL(url); }); shareBtn.addEventListener('click', upload); })();