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