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:
@@ -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…');
|
||||
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);
|
||||
})();
|
||||
Reference in New Issue
Block a user