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,23 @@
|
|||||||
|
# Copy to .env and adjust. Read two ways: `npm start`/`npm run dev` in
|
||||||
|
# server/ load it directly via Node's --env-file-if-exists; `docker
|
||||||
|
# compose up` reads it as compose's env file instead (IMAGE/HOST_PORT are
|
||||||
|
# only meaningful there, the rest get passed through to the container).
|
||||||
|
|
||||||
|
# Image to deploy — set by CI/CD normally; only needed for a manual
|
||||||
|
# docker compose up.
|
||||||
|
IMAGE=repo.explewd.com/explewd/postcard:latest
|
||||||
|
|
||||||
|
# Host port to expose (container always listens on 3098).
|
||||||
|
HOST_PORT=3098
|
||||||
|
|
||||||
|
# Retention — a postcard link is meant to be opened once by the
|
||||||
|
# recipient, not browsed later, so this is shorter than latent's.
|
||||||
|
TTL_HOURS=72
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES=20971520
|
||||||
|
RATE_LIMIT_PER_HOUR=30
|
||||||
|
|
||||||
|
# Optional day-context source for caption drafting. GET {url}?date=YYYY-MM-DD
|
||||||
|
# must return { git?, spotify?, note? }. Left unset, captions fall back to
|
||||||
|
# generic postcard-voice lines instead of invented personal data.
|
||||||
|
DAY_SUMMARY_URL=https://example.com/api/day-summary
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
data
|
||||||
|
server/data
|
||||||
|
.claude/
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:24-slim
|
||||||
|
|
||||||
|
# better-sqlite3 has a native addon with no prebuilt binary for this
|
||||||
|
# platform/Node combo yet — build it from source, then drop the toolchain.
|
||||||
|
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY server/package*.json server/
|
||||||
|
RUN npm install --prefix server --omit=dev \
|
||||||
|
&& apt-get purge -y python3 make g++ && apt-get autoremove -y
|
||||||
|
|
||||||
|
COPY server/src/ server/src/
|
||||||
|
COPY index.html style.css app.js exif.js favicon.svg ./
|
||||||
|
COPY fonts/ fonts/
|
||||||
|
|
||||||
|
# Do NOT copy data/ — mutable runtime volume holding the SQLite db and
|
||||||
|
# image blobs, would get clobbered on every redeploy if baked in.
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3098
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
EXPOSE 3098
|
||||||
|
|
||||||
|
CMD ["node", "server/src/index.js"]
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# Proposal: `postcard` — turn a phone photo into a sendable postcard
|
||||||
|
|
||||||
|
**Status:** proposed, not built. Self-contained — written so a fresh
|
||||||
|
agent with no prior conversation context can pick this up and implement
|
||||||
|
it without anything explained first. No repo exists yet; this file is
|
||||||
|
the seed of one.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Upload a photo from your phone (or any device), it gets composited onto
|
||||||
|
a vintage postcard template — border, stamp corner, a handwritten-style
|
||||||
|
caption — and you get a shareable link or a downloadable image. The
|
||||||
|
caption is auto-drafted from whatever context is available for that
|
||||||
|
day (git activity, what was playing, a note title) so the postcard reads
|
||||||
|
like it was actually written *from* that day, not just a filter applied
|
||||||
|
to a photo.
|
||||||
|
|
||||||
|
Structurally this is `latent`'s upload → transform → deliver shape, with
|
||||||
|
the transform swapped from film-emulation filters to postcard
|
||||||
|
compositing, and one addition: a short generated caption line instead of
|
||||||
|
(or alongside) canvas filters. Reuse `latent`'s validated pieces
|
||||||
|
wholesale rather than re-deriving them — same phone-upload flow, same
|
||||||
|
blob+SQLite+TTL server, same no-accounts/link-is-the-credential model.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Static/client-heavy frontend (canvas compositing, same "no build step"
|
||||||
|
family as `latent`/`typo`/`flit`/`wisp`) plus a small single-process
|
||||||
|
server, copied from `latent/server` almost unchanged: `db.js` (SQLite
|
||||||
|
metadata), `ids.js` (opaque random IDs), `cleanup.js` (interval TTL
|
||||||
|
sweep), `ratelimit.js` (basic IP throttle on upload), `routes.js`,
|
||||||
|
`index.js`. No accounts, no auth — the link is the credential, same as
|
||||||
|
`latent`/`wisp`.
|
||||||
|
|
||||||
|
### Editing (client-side, `<canvas>`)
|
||||||
|
|
||||||
|
1. **Upload** — `<input type="file" capture>` for direct camera access,
|
||||||
|
normal file picker as fallback (same open question `latent` already
|
||||||
|
flagged, same answer: ship both).
|
||||||
|
2. **Template** — a handful of postcard border/stamp-corner presets
|
||||||
|
(plain cream border, torn-edge, faded color-photo-era border) — pick
|
||||||
|
a vibe, not a full editor. Reuse `latent`'s "presets, not sliders"
|
||||||
|
call for the same reason: faster to ship, more toy than tool.
|
||||||
|
3. **Caption** — an editable text field, pre-filled by a generated draft
|
||||||
|
(see below), rendered onto the card in a handwriting-style webfont.
|
||||||
|
Editable because auto-drafted text should be a starting point, not
|
||||||
|
the final word — the person sending it should be able to make it
|
||||||
|
actually theirs.
|
||||||
|
4. **Postmark/stamp corner** — today's date and a small generated
|
||||||
|
"location" stamp if EXIF GPS is present (round-tripped through the
|
||||||
|
same reverse-geocoding approach `rewind`/`galr` already use, if one
|
||||||
|
exists — otherwise just the date, no invented location).
|
||||||
|
5. Compositing is canvas-based, non-destructive within the session
|
||||||
|
(recompute from the original photo + a param object, matching
|
||||||
|
`latent`'s pattern) so switching templates doesn't require
|
||||||
|
re-uploading.
|
||||||
|
|
||||||
|
### Caption generation
|
||||||
|
|
||||||
|
The interesting new piece `latent` didn't need. Draft a one-line caption
|
||||||
|
from whatever of these is available and non-empty for the photo's
|
||||||
|
date (EXIF date if present, else upload date):
|
||||||
|
|
||||||
|
- Git activity that day (via the same Gitea-API aggregate-count approach
|
||||||
|
`rewind` already built — "spent the day untangling a deploy script" /
|
||||||
|
"quiet one, no commits")
|
||||||
|
- What was playing (Spotify plays log, same source `/wrapped`/`/now`
|
||||||
|
already read — "[artist] on repeat")
|
||||||
|
- A note/blog title from that date, if one exists
|
||||||
|
|
||||||
|
This is explicitly a **remix of existing fragments**, not a generative
|
||||||
|
free-text model call — same spirit as the haiku/found-poetry idea
|
||||||
|
below: assemble from real fragments of that day, phrase them into one
|
||||||
|
short sentence with a small set of template shapes ("Spent the day
|
||||||
|
{git_fragment}. {spotify_fragment} the whole time." /
|
||||||
|
"{spotify_fragment}. {git_fragment}, mostly."), and always leave it
|
||||||
|
editable. If none of the three sources have data for that date, fall
|
||||||
|
back to a small set of generic postcard-voice lines ("Wish you were
|
||||||
|
here. Mostly wasn't.") rather than leaving the field empty.
|
||||||
|
|
||||||
|
### Finishing paths
|
||||||
|
|
||||||
|
Same two-path split as `latent`:
|
||||||
|
|
||||||
|
1. **Instant** — composite, then download directly, entirely
|
||||||
|
client-side, no server touched. Optional "generate share link"
|
||||||
|
(explicit action, not automatic).
|
||||||
|
2. **Send** — uploads the composited image to server storage, returns a
|
||||||
|
link. No delayed-reveal mechanic here (that's `latent`'s gimmick, not
|
||||||
|
this one) — the postcard metaphor's own delay is real mail taking a
|
||||||
|
few days to arrive, which the link doesn't need to fake.
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
Copied from `latent/server`'s shape almost directly:
|
||||||
|
|
||||||
|
**Table `postcards`:**
|
||||||
|
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | text PK | random opaque ID, used in the URL |
|
||||||
|
| `blob_path` | text | composited image on disk |
|
||||||
|
| `mime` | text | |
|
||||||
|
| `created_at` | integer | |
|
||||||
|
| `expires_at` | integer | TTL sweep target, same as `latent` |
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
|
||||||
|
- `POST /api/upload` — body: composited image bytes. Returns `{ id, url }`.
|
||||||
|
- `GET /p/:id` — serves the postcard image directly (no develop-later
|
||||||
|
gate — this app's whole point is immediacy, unlike `latent`).
|
||||||
|
- TTL sweep, same interval-based pattern as `latent`/`wisp`.
|
||||||
|
|
||||||
|
No auth. Rate-limit `/api/upload` the same way `latent` does.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No accounts, no "your postcards" history — every card stands alone via
|
||||||
|
its own link, same throwaway philosophy as `latent`/`wisp`.
|
||||||
|
- No delayed-reveal mechanic — that's `latent`'s gimmick; this one's
|
||||||
|
point is speed, not anticipation.
|
||||||
|
- No free-text LLM-generated captions — template-and-fragment assembly
|
||||||
|
from real personal data only, kept editable, never presented as if the
|
||||||
|
app "wrote" it.
|
||||||
|
- No collaborative/multi-recipient cards — one photo, one card, one link.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Where the day-context lookups live** — `rewind` and `/wrapped`/`/now`
|
||||||
|
already have working Gitea-API and Spotify-log integrations; decide
|
||||||
|
whether `postcard`'s server calls those APIs directly (network
|
||||||
|
dependency on goonk's API being reachable) or whether this only makes
|
||||||
|
sense as a goonk-hosted feature rather than a fully separate sibling
|
||||||
|
project. Worth resolving before writing `routes.js`.
|
||||||
|
- **Location stamp source** — confirm whether `galr`/`rewind` already do
|
||||||
|
reverse geocoding anywhere reusable, or whether this ships without a
|
||||||
|
location stamp in a first pass.
|
||||||
|
- **Handwriting webfont choice and licensing** — pick one, check its
|
||||||
|
license allows self-hosting.
|
||||||
|
- **Image size limits** — same question `latent` flagged, same likely
|
||||||
|
answer (downscale on upload, cap stored size).
|
||||||
|
- **Retention window** — shorter than `latent`'s, probably — a postcard
|
||||||
|
link is meant to be opened once by the recipient, not browsed later.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# postcard
|
||||||
|
|
||||||
|
Turn a phone photo into a sendable postcard. Upload a photo, it gets
|
||||||
|
composited onto a vintage postcard template (border, stamp corner,
|
||||||
|
handwriting-style caption) entirely in the browser, then either download
|
||||||
|
it right away or upload it for a shareable link.
|
||||||
|
|
||||||
|
The caption is auto-drafted by assembling real fragments of that day
|
||||||
|
(git activity, what was playing, a note title) via `server/src/caption.js`
|
||||||
|
— never a free-text model call — and stays editable. With no day-context
|
||||||
|
source configured it falls back to a small set of generic postcard-voice
|
||||||
|
lines.
|
||||||
|
|
||||||
|
Structurally this is a copy of [`latent`](../latent)'s upload → transform
|
||||||
|
→ deliver shape and server (`db.js`/`ids.js`/`cleanup.js`/`ratelimit.js`/
|
||||||
|
`routes.js`), with the transform swapped for postcard compositing. See
|
||||||
|
[PROPOSAL.md](PROPOSAL.md) for the original design writeup.
|
||||||
|
|
||||||
|
## Two finishing paths off one editor
|
||||||
|
|
||||||
|
1. **Instant** — composite, then download directly, entirely client-side.
|
||||||
|
Optionally generate a share link (explicit action, not automatic).
|
||||||
|
2. **Send** — uploads the composited image to server storage, returns a
|
||||||
|
link immediately (no delayed-reveal gate — that's `latent`'s gimmick,
|
||||||
|
not this one's).
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install --prefix server
|
||||||
|
npm start --prefix server
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:3098. `server`'s `start`/`dev` scripts load
|
||||||
|
`../.env` automatically via Node's `--env-file-if-exists` — copy
|
||||||
|
`.env.example` to `.env` at the repo root and adjust. `DATA_DIR` defaults
|
||||||
|
to `./server/data` (gitignored) for the SQLite db and image blobs.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
`docker compose up` using the provided `docker-compose.yml` (copy
|
||||||
|
`.env.example` to `.env` and adjust — `IMAGE`/`HOST_PORT` are only read by
|
||||||
|
compose, the rest are passed through to the container).
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
All via env vars:
|
||||||
|
|
||||||
|
- `TTL_HOURS` — link retention (default 72h; shorter than `latent`'s,
|
||||||
|
since a postcard link is meant to be opened once, not browsed later).
|
||||||
|
- `MAX_UPLOAD_BYTES`, `RATE_LIMIT_PER_HOUR` — basic abuse limits; no
|
||||||
|
accounts or auth on any endpoint, the link itself is the credential.
|
||||||
|
- `DAY_SUMMARY_URL` — optional, no code-level default (set it in `.env`).
|
||||||
|
If set, `GET {url}?date=YYYY-MM-DD` is expected to return
|
||||||
|
`{ git?, spotify?, note? }` fragments for that date. `.env` here points
|
||||||
|
it at `https://goonk.se/api/day-summary`, goonk's live endpoint
|
||||||
|
(confirmed returning `{ git, spotify? }` — no `note` field observed yet).
|
||||||
|
A response body containing an `error` key (goonk's shape for a
|
||||||
|
missing/malformed date) is treated the same as no summary. Left unset,
|
||||||
|
captions fall back to generic lines — no invented personal data.
|
||||||
|
- `NOMINATIM_URL` — reverse-geocoding endpoint for the stamp corner's
|
||||||
|
location line (only queried when a photo has EXIF GPS). Defaults to the
|
||||||
|
public `nominatim.openstreetmap.org`; `server/src/geocode.js` throttles
|
||||||
|
to 1 req/sec and caches by rounded coordinate per that API's usage
|
||||||
|
policy. No API key needed.
|
||||||
|
|
||||||
|
## Notable implementation details
|
||||||
|
|
||||||
|
- EXIF is parsed client-side with no dependency (`exif.js`) — just enough
|
||||||
|
to read `DateTimeOriginal` and GPS lat/lon out of a JPEG. Parse
|
||||||
|
failures/missing tags fall back to upload date / no location stamp,
|
||||||
|
never invented data.
|
||||||
|
- The caption's handwriting font is self-hosted (`fonts/caveat-latin.woff2`,
|
||||||
|
Caveat, SIL OFL — see `fonts/OFL.txt`), not loaded from
|
||||||
|
fonts.googleapis.com at runtime.
|
||||||
|
- The upload card offers separate "take a photo" (camera capture) and
|
||||||
|
"choose from library" buttons rather than relying on the OS's file
|
||||||
|
picker to offer both — some mobile browsers skip the chooser and go
|
||||||
|
straight to the camera when `capture` is set on a single input.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
No accounts, no "your postcards" history, no delayed-reveal mechanic, no
|
||||||
|
free-text LLM-generated captions, no collaborative/multi-recipient cards.
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${IMAGE}
|
||||||
|
container_name: postcard
|
||||||
|
restart: unless-stopped
|
||||||
|
pull_policy: always
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-3098}:3098"
|
||||||
|
environment:
|
||||||
|
- TTL_HOURS=${TTL_HOURS:-72}
|
||||||
|
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-20971520}
|
||||||
|
- RATE_LIMIT_PER_HOUR=${RATE_LIMIT_PER_HOUR:-30}
|
||||||
|
# Day-context source for caption drafting — see README. Left unset,
|
||||||
|
# captions fall back to generic postcard-voice lines.
|
||||||
|
- DAY_SUMMARY_URL=${DAY_SUMMARY_URL:-}
|
||||||
|
# Reverse-geocoding endpoint for the stamp corner's location line.
|
||||||
|
# Defaults to the public Nominatim instance if unset.
|
||||||
|
- NOMINATIM_URL=${NOMINATIM_URL:-}
|
||||||
|
volumes:
|
||||||
|
# Named volume, not a bind mount into the build context — the SQLite
|
||||||
|
# db and image blobs live here and must survive image redeploys.
|
||||||
|
- postcard-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postcard-data:
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Minimal EXIF reader — just enough to pull DateTimeOriginal and GPS lat/lon
|
||||||
|
// out of a JPEG, with no dependency (this project's "no build step" family
|
||||||
|
// doesn't pull in npm packages for the client). Not a general-purpose EXIF
|
||||||
|
// library: unsupported/malformed data is treated as "nothing found," never
|
||||||
|
// thrown, since the caller always has upload-date/no-location fallbacks.
|
||||||
|
window.PostcardExif = (() => {
|
||||||
|
const TYPE_SIZES = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
|
||||||
|
|
||||||
|
function readValue(view, type, count, offset, littleEndian) {
|
||||||
|
// Only the types actually used by the tags we read (ASCII, SHORT,
|
||||||
|
// LONG, RATIONAL) — anything else returns null rather than guessing.
|
||||||
|
if (type === 2) { // ASCII
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < count - 1; i++) s += String.fromCharCode(view.getUint8(offset + i));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (type === 3) return view.getUint16(offset, littleEndian); // SHORT
|
||||||
|
if (type === 4) return view.getUint32(offset, littleEndian); // LONG
|
||||||
|
if (type === 5) { // RATIONAL
|
||||||
|
const num = view.getUint32(offset, littleEndian);
|
||||||
|
const den = view.getUint32(offset + 4, littleEndian);
|
||||||
|
return den === 0 ? 0 : num / den;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIfd(view, tiffStart, ifdOffset, littleEndian) {
|
||||||
|
const entries = {};
|
||||||
|
const count = view.getUint16(tiffStart + ifdOffset, littleEndian);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const entryOffset = tiffStart + ifdOffset + 2 + i * 12;
|
||||||
|
const tag = view.getUint16(entryOffset, littleEndian);
|
||||||
|
const type = view.getUint16(entryOffset + 2, littleEndian);
|
||||||
|
const valueCount = view.getUint32(entryOffset + 4, littleEndian);
|
||||||
|
const size = (TYPE_SIZES[type] || 1) * valueCount;
|
||||||
|
const valueOffset = size > 4
|
||||||
|
? tiffStart + view.getUint32(entryOffset + 8, littleEndian)
|
||||||
|
: entryOffset + 8;
|
||||||
|
if (type === 5 && valueCount === 3) {
|
||||||
|
// GPSLatitude/Longitude: [degrees, minutes, seconds] rationals.
|
||||||
|
entries[tag] = [0, 1, 2].map(i => readValue(view, type, 2, valueOffset + i * 8, littleEndian));
|
||||||
|
} else {
|
||||||
|
entries[tag] = readValue(view, type, valueCount, valueOffset, littleEndian);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dmsToDecimal(dms, ref) {
|
||||||
|
if (!Array.isArray(dms) || dms.length !== 3) return null;
|
||||||
|
const [d, m, s] = dms;
|
||||||
|
let dec = d + m / 60 + s / 3600;
|
||||||
|
if (ref === 'S' || ref === 'W') dec = -dec;
|
||||||
|
return dec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTimeOriginal(str) {
|
||||||
|
// "YYYY:MM:DD HH:MM:SS" — EXIF's own format, always local time with no
|
||||||
|
// timezone info, so this is treated as a plain calendar date.
|
||||||
|
const m = /^(\d{4}):(\d{2}):(\d{2})/.exec(str || '');
|
||||||
|
if (!m) return null;
|
||||||
|
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(buffer) {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
if (view.getUint16(0) !== 0xffd8) return null; // not a JPEG
|
||||||
|
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < view.byteLength - 4) {
|
||||||
|
const marker = view.getUint16(offset);
|
||||||
|
if ((marker & 0xff00) !== 0xff00) break; // not a valid marker, bail out
|
||||||
|
if (marker === 0xffd8) { offset += 2; continue; }
|
||||||
|
if (marker === 0xffda || marker === 0xffd9) break; // start of scan / EOI — no more APPn markers past this
|
||||||
|
|
||||||
|
const segLength = view.getUint16(offset + 2);
|
||||||
|
if (marker === 0xffe1) {
|
||||||
|
const exifStart = offset + 4;
|
||||||
|
if (view.getUint32(exifStart) === 0x45786966) { // "Exif"
|
||||||
|
const tiffStart = exifStart + 6;
|
||||||
|
const littleEndian = view.getUint16(tiffStart) === 0x4949;
|
||||||
|
const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian);
|
||||||
|
const ifd0 = readIfd(view, tiffStart, ifd0Offset, littleEndian);
|
||||||
|
|
||||||
|
let date = null;
|
||||||
|
let lat = null, lon = null;
|
||||||
|
|
||||||
|
if (ifd0[0x8769] != null) {
|
||||||
|
const exifIfd = readIfd(view, tiffStart, ifd0[0x8769], littleEndian);
|
||||||
|
date = parseDateTimeOriginal(exifIfd[0x9003]);
|
||||||
|
}
|
||||||
|
if (ifd0[0x8825] != null) {
|
||||||
|
const gpsIfd = readIfd(view, tiffStart, ifd0[0x8825], littleEndian);
|
||||||
|
lat = dmsToDecimal(gpsIfd[0x0002], gpsIfd[0x0001]);
|
||||||
|
lon = dmsToDecimal(gpsIfd[0x0004], gpsIfd[0x0003]);
|
||||||
|
}
|
||||||
|
return { date, lat, lon };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += 2 + segLength;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Returns { date: Date|null, lat: number|null, lon: number|null }, or
|
||||||
|
// null on any parse failure — never throws.
|
||||||
|
async read(file) {
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
return parse(buffer);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect x="3" y="7" width="26" height="18" rx="1.5" fill="#0c0a08" stroke="#d9873f" stroke-width="1.5"/>
|
||||||
|
<path d="M3 8 L16 18 L29 8" fill="none" stroke="#d9873f" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,6 @@
|
|||||||
|
Caveat — SIL Open Font License, Version 1.1
|
||||||
|
|
||||||
|
Fetched from Google Fonts (fonts.gstatic.com), latin subset, variable
|
||||||
|
weight (400-700). See https://fonts.google.com/specimen/Caveat for the
|
||||||
|
canonical source and full license text:
|
||||||
|
https://openfontlicense.org
|
||||||
Binary file not shown.
+74
@@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<title>postcard</title>
|
||||||
|
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="header-row">
|
||||||
|
<h1>postcard</h1>
|
||||||
|
</div>
|
||||||
|
<p class="hint">turn a photo into something that looks like it was mailed</p>
|
||||||
|
|
||||||
|
<div class="card" id="drop-card">
|
||||||
|
<label class="drop-zone" id="drop-zone" for="file-input">
|
||||||
|
<span class="drop-zone-label">choose a photo, or drag one here</span>
|
||||||
|
<span class="drop-zone-sub">nothing leaves your browser until you say so</span>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="file-input" accept="image/*" hidden>
|
||||||
|
|
||||||
|
<div class="inline-row source-row">
|
||||||
|
<button class="btn btn-small" id="camera-btn">take a photo</button>
|
||||||
|
<button class="btn btn-small" id="library-btn">choose from library</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="camera-input" accept="image/*" capture="environment" hidden>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="editor-card" hidden>
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">template</div>
|
||||||
|
<div class="preset-row" id="template-row">
|
||||||
|
<button class="preset-btn active" data-template="cream">plain cream</button>
|
||||||
|
<button class="preset-btn" data-template="torn">torn edge</button>
|
||||||
|
<button class="preset-btn" data-template="faded">faded color-era</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-label">
|
||||||
|
<span>caption</span>
|
||||||
|
<span class="control-value" id="caption-status"></span>
|
||||||
|
</div>
|
||||||
|
<textarea id="caption-input" class="caption-input" rows="2" maxlength="140" placeholder="wish you were here..."></textarea>
|
||||||
|
<div class="inline-row">
|
||||||
|
<button class="btn btn-small" id="redraft-btn">redraft</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions actions-row">
|
||||||
|
<button class="btn" id="new-photo-btn">new photo</button>
|
||||||
|
<button class="btn btn-primary" id="download-btn">download</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions actions-row">
|
||||||
|
<button class="btn" id="share-btn">generate share link</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result-panel" class="result-panel" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="exif.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1252
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "postcard-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Small server for postcard: composited-image storage behind opaque links, plus day-context caption drafting",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --env-file-if-exists=../.env src/index.js",
|
||||||
|
"dev": "node --env-file-if-exists=../.env --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { config } from './config.js'
|
||||||
|
|
||||||
|
// Remix of real fragments from the day, not a free-text model call — per
|
||||||
|
// PROPOSAL.md's non-goal on LLM-generated captions. Always returns
|
||||||
|
// something (never an empty field): falls back to generic postcard-voice
|
||||||
|
// lines when no day-context source is configured or it has nothing for
|
||||||
|
// that date.
|
||||||
|
const FALLBACKS = [
|
||||||
|
'Wish you were here. Mostly wasn\'t.',
|
||||||
|
'Having a time. More on that later, maybe.',
|
||||||
|
'Sending this before I talk myself out of it.',
|
||||||
|
'Quiet one. Thinking of you anyway.',
|
||||||
|
]
|
||||||
|
|
||||||
|
function pick(seedStr, arr) {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < seedStr.length; i++) h = (h * 31 + seedStr.charCodeAt(i)) >>> 0
|
||||||
|
return arr[h % arr.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDaySummary(dateStr) {
|
||||||
|
if (!config.daySummaryUrl) return null
|
||||||
|
try {
|
||||||
|
const url = `${config.daySummaryUrl}?date=${encodeURIComponent(dateStr)}`
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json() // { git?: string, spotify?: string, note?: string }
|
||||||
|
// goonk's /api/day-summary returns HTTP 200 with an `error` body for a
|
||||||
|
// missing/malformed date instead of a non-2xx status — treat that the
|
||||||
|
// same as "no summary" rather than reading undefined fragments.
|
||||||
|
if (data && typeof data.error === 'string') return null
|
||||||
|
return data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHAPES = [
|
||||||
|
{ needs: ['git', 'spotify'], build: ({ git, spotify }) => `Spent the day ${git}. ${spotify} the whole time.` },
|
||||||
|
{ needs: ['git', 'spotify'], build: ({ git, spotify }) => `${spotify}. ${git}, mostly.` },
|
||||||
|
{ needs: ['git'], build: ({ git }) => `Spent the day ${git}.` },
|
||||||
|
{ needs: ['spotify'], build: ({ spotify }) => `${spotify} all day.` },
|
||||||
|
{ needs: ['note'], build: ({ note }) => `Been thinking about "${note}."` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function draftCaption(dateStr) {
|
||||||
|
const summary = await fetchDaySummary(dateStr)
|
||||||
|
const fragments = { git: summary?.git || null, spotify: summary?.spotify || null, note: summary?.note || null }
|
||||||
|
|
||||||
|
const usable = SHAPES.filter(shape => shape.needs.every(key => fragments[key]))
|
||||||
|
if (usable.length === 0) return pick(dateStr, FALLBACKS)
|
||||||
|
|
||||||
|
const shape = pick(dateStr + 'shape', usable)
|
||||||
|
return shape.build(fragments)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import { config } from './config.js'
|
||||||
|
import { db, blobPath } from './db.js'
|
||||||
|
import { sweepRateLimitWindows } from './ratelimit.js'
|
||||||
|
|
||||||
|
// Interval-based sweep, not push-based — deletes blobs + rows past
|
||||||
|
// expires_at regardless of whether they were ever viewed, matching
|
||||||
|
// latent/wisp's "deliberately dumb" precedent.
|
||||||
|
export function sweepExpiredPostcards() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const expired = db.prepare(`SELECT id FROM postcards WHERE expires_at < ?`).all(now)
|
||||||
|
|
||||||
|
for (const { id } of expired) {
|
||||||
|
fs.rm(blobPath(id), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
if (expired.length > 0) {
|
||||||
|
db.prepare(`DELETE FROM postcards WHERE expires_at < ?`).run(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
sweepRateLimitWindows()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCleanupJob() {
|
||||||
|
return setInterval(sweepExpiredPostcards, config.cleanupIntervalSeconds * 1000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT ?? 3098),
|
||||||
|
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
|
||||||
|
|
||||||
|
// Shorter than latent's — a postcard link is meant to be opened once by
|
||||||
|
// the recipient, not browsed later, per PROPOSAL.md's retention question.
|
||||||
|
ttlHours: Number(process.env.TTL_HOURS ?? 72),
|
||||||
|
|
||||||
|
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 20 * 1024 * 1024), // 20MB
|
||||||
|
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
|
||||||
|
|
||||||
|
// Basic IP-based throttle so /api/upload can't become an open file host.
|
||||||
|
rateLimitPerHour: Number(process.env.RATE_LIMIT_PER_HOUR ?? 30),
|
||||||
|
|
||||||
|
// Optional day-context source for caption drafting — goonk's live
|
||||||
|
// GET {base}/api/day-summary?date=YYYY-MM-DD -> { git, spotify, note? }.
|
||||||
|
// No code-level default; set via .env. Left unset, caption generation
|
||||||
|
// falls back to generic postcard-voice lines.
|
||||||
|
daySummaryUrl: process.env.DAY_SUMMARY_URL ?? '',
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { config } from './config.js'
|
||||||
|
|
||||||
|
fs.mkdirSync(config.dataDir, { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
|
||||||
|
|
||||||
|
export const db = new Database(path.join(config.dataDir, 'postcard.db'))
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS postcards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
blob_path TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
export function blobPath(id) {
|
||||||
|
return path.join(config.dataDir, 'blobs', id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Reverse geocoding for the stamp corner's location line — confirmed via
|
||||||
|
// PROPOSAL.md's open question that no reverse-geocoding code already
|
||||||
|
// exists in goonk/rewind/galr to reuse, so this talks to the public
|
||||||
|
// Nominatim (OpenStreetMap) API directly. No API key required, but its
|
||||||
|
// usage policy (https://operations.osmfoundation.org/policies/nominatim/)
|
||||||
|
// caps unauthenticated use at 1 request/sec and requires an identifying
|
||||||
|
// User-Agent — both enforced below.
|
||||||
|
// `||` not `??` — docker-compose's environment: block sets this to an
|
||||||
|
// empty string (not unset) when NOMINATIM_URL isn't provided in .env, and
|
||||||
|
// an empty string should still fall through to the real default.
|
||||||
|
const NOMINATIM_URL = process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org/reverse'
|
||||||
|
const USER_AGENT = 'postcard/0.1 (https://github.com/explewd/postcard)'
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // a coordinate's city/country doesn't change
|
||||||
|
|
||||||
|
const cache = new Map() // "lat,lon" (rounded) -> { label, ts }
|
||||||
|
let lastRequestAt = 0
|
||||||
|
|
||||||
|
function cacheKey(lat, lon) {
|
||||||
|
// Rounded to ~1km — plenty for a "City, Country" label, and it turns
|
||||||
|
// nearby-but-not-identical GPS fixes into cache hits.
|
||||||
|
return `${lat.toFixed(2)},${lon.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFromAddress(address) {
|
||||||
|
if (!address) return null
|
||||||
|
const place = address.city || address.town || address.village || address.municipality || address.county
|
||||||
|
const country = address.country
|
||||||
|
return [place, country].filter(Boolean).join(', ') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reverseGeocode(lat, lon) {
|
||||||
|
const key = cacheKey(lat, lon)
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.label
|
||||||
|
|
||||||
|
// Serialize outgoing requests to respect Nominatim's 1 req/sec cap.
|
||||||
|
const wait = Math.max(0, 1100 - (Date.now() - lastRequestAt))
|
||||||
|
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait))
|
||||||
|
lastRequestAt = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${NOMINATIM_URL}?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&zoom=10&addressdetails=1&accept-language=en`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
const label = labelFromAddress(data.address)
|
||||||
|
cache.set(key, { label, ts: Date.now() })
|
||||||
|
return label
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||||
|
|
||||||
|
// 128-bit random value, rendered as base62 — unguessable, since the link
|
||||||
|
// itself is the only credential (no accounts, per PROPOSAL.md).
|
||||||
|
function randomBase62(bits) {
|
||||||
|
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
|
||||||
|
let value = 0n
|
||||||
|
for (const b of bytes) value = (value << 8n) | BigInt(b)
|
||||||
|
|
||||||
|
let out = ''
|
||||||
|
const base = BigInt(BASE62.length)
|
||||||
|
while (value > 0n) {
|
||||||
|
out = BASE62[Number(value % base)] + out
|
||||||
|
value /= base
|
||||||
|
}
|
||||||
|
return out.padStart(Math.ceil(bits / Math.log2(62)), '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newPostcardId() {
|
||||||
|
return randomBase62(128)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { config } from './config.js'
|
||||||
|
import { apiRouter, imageRouter } from './routes.js'
|
||||||
|
import { sweepExpiredPostcards, startCleanupJob } from './cleanup.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
// Deployed behind a reverse proxy in production — needed for accurate
|
||||||
|
// req.ip in the rate limiter.
|
||||||
|
app.set('trust proxy', true)
|
||||||
|
|
||||||
|
// Mounted before the static frontend and before any body parser: the
|
||||||
|
// upload route reads the raw request stream itself and must not have it
|
||||||
|
// consumed by express.json()/express.raw() first.
|
||||||
|
app.use('/api', apiRouter)
|
||||||
|
app.use('/', imageRouter)
|
||||||
|
|
||||||
|
const staticRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||||
|
app.use(express.static(staticRoot))
|
||||||
|
|
||||||
|
sweepExpiredPostcards()
|
||||||
|
startCleanupJob()
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`postcard server listening on :${config.port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { config } from './config.js'
|
||||||
|
|
||||||
|
// Deliberately dumb in-memory fixed-window counter, not a distributed
|
||||||
|
// rate limiter — this is a single-process toy server, same as latent/wisp.
|
||||||
|
const windows = new Map() // ip -> { count, windowStart }
|
||||||
|
|
||||||
|
export function checkRateLimit(ip) {
|
||||||
|
const now = Date.now()
|
||||||
|
const hourMs = 60 * 60 * 1000
|
||||||
|
const entry = windows.get(ip)
|
||||||
|
|
||||||
|
if (!entry || now - entry.windowStart > hourMs) {
|
||||||
|
windows.set(ip, { count: 1, windowStart: now })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count += 1
|
||||||
|
return entry.count <= config.rateLimitPerHour
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevents the Map from growing forever across long-running processes.
|
||||||
|
export function sweepRateLimitWindows() {
|
||||||
|
const now = Date.now()
|
||||||
|
const hourMs = 60 * 60 * 1000
|
||||||
|
for (const [ip, entry] of windows) {
|
||||||
|
if (now - entry.windowStart > hourMs) windows.delete(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { config } from './config.js'
|
||||||
|
import { db, blobPath } from './db.js'
|
||||||
|
import { newPostcardId } from './ids.js'
|
||||||
|
import { checkRateLimit } from './ratelimit.js'
|
||||||
|
import { draftCaption } from './caption.js'
|
||||||
|
import { reverseGeocode } from './geocode.js'
|
||||||
|
|
||||||
|
export const apiRouter = Router()
|
||||||
|
export const imageRouter = Router()
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||||
|
|
||||||
|
// GET /api/caption?date=YYYY-MM-DD — draft a one-line caption from that
|
||||||
|
// day's context. Editable client-side before it's ever composited in.
|
||||||
|
apiRouter.get('/caption', async (req, res) => {
|
||||||
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : new Date().toISOString().slice(0, 10)
|
||||||
|
const caption = await draftCaption(date)
|
||||||
|
res.json({ caption })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/geocode?lat=&lon= — "City, Country" for the stamp corner's
|
||||||
|
// location line, only ever called when the uploaded photo had EXIF GPS.
|
||||||
|
// Proxied server-side so the client never talks to Nominatim directly
|
||||||
|
// (keeps the 1req/sec throttling and User-Agent policy in one place).
|
||||||
|
apiRouter.get('/geocode', async (req, res) => {
|
||||||
|
const lat = Number(req.query.lat)
|
||||||
|
const lon = Number(req.query.lon)
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
|
res.status(400).json({ error: 'lat and lon required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const label = await reverseGeocode(lat, lon)
|
||||||
|
res.json({ label })
|
||||||
|
})
|
||||||
|
|
||||||
|
apiRouter.post('/upload', (req, res) => {
|
||||||
|
const ip = req.ip
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
res.status(429).json({ error: 'too many uploads, try again later' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = (req.get('Content-Type') || '').split(';')[0].trim()
|
||||||
|
if (!ALLOWED_MIME.has(mime)) {
|
||||||
|
res.status(415).json({ error: 'unsupported image type' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(req.get('Content-Length') ?? 0)
|
||||||
|
if (contentLength > config.maxUploadBytes) {
|
||||||
|
res.status(413).json({ error: 'file too large' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = newPostcardId()
|
||||||
|
const dest = blobPath(id)
|
||||||
|
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
|
||||||
|
|
||||||
|
let bytesReceived = 0
|
||||||
|
let aborted = false
|
||||||
|
|
||||||
|
req.on('data', chunk => {
|
||||||
|
bytesReceived += chunk.length
|
||||||
|
if (bytesReceived > config.maxUploadBytes) {
|
||||||
|
aborted = true
|
||||||
|
writeStream.destroy()
|
||||||
|
req.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
req.pipe(writeStream)
|
||||||
|
|
||||||
|
writeStream.on('error', () => {
|
||||||
|
fs.rm(dest, { force: true }, () => {})
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
writeStream.on('finish', () => {
|
||||||
|
if (aborted) {
|
||||||
|
fs.rm(dest, { force: true }, () => {})
|
||||||
|
if (!res.headersSent) res.status(413).json({ error: 'file too large' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const expiresAt = now + config.ttlHours * 3600
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO postcards (id, blob_path, mime, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
).run(id, dest, mime, now, expiresAt)
|
||||||
|
|
||||||
|
res.json({ id, url: `/p/${id}` })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// No develop-later gate — this app's whole point is immediacy, unlike latent.
|
||||||
|
imageRouter.get('/p/:id', (req, res) => {
|
||||||
|
const postcard = db.prepare(`SELECT * FROM postcards WHERE id = ?`).get(req.params.id)
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
if (!postcard || postcard.expires_at < now || !fs.existsSync(postcard.blob_path)) {
|
||||||
|
res.status(404).send(renderPage('not found', '<p>this one\'s gone — wrong link, or it already expired.</p>'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', postcard.mime)
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
fs.createReadStream(postcard.blob_path).pipe(res)
|
||||||
|
})
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(title, bodyHtml) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>postcard — ${escapeHtml(title)}</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="header-row"><h1>postcard</h1></div>
|
||||||
|
<div class="card">${bodyHtml}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/* Self-hosted (fonts/caveat-latin.woff2, SIL OFL — see fonts/OFL.txt) so
|
||||||
|
the caption webfont doesn't depend on fonts.googleapis.com at runtime. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Caveat';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('fonts/caveat-latin.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0c0a08;
|
||||||
|
--bg-alt: #14100c;
|
||||||
|
--text: #cbc3b8;
|
||||||
|
--muted: #6b6258;
|
||||||
|
--heading: #f2e9dc;
|
||||||
|
--accent: #d9873f;
|
||||||
|
--accent-dim: rgba(217, 135, 63, 0.12);
|
||||||
|
--accent-border:rgba(217, 135, 63, 0.28);
|
||||||
|
--border: rgba(255, 245, 230, 0.08);
|
||||||
|
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
|
||||||
|
--error: #ff6b6b;
|
||||||
|
--error-dim: rgba(255, 107, 107, 0.14);
|
||||||
|
|
||||||
|
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||||
|
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
|
font: 16px/1.6 var(--sans);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
color-scheme: dark;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body { margin: 0; }
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 640px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 16px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
|
||||||
|
p { margin: 0; }
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row h1::before {
|
||||||
|
content: '> ';
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-alt);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop zone ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover, .drop-zone.drag-over {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-row {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-sub {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Canvas ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.canvas-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Controls ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-value {
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn:hover { border-color: var(--accent-border); }
|
||||||
|
|
||||||
|
.preset-btn.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Caption input ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.caption-input {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline rows ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.inline-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions / buttons ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: #e59a53; border-color: #e59a53; color: #000; }
|
||||||
|
|
||||||
|
/* ── Result panel ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.result-panel {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel a {
|
||||||
|
color: var(--accent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel.error { color: var(--error); border-color: var(--error-dim); }
|
||||||
Reference in New Issue
Block a user