web: a film sim is colour and tone only

The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.

LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.

Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
This commit is contained in:
2026-09-22 08:32:28 +07:00
parent 52b672deec
commit 428e7fa682
18 changed files with 1831 additions and 148 deletions
+35 -2
View File
@@ -88,6 +88,11 @@ CREATE TABLE IF NOT EXISTS email_verifications (
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS places (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
@@ -711,6 +716,14 @@ export function photoFile(id: number): { file: string; mime: string } | undefine
| undefined;
}
// The same row, owner-scoped: the routes that serve or write the editable base
// behind a saved render answer only to the account that made the photo.
export function photoFileOwned(userId: number, id: number): { file: string; mime: string } | undefined {
return db.prepare('SELECT file, mime FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as
| { file: string; mime: string }
| undefined;
}
// The QR card's payload: the stored look, as raw JSON, and where the photo is
// allowed to show. The route decides who may read it (a curated `qr` slot), so
// this returns the row as stored, recipe included.
@@ -761,6 +774,22 @@ export function deleteAllPhotos(): string[] {
// look, so every frame on the strip is rated the same way. `visitor` is the
// salted-address hash the counter already uses, which is what makes a vote
// one-per-visitor without an account and without storing anything identifying.
// Reverse-geocode cache, keyed by the coordinate rounded to ~11m. An empty
// string is a coordinate the geocoder answered about and had no name for, which
// is a real answer and is cached like any other; `undefined` means "never
// asked" — the caller then makes the call.
export function findPlace(key: string): string | undefined {
const row = db.prepare('SELECT name FROM places WHERE key = ?').get(key) as { name: string } | undefined;
return row?.name;
}
export function savePlace(key: string, name: string): void {
db.prepare(
`INSERT INTO places (key, name, at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET name = excluded.name, at = excluded.at`,
).run(key, name, new Date().toISOString());
}
export type Rating = { avg: number; n: number; mine: number };
export function rateLook(key: string, visitor: string, stars: number): void {
@@ -860,11 +889,15 @@ const BUCKET_COLUMN = {
export function eventStats(days: number, limit = 12): EventStats {
const since = new Date(Date.now() - days * 86_400_000).toISOString();
// Real visits only: the crawler and headless-reading rows the counter used to
// keep are filtered here as well as at the door, so the numbers are traffic
// and not a scan of the page by something with no one behind it.
const grouped = (column: string, kind: EventKind | null): EventBucket[] =>
db
.prepare(
`SELECT ${column} AS key, COUNT(*) AS n FROM events
WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> ''
AND COALESCE(device, '') <> 'bot'
AND (? IS NULL OR kind = ?)
GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`,
)
@@ -876,7 +909,7 @@ export function eventStats(days: number, limit = 12): EventStats {
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks,
COUNT(DISTINCT visitor) AS visitors
FROM events WHERE at >= ?`,
FROM events WHERE at >= ? AND COALESCE(device, '') <> 'bot'`,
)
.get(since) as { views: number | null; clicks: number | null; visitors: number };
@@ -892,7 +925,7 @@ export function eventStats(days: number, limit = 12): EventStats {
`SELECT substr(at, 1, 10) AS date,
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks
FROM events WHERE at >= ? GROUP BY date`,
FROM events WHERE at >= ? AND COALESCE(device, '') <> 'bot' GROUP BY date`,
)
.all(since) as { date: string; views: number; clicks: number }[];
for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row);