Phone photo -> composited postcard (canvas templates, stamp corner, EXIF-aware date/location, self-hosted handwriting font) with a small server for share links, day-context caption drafting off goonk's day-summary API, and Nominatim reverse geocoding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
377 lines
13 KiB
JavaScript
377 lines
13 KiB
JavaScript
(() => {
|
|
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
|
|
|
|
const params = {
|
|
template: 'cream',
|
|
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();
|
|
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) / 2);
|
|
} else {
|
|
cropH = Math.round(iw / targetAspect);
|
|
cropY = Math.round((ih - cropH) / 2);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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 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: <a href="${link}">${link}</a>`);
|
|
} 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);
|
|
})();
|