Initial commit: postcard v1

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>
This commit is contained in:
Fredrik Johansson
2026-08-06 19:49:03 +02:00
co-authored by Claude Sonnet 5
commit cf0f5fca2c
24 changed files with 2839 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
(() => {
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&hellip;');
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);
})();