A self-hosted print studio: images at exact physical sizes, sheet layout with crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery, share links, mail, Telegram — is an optional module that is off by default. This is the neutral public twin of a private tool. Compared to that original: - optional modules behind KLARBILD_MODULES; the default install is print-only - English interface with German as a full second language (1,038 strings); the English sentence is the translation key - no built-in accounts — first boot creates one admin from ADMIN_USERNAME / ADMIN_PASSWORD, or generates a password and logs it once - delivery and backup targets are generic SFTP/FTPS endpoints, off by default - English routes, English identifiers, English comments throughout - two compose files: print-only by default, everything in the full one - AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY 124 tests green, type check clean, verified end to end against a live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
164 files changed
+27190
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
/**
|
||||
* Your own account: store an email address and switch notifications on or off.
|
||||
*
|
||||
* The address is at the same time the way to reset a forgotten password — that
|
||||
* is spelled out here as well, so nobody mistakes it for pure advertising mail.
|
||||
*/
|
||||
interface Me {
|
||||
username: string | null; display_name: string | null;
|
||||
email: string | null; notify_jobs: boolean; mail_ready: boolean; role: string;
|
||||
}
|
||||
|
||||
export default function AccountApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoadError(false);
|
||||
fetch('/api/me')
|
||||
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
|
||||
.then((m) => { setMe(m); setEmail(m.email || ''); })
|
||||
// Without this branch it said "One moment …" here for ever.
|
||||
.catch(() => setLoadError(true));
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
const say = (text: string, ok: boolean) => { setMsg({ text, ok }); setTimeout(() => setMsg(null), 5000); };
|
||||
|
||||
const patch = async (body: unknown, success: string) => {
|
||||
// Take on the new value right away, otherwise the checkbox jumps back to
|
||||
// the old state on rerender and you click a second time by accident.
|
||||
setMe((m) => (m ? { ...m, ...(body as object) } as Me : m));
|
||||
setBusy(true);
|
||||
const r = await fetch('/api/me', {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||
setBusy(false);
|
||||
if (r.error) { say(r.error, false); return false; }
|
||||
say(success, true);
|
||||
const m = await fetch('/api/me').then((x) => x.json()).catch(() => null);
|
||||
if (m) setMe(m);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (loadError) return (
|
||||
<div className="ac">
|
||||
<section className="ac-card"><div className="ac-body">
|
||||
<p className="ac-hint">{t('Your account could not be loaded just now — maybe the connection was gone for a moment.')}</p>
|
||||
<button className="ac-btn strong" onClick={load}>{t('Try again')}</button>
|
||||
</div></section>
|
||||
</div>
|
||||
);
|
||||
if (!me) return <p className="ac-hint">{t('One moment …')}</p>;
|
||||
|
||||
return (
|
||||
<div className="ac">
|
||||
<section className="ac-card">
|
||||
<div className="ac-head"><span className="mono-label">{t('Sign in')}</span></div>
|
||||
<div className="ac-body">
|
||||
<div className="ac-row">
|
||||
<span className="ac-label">{t('Username')}</span>
|
||||
<span className="ac-value">{me.username}</span>
|
||||
</div>
|
||||
{me.display_name && (
|
||||
<div className="ac-row">
|
||||
<span className="ac-label">{t('Shown as')}</span>
|
||||
<span className="ac-value">{me.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ac-row">
|
||||
<span className="ac-label">{t('Password')}</span>
|
||||
<span className="ac-value"><a href="/password">{t('Set a new password')}</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ac-card">
|
||||
<div className="ac-head"><span className="mono-label">{t('Email address')}</span></div>
|
||||
<div className="ac-body">
|
||||
<p className="ac-hint">
|
||||
{t('This lets you have a new password sent to you if you have forgotten yours. Without an address on file that is not possible — then an admin has to step in.')}
|
||||
</p>
|
||||
<div className="ac-input">
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com" aria-label={t('Email address')} autoComplete="email" />
|
||||
<button className="ac-btn strong" disabled={busy || email === (me.email || '')}
|
||||
onClick={() => patch({ email }, email ? t('Address saved.') : t('Address removed.'))}>
|
||||
{t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
{!me.mail_ready && (
|
||||
<p className="ac-warn">
|
||||
{t('No mail sending is set up on this server yet — the address is stored, but nothing arrives yet. An admin sets that up under Admin → Email.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ac-card">
|
||||
<div className="ac-head"><span className="mono-label">{t('Notifications')}</span></div>
|
||||
<div className="ac-body">
|
||||
<label className="ac-toggle">
|
||||
<input type="checkbox" checked={me.notify_jobs} disabled={!me.email || busy}
|
||||
onChange={(e) => patch({ notify_jobs: e.target.checked },
|
||||
e.target.checked ? t('Notices switched on.') : t('Notices switched off.'))} />
|
||||
<span>
|
||||
<b>{t('Tell me when a job is finished')}</b>
|
||||
<em>{t('One short mail per job — no newsletter, no advertising.')}
|
||||
{!me.email && ' ' + t('For that you first need an email address (above).')}</em>
|
||||
</span>
|
||||
</label>
|
||||
<p className="ac-hint">
|
||||
{t('Jobs from a private session deliberately never trigger a mail — the whole point is that they leave no trace.')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className={`ac-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.text}</p>}
|
||||
|
||||
<style>{`
|
||||
.ac{display:grid;gap:14px;max-width:620px;}
|
||||
.ac-card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.ac-head{padding:13px 16px;border-bottom:1px solid var(--line);}
|
||||
.ac-body{padding:16px;display:grid;gap:12px;}
|
||||
.ac-row{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
|
||||
padding-bottom:9px;border-bottom:1px solid var(--line);font-size:.92rem;}
|
||||
.ac-row:last-child{border-bottom:none;padding-bottom:0;}
|
||||
.ac-label{color:var(--soft);font-size:.85rem;}
|
||||
.ac-value{font-weight:500;overflow-wrap:anywhere;text-align:right;}
|
||||
.ac-value a{color:var(--accent);}
|
||||
.ac-hint{margin:0;font-size:.85rem;color:var(--ink-soft);line-height:1.6;}
|
||||
.ac-input{display:flex;gap:8px;}
|
||||
.ac-input input{flex:1;min-width:0;padding:11px 12px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;font-family:inherit;}
|
||||
.ac-btn{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
|
||||
color:var(--ink);padding:11px 16px;font-size:.9rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
|
||||
.ac-btn.strong{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
|
||||
.ac-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
|
||||
.ac-toggle{display:flex;gap:11px;align-items:flex-start;cursor:pointer;font-size:.92rem;}
|
||||
.ac-toggle input{width:19px;height:19px;margin-top:2px;flex:none;accent-color:var(--accent);}
|
||||
.ac-toggle input:disabled{cursor:not-allowed;}
|
||||
.ac-toggle b{display:block;}
|
||||
.ac-toggle em{display:block;font-style:normal;color:var(--soft);font-size:.82rem;margin-top:3px;line-height:1.5;}
|
||||
.ac-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
|
||||
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
|
||||
.ac-msg{margin:0;font-size:.9rem;}
|
||||
.ac-msg.ok{color:var(--ok);}
|
||||
.ac-msg.err{color:var(--err);}
|
||||
@media(max-width:560px){
|
||||
.ac-input{flex-direction:column;}
|
||||
.ac-btn{width:100%;padding:13px;}
|
||||
.ac-row{flex-direction:column;gap:3px;}
|
||||
.ac-value{text-align:left;}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
const [s, setS] = useState<any>(null);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [avail, setAvail] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [tgLinks, setTgLinks] = useState<any[]>([]);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [orKey, setOrKey] = useState(''); const [dtPw, setDtPw] = useState(''); const [mirrorPw, setMirrorPw] = useState('');
|
||||
const [storage, setStorage] = useState<any>(null);
|
||||
const [rename, setRename] = useState<any>(null); // dry run / result of the rename
|
||||
const [mailTo, setMailTo] = useState(''); // address for the test message
|
||||
const [smtpPw, setSmtpPw] = useState(''); // new mailbox password (never prefilled)
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
||||
const notify = (text: string) => { setToast(text); setTimeout(() => setToast(null), 2600); };
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [a, b, c, d, e, f, g] = await Promise.all([
|
||||
fetch('/api/admin/settings').then((r) => r.json()),
|
||||
fetch('/api/admin/stats').then((r) => r.json()),
|
||||
fetch('/api/admin/models?available=1').then((r) => r.json()),
|
||||
fetch('/api/admin/users').then((r) => r.json()),
|
||||
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
|
||||
fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })),
|
||||
fetch('/api/admin/delivery-targets').then((r) => r.json()).catch(() => ({ targets: [] })),
|
||||
]);
|
||||
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
|
||||
setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null); setTargets(g.targets || []);
|
||||
}, []);
|
||||
const addTarget = async () => {
|
||||
if (!nt.name?.trim()) { notify(t('Name is missing.')); return; }
|
||||
await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nt) });
|
||||
setNt({ protocol: 'sftp' }); notify(t('Target created.')); load();
|
||||
};
|
||||
const delTarget = async (id: string) => { if (!confirm(t('Delete target?'))) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); };
|
||||
const toggleBackup = async (target: any) => {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: target.id, is_backup: !target.is_backup }) });
|
||||
load();
|
||||
};
|
||||
const toggleTargetSidecar = async (target: any) => {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: target.id, metadata_sidecar: !target.metadata_sidecar }) });
|
||||
load();
|
||||
};
|
||||
const backupAllTargets = async () => {
|
||||
if (!confirm(t('Back up all finished images to every backup target (mirror + marked targets)?'))) return;
|
||||
notify(t('Backing up … (may take a while)'));
|
||||
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json());
|
||||
notify(r.items != null
|
||||
? (r.failed
|
||||
? t('{n} copies to backup targets, {failed} errors.', { n: r.mirrored, failed: r.failed })
|
||||
: t('{n} copies to backup targets.', { n: r.mirrored }))
|
||||
: (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const genToken = async () => {
|
||||
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) });
|
||||
notify(t('API token generated.')); load();
|
||||
};
|
||||
const testTarget = async (id: string) => {
|
||||
notify(t('Testing target …'));
|
||||
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||
};
|
||||
const fmtBytes = (n: number | null) => n == null ? '—'
|
||||
: n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`;
|
||||
|
||||
const pairCode = async (userId: string) => {
|
||||
const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json());
|
||||
if (r.code) alert(t('Pairing code: {code}\n\nValid for 15 minutes. Enter it in the Klarbild Telegram chat.', { code: r.code }));
|
||||
};
|
||||
const unlink = async (chat_id: number) => {
|
||||
await fetch('/api/admin/telegram/links', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id }) });
|
||||
load();
|
||||
};
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const field = (k: string, v: any) => setS((p: any) => ({ ...p, [k]: v }));
|
||||
const saveSettings = async () => {
|
||||
const body: any = {
|
||||
delivery_host: s.delivery_host, delivery_protocol: s.delivery_protocol, delivery_port: s.delivery_port,
|
||||
delivery_user: s.delivery_user, delivery_base_path: s.delivery_base_path, delivery_default_folder: s.delivery_default_folder,
|
||||
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||
output_ext: s.output_ext || 'png',
|
||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||
delivery_metadata_sidecar: !!s.delivery_metadata_sidecar, mirror_metadata_sidecar: !!s.mirror_metadata_sidecar,
|
||||
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||
mirror_enabled: !!s.mirror_enabled, mirror_host: s.mirror_host, mirror_protocol: s.mirror_protocol, mirror_port: s.mirror_port,
|
||||
mirror_user: s.mirror_user, mirror_base_path: s.mirror_base_path,
|
||||
// Mail and sharing — without these lines the whole card was discarded
|
||||
// silently and the interface still reported "Saved."
|
||||
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure !== false,
|
||||
smtp_user: s.smtp_user, smtp_from: s.smtp_from,
|
||||
share_default_days: s.share_default_days,
|
||||
};
|
||||
if (orKey.trim()) body.openrouter_key = orKey.trim();
|
||||
if (dtPw.trim()) body.delivery_password = dtPw.trim();
|
||||
if (mirrorPw.trim()) body.mirror_password = mirrorPw.trim();
|
||||
if (smtpPw.trim()) body.smtp_password = smtpPw.trim();
|
||||
const r = await fetch('/api/admin/settings', {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||
if (r?.error) { notify(r.error); return; }
|
||||
setOrKey(''); setDtPw(''); setMirrorPw(''); setSmtpPw(''); notify(t('Saved.')); load();
|
||||
};
|
||||
const testDeliveryTarget = async () => {
|
||||
notify(t('Testing …'));
|
||||
const r = await fetch('/api/admin/test-delivery', { method: 'POST' }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||
};
|
||||
const [dtList, setDtList] = useState<any>(null);
|
||||
const lsDeliveryTarget = async (path?: string) => {
|
||||
notify(t('Reading folder on the delivery target …'));
|
||||
const r = await fetch('/api/admin/delivery-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }).then((x) => x.json());
|
||||
setDtList(r); notify(r.error ? r.error : t('{n} entries in {path}', { n: (r.entries || []).length, path: r.path }));
|
||||
};
|
||||
const cleanupDeliveryTarget = async () => {
|
||||
const gallery = s.delivery_default_folder || 'Gallery A';
|
||||
notify(t('Cleaning up leftovers …'));
|
||||
const r = await fetch('/api/admin/delivery-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cleanup_tmp', gallery }) }).then((x) => x.json());
|
||||
notify(r.error ? r.error : t('{n} leftovers removed from “{folder}”.', { n: r.cleaned ?? 0, folder: gallery }));
|
||||
};
|
||||
const testMirror = async () => {
|
||||
notify(t('Testing backup mirror …'));
|
||||
const r = await fetch('/api/admin/test-mirror', { method: 'POST' }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||
};
|
||||
const netcheck = async () => {
|
||||
if (!s.mirror_host) { notify(t('Enter a host and save first.')); return; }
|
||||
notify(t('Checking reachability …'));
|
||||
const r = await fetch('/api/admin/netcheck', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: s.mirror_host, port: s.mirror_port || 22 }) }).then((x) => x.json());
|
||||
const dnsMsg = r.resolve ? (r.resolve.ok ? t('DNS ✓ ({address})', { address: r.resolve.address }) : t('DNS ✕ ({error})', { error: r.resolve.error })) : '';
|
||||
const tcpMsg = r.tcp ? (r.tcp.ok ? t('Port reachable in {ms}ms ✓', { ms: r.tcp.ms }) : t('not reachable: {error}', { error: r.tcp.error })) : (r.error || t('Error'));
|
||||
notify(`${dnsMsg}${dnsMsg ? ' · ' : ''}${tcpMsg}`);
|
||||
};
|
||||
const storageAction = async (action: string, confirmMsg?: string) => {
|
||||
if (confirmMsg && !confirm(confirmMsg)) return;
|
||||
notify(t('Cleaning up …'));
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json());
|
||||
notify(r.deleted != null ? t('{n} old items removed.', { n: r.deleted })
|
||||
: r.purged != null ? t('{n} source files deleted.', { n: r.purged })
|
||||
: r.made != null ? t('{n} preview images generated.', { n: r.made })
|
||||
: (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const testMail = async (withSend: boolean) => {
|
||||
if (withSend && !mailTo.trim()) { notify(t('Enter an address for the test message.')); return; }
|
||||
notify(withSend ? t('Sending test message …') : t('Checking connection …'));
|
||||
const r = await fetch('/api/admin/test-mail', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(withSend ? { to: mailTo.trim() } : {}) }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : t('Error')));
|
||||
};
|
||||
|
||||
// Unique file names — first show what would happen, then apply it.
|
||||
const renamePreview = async () => {
|
||||
notify(t('Checking file names …'));
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'rename_preview' }) }).then((x) => x.json());
|
||||
if (r.error) { notify(r.error); return; }
|
||||
setRename(r);
|
||||
notify(r.renamed
|
||||
? (r.wereDuplicates
|
||||
? t('{n} of {checked} images would get a new name — {dupes} names are currently used twice.', { n: r.renamed, checked: r.checked, dupes: r.wereDuplicates })
|
||||
: t('{n} of {checked} images would get a new name.', { n: r.renamed, checked: r.checked }))
|
||||
: t('All {checked} names are already unique.', { checked: r.checked }));
|
||||
};
|
||||
const renameApply = async () => {
|
||||
if (!rename) { notify(t('Run “Check library” first — then you can see what would change.')); return; }
|
||||
if (!confirm(t('Give every image in the library a unique name?\n\nThe files themselves stay untouched — they are stored under their ID. '
|
||||
+ 'Copies already placed on the delivery target keep their old name; that is what “Deliver again” is for afterwards.'))) return;
|
||||
notify(t('Renaming …'));
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'rename_apply' }) }).then((x) => x.json());
|
||||
setRename(r);
|
||||
notify(r.error || t('{n} images renamed, {ok} were already fine.', { n: r.renamed, ok: r.unchanged }));
|
||||
load();
|
||||
};
|
||||
const redeliverAll = async () => {
|
||||
if (!confirm(t('Upload all already delivered images again — under the new name?\n\n'
|
||||
+ 'The old files stay on the target and have to be removed there by hand. This can take a while.'))) return;
|
||||
notify(t('Delivering again … (may take a while)'));
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'redeliver_all' }) }).then((x) => x.json());
|
||||
notify(r.attempted != null
|
||||
? (r.failed
|
||||
? t('{ok}/{tried} delivered again, {failed} failed.', { ok: r.ok, tried: r.attempted, failed: r.failed })
|
||||
: t('{ok}/{tried} delivered again.', { ok: r.ok, tried: r.attempted }))
|
||||
: (r.error || t('Error')));
|
||||
load();
|
||||
};
|
||||
const mirrorAll = async () => {
|
||||
if (!s.mirror_enabled) { notify(t('Enable the backup mirror and save first.')); return; }
|
||||
if (!confirm(t('Back up all finished images to the mirror?'))) return;
|
||||
notify(t('Backing up to the mirror … (may take a while)'));
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json());
|
||||
notify(r.total != null
|
||||
? (r.failed
|
||||
? t('{n}/{total} backed up to the mirror, {failed} failed.', { n: r.mirrored, total: r.total, failed: r.failed })
|
||||
: t('{n}/{total} backed up to the mirror.', { n: r.mirrored, total: r.total }))
|
||||
: (r.error || t('Error')));
|
||||
load();
|
||||
};
|
||||
const resetAll = async () => {
|
||||
const answer = prompt(t('WARNING: Deletes ALL images, jobs and folders irreversibly (users, settings, models & preset recipes stay).\n\nType RESET to confirm:'));
|
||||
if (answer !== 'RESET') { if (answer != null) notify(t('Cancelled — not confirmed.')); return; }
|
||||
notify(t('Resetting …'));
|
||||
const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json());
|
||||
notify(r.ok ? t('Reset done: {n} images removed.', { n: r.deleted_items }) : (r.error || t('Error')));
|
||||
load();
|
||||
};
|
||||
const addModel = async (m: any) => {
|
||||
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
|
||||
load();
|
||||
};
|
||||
const modelAction = async (id: string, action: string) => {
|
||||
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, action }) });
|
||||
load();
|
||||
};
|
||||
const setPw = async (id: string) => {
|
||||
const pw = prompt(t('New password?')); if (!pw) return;
|
||||
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
|
||||
notify(t('Password set.'));
|
||||
};
|
||||
const setUserPrivate = async (id: string, v: boolean) => {
|
||||
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, private_forced: v }) });
|
||||
load();
|
||||
};
|
||||
const addUser = async () => {
|
||||
const username = prompt(t('Username?')); if (!username) return;
|
||||
const password = prompt(t('Password?')); if (!password) return;
|
||||
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
||||
load();
|
||||
};
|
||||
|
||||
if (!s) return <p className="hint">{t('Loading …')}</p>;
|
||||
const stored = new Set(models.map((m) => m.model_id));
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
{stats && (
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Usage & cost')}</span></div>
|
||||
<div className="numbers">
|
||||
<div><b>{stats.total}</b><span>{t('Images total')}</span></div>
|
||||
<div><b>{stats.error_rate}%</b><span>{t('Error rate')}</span></div>
|
||||
<div><b>${stats.cost_30?.toFixed(2)}</b><span>{t('Cost 30 days')}</span></div>
|
||||
<div><b>${stats.cost_all?.toFixed(2)}</b><span>{t('Cost total')}</span></div>
|
||||
</div>
|
||||
<div className="hint">{t('Today ${today} · 7 days ${week}', { today: stats.cost_today?.toFixed(2), week: stats.cost_7?.toFixed(2) })}
|
||||
{stats.last_activity ? ` · ${t('last {when}', { when: new Date(stats.last_activity).toLocaleString() })}` : ''}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Access & limits')}</span></div>
|
||||
<div className="controls">
|
||||
<div className="field"><label>{t('OpenRouter key')} {s.openrouter_key_set && <em>({s.openrouter_key_masked})</em>}</label>
|
||||
<input className="input" placeholder={s.openrouter_key_set ? t('Enter a new key to replace it') : 'sk-or-…'} value={orKey} onChange={(e) => setOrKey(e.target.value)} />
|
||||
<div className="hint">{t('Stored encrypted, takes precedence over the environment variable.')}</div>
|
||||
</div>
|
||||
<div className="field"><label>{t('Monthly budget ($)')}</label>
|
||||
<input className="input" type="number" step="0.5" value={s.monthly_budget ?? ''} onChange={(e) => field('monthly_budget', e.target.value)} />
|
||||
<div className="hint">{t('When it is reached the queue stops.')}</div>
|
||||
</div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Default dpi')}</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Concurrency')}</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>{t('Cricut sheet size (cm)')}</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Default file format')}</label>
|
||||
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
|
||||
<option value="png">{t('PNG (lossless, large)')}</option>
|
||||
<option value="jpg">{t('JPG (small, easy on the delivery target)')}</option>
|
||||
</select>
|
||||
<div className="hint">{t('Applies to new conversions without their own format choice. Cut-out subjects always stay PNG.')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Delivery target')}</span>
|
||||
<div className="row"><button className="mini" onClick={() => lsDeliveryTarget()}>{t('Show folder')}</button>
|
||||
<button className="mini" onClick={cleanupDeliveryTarget}>{t('Clean up leftovers')}</button>
|
||||
<button className="mini" onClick={testDeliveryTarget}>{t('Test connection')}</button></div></div>
|
||||
<div className="controls">
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Host')}</label><input className="input" value={s.delivery_host ?? ''} onChange={(e) => field('delivery_host', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Port')}</label><input className="input" type="number" value={s.delivery_port ?? 22} onChange={(e) => field('delivery_port', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Protocol')}</label>
|
||||
<select className="input" value={s.delivery_protocol ?? 'sftp'} onChange={(e) => field('delivery_protocol', e.target.value)}>
|
||||
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
|
||||
<div className="field"><label>{t('User')}</label><input className="input" value={s.delivery_user ?? ''} onChange={(e) => field('delivery_user', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>{t('Password')} {s.delivery_password_set && <em>({t('set')})</em>}</label>
|
||||
<input className="input" type="password" placeholder={s.delivery_password_set ? '••••••' : ''} value={dtPw} onChange={(e) => setDtPw(e.target.value)} /></div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Base folder (SFTP root)')}</label><input className="input" value={s.delivery_base_path ?? '/'} onChange={(e) => field('delivery_base_path', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Default folder on the target')}</label><input className="input" placeholder="Gallery A" value={s.delivery_default_folder ?? ''} onChange={(e) => field('delivery_default_folder', e.target.value)} /></div>
|
||||
</div>
|
||||
<label className="toggle"><input type="checkbox" checked={!!s.delivery_metadata_sidecar} onChange={(e) => field('delivery_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>{t('Send the sidecar file (.md) to the delivery target')}</b><em>{t('Only switch this on if the target is supposed to process the text files — otherwise leave it off.')}</em></span></label>
|
||||
<div className="hint">{t('Images land in base folder / folder on the target — for example:')} <code>/ + Gallery A</code>{' '}
|
||||
{t('“Show folder” lists what actually sits where over SFTP.')}</div>
|
||||
{dtList && (
|
||||
<div className="dtlist">
|
||||
<div className="hint"><b>{dtList.path}</b>{dtList.error ? ` — ${dtList.error}` : ''}</div>
|
||||
{(dtList.entries || []).map((e: any, i: number) => (
|
||||
<div key={i} className="dtrow">
|
||||
<button className="link2" onClick={() => lsDeliveryTarget((dtList.path.replace(/\/$/, '')) + '/' + e.name)}>{e.type === 'd' || e.type === 2 ? '📁' : '📄'} {e.name}</button>
|
||||
<span className="hint">{e.size ? `${Math.round(e.size / 1024)} KB` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Storage & cleanup')}</span>
|
||||
{storage && <span className="hint">{t('{bytes} · {results} results · {sources} sources · {thumbs} previews',
|
||||
{ bytes: fmtBytes(storage.bytes), results: storage.results, sources: storage.sources, thumbs: storage.thumbs })}</span>}</div>
|
||||
<div className="controls">
|
||||
<label className="toggle"><input type="checkbox" checked={s.make_thumbnails !== false} onChange={(e) => field('make_thumbnails', e.target.checked)} />
|
||||
<span><b>{t('Generate preview images')}</b><em>{t('Small, economical images for the library preview.')}</em></span></label>
|
||||
<label className="toggle"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
|
||||
<span><b>{t('Delete source images after processing')}</b><em>{t('Saves space — originals are removed once the result exists.')}</em></span></label>
|
||||
<div className="hint">{t('The metadata sidecar file (.md with prompt/model/format) can now be switched on and off per target — on the delivery target, on the backup mirror and on every FTP target separately (see the card for each).')}</div>
|
||||
<div className="field"><label>{t('Retention (days)')}</label>
|
||||
<input className="input" type="number" min={0} placeholder={t('empty = unlimited')} value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||
<div className="hint">{t('Older images are removed automatically (daily). Empty = delete nothing.')}</div></div>
|
||||
<div className="row">
|
||||
<button className="mini" onClick={() => storageAction('purge_sources', t('Delete the source images of all finished items? Results are kept.'))}>{t('Delete sources now')}</button>
|
||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>{t('Generate missing preview images')}</button>
|
||||
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', t('Delete items older than {days} days now?', { days: s.retention_days }))}>{t('Apply retention now')}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Email')}</span>
|
||||
<div className="row">
|
||||
<button className="mini" onClick={() => testMail(false)}>{t('Test connection')}</button>
|
||||
<button className="mini" onClick={() => testMail(true)}>{t('Test message')}</button>
|
||||
</div></div>
|
||||
<div className="controls">
|
||||
<div className="hint">{t('Klarbild sends through your own mailbox — that way SPF and DKIM are right by themselves and no further provider is involved.')}{' '}
|
||||
{t('It is needed for password reset, notices about finished jobs and share links by mail.')}{' '}
|
||||
{t('The password is stored encrypted and never shown again.')}</div>
|
||||
{s.smtp_source === 'environment' && s.smtp_effective && (
|
||||
<div className="notice">
|
||||
{t('Sending already works — through the server setting ({user} on {host}:{port}).',
|
||||
{ user: s.smtp_effective.user, host: s.smtp_effective.host, port: s.smtp_effective.port })}{' '}
|
||||
{t('The fields below are empty because nothing is entered here; that is fine.')}{' '}
|
||||
{t('Anyone who enters something here and saves it overwrites the server setting.')}
|
||||
</div>
|
||||
)}
|
||||
{s.smtp_source === 'database' && (
|
||||
<div className="notice">{t('Sending runs on what is entered here.')}</div>
|
||||
)}
|
||||
{!s.smtp_source && (
|
||||
<div className="notice warn">{t('No sending set up yet — forgotten password, job notices and mailing share links do not work until then.')}</div>
|
||||
)}
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Outgoing mail server')}</label>
|
||||
<input className="input" placeholder={t('e.g. smtp.example.com')} value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Port')}</label>
|
||||
<input className="input" type="number" placeholder="465" value={s.smtp_port ?? 465} onChange={(e) => field('smtp_port', e.target.value)} />
|
||||
<div className="hint">{t('465 = SSL · 587 = STARTTLS')}</div></div>
|
||||
</div>
|
||||
<label className="toggle"><input type="checkbox" checked={s.smtp_secure !== false} onChange={(e) => field('smtp_secure', e.target.checked)} />
|
||||
<span><b>{t('Connect encrypted (SSL)')}</b><em>{t('On for port 465, off for 587.')}</em></span></label>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Username')}</label>
|
||||
<input className="input" placeholder="mail@your-domain.com" value={s.smtp_user ?? ''} onChange={(e) => field('smtp_user', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Password')} {s.smtp_password_set ? <span className="hint">· {t('set')}</span> : null}</label>
|
||||
<input className="input" type="password" value={smtpPw} autoComplete="new-password"
|
||||
placeholder={s.smtp_password_set ? t('leave unchanged') : t('Mailbox password')}
|
||||
onChange={(e) => setSmtpPw(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>{t('Sender')}</label>
|
||||
<input className="input" placeholder="Klarbild <mail@your-domain.com>" value={s.smtp_from ?? ''} onChange={(e) => field('smtp_from', e.target.value)} />
|
||||
<div className="hint">{t('Empty = username. It has to belong to the mailbox, otherwise recipients reject the mail.')}</div></div>
|
||||
<div className="field"><label>{t('Test message to')}</label>
|
||||
<input className="input" type="email" placeholder="you@example.com" value={mailTo} onChange={(e) => setMailTo(e.target.value)} />
|
||||
<div className="hint">{t('“Test connection” only checks the login. Only the test message proves that the mail really arrives — look in the spam folder too.')}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Share links')}</span></div>
|
||||
<div className="controls">
|
||||
<div className="field"><label>{t('Default expiry (days)')}</label>
|
||||
<input className="input" type="number" min={0} max={3650} value={s.share_default_days ?? 30}
|
||||
onChange={(e) => field('share_default_days', e.target.value)} />
|
||||
<div className="hint">{t('Default for new links; changeable at any time when one is created.')} <b>{t('0 = unlimited.')}</b>{' '}
|
||||
{t('There are children in the images — a link that expires by itself is the safe default.')}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('File names')}</span>
|
||||
<span className="hint">{t('YYYY-MM-DD_HHMMSS_subject_format_shortid')}</span></div>
|
||||
<div className="controls">
|
||||
<div className="hint">{t('Every result gets a name that starts with date and time — that way every file list sorts chronologically by itself — and ends with a short id taken from the image ID.')}{' '}
|
||||
{t('That way two similar images from the same day can no longer overwrite each other.')}{' '}
|
||||
{t('Example:')} <code>2026-08-20_143207_portrait_the-frame_a3f9.jpg</code></div>
|
||||
<div className="row">
|
||||
<button className="mini" onClick={renamePreview}>{t('Check library')}</button>
|
||||
<button className="mini" onClick={renameApply}>{t('Rename now')}</button>
|
||||
<button className="mini" onClick={redeliverAll}>{t('Deliver again')}</button>
|
||||
</div>
|
||||
{rename && (
|
||||
<div className="hint">
|
||||
<b>{t('{n} images checked', { n: rename.checked })}</b> ·{' '}
|
||||
{rename.dryRun
|
||||
? t('{n} would get a new name', { n: rename.renamed })
|
||||
: t('{n} renamed', { n: rename.renamed })} ·
|
||||
{' '}{t('{n} unchanged', { n: rename.unchanged })}
|
||||
{rename.wereDuplicates ? ` · ${t('{n} names were used twice', { n: rename.wereDuplicates })}` : ''}
|
||||
{rename.examples?.length ? (
|
||||
<ul className="namelist">
|
||||
{rename.examples.map((b: any) => (
|
||||
<li key={b.id}><s>{b.before}</s> → <code>{b.after}</code></li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="hint">{t('“Deliver again” uploads every already delivered image to the target again under the new name. The old files stay there — deleting something at the client automatically would be the wrong reflex; you do that on the delivery target yourself.')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Backup mirror (second copy)')}</span>
|
||||
<div className="row"><button className="mini" onClick={netcheck}>{t('Reachability')}</button>
|
||||
<button className="mini" onClick={testMirror}>{t('Test connection')}</button></div></div>
|
||||
<div className="controls">
|
||||
<label className="toggle"><input type="checkbox" checked={!!s.mirror_enabled} onChange={(e) => field('mirror_enabled', e.target.checked)} />
|
||||
<span><b>{t('Mirror results to the backup mirror as well')}</b><em>{t('Every finished image is backed up to {path}.', { path: 'klarbild/YYYY-MM/' })}</em></span></label>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Host')}</label><input className="input" placeholder={t('e.g. mirror.local or DDNS')} value={s.mirror_host ?? ''} onChange={(e) => field('mirror_host', e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Port')}</label><input className="input" type="number" value={s.mirror_port ?? 22} onChange={(e) => field('mirror_port', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Protocol')}</label>
|
||||
<select className="input" value={s.mirror_protocol ?? 'sftp'} onChange={(e) => field('mirror_protocol', e.target.value)}>
|
||||
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
|
||||
<div className="field"><label>{t('User')}</label><input className="input" value={s.mirror_user ?? ''} onChange={(e) => field('mirror_user', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="field"><label>{t('Password')} {s.mirror_password_set && <em>({t('set')})</em>}</label>
|
||||
<input className="input" type="password" placeholder={s.mirror_password_set ? '••••••' : ''} value={mirrorPw} onChange={(e) => setMirrorPw(e.target.value)} /></div>
|
||||
<div className="field"><label>{t('Base folder on the mirror')}</label><input className="input" placeholder="/Klarbild" value={s.mirror_base_path ?? ''} onChange={(e) => field('mirror_base_path', e.target.value)} /></div>
|
||||
<label className="toggle"><input type="checkbox" checked={!!s.mirror_metadata_sidecar} onChange={(e) => field('mirror_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>{t('Send the sidecar file (.md) to the mirror')}</b><em>{t('Companion text file (prompt, model, format …) next to every backed-up image.')}</em></span></label>
|
||||
<button className="mini" onClick={mirrorAll}>{t('Back up all existing images to the mirror')}</button>
|
||||
<div className="hint">{t('Results land under base folder / YYYY-MM /. Credentials are stored encrypted.')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('More output targets (FTP/SFTP)')}</span>
|
||||
<button className="mini" onClick={backupAllTargets}>{t('Back up all images')}</button></div>
|
||||
<div className="controls">
|
||||
<div className="hint">{t('Additional FTP/SFTP targets — for example a second folder on the delivery target or another server.')}{' '}
|
||||
{t('A preset/folder can point at one target permanently; selectable in the studio under “Where to?” (the backup mirror too).')}{' '}
|
||||
{t('“Backup” targets automatically receive all results (like the backup mirror).')}</div>
|
||||
<div className="list">
|
||||
{targets.map((tg) => (
|
||||
<div key={tg.id} className="item">
|
||||
<div><b>{tg.name}</b>{tg.is_backup && <span className="pill">{t('Backup')}</span>}{tg.metadata_sidecar && <span className="pill alpha">.md</span>} <span className="hint">{tg.protocol}://{tg.username}@{tg.host}:{tg.port} · {tg.base_path || '/'}{tg.password_set ? '' : ` · ⚠︎ ${t('no password')}`}</span></div>
|
||||
<div className="row">
|
||||
<button className="mini" onClick={() => toggleBackup(tg)}>{tg.is_backup ? t('Backup off') : t('As backup')}</button>
|
||||
<button className="mini" onClick={() => toggleTargetSidecar(tg)}>{tg.metadata_sidecar ? t('.md off') : t('.md on')}</button>
|
||||
<button className="mini" onClick={() => testTarget(tg.id)}>{t('Test')}</button>
|
||||
<button className="mini" onClick={() => delTarget(tg.id)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{targets.length === 0 && <div className="hint">{t('No additional targets yet.')}</div>}
|
||||
</div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Name')}</label><input className="input" placeholder={t('e.g. The Frame FTP')} value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
|
||||
<div className="field"><label>{t('Protocol')}</label>
|
||||
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
|
||||
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
|
||||
</div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('Host')}</label><input className="input" value={nt.host ?? ''} onChange={(e) => setNt({ ...nt, host: e.target.value })} /></div>
|
||||
<div className="field"><label>{t('Port')}</label><input className="input" type="number" value={nt.port ?? ''} onChange={(e) => setNt({ ...nt, port: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="two">
|
||||
<div className="field"><label>{t('User')}</label><input className="input" value={nt.username ?? ''} onChange={(e) => setNt({ ...nt, username: e.target.value })} /></div>
|
||||
<div className="field"><label>{t('Password')}</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="field"><label>{t('Base folder')}</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
||||
<label className="toggle"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
||||
<span><b>{t('As a backup target')}</b><em>{t('Receives all results automatically.')}</em></span></label>
|
||||
<label className="toggle"><input type="checkbox" checked={!!nt.metadata_sidecar} onChange={(e) => setNt({ ...nt, metadata_sidecar: e.target.checked })} />
|
||||
<span><b>{t('Send the sidecar file (.md) along')}</b><em>{t('Companion text file per image.')}</em></span></label>
|
||||
<button className="mini" onClick={addTarget}>{t('Add target')}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Automation / MCP access')}</span></div>
|
||||
<div className="controls">
|
||||
<div className="hint">{t('API token for external/programmatic access (for example Klarbild MCP: “take these images and process them”).')}{' '}
|
||||
{t('As {header} to {uploads} and {jobs}.', { header: 'Authorization: Bearer <Token>', uploads: '/api/uploads', jobs: '/api/jobs' })}</div>
|
||||
{s.api_token
|
||||
? <div className="field"><label>{t('API token')}</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
|
||||
: <div className="hint">{t('No token generated yet.')}</div>}
|
||||
<button className="mini" onClick={genToken}>{s.api_token ? t('Generate a new token') : t('Generate token')}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Permissions & privacy')}</span></div>
|
||||
<div className="controls">
|
||||
<div className="field"><label>{t('Who sees which generations?')}</label>
|
||||
<div className="choice">
|
||||
<button className={s.library_visibility !== 'shared' ? 'on' : ''} onClick={() => field('library_visibility', 'own')}>{t('Own only')}</button>
|
||||
<button className={s.library_visibility === 'shared' ? 'on' : ''} onClick={() => field('library_visibility', 'shared')}>{t('Everyone sees everything')}</button>
|
||||
</div>
|
||||
<div className="hint">{t('Admins always see everything. “Own only”: everyone sees only the images they generated themselves.')}</div>
|
||||
</div>
|
||||
<label className="toggle"><input type="checkbox" checked={!!s.anonymous_generations} onChange={(e) => field('anonymous_generations', e.target.checked)} />
|
||||
<span><b>{t('Anonymous generations')}</b><em>{t('Who generated an image is hidden from non-admins.')}</em></span></label>
|
||||
<label className="toggle"><input type="checkbox" checked={!!s.private_allowed} onChange={(e) => field('private_allowed', e.target.checked)} />
|
||||
<span><b>{t('Allow private sessions')}</b><em>{t('Users get the “Private session” switch in the studio (generate only, forget afterwards).')}</em></span></label>
|
||||
<label className="toggle"><input type="checkbox" checked={!!s.allow_nsfw} onChange={(e) => field('allow_nsfw', e.target.checked)} />
|
||||
<span><b>{t('Allow NSFW models')}</b><em>{t('Models marked as NSFW become selectable and visible. The responsibility lies with the operator; content involving minors is prohibited without exception.')}</em></span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button className="btn" onClick={saveSettings}>{t('Save settings')}</button>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Models')}</span></div>
|
||||
<div className="list">
|
||||
{models.map((m) => (
|
||||
<div key={m.id} className="item">
|
||||
<div><b>{m.label}</b>{m.is_default && <span className="pill">{t('Default')}</span>}{m.supports_alpha && <span className="pill alpha">{t('transparent')}</span>}{m.nsfw && <span className="pill nsfw">NSFW</span>}<div className="hint">{m.model_id}</div></div>
|
||||
<div className="row">
|
||||
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>{t('Default')}</button>}
|
||||
<button className="mini" onClick={() => modelAction(m.id, 'nsfw')}>{m.nsfw ? t('NSFW off') : 'NSFW'}</button>
|
||||
<button className="mini" onClick={() => modelAction(m.id, 'delete')}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{models.length === 0 && <div className="hint">{t('No models yet. Add one from the available ones:')}</div>}
|
||||
<div className="avail">
|
||||
{avail.filter((m) => !stored.has(m.model_id)).slice(0, 12).map((m) => (
|
||||
<button key={m.model_id} className="mini" onClick={() => addModel(m)}>+ {m.name}{m.supports_alpha ? ' ◇' : ''}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Users')}</span><button className="mini" onClick={addUser}>{t('New user')}</button></div>
|
||||
<div className="list">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="item">
|
||||
<div><b>{u.display_name || u.username}</b>{u.private_forced && <span className="pill">{t('private')}</span>} <span className="hint">{u.username} · {u.role}</span></div>
|
||||
<div className="row">
|
||||
<button className="mini" onClick={() => setUserPrivate(u.id, !u.private_forced)}>{u.private_forced ? t('Private off') : t('Force private')}</button>
|
||||
<button className="mini" onClick={() => pairCode(u.id)}>{t('Pair Telegram')}</button>
|
||||
<button className="mini" onClick={() => setPw(u.id)}>{t('Set password')}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="cardHead"><span className="mono-label">{t('Telegram bot')}</span></div>
|
||||
<div className="list">
|
||||
<div className="hint">{t('Generate a code with “Pair Telegram” on a user and enter it in the chat with the bot. After that, forward images there → pick a preset recipe → results come back.')}</div>
|
||||
{tgLinks.map((l) => (
|
||||
<div key={l.chat_id} className="item">
|
||||
<div><b>{t('Chat {id}', { id: l.chat_id })}</b> <span className="hint">{l.user_name || ''} · {t('since {date}', { date: new Date(l.linked_at).toLocaleDateString() })}</span></div>
|
||||
<button className="mini" onClick={() => unlink(l.chat_id)}>{t('Unlink')}</button>
|
||||
</div>
|
||||
))}
|
||||
{tgLinks.length === 0 && <div className="hint">{t('No paired chats yet.')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card danger">
|
||||
<div className="cardHead"><span className="mono-label">{t('Reset')}</span></div>
|
||||
<div className="controls">
|
||||
<div className="hint">{t('Deletes all images, jobs and folders irreversibly, so you can start over cleanly. Users, settings, models and preset recipes are kept. Tip: run “Back up all existing images to the mirror” first.')}</div>
|
||||
<button className="btn warn" onClick={resetAll}>{t('Reset library & jobs')}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<AdminStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminStyles() {
|
||||
return <style>{`
|
||||
.admin{display:flex;flex-direction:column;gap:16px;max-width:640px;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||
.controls{padding:16px;display:flex;flex-direction:column;gap:14px;}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
|
||||
.field label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-bottom:6px;}
|
||||
.field em{text-transform:none;letter-spacing:0;color:var(--accent);font-style:normal;}
|
||||
/* 16 px, not 14: below that iOS zooms into the field on focus — the
|
||||
emergency brake in tokens.css only reaches 767 px, but the admin is also
|
||||
used on the iPad. */
|
||||
.input{width:100%;background:var(--card);border:1px solid var(--line);border-radius:3px;padding:10px 11px;font-family:inherit;font-size:16px;color:var(--ink);}
|
||||
.hint{font-size:11.5px;color:var(--soft);line-height:1.5;}
|
||||
.btn{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
.btn.warn{background:var(--err);}
|
||||
.card.danger{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
|
||||
.card.danger .mono-label{color:var(--err);}
|
||||
.controls code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
.numbers{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
|
||||
.numbers div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
|
||||
.numbers b{display:block;font-size:22px;line-height:1;}.numbers span{font-size:11.5px;color:var(--soft);}
|
||||
.card>.hint{padding:0 16px 14px;}
|
||||
.notice{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
background:var(--paper);font-size:12.5px;line-height:1.55;}
|
||||
.notice.warn{border-color:var(--warn);background:oklch(72% .15 75 / .08);}
|
||||
.notice code{font-family:var(--font-mono);font-size:11.5px;}
|
||||
/* On a phone everything was two columns — a field like "Outgoing mail
|
||||
server" ended up around 156 px wide. And tap targets under 44 px are
|
||||
not hit reliably. */
|
||||
@media(max-width:560px){
|
||||
.two{grid-template-columns:1fr;}
|
||||
.mini{padding:11px 13px;font-size:13px;}
|
||||
.row{flex-wrap:wrap;}
|
||||
}
|
||||
/* Before/after of the rename — long names must not blow the card apart. */
|
||||
.namelist{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px;max-height:220px;overflow:auto;}
|
||||
.namelist li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;}
|
||||
.namelist s{color:var(--soft);}
|
||||
.namelist code{color:var(--ink);}
|
||||
.list{padding:8px 16px 16px;display:flex;flex-direction:column;}
|
||||
.item{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line);}
|
||||
.item b{font-size:14px;}
|
||||
.pill{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
|
||||
.pill.alpha{background:var(--paper);color:var(--soft);}
|
||||
.pill.nsfw{background:var(--err);color:#fff;}
|
||||
.toggle{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
|
||||
.toggle input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||
.toggle b{display:block;font-size:14px;}
|
||||
.toggle em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:1px;line-height:1.4;}
|
||||
.toggle code{font-family:var(--font-mono);font-size:11px;}
|
||||
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
|
||||
.row{display:flex;gap:6px;}
|
||||
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
.dtlist{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:10px;margin-top:8px;max-height:260px;overflow:auto;}
|
||||
.dtrow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 0;}
|
||||
.link2{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:12px;text-align:left;padding:0;}
|
||||
`}</style>;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { filenameFromHeader } from '../lib/naming';
|
||||
import ShareDialog, { type ShareTarget } from './ShareDialog';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
|
||||
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; mirror_status: string;
|
||||
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null;
|
||||
color_tag: string | null; created_at: string; }
|
||||
|
||||
const TAGS = [
|
||||
{ id: 'red', label: 'Red', color: '#E5484D' },
|
||||
{ id: 'orange', label: 'Orange', color: '#E8830C' },
|
||||
{ id: 'green', label: 'Green', color: '#46A758' },
|
||||
{ id: 'final', label: 'Final', color: '#3A3733' },
|
||||
];
|
||||
const tagColor = (id: string | null) => TAGS.find((x) => x.id === id)?.color || null;
|
||||
|
||||
export default function LibraryApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [folders, setFolders] = useState<any[]>([]);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [folderFilter, setFolderFilter] = useState<string | null>(null);
|
||||
const [pick, setPick] = useState<Set<string>>(new Set());
|
||||
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
|
||||
const [compare, setCompare] = useState<Item | null>(null);
|
||||
const [promptOf, setPromptOf] = useState<Item | null>(null);
|
||||
const [saveView, setSaveView] = useState<Item | null>(null);
|
||||
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||
const [share, setShare] = useState<ShareTarget | null>(null);
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2400); };
|
||||
|
||||
// Model name: the admin label, otherwise the readable tail of the model ID.
|
||||
const modelName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const m = models.find((x) => x.model_id === id);
|
||||
if (m) return m.label;
|
||||
return id.split('/').pop()!.replace(/-/g, ' ');
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [i, f, m] = await Promise.all([
|
||||
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
||||
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
||||
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
||||
]);
|
||||
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const rename = async (id: string, filename: string) =>
|
||||
fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
||||
const setTag = async (id: string, color_tag: string | null) => {
|
||||
setItems((x) => x.map((y) => y.id === id ? { ...y, color_tag } : y));
|
||||
setTagMenu(null);
|
||||
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_tag }) });
|
||||
};
|
||||
const setFolder = async (id: string, folder_id: string | null) => {
|
||||
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||
load();
|
||||
};
|
||||
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
|
||||
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify(t('Deleted.')); };
|
||||
const deliverSelected = async () => {
|
||||
notify(t('Sending to the delivery target …'));
|
||||
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
||||
setPick(new Set()); load(); notify(t('{done}/{total} delivered to the target.', { done: r.delivered, total: r.total }));
|
||||
};
|
||||
const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
const moveSelected = async (folder_id: string | null) => {
|
||||
for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
||||
setPick(new Set()); load(); notify(folder_id ? t('Moved into the folder.') : t('Removed from the folder.'));
|
||||
};
|
||||
const zipSelected = async () => {
|
||||
notify(t('Packing ZIP …'));
|
||||
const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) });
|
||||
if (!res.ok) { notify(t('ZIP failed.')); return; }
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
// The name comes from the server — building our own here would mean two
|
||||
// selections made on the same day end up with the same name again.
|
||||
a.download = filenameFromHeader(res.headers.get('Content-Disposition'), 'klarbild-selection.zip'); a.click();
|
||||
URL.revokeObjectURL(a.href); notify(t('ZIP downloaded.'));
|
||||
};
|
||||
const alternative = async (id: string) => {
|
||||
notify(t('Generating an alternative …'));
|
||||
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json());
|
||||
if (r.ok) notify(t('The alternative is being generated — it will show up here shortly.')); else notify(r.error || t('Error.'));
|
||||
setTimeout(load, 2500);
|
||||
};
|
||||
// Carry the result into the studio as a new source and keep working on it.
|
||||
const reuse = (id: string) => { window.location.href = `/?reuse=${id}`; };
|
||||
|
||||
const newFolder = async () => {
|
||||
const name = prompt(t('Folder name?')); if (!name) return;
|
||||
const targetFolder = prompt(t('Folder on the delivery target for this folder? (empty = default)')) || '';
|
||||
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, delivery_folder: targetFolder.trim() || null }) });
|
||||
load();
|
||||
};
|
||||
const delFolder = async (id: string) => {
|
||||
if (!confirm(t('Delete folder? The images are kept.'))) return;
|
||||
await fetch(`/api/folders?id=${id}`, { method: 'DELETE' });
|
||||
if (folderFilter === id) setFolderFilter(null);
|
||||
load();
|
||||
};
|
||||
const renameFolder = async (o: any) => {
|
||||
const name = prompt(t('Rename folder:'), o.name); if (name == null) return;
|
||||
const targetFolder = prompt(t('Folder on the delivery target (empty = default):'), o.delivery_folder || '');
|
||||
await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: o.id, name: name.trim() || o.name, delivery_folder: (targetFolder || '').trim() || null }) });
|
||||
load();
|
||||
};
|
||||
|
||||
// Filter by folder and flag, then group alternatives under their origin.
|
||||
const filtered = items
|
||||
.filter((v) => !folderFilter || v.folder_id === folderFilter)
|
||||
.filter((v) => !tagFilter || v.color_tag === tagFilter);
|
||||
const groupMap = new Map<string, Item[]>();
|
||||
for (const it of filtered) {
|
||||
const root = it.variant_of || it.id;
|
||||
if (!groupMap.has(root)) groupMap.set(root, []);
|
||||
groupMap.get(root)!.push(it);
|
||||
}
|
||||
const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)));
|
||||
groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at));
|
||||
const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="head-row">
|
||||
<span className="mono-label">{t('Library')}{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
||||
<div className="row">
|
||||
{pick.size > 0 && <>
|
||||
<span className="hint">{t('{n} selected', { n: pick.size })}</span>
|
||||
<button className="mini strong" onClick={deliverSelected}>{t('Deliver')}</button>
|
||||
<button className="mini" onClick={() => setShare({ itemIds: [...pick] })}>{t('Share')}</button>
|
||||
<button className="mini" onClick={zipSelected}>{t('Download ZIP')}</button>
|
||||
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
|
||||
<option value="" disabled>{t('Into folder …')}</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
<option value="__none">{t('No folder')}</option>
|
||||
</select>
|
||||
<button className="mini" onClick={delSelected}>{t('Delete')}</button>
|
||||
</>}
|
||||
<button className="mini" onClick={newFolder}>{t('New folder')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder filter */}
|
||||
<div className="folderbar">
|
||||
<button className={`chip ${!folderFilter ? 'active' : ''}`} onClick={() => setFolderFilter(null)}>{t('All')} · {items.length}</button>
|
||||
{folders.map((o) => (
|
||||
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'active' : ''}`}>
|
||||
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
|
||||
{o.name} · {o.count}{o.delivery_folder ? ' →' : ''}
|
||||
</button>
|
||||
{folderFilter === o.id && <>
|
||||
<button className="chip-x" title={t('Rename folder')} onClick={() => renameFolder(o)}>✎</button>
|
||||
<button className="chip-x del" title={t('Delete folder')} onClick={() => delFolder(o.id)}>✕</button>
|
||||
</>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint notice">{t('Folders group the library, and each folder can get its own folder on the delivery target (arrow = its own target folder is set).')}</div>
|
||||
|
||||
{/* Filter by color flag */}
|
||||
<div className="folderbar tagbar">
|
||||
<button className={`chip ${!tagFilter ? 'active' : ''}`} onClick={() => setTagFilter(null)}>{t('All flags')}</button>
|
||||
{TAGS.map((tag) => (
|
||||
<button key={tag.id} className={`chip flagchip ${tagFilter === tag.id ? 'active' : ''}`} onClick={() => setTagFilter(tagFilter === tag.id ? null : tag.id)}>
|
||||
<span className="flag" style={{ color: tag.color }}>⚑</span> {t(tag.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty"><span className="regmark" /><h3>{folderFilter ? t('Folder is empty') : t('Nothing created yet')}</h3>
|
||||
<p>{folderFilter ? t('Assign images to this folder from the folder menu.') : t('Start in the studio — the results collect here.')}</p></div>
|
||||
) : (
|
||||
<div className="gallery">
|
||||
{groups.map((g) => {
|
||||
const v = shown(g);
|
||||
const multi = g.length > 1;
|
||||
return (
|
||||
<article key={g[0].id} className={`tile ${pick.has(v.id) ? 'selected' : ''}`}>
|
||||
<div className="tile-image" onClick={() => toggle(v.id)}>
|
||||
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
<span className="check">{pick.has(v.id) ? '✓' : ''}</span>
|
||||
{(v.mode === 'compose' || v.mode === 'generate') &&
|
||||
<span className="modetag">{v.mode === 'compose' ? t('combined') : t('generated')}</span>}
|
||||
{v.color_tag && <span className="flagbadge" style={{ color: tagColor(v.color_tag)! }}>⚑</span>}
|
||||
{multi && <span className="version">{t('Version {n}/{total}', { n: g.indexOf(v) + 1, total: g.length })}</span>}
|
||||
</div>
|
||||
|
||||
{multi && (
|
||||
<div className="variants">
|
||||
{g.map((x, idx) => (
|
||||
<button key={x.id} className={`vthumb ${x.id === v.id ? 'active' : ''}`}
|
||||
onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={t('Version {n}', { n: idx + 1 })}>
|
||||
<img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input className="name" defaultValue={v.filename || ''} key={v.id} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''}
|
||||
{v.delivery_status === 'delivered' && <span className="dbadge ok">{t('Delivered')} ✓</span>}
|
||||
{v.delivery_status === 'pending' && <span className="dbadge">{t('Delivering')} …</span>}
|
||||
{v.delivery_status === 'failed' && <span className="dbadge err">{t('Delivery')} ✕</span>}
|
||||
{v.mirror_status === 'mirrored' && <span className="dbadge ok">{t('Mirror')} ✓</span>}
|
||||
{v.mirror_status === 'failed' && <span className="dbadge err">{t('Mirror')} ✕</span>}
|
||||
</div>
|
||||
<div className="row tight">
|
||||
<div className="tagwrap">
|
||||
<button className="mini flagbtn" style={v.color_tag ? { color: tagColor(v.color_tag)!, borderColor: tagColor(v.color_tag)! } : {}}
|
||||
onClick={() => setTagMenu(tagMenu === v.id ? null : v.id)} title={t('Flag')}>⚑</button>
|
||||
{tagMenu === v.id && (
|
||||
<div className="tagmenu" onMouseLeave={() => setTagMenu(null)}>
|
||||
<button onClick={() => setTag(v.id, null)}><span className="flag none">⚑</span> {t('None')}</button>
|
||||
{TAGS.map((tag) => (
|
||||
<button key={tag.id} onClick={() => setTag(v.id, tag.id)}><span className="flag" style={{ color: tag.color }}>⚑</span> {t(tag.label)}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>{t('Prompt')}</button>}
|
||||
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>{t('Before/After')}</button>}
|
||||
<button className="mini strong2" onClick={() => setSaveView(v)} title={t('Open large, then save (iPhone: Save to Photos)')}>{t('Download')}</button>
|
||||
<button className="mini" onClick={() => reuse(v.id)} title={t('Keep working on the result in the studio')}>{t('Continue')}</button>
|
||||
<button className="mini" onClick={() => alternative(v.id)} title={t('Generate another version')}>{t('+ Alt.')}</button>
|
||||
<button className="mini" onClick={() => setShare({ itemIds: [v.id] })}
|
||||
title={t('Create a link — opens without a Klarbild account')}>{t('Share')}</button>
|
||||
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||
<option value="">{t('No folder')}</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{share && (
|
||||
<ShareDialog target={share} onClose={() => setShare(null)} locale={locale} dict={dict} />
|
||||
)}
|
||||
|
||||
{compare && (
|
||||
<div className="lightbox" onClick={() => setCompare(null)}>
|
||||
<div className="compare" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="under" src={`/api/items/${compare.id}/file?src=1`} alt={t('before')} />
|
||||
<img className="over" src={`/api/items/${compare.id}/file`} alt={t('after')} id="ov" />
|
||||
<input type="range" min={0} max={100} defaultValue={50}
|
||||
onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} />
|
||||
</div>
|
||||
<div className="lightbox-foot">{compare.filename} · {compare.output_px}px — {compare.mode === 'compose'
|
||||
? t('Slider: source on the left, result on the right')
|
||||
: t('Slider: original on the left, result on the right')}</div>
|
||||
</div>
|
||||
)}
|
||||
{saveView && (
|
||||
<div className="lightbox" onClick={() => setSaveView(null)}>
|
||||
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
|
||||
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
|
||||
</div>
|
||||
<div className="save-foot" onClick={(e) => e.stopPropagation()}>
|
||||
<span>{t('📱 iPhone/iPad: press and hold the image → “Save to Photos”.')}</span>
|
||||
<a className="mini strong" href={`/api/items/${saveView.id}/file?download=1`}>{t('Original as a file (.png)')}</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>{t('Close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{promptOf && (
|
||||
<div className="lightbox" onClick={() => setPromptOf(null)}>
|
||||
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="pb-head"><b>{promptOf.filename}</b>
|
||||
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify(t('Prompt copied.')); }}>{t('Copy')}</button></div>
|
||||
<pre className="pb-text">{promptOf.prompt_used}</pre>
|
||||
<div className="hint">{promptOf.model_used ? `${t('Model: {name}', { name: modelName(promptOf.model_used) || '' })} · ` : ''}{promptOf.output_px}px</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<LibStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibStyles() {
|
||||
return <style>{`
|
||||
.head-row{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.row.tight{margin-top:8px;}
|
||||
.hint{font-size:11.5px;color:var(--soft);}
|
||||
.notice{margin:0 0 14px;line-height:1.5;}
|
||||
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||
.folderbar{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
|
||||
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
|
||||
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
|
||||
.chip.active,.chip-wrap.active .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
|
||||
.chip-x{border:none;background:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0 4px;}
|
||||
.chip-x.del{color:var(--err);padding:0 8px 0 2px;}
|
||||
.empty{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.empty h3{margin:0;font-size:17px;}.empty p{margin:0;color:var(--soft);max-width:38ch;}
|
||||
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
|
||||
@media(min-width:768px){.gallery{grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;}}
|
||||
.tile{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:8px;}
|
||||
.tile.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.tile-image{position:relative;border-radius:3px;overflow:hidden;cursor:pointer;
|
||||
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/16px 16px;}
|
||||
.tile-image img{width:100%;height:auto;display:block;transition:transform .12s;}
|
||||
.tile-image:active img{transform:scale(.98);}
|
||||
.check{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
|
||||
.tile.selected .check{background:var(--accent);}
|
||||
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
|
||||
/* Bottom left — the selection circle (.check) sits top left, the mode tag top
|
||||
right, the version number bottom right. The flag used to sit exactly on the
|
||||
selection circle and covered it. */
|
||||
.flagbadge{position:absolute;bottom:5px;left:6px;font-size:19px;line-height:1;
|
||||
text-shadow:0 1px 3px rgba(0,0,0,.55);pointer-events:none;}
|
||||
.tagbar{margin:0 0 12px;}
|
||||
.flagchip .flag{font-size:13px;vertical-align:-1px;}
|
||||
.flag.none{color:var(--soft);}
|
||||
.tagwrap{position:relative;display:inline-block;}
|
||||
.flagbtn{font-size:14px;line-height:1;padding:5px 9px;}
|
||||
.tagmenu{position:absolute;bottom:calc(100% + 4px);left:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:5px;display:flex;flex-direction:column;min-width:130px;}
|
||||
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
|
||||
.tagmenu button:hover{background:var(--paper);}
|
||||
.tagmenu .flag{font-size:15px;}
|
||||
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
|
||||
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
|
||||
.saveimg.full{position:relative;z-index:1;}
|
||||
.save-foot{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
|
||||
.version{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
|
||||
.variants{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
|
||||
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
|
||||
.vthumb.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
|
||||
.meta{font-size:11px;color:var(--soft);margin-top:5px;}
|
||||
.mini.strong{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
|
||||
.mini.strong2{border-color:var(--accent);color:var(--accent);font-weight:600;}
|
||||
.dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
|
||||
.dbadge.ok{background:var(--accent-bg);color:var(--accent);}
|
||||
.dbadge.err{color:var(--err);}
|
||||
.mini-select{border:1px solid var(--line);border-radius:3px;padding:5px 7px;font-family:inherit;font-size:12px;background:#fff;}
|
||||
.lightbox{position:fixed;inset:0;background:rgba(22,21,15,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:26px;z-index:50;}
|
||||
.compare{position:relative;max-width:90vw;max-height:76vh;}
|
||||
.compare img{max-width:90vw;max-height:70vh;display:block;border-radius:3px;}
|
||||
.compare .over{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
|
||||
.compare input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
|
||||
.lightbox-foot{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
|
||||
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
||||
.pb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
||||
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
`}</style>;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { filenameFromHeader } from '../lib/naming';
|
||||
import { useT, type Dict, type Locale, type TFunction } from '../lib/useT';
|
||||
|
||||
/**
|
||||
* Recently printed.
|
||||
*
|
||||
* What is stored is **the instructions only** — sheet, sizes, quantities and the
|
||||
* references to the images. No PDF: an A3 sheet is quickly 20–80 MB, and the
|
||||
* module computes without AI, so the same job yields exactly the same sheet
|
||||
* again. Generating it again is cheaper than keeping it.
|
||||
*
|
||||
* "Print again" therefore only works if **all** images came from the library and
|
||||
* are still there. For uploaded files the source is gone after the clean-up —
|
||||
* which is then also stated on the entry.
|
||||
*/
|
||||
interface Run {
|
||||
id: string; origin: string; summary: string | null; paper: string | null;
|
||||
pages: number; pieces: number; bytes: number | null; created_at: string;
|
||||
images: { id: string; filename: string | null }[];
|
||||
missing: number; uploaded: number; repeatable: boolean; config: any;
|
||||
}
|
||||
|
||||
const when = (s: string, t: TFunction, dateLocale: string) => {
|
||||
const d = new Date(s);
|
||||
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
|
||||
if (minutes < 1) return t('just now');
|
||||
if (minutes < 60) return t('{n} min ago', { n: minutes });
|
||||
if (minutes < 24 * 60) return t('{n} h ago', { n: Math.round(minutes / 60) });
|
||||
return d.toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
};
|
||||
// A test sheet is 20 KB, an A3 poster 80 MB. `toFixed(1)` in MB turned the small
|
||||
// case into "0.0 MB" — so below one megabyte the size is given in KB.
|
||||
const size = (b: number | null) => {
|
||||
if (!b) return '';
|
||||
if (b < 1024 * 1024) return `${Math.max(1, Math.round(b / 1024))} KB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
};
|
||||
// Plural: pick the whole sentence, never glue a number to a word — which word
|
||||
// form a number takes is a property of the language, not of the number.
|
||||
const plural = (t: TFunction, n: number, one: string, many: string) => t(n === 1 ? one : many, { n });
|
||||
|
||||
export default function PrintRuns({ onLoad, locale, dict }: { onLoad?: (config: any) => void; locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
// Dates stay numeric (day/month/year); only the order and separators follow
|
||||
// the language of the instance.
|
||||
const dateLocale = locale === 'de' ? 'de-DE' : 'en-GB';
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/print/runs');
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const d = await r.json();
|
||||
setRuns(d.runs || []); setFailed(false);
|
||||
} catch { setFailed(true); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const say = (text: string, ok = true) => { setMsg({ t: text, ok }); setTimeout(() => setMsg(null), 5000); };
|
||||
|
||||
/** Produce the same sheet once more — same request, same PDF. */
|
||||
const again = async (r: Run) => {
|
||||
setBusy(r.id);
|
||||
try {
|
||||
const res = await fetch('/api/print/sheet', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
// `origin` marks the run as a repeat, so the list does not look as if
|
||||
// someone had built the same sheet twice by hand.
|
||||
body: JSON.stringify({ ...r.config, origin: 'repeat' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
say(j.error || t('That did not work — an image may be missing.'), false);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
// The repeat print is a **new** file with a new timestamp and a new short
|
||||
// id — so it does not overwrite the first sheet.
|
||||
a.download = filenameFromHeader(res.headers.get('Content-Disposition'), 'klarbild-print.pdf');
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||
say(t('PDF generated again. When printing, choose “Actual size / 100 %”.'));
|
||||
load();
|
||||
} catch { say(t('Network error.'), false); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const remove = async (r: Run) => {
|
||||
if (!confirm(t('Take this entry off the list?\n\nThe images stay in the library — only the history entry disappears.'))) return;
|
||||
const res = await fetch(`/api/print/runs?id=${r.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
|
||||
if (res.ok) { setRuns((p) => p.filter((x) => x.id !== r.id)); } else say(res.error || t('That did not work.'), false);
|
||||
};
|
||||
|
||||
if (loading) return <p className="pr-empty">{t('Loading …')}</p>;
|
||||
|
||||
if (failed) return (
|
||||
<div className="pr">
|
||||
<p className="pr-empty">{t('The history could not be loaded.')}{' '}
|
||||
<button className="pr-btn" onClick={() => { setLoading(true); load(); }}>{t('Try again')}</button></p>
|
||||
<PrintRunsStyles />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!runs.length) return (
|
||||
<div className="pr">
|
||||
<div className="pr-empty-box">
|
||||
<span className="regmark" />
|
||||
<h2>{t('Nothing printed yet')}</h2>
|
||||
<p>{t('As soon as you create a PDF in the print area, it stands here — with a button to get exactly the same sheet again.')}{' '}
|
||||
<a href="/print">{t('Open the print area')}</a></p>
|
||||
</div>
|
||||
<PrintRunsStyles />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pr">
|
||||
{msg && <p className={`pr-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||
|
||||
<div className="pr-list">
|
||||
{runs.map((r) => (
|
||||
<article key={r.id} className="pr-card">
|
||||
<div className="pr-head">
|
||||
<div className="pr-title">
|
||||
<h3>{r.summary || t('Print sheet')}</h3>
|
||||
<span className="pr-when">{when(r.created_at, t, dateLocale)}</span>
|
||||
</div>
|
||||
{r.origin === 'repeat' && <span className="pr-mark">{t('repeated')}</span>}
|
||||
{r.origin === 'telegram' && <span className="pr-mark">Telegram</span>}
|
||||
</div>
|
||||
|
||||
<div className="pr-meta">
|
||||
{r.paper} · {plural(t, r.pages, '{n} sheet', '{n} sheets')} · {plural(t, r.pieces, '{n} piece', '{n} pieces')}
|
||||
{r.bytes ? <> · {t('the PDF was {size}', { size: size(r.bytes) })}</> : null}
|
||||
</div>
|
||||
|
||||
{r.images.length > 0 && (
|
||||
<div className="pr-images">
|
||||
{r.images.slice(0, 8).map((b) => (
|
||||
<img key={b.id} src={`/api/items/${b.id}/file?thumb=1`} alt={b.filename || ''}
|
||||
title={b.filename || ''} loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; }} />
|
||||
))}
|
||||
{r.images.length > 8 && <span className="pr-more">+{r.images.length - 8}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pr-actions">
|
||||
{r.repeatable ? (
|
||||
<button className="pr-btn strong" disabled={busy === r.id} onClick={() => again(r)}>
|
||||
{busy === r.id ? t('Being generated …') : t('Print again')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="pr-why">
|
||||
{r.missing > 0
|
||||
? plural(t, r.missing,
|
||||
'{n} image has been deleted since — cannot be repeated.',
|
||||
'{n} images have been deleted since — cannot be repeated.')
|
||||
: t('Uploaded back then instead of taken from the library — the source file is gone.')}
|
||||
</span>
|
||||
)}
|
||||
{onLoad && (
|
||||
<button className="pr-btn" onClick={() => onLoad(r.config)}>{t('Apply these settings')}</button>
|
||||
)}
|
||||
<button className="pr-btn danger" onClick={() => remove(r)}>{t('Remove')}</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="pr-hint">
|
||||
{t('Only the settings are kept here, not the PDFs — an A3 sheet would quickly be 20 to 80 MB. Because the print module computes without AI, the same job yields exactly the same sheet again. The last {n} prints per person are kept.', { n: 100 })}
|
||||
</p>
|
||||
|
||||
<PrintRunsStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrintRunsStyles() {
|
||||
return <style>{`
|
||||
.pr{display:grid;gap:14px;}
|
||||
.pr-empty{color:var(--soft);}
|
||||
.pr-empty-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:44px 24px;text-align:center;}
|
||||
.pr-empty-box h2{margin:14px 0 8px;font-size:1.15rem;}
|
||||
.pr-empty-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
|
||||
.pr-list{display:grid;gap:12px;}
|
||||
.pr-card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:15px;display:grid;gap:10px;}
|
||||
.pr-head{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}
|
||||
.pr-title{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;min-width:0;flex:1;}
|
||||
.pr-title h3{margin:0;font-size:.98rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
|
||||
.pr-when{font-size:.78rem;color:var(--soft);white-space:nowrap;}
|
||||
.pr-mark{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||
padding:3px 8px;border-radius:20px;background:var(--paper);color:var(--soft);border:1px solid var(--line);}
|
||||
.pr-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||
.pr-images{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||||
.pr-images img{width:46px;height:46px;object-fit:cover;border:1px solid var(--line);border-radius:3px;
|
||||
background:var(--paper);}
|
||||
.pr-more{font-family:var(--font-mono);font-size:11px;color:var(--soft);}
|
||||
.pr-actions{display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
|
||||
.pr-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
padding:9px 12px;font-size:.84rem;cursor:pointer;font-family:inherit;color:var(--ink);white-space:nowrap;}
|
||||
.pr-btn:hover{border-color:var(--accent);}
|
||||
.pr-btn.strong{background:var(--accent);color:var(--card);border-color:var(--accent);font-weight:600;}
|
||||
.pr-btn.danger{color:var(--err);}
|
||||
.pr-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
|
||||
.pr-why{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||
.pr-hint{margin:0;font-size:.78rem;color:var(--soft);line-height:1.6;max-width:70ch;}
|
||||
.pr-msg{margin:0;font-size:.9rem;}
|
||||
.pr-msg.ok{color:var(--ok);}
|
||||
.pr-msg.err{color:var(--err);}
|
||||
@media(max-width:640px){
|
||||
.pr-btn{flex:1;text-align:center;padding:11px 10px;}
|
||||
.pr-why{flex-basis:100%;}
|
||||
}
|
||||
`}</style>;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
queued: 'waiting', running: 'running', done: 'done', failed: 'failed',
|
||||
skipped: 'skipped', paused: 'paused', cancelled: 'cancelled',
|
||||
};
|
||||
|
||||
/** Point in time, short: today → "HH:MM", otherwise "DD.MM. HH:MM". */
|
||||
function fmtTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const today = new Date();
|
||||
const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
|
||||
return sameDay ? hm : `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${hm}`;
|
||||
}
|
||||
|
||||
export default function QueueApp({ jobId, locale, dict }: { jobId: string | null; locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
const [job, setJob] = useState<any>(null);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [saveView, setSaveView] = useState<any | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (jobId) {
|
||||
const r = await fetch(`/api/jobs/${jobId}`).then((x) => x.json()).catch(() => null);
|
||||
if (r?.job) { setJob(r.job); setItems(r.items || []); }
|
||||
} else {
|
||||
const r = await fetch('/api/jobs').then((x) => x.json()).catch(() => null);
|
||||
setJobs(r?.jobs || []);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const active = !job || ['queued', 'running', 'paused'].includes(job?.status);
|
||||
const ti = setInterval(load, active ? 2000 : 6000);
|
||||
return () => clearInterval(ti);
|
||||
}, [load, job?.status]);
|
||||
|
||||
const act = async (path: string) => { await fetch(path, { method: 'POST' }); load(); };
|
||||
|
||||
if (!jobId) {
|
||||
return (
|
||||
<div className="q">
|
||||
<div className="cardHead"><span className="mono-label">{t('Jobs')}</span></div>
|
||||
{jobs.length === 0 ? <p className="hint">{t('No jobs yet. Start in the studio.')}</p> : (
|
||||
<div className="joblist">
|
||||
{jobs.map((j) => (
|
||||
<a key={j.id} className="jobrow" href={`/queue?job=${j.id}`}>
|
||||
<span className={`dot ${j.status}`} />
|
||||
<b>{j.done_count}/{j.total}</b>
|
||||
<span className="hint">{t(STATUS_LABELS[j.status] || j.status)}{j.by_name ? ` · ${j.by_name}` : ''}
|
||||
{j.finished_at && ['done', 'failed', 'cancelled'].includes(j.status) ? ` · ${t('done {time}', { time: fmtTime(j.finished_at) })}` : ''}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<QStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const done = items.filter((i) => i.status === 'done').length;
|
||||
const failed = items.filter((i) => i.status === 'failed').length;
|
||||
const open = items.filter((i) => ['queued', 'running'].includes(i.status)).length;
|
||||
const pct = items.length ? Math.round(((done + failed) / items.length) * 100) : 0;
|
||||
const etaSec = open * 20;
|
||||
|
||||
return (
|
||||
<div className="q">
|
||||
<div className="cardHead">
|
||||
<span className="mono-label">{t('Job')} · {t(STATUS_LABELS[job?.status] || job?.status)}
|
||||
{job?.finished_at && ['done', 'failed', 'cancelled'].includes(job?.status) ? ` · ${t('ended {time}', { time: fmtTime(job.finished_at) })}` : ''}</span>
|
||||
<div className="row">
|
||||
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>{t('Pause')}</button>}
|
||||
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>{t('Resume')}</button>}
|
||||
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>{t('Retry the failed ones')}</button>}
|
||||
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>{t('Cancel')}</button>}
|
||||
{['done', 'failed', 'cancelled'].includes(job?.status) && (
|
||||
<button className="mini" onClick={async () => {
|
||||
if (!confirm(t('Delete this job?'))) return;
|
||||
await fetch(`/api/jobs/${jobId}/delete`, { method: 'POST' });
|
||||
location.href = '/queue';
|
||||
}}>{t('Delete')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress">
|
||||
<div className="bar"><i style={{ width: `${pct}%` }} /></div>
|
||||
<div className="hint">{failed
|
||||
? t('{done} done, {failed} failed of {total}', { done, failed, total: items.length })
|
||||
: t('{done} done of {total}', { done, total: items.length })}
|
||||
{open > 0 && ` · ${etaSec >= 60
|
||||
? t('about {n} more min', { n: Math.ceil(etaSec / 60) })
|
||||
: t('about {n} more sec', { n: etaSec })}`}</div>
|
||||
</div>
|
||||
|
||||
<div className="items">
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className={`item ${it.status}`}>
|
||||
<div className="thumb">
|
||||
{it.status === 'done' && it.result_path
|
||||
? <img src={`/api/items/${it.id}/file`} alt="" loading="lazy" />
|
||||
: <span className={`dot ${it.status}`} />}
|
||||
</div>
|
||||
<div className="itxt">
|
||||
<b>{it.filename || t('Image {n}', { n: it.position + 1 })}</b>
|
||||
<span className="hint">{STATUS_LABELS[it.status] ? t(STATUS_LABELS[it.status]) : ''}{it.output_px ? ` · ${it.output_px}px` : ''}</span>
|
||||
{it.error_message && <span className="hint err">{it.error_message}</span>}
|
||||
</div>
|
||||
<div className="iact">
|
||||
{it.status === 'done' && <button className="mini" onClick={() => setSaveView(it)}>{t('Download')}</button>}
|
||||
{it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>{t('Again')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{saveView && (
|
||||
<div className="zoom" onClick={() => setSaveView(null)}>
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file?preview=1`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="save-foot" onClick={(e) => e.stopPropagation()}>
|
||||
<span>{t('📱 iPhone/iPad: press and hold the image → “Save to Photos”.')}</span>
|
||||
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>{t('Original as a file (.png)')}</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>{t('Close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QStyles() {
|
||||
return <style>{`
|
||||
.q{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);gap:10px;flex-wrap:wrap;}
|
||||
.row{display:flex;gap:6px;flex-wrap:wrap;}
|
||||
.progress{padding:16px;border-bottom:1px solid var(--line);}
|
||||
.bar{height:6px;background:var(--paper);border-radius:20px;overflow:hidden;}
|
||||
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s;}
|
||||
.hint{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;display:block;}
|
||||
.hint.err{color:var(--err);}
|
||||
.items{display:flex;flex-direction:column;}
|
||||
.item{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);}
|
||||
.thumb{width:46px;height:46px;flex:none;border:1px solid var(--line);border-radius:3px;overflow:hidden;display:flex;align-items:center;justify-content:center;
|
||||
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/12px 12px;}
|
||||
.thumb img{width:100%;height:100%;object-fit:contain;}
|
||||
.itxt{flex:1;min-width:0;}
|
||||
.itxt b{font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.iact{flex:none;}
|
||||
.mini{background:#fff;border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||
.dot{width:11px;height:11px;border-radius:50%;background:var(--mark);display:inline-block;}
|
||||
.dot.running{background:var(--accent);}
|
||||
.dot.done{background:var(--ok);}
|
||||
.dot.failed{background:var(--err);}
|
||||
.dot.queued{background:var(--mark);}
|
||||
.zoom{position:fixed;inset:0;background:rgba(22,21,15,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:20px;z-index:50;}
|
||||
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||
.save-foot{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;}
|
||||
.save-foot .mini{text-decoration:none;}
|
||||
.joblist{display:flex;flex-direction:column;}
|
||||
.jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);}
|
||||
p.hint{padding:16px;}
|
||||
`}</style>;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
/**
|
||||
* Share dialog: turn a picture selection into a link and hand it on.
|
||||
*
|
||||
* Deliberately in two steps — settings first, then handing it on. The link comes
|
||||
* into being on the click on "Create link"; until then nothing is public. After
|
||||
* that the same dialog shows the routes: copy, WhatsApp, email.
|
||||
*/
|
||||
export interface ShareTarget { itemIds: string[]; title?: string }
|
||||
|
||||
const DAYS = [
|
||||
{ v: 7, label: '7 days' },
|
||||
{ v: 30, label: '30 days' },
|
||||
{ v: 90, label: '3 months' },
|
||||
{ v: 365, label: '1 year' },
|
||||
{ v: 0, label: 'no expiry' },
|
||||
];
|
||||
|
||||
export default function ShareDialog(
|
||||
{ target, onClose, onDone, locale, dict }:
|
||||
{ target: ShareTarget; onClose: () => void; onDone?: () => void; locale?: Locale; dict?: Dict | null },
|
||||
) {
|
||||
const t = useT(dict);
|
||||
const many = target.itemIds.length > 1;
|
||||
const [title, setTitle] = useState(target.title || '');
|
||||
const [note, setNote] = useState('');
|
||||
const [days, setDays] = useState(30);
|
||||
const [download, setDownload] = useState(true);
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [link, setLink] = useState<{ slug: string; id: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||||
const [mailTo, setMailTo] = useState('');
|
||||
const [mailText, setMailText] = useState('');
|
||||
const [mailReady, setMailReady] = useState<boolean | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
|
||||
const url = link ? `${location.origin}/s/${link.slug}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/me').then((r) => r.json()).then((m) => {
|
||||
setMailReady(!!m.mail_ready);
|
||||
// Take over the default from the admin area — otherwise the setting there
|
||||
// promises something that never arrives here.
|
||||
if (typeof m.share_default_days === 'number') setDays(m.share_default_days);
|
||||
}).catch(() => setMailReady(null));
|
||||
}, []);
|
||||
|
||||
// Trap the focus and hold the background still while the dialog is open.
|
||||
useEffect(() => {
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
box.current?.querySelector<HTMLElement>('input,select,button')?.focus();
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key !== 'Tab') return;
|
||||
const f = box.current?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input,select,textarea');
|
||||
if (!f?.length) return;
|
||||
const [first, last] = [f[0], f[f.length - 1]];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
document.addEventListener('keydown', key);
|
||||
return () => { document.removeEventListener('keydown', key); document.body.style.overflow = previous; };
|
||||
}, [onClose]);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true); setMsg(null);
|
||||
const r = await fetch('/api/shares', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemIds: target.itemIds, title: title || null, note: note || null,
|
||||
days, allowDownload: download, password: passphrase || null,
|
||||
}),
|
||||
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||
setBusy(false);
|
||||
if (r.error) { setMsg({ t: r.error, ok: false }); return; }
|
||||
setLink({ slug: r.share.slug, id: r.share.id });
|
||||
onDone?.();
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(url); }
|
||||
catch {
|
||||
// Without clipboard permission: select the field so it can be copied by hand.
|
||||
box.current?.querySelector<HTMLInputElement>('.linkfield')?.select();
|
||||
}
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const waText = `${title || t('Images')}\n${url}`;
|
||||
const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`;
|
||||
|
||||
const sendMail = async () => {
|
||||
if (!mailTo.trim()) { setMsg({ t: t('Enter a recipient address.'), ok: false }); return; }
|
||||
setBusy(true); setMsg(null);
|
||||
const r = await fetch('/api/shares/send', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shareId: link!.id, to: mailTo, message: mailText }),
|
||||
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||
setBusy(false);
|
||||
if (r.error) { setMsg({ t: r.error, ok: false }); return; }
|
||||
// Partial success is success: before, a text pattern match coloured
|
||||
// "2 sent, 1 did not go through." entirely red.
|
||||
setMsg(r.failed?.length
|
||||
? { t: t('{sent} sent, {failed} did not go through.', { sent: r.ok, failed: r.failed.length }), ok: r.ok > 0 }
|
||||
: { t: r.ok === 1 ? t('Sent to the address.') : t('Sent to {n} addresses.', { n: r.ok }), ok: true });
|
||||
setMailTo('');
|
||||
};
|
||||
|
||||
// Two sentences, one key, with the passphrase kept in bold: the {passphrase}
|
||||
// slot is split out of the translated sentence so the word lands where the
|
||||
// language puts it instead of the sentence being glued from two halves.
|
||||
const warn = t('The link is protected with {passphrase}. Send the passphrase separately from the link — otherwise the protection is worth nothing.')
|
||||
.split('{passphrase}');
|
||||
const mailHint = t('Sending email straight from here is not set up yet — an admin can add that under {where}. Copying and WhatsApp work regardless.')
|
||||
.split('{where}');
|
||||
|
||||
return (
|
||||
<div className="tl-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="tl-box" ref={box} role="dialog" aria-modal="true" aria-label={t('Share')}>
|
||||
<div className="tl-head">
|
||||
<h2>{many ? t('Share {n} images', { n: target.itemIds.length }) : t('Share image')}</h2>
|
||||
<button className="tl-x" onClick={onClose} aria-label={t('Close')}>✕</button>
|
||||
</div>
|
||||
|
||||
{!link ? (
|
||||
<div className="tl-body">
|
||||
<p className="tl-lead">
|
||||
{t('This creates a link that someone without a Klarbild account can open too. It expires by itself and can be switched off again at any time.')}
|
||||
</p>
|
||||
|
||||
<label className="tl-field">{t('Heading')}
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={many ? t('e.g. Nursery photos August') : t('e.g. Ada in the garden')} maxLength={120} />
|
||||
</label>
|
||||
|
||||
<label className="tl-field">{t('Valid for')}
|
||||
<select value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
||||
{DAYS.map((d) => <option key={d.v} value={d.v}>{t(d.label)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* The common case is "one picture for grandma" — that one needs none of
|
||||
this. Folded away, with a summary that names the current state. */}
|
||||
<details className="tl-more">
|
||||
<summary>
|
||||
<span>{t('More settings')}</span>
|
||||
<em>{download ? t('download allowed') : t('view only')}
|
||||
{' · '}{passphrase ? t('with passphrase') : t('without passphrase')}</em>
|
||||
</summary>
|
||||
<div className="tl-more-body">
|
||||
<label className="tl-field">{t('Message on the page')}
|
||||
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2}
|
||||
placeholder={t('Shown above the pictures')} maxLength={500} />
|
||||
</label>
|
||||
|
||||
<label className="tl-check">
|
||||
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
|
||||
<span><b>{t('Allow downloading')}</b><em>{t('Without the tick the pictures can only be viewed.')}</em></span>
|
||||
</label>
|
||||
|
||||
<label className="tl-field">{t('Passphrase')}
|
||||
<input type="text" value={passphrase} onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t('Empty = everyone with the link gets in')} maxLength={100} />
|
||||
<span className="tl-hint">{t('Send the passphrase by a different route than the link.')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||
<div className="tl-foot">
|
||||
<button className="tl-secondary" onClick={onClose}>{t('Cancel')}</button>
|
||||
<button className="tl-primary" onClick={create} disabled={busy}>
|
||||
{busy ? t('One moment …') : t('Create link')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tl-body">
|
||||
<p className="tl-lead">{t('The link is there. This is how it reaches the recipient:')}</p>
|
||||
|
||||
<div className="tl-linkrow">
|
||||
<input className="linkfield" value={url} readOnly onFocus={(e) => e.currentTarget.select()} aria-label={t('Share link')} />
|
||||
<button className="tl-primary" onClick={copy}>{copied ? t('Copied ✓') : t('Copy')}</button>
|
||||
</div>
|
||||
|
||||
<div className="tl-ways">
|
||||
<a className="tl-way wa" href={waHref} target="_blank" rel="noopener">
|
||||
<span className="tl-way-title">WhatsApp</span>
|
||||
<span className="tl-way-hint">{t('Opens WhatsApp with the text ready')}</span>
|
||||
</a>
|
||||
<a className="tl-way" href={`mailto:?subject=${encodeURIComponent(title || t('Images'))}&body=${encodeURIComponent(waText)}`}>
|
||||
<span className="tl-way-title">{t('Your own mail program')}</span>
|
||||
<span className="tl-way-hint">{t('Opens your mail app')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{passphrase && (
|
||||
<p className="tl-warn">
|
||||
{warn[0]}<b>{passphrase}</b>{warn[1]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="tl-divider"><span>{t('or send it straight from here')}</span></div>
|
||||
|
||||
{mailReady === null ? null : mailReady ? (
|
||||
<>
|
||||
<label className="tl-field">{t('To')}
|
||||
<input value={mailTo} onChange={(e) => setMailTo(e.target.value)} type="email"
|
||||
placeholder={t('grandma@example.com, grandpa@example.com')} />
|
||||
<span className="tl-hint">{t('Separate several addresses with a comma.')}</span>
|
||||
</label>
|
||||
<label className="tl-field"><span className="tl-fieldhead">{t('Add a note')} <em>{t('optional')}</em></span>
|
||||
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
|
||||
placeholder={t('A few lines for the recipient')} maxLength={1000} />
|
||||
</label>
|
||||
<button className="tl-secondary wide" onClick={sendMail} disabled={busy}>
|
||||
{busy ? t('Sending …') : t('Send by email')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="tl-hint">
|
||||
{mailHint[0]}<b>{t('Admin → Email')}</b>{mailHint[1]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{msg && <p className={`tl-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||
|
||||
<div className="tl-foot">
|
||||
<a className="tl-secondary" href="/shares">{t('Manage all links')}</a>
|
||||
<button className="tl-secondary" onClick={onClose}>{t('Done')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.tl-backdrop{position:fixed;inset:0;z-index:80;background:rgba(22,21,15,.55);
|
||||
display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);}
|
||||
.tl-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
width:min(520px,100%);max-height:min(88vh,760px);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(22,21,15,.25);}
|
||||
.tl-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
||||
padding:16px 18px;border-bottom:1px solid var(--line);}
|
||||
.tl-head h2{margin:0;font-size:1.05rem;letter-spacing:-.01em;}
|
||||
.tl-x{border:none;background:none;font-size:17px;cursor:pointer;color:var(--soft);
|
||||
width:34px;height:34px;border-radius:50%;line-height:1;}
|
||||
.tl-x:hover{background:var(--paper);color:var(--ink);}
|
||||
.tl-body{padding:18px;overflow:auto;display:grid;gap:14px;}
|
||||
.tl-lead{margin:0;font-size:.9rem;color:var(--ink-soft);line-height:1.6;}
|
||||
.tl-field{display:grid;gap:6px;font-size:.86rem;color:var(--ink-soft);}
|
||||
.tl-field input,.tl-field select,.tl-field textarea{padding:10px 11px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;
|
||||
font-family:inherit;width:100%;box-sizing:border-box;}
|
||||
.tl-field textarea{resize:vertical;line-height:1.5;}
|
||||
.tl-fieldhead{display:flex;gap:6px;align-items:baseline;}
|
||||
.tl-fieldhead em{font-style:normal;color:var(--soft);font-size:.78rem;}
|
||||
.tl-hint{font-size:.78rem;color:var(--soft);line-height:1.5;}
|
||||
.tl-check{display:flex;gap:10px;align-items:flex-start;font-size:.88rem;cursor:pointer;}
|
||||
.tl-check input{width:18px;height:18px;margin-top:2px;flex:none;accent-color:var(--accent);}
|
||||
.tl-check b{display:block;font-weight:600;color:var(--ink);}
|
||||
.tl-check em{display:block;font-style:normal;color:var(--soft);font-size:.8rem;margin-top:2px;}
|
||||
.tl-linkrow{display:flex;gap:8px;}
|
||||
.tl-linkrow input{flex:1;min-width:0;padding:10px 11px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);font-family:var(--font-mono);
|
||||
font-size:12.5px;color:var(--ink);}
|
||||
.tl-primary{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
|
||||
padding:11px 16px;font-weight:600;font-size:.92rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
|
||||
.tl-primary.wide,.tl-secondary.wide{width:100%;}
|
||||
.tl-primary:disabled{background:var(--paper);color:var(--soft);border:1px solid var(--line);cursor:not-allowed;}
|
||||
.tl-secondary{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
|
||||
color:var(--ink);padding:11px 16px;font-size:.92rem;cursor:pointer;font-family:inherit;
|
||||
text-decoration:none;display:inline-block;text-align:center;}
|
||||
.tl-ways{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
|
||||
.tl-way{display:grid;gap:2px;padding:11px 13px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);text-decoration:none;color:var(--ink);}
|
||||
.tl-way:hover{border-color:var(--accent);}
|
||||
.tl-way.wa{border-color:#25D366;}
|
||||
.tl-way-title{font-weight:600;font-size:.88rem;}
|
||||
.tl-way-hint{font-size:.75rem;color:var(--soft);line-height:1.4;}
|
||||
.tl-more{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--paper);}
|
||||
.tl-more>summary{list-style:none;cursor:pointer;display:flex;align-items:baseline;
|
||||
justify-content:space-between;gap:10px;padding:12px;font-size:.88rem;}
|
||||
.tl-more>summary>span{white-space:nowrap;}
|
||||
.tl-more>summary::-webkit-details-marker{display:none;}
|
||||
.tl-more>summary::after{content:'▾';color:var(--soft);font-size:11px;transition:transform .15s;}
|
||||
.tl-more[open]>summary::after{transform:rotate(180deg);}
|
||||
.tl-more>summary em{font-style:normal;font-size:.76rem;color:var(--soft);
|
||||
margin-left:auto;text-align:right;line-height:1.4;}
|
||||
.tl-more-body{display:grid;gap:12px;padding:0 12px 12px;}
|
||||
.tl-divider{display:flex;align-items:center;gap:10px;color:var(--soft);
|
||||
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;}
|
||||
.tl-divider::before,.tl-divider::after{content:'';flex:1;height:1px;background:var(--line);}
|
||||
.tl-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
|
||||
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
|
||||
.tl-msg{margin:0;font-size:.86rem;line-height:1.5;}
|
||||
.tl-msg.err{color:var(--err);}
|
||||
.tl-msg.ok{color:var(--ok);}
|
||||
.tl-foot{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
|
||||
@media(max-width:560px){
|
||||
.tl-backdrop{padding:0;align-items:flex-end;}
|
||||
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;
|
||||
padding-bottom:env(safe-area-inset-bottom);}
|
||||
.tl-ways{grid-template-columns:1fr;}
|
||||
.tl-foot{flex-direction:column-reverse;}
|
||||
.tl-foot>*{width:100%;}
|
||||
.tl-primary,.tl-secondary{padding:13px 16px;}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
/**
|
||||
* Managing the share links: see how often a link was called up, change it,
|
||||
* switch it off or delete it.
|
||||
*
|
||||
* The counter is deliberately plain: views, downloads and the number of
|
||||
* distinct visitors. Names or addresses are nowhere to be found: what is
|
||||
* stored is a hash of the IP, salted with the server secret **and** with the
|
||||
* identifier of the link. That makes it possible to recognise the same visitor
|
||||
* within one link, but not to follow them across several links.
|
||||
* Named honestly, that is pseudonymisation, not anonymisation — whoever has the
|
||||
* server key could compute their way through the IPv4 space. Which is also why
|
||||
* it is only kept for 90 days.
|
||||
*/
|
||||
interface Share {
|
||||
id: string; slug: string; kind: string; title: string | null; note: string | null;
|
||||
expires_at: string | null; revoked_at: string | null; allow_download: boolean;
|
||||
password_hash: string | null; views: number; downloads: number;
|
||||
last_seen_at: string | null; created_at: string; count: number; visitor: number;
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
{ v: 7, label: '7 days from now' }, { v: 30, label: '30 days from now' },
|
||||
{ v: 90, label: '3 months from now' }, { v: 365, label: '1 year from now' }, { v: 0, label: 'no expiry' },
|
||||
];
|
||||
|
||||
const date = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
|
||||
const time = (s: string | null) => s ? new Date(s).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||||
|
||||
type State = 'active' | 'expired' | 'off';
|
||||
const state = (s: Share): State =>
|
||||
s.revoked_at ? 'off'
|
||||
: s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? 'expired'
|
||||
: 'active';
|
||||
|
||||
export default function SharesApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [visits, setVisits] = useState<Record<string, any[]>>({});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// A network error must not look like "nothing shared" — otherwise one thinks
|
||||
// one's own links are gone.
|
||||
try {
|
||||
const r = await fetch('/api/shares');
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const d = await r.json();
|
||||
setShares(d.shares || []); setLoadError(false);
|
||||
} catch { setLoadError(true); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const say = (text: string) => { setMsg(text); setTimeout(() => setMsg(null), 4000); };
|
||||
|
||||
const patch = async (id: string, body: unknown) => {
|
||||
const r = await fetch(`/api/shares/${id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((x) => x.json()).catch(() => ({ error: t('Network error.') }));
|
||||
// `{ok:false}` means: the link belongs to someone else or was already in that
|
||||
// state. Without feedback the button seems broken.
|
||||
if (r.error) say(r.error);
|
||||
else if (r.ok === false) { say(t('Nothing to change — perhaps already done?')); await load(); }
|
||||
else await load();
|
||||
};
|
||||
|
||||
const remove = async (s: Share) => {
|
||||
if (!confirm(t('Delete the link "{name}" for good?\n\nIt cannot be opened afterwards and the access figures are gone. Just switching it off is usually enough.',
|
||||
{ name: s.title || s.slug }))) return;
|
||||
const r = await fetch(`/api/shares/${s.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
|
||||
if (r.ok) { say(t('Link deleted.')); await load(); } else say(r.error || t('That did not work.'));
|
||||
};
|
||||
|
||||
const copy = async (s: Share) => {
|
||||
const url = `${location.origin}/s/${s.slug}`;
|
||||
try { await navigator.clipboard.writeText(url); } catch { prompt(t('Copy link:'), url); }
|
||||
setCopied(s.id); setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const details = async (s: Share) => {
|
||||
if (open === s.id) { setOpen(null); return; }
|
||||
setOpen(s.id);
|
||||
// Load again on every unfolding — otherwise the list shows an old state the
|
||||
// second time round without anyone noticing.
|
||||
const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ visits: [] }));
|
||||
setVisits((z) => ({ ...z, [s.id]: r.visits || [] }));
|
||||
};
|
||||
|
||||
if (loading) return <p className="tw-empty">{t('One moment …')}</p>;
|
||||
|
||||
// One sentence, one key, with the link and the emphasis kept: the slots are
|
||||
// split out of the translated sentence so both land where the language puts
|
||||
// them instead of the sentence being glued from three fragments.
|
||||
const emptyParts = t('Tap {share} in the {library} — then an entry appears here that you can switch off again at any time.')
|
||||
.split(/(\{share\}|\{library\})/);
|
||||
const privacyParts = t('What is recorded is {when} it was opened and with what kind of device — not {who}. Of the IP address only an encrypted fingerprint is stored, which is moreover tied to this one link; across several links nobody can be followed with it. After 90 days everything is deleted.')
|
||||
.split(/(\{when\}|\{who\})/);
|
||||
|
||||
return (
|
||||
<div className="tw">
|
||||
{msg && <div className="tw-toast" role="status">{msg}</div>}
|
||||
|
||||
{loadError ? (
|
||||
<div className="tw-empty-box">
|
||||
<span className="regmark" />
|
||||
<h2>{t('The list could not be loaded')}</h2>
|
||||
<p>{t('Maybe the connection was briefly gone. Your links are not affected by it.')}</p>
|
||||
<p><button className="tw-btn strong" onClick={() => { setLoading(true); load(); }}>{t('Try again')}</button></p>
|
||||
</div>
|
||||
) : !shares.length ? (
|
||||
<div className="tw-empty-box">
|
||||
<span className="regmark" />
|
||||
<h2>{t('Nothing shared yet')}</h2>
|
||||
<p>{emptyParts.map((p, i) =>
|
||||
p === '{share}' ? <b key={i}>{t('Share')}</b>
|
||||
: p === '{library}' ? <a key={i} href="/library">{t('library')}</a>
|
||||
: p)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tw-list">
|
||||
{shares.map((s) => {
|
||||
const z = state(s);
|
||||
return (
|
||||
<article key={s.id} className={`tw-card ${z}`}>
|
||||
<div className="tw-head">
|
||||
<div className="tw-title">
|
||||
<h3>{s.title || (s.count > 1 ? t('{n} images', { n: s.count }) : t('One image'))}</h3>
|
||||
<span className={`tw-status ${z}`}>
|
||||
{z === 'active' ? t('active') : z === 'off' ? t('switched off') : t('expired')}
|
||||
</span>
|
||||
{s.password_hash && <span className="tw-protected" title={t('Protected with a passphrase')}>{t('protected')}</span>}
|
||||
</div>
|
||||
<div className="tw-counts">
|
||||
<span><b>{s.views}</b> {t('views')}</span>
|
||||
<span><b>{s.downloads}</b> {t('downloads')}</span>
|
||||
<span><b>{s.visitor}</b> {s.visitor === 1 ? t('visitor') : t('visitors')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tw-meta">
|
||||
{s.count === 1 ? t('{n} image', { n: s.count }) : t('{n} images', { n: s.count })} ·
|
||||
{' '}{s.expires_at ? t('valid until {date}', { date: date(s.expires_at) }) : t('without expiry')} ·
|
||||
{' '}{t('created {date}', { date: date(s.created_at) })}
|
||||
{s.last_seen_at && <> · {t('last seen {time}', { time: time(s.last_seen_at) })}</>}
|
||||
{!s.allow_download && <> · {t('view only')}</>}
|
||||
</div>
|
||||
|
||||
<div className="tw-row">
|
||||
<input className="tw-link" readOnly value={`${location.origin}/s/${s.slug}`}
|
||||
onFocus={(e) => e.currentTarget.select()} aria-label={t('Share link')} />
|
||||
<button className="tw-btn" onClick={() => copy(s)}>{copied === s.id ? t('Copied ✓') : t('Copy')}</button>
|
||||
<a className="tw-btn" href={`/s/${s.slug}`} target="_blank" rel="noopener">{t('Open')}</a>
|
||||
</div>
|
||||
|
||||
<div className="tw-actions">
|
||||
{z === 'off'
|
||||
? <button className="tw-btn strong" onClick={() => patch(s.id, { action: 'unrevoke' })}>{t('Switch back on')}</button>
|
||||
: <button className="tw-btn" onClick={() => patch(s.id, { action: 'revoke' })}>{t('Switch off')}</button>}
|
||||
<select className="tw-btn" value="" onChange={(e) => e.target.value !== '' && patch(s.id, { days: Number(e.target.value) })}
|
||||
aria-label={t('Change validity')}>
|
||||
<option value="" disabled>{t('Change validity …')}</option>
|
||||
{DAYS.map((d) => <option key={d.v} value={d.v}>{t(d.label)}</option>)}
|
||||
</select>
|
||||
<button className="tw-btn" onClick={() => patch(s.id, { allowDownload: !s.allow_download })}>
|
||||
{s.allow_download ? t('Block download') : t('Allow download')}
|
||||
</button>
|
||||
<button className="tw-btn" onClick={() => {
|
||||
const w = prompt(s.password_hash
|
||||
? t('New passphrase (leave empty = remove protection):')
|
||||
: t('Set passphrase:'));
|
||||
if (w !== null) patch(s.id, { password: w });
|
||||
}}>{s.password_hash ? t('Change passphrase') : t('Set passphrase')}</button>
|
||||
<button className="tw-btn" onClick={() => details(s)}>{open === s.id ? t('Hide visits') : t('Visits')}</button>
|
||||
<button className="tw-btn danger" onClick={() => remove(s)}>{t('Delete')}</button>
|
||||
</div>
|
||||
|
||||
{open === s.id && (
|
||||
<div className="tw-visits">
|
||||
{!visits[s.id]?.length ? <p className="tw-hint">{t('No visits yet.')}</p> : (
|
||||
<ul>
|
||||
{visits[s.id].slice(0, 30).map((v, i) => (
|
||||
<li key={i}>
|
||||
<span className="tw-kind">{v.kind === 'download' ? t('Download') : t('View')}</span>
|
||||
<span className="tw-when">{time(v.at)}</span>
|
||||
<span className="tw-ua">{(v.user_agent || '').slice(0, 60)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="tw-hint">{privacyParts.map((p, i) =>
|
||||
p === '{when}' ? <b key={i}>{t('when')}</b>
|
||||
: p === '{who}' ? <b key={i}>{t('who')}</b>
|
||||
: p)}</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.tw{display:grid;gap:14px;}
|
||||
.tw-empty{color:var(--soft);}
|
||||
.tw-empty-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:44px 24px;text-align:center;}
|
||||
.tw-empty-box h2{margin:14px 0 8px;font-size:1.15rem;}
|
||||
.tw-empty-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
|
||||
.tw-toast{position:sticky;top:70px;z-index:5;background:var(--ink);color:var(--card);
|
||||
padding:10px 14px;border-radius:var(--radius-sm);font-size:.9rem;}
|
||||
.tw-list{display:grid;gap:12px;}
|
||||
.tw-card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:16px;display:grid;gap:11px;}
|
||||
.tw-card.off,.tw-card.expired{background:var(--paper);}
|
||||
.tw-head{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between;}
|
||||
.tw-title{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0;}
|
||||
.tw-title h3{margin:0;font-size:1rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
|
||||
.tw-status{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||
padding:3px 8px;border-radius:20px;}
|
||||
.tw-status.active{background:oklch(60% .15 150 / .14);color:oklch(42% .13 150);}
|
||||
.tw-status.off{background:oklch(55% .19 25 / .12);color:var(--err);}
|
||||
.tw-status.expired{background:var(--paper);color:var(--soft);border:1px solid var(--line);}
|
||||
.tw-protected{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||
padding:3px 8px;border-radius:20px;background:oklch(72% .15 75 / .16);color:oklch(48% .13 75);}
|
||||
.tw-counts{display:flex;gap:14px;font-size:.78rem;color:var(--soft);white-space:nowrap;}
|
||||
.tw-counts b{color:var(--ink);font-size:.95rem;}
|
||||
.tw-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||
.tw-row{display:flex;gap:7px;}
|
||||
.tw-link{flex:1;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
background:var(--paper);font-family:var(--font-mono);font-size:12px;color:var(--ink);}
|
||||
.tw-actions{display:flex;flex-wrap:wrap;gap:6px;}
|
||||
.tw-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
padding:8px 11px;font-size:.82rem;cursor:pointer;font-family:inherit;color:var(--ink);
|
||||
text-decoration:none;display:inline-block;white-space:nowrap;}
|
||||
.tw-btn:hover{border-color:var(--accent);}
|
||||
.tw-btn.strong{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
|
||||
.tw-btn.danger{color:var(--err);}
|
||||
.tw-visits{border-top:1px solid var(--line);padding-top:11px;}
|
||||
.tw-visits ul{list-style:none;margin:0 0 10px;padding:0;display:grid;gap:5px;max-height:260px;overflow:auto;}
|
||||
.tw-visits li{display:grid;grid-template-columns:82px 118px minmax(0,1fr);gap:8px;
|
||||
font-size:.76rem;align-items:baseline;}
|
||||
.tw-kind{font-weight:600;}
|
||||
.tw-when{font-family:var(--font-mono);font-size:10.5px;color:var(--soft);}
|
||||
.tw-ua{color:var(--soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tw-hint{font-size:.76rem;color:var(--soft);line-height:1.5;margin:0;}
|
||||
@media(max-width:640px){
|
||||
.tw-head{flex-direction:column;gap:8px;}
|
||||
.tw-counts{gap:12px;}
|
||||
.tw-row{flex-wrap:wrap;}
|
||||
.tw-link{flex-basis:100%;}
|
||||
.tw-btn{flex:1;text-align:center;padding:11px 10px;}
|
||||
.tw-visits li{grid-template-columns:74px 1fr;}
|
||||
.tw-ua{display:none;}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useT, type Dict, type Locale } from '../lib/useT';
|
||||
|
||||
/* Formats (01 §5) — cm in portrait convention; screen = fixed pixels */
|
||||
const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [number, number] }[] = [
|
||||
{ id: 'keep', label: 'Keep original' },
|
||||
{ id: '9x13', label: '9 × 13 cm', cm: [9, 13] },
|
||||
{ id: '10x15', label: '10 × 15 cm', cm: [10, 15] },
|
||||
{ id: '13x18', label: '13 × 18 cm', cm: [13, 18] },
|
||||
{ id: '15x20', label: '15 × 20 cm', cm: [15, 20] },
|
||||
{ id: '20x30', label: '20 × 30 cm', cm: [20, 30] },
|
||||
{ id: '30x40', label: '30 × 40 cm', cm: [30, 40] },
|
||||
{ id: '30x45', label: '30 × 45 cm', cm: [30, 45] },
|
||||
{ id: '40x50', label: '40 × 50 cm', cm: [40, 50] },
|
||||
{ id: '40x60', label: '40 × 60 cm', cm: [40, 60] },
|
||||
{ id: '50x70', label: '50 × 70 cm', cm: [50, 70] },
|
||||
{ id: '60x90', label: '60 × 90 cm', cm: [60, 90] },
|
||||
{ id: 'A4', label: 'DIN A4', cm: [21, 29.7] },
|
||||
{ id: 'A3', label: 'DIN A3', cm: [29.7, 42] },
|
||||
{ id: 'A2', label: 'DIN A2', cm: [42, 59.4] },
|
||||
{ id: '20x20', label: '20 × 20 cm', cm: [20, 20] },
|
||||
{ id: '30x30', label: '30 × 30 cm', cm: [30, 30] },
|
||||
{ id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] },
|
||||
{ id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] },
|
||||
{ id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] },
|
||||
{ id: 'custom', label: 'Custom size (cm)' },
|
||||
];
|
||||
|
||||
/** Free-text size → format key + cm. "25x35" → 25×35 cm; "5" → 5×5 cm sticker. */
|
||||
function parseCustomFmt(s: string): { key: string; cm: [number, number] | null } {
|
||||
const v = (s || '').trim().replace(',', '.');
|
||||
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(v);
|
||||
if (wh) return { key: `${wh[1]}x${wh[2]}`, cm: [parseFloat(wh[1]), parseFloat(wh[2])] };
|
||||
const n = /^(\d+(?:\.\d+)?)$/.exec(v);
|
||||
if (n) return { key: `sticker${n[1]}`, cm: [parseFloat(n[1]), parseFloat(n[1])] };
|
||||
return { key: '', cm: null };
|
||||
}
|
||||
|
||||
const TASKS = [
|
||||
{ id: 'clean', name: 'Clean up', hint: 'Remove frame, wall and shop interface.' },
|
||||
{ id: 'cutout', name: 'Cut out', hint: 'Subject only, background transparent.' },
|
||||
{ id: 'format', name: 'Format', hint: 'Bring it to a given size.' },
|
||||
{ id: 'contour', name: 'Contour', hint: 'Add a white sticker border (only together with Cut out).' },
|
||||
];
|
||||
|
||||
type Mode = 'each' | 'compose' | 'generate';
|
||||
const MODES: { id: Mode; name: string; hint: string }[] = [
|
||||
{ id: 'each', name: 'Clean up', hint: 'Clean up screenshots, cut them out, bring them to size.' },
|
||||
{ id: 'compose', name: 'Convert', hint: 'Convert one image (e.g. photo → oil painting) or combine several — with text.' },
|
||||
{ id: 'generate', name: 'Generate', hint: 'A completely new image from text alone.' },
|
||||
];
|
||||
|
||||
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
|
||||
|
||||
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
|
||||
|
||||
export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; locale?: Locale; dict?: Dict | null }) {
|
||||
const t = useT(dict);
|
||||
const [presets, setPresets] = useState<any[]>(recipes || []);
|
||||
const [mode, setMode] = useState<Mode>('each');
|
||||
const [pics, setPics] = useState<Pic[]>([]);
|
||||
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
|
||||
const [format, setFormat] = useState('30x40');
|
||||
const [portrait, setPortrait] = useState(true);
|
||||
const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
|
||||
const [contourMm, setContourMm] = useState(3);
|
||||
const [customFmt, setCustomFmt] = useState(''); // free-text size for "Custom size"/"Sticker"
|
||||
const [outputExt, setOutputExt] = useState<'' | 'png' | 'jpg'>(''); // '' = the global default setting
|
||||
const [custom, setCustom] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [delivery, setDelivery] = useState<'library' | 'remote' | 'both'>('library');
|
||||
const [gallery, setGallery] = useState('');
|
||||
const [targetId, setTargetId] = useState('');
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [presetSel, setPresetSel] = useState('');
|
||||
const [me, setMe] = useState<any>({});
|
||||
const [priv, setPriv] = useState(false);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [modelKey, setModelKey] = useState<string>('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fmt = FORMATS.find((f) => f.id === format);
|
||||
const hasCutout = tasks.includes('cutout');
|
||||
const hasFormat = tasks.includes('format');
|
||||
const theframeConflict = hasCutout && (format === 'theframe' || format === 'portrait916');
|
||||
// In compose/generate the size is always available (without the "Format" task).
|
||||
const showFormat = mode === 'each' ? hasFormat : true;
|
||||
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
|
||||
|
||||
const isFreeFmt = format === 'custom' || format === 'sticker';
|
||||
const parsedFmt = isFreeFmt ? parseCustomFmt(customFmt) : { key: '', cm: null };
|
||||
// "Sticker" without an entry still means 5×5 cm; "Custom size" needs an entry.
|
||||
const effCm: [number, number] | null = fmt?.screen ? null
|
||||
: parsedFmt.cm || (format === 'sticker' ? [5, 5] : (fmt?.cm || null));
|
||||
// What is actually stored as output_format (e.g. "25x35", "sticker5").
|
||||
const effFormat = isFreeFmt ? (parsedFmt.key || (format === 'sticker' ? 'sticker' : '')) : format;
|
||||
|
||||
const targetPx: [number, number] | null = fmt?.screen
|
||||
? (fmt.screen as [number, number])
|
||||
: effCm
|
||||
? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])])
|
||||
: null;
|
||||
|
||||
const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2600); };
|
||||
|
||||
const upload = useCallback(async (files: FileList | File[]) => {
|
||||
const arr = Array.from(files).filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)).slice(0, 100);
|
||||
for (const f of arr) {
|
||||
const id = crypto.randomUUID();
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setPics((p) => p.map((x) => x.id === id ? { ...x, src: reader.result as string } : x));
|
||||
reader.readAsDataURL(f);
|
||||
setPics((p) => [...p, { id, src: '', name: f.name, uploading: true }]);
|
||||
const fd = new FormData(); fd.append('files', f);
|
||||
try {
|
||||
const res = await fetch('/api/uploads', { method: 'POST', body: fd });
|
||||
const j = await res.json();
|
||||
const info = j.files?.[0];
|
||||
setPics((p) => p.map((x) => x.id === id
|
||||
? { ...x, uploading: false, source_path: info?.source_path, error: info?.error } : x));
|
||||
} catch {
|
||||
setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: t('Upload failed') } : x));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
if (mode === 'generate') return;
|
||||
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||||
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||||
};
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [upload, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/models').then((r) => r.json()).then((j) => {
|
||||
const ms = j.models || []; setModels(ms);
|
||||
const def = ms.find((m: any) => m.is_default) || ms[0];
|
||||
if (def) setModelKey(def.model_id);
|
||||
}).catch(() => {});
|
||||
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||
fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {});
|
||||
loadPresets();
|
||||
}, []);
|
||||
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
|
||||
const saveAsPreset = async () => {
|
||||
const name = prompt(t('Name of the preset?')); if (!name?.trim()) return;
|
||||
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
|
||||
const body = {
|
||||
name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep',
|
||||
orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
output_ext: outputExt || null,
|
||||
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
|
||||
delivery, delivery_folder: gallery.trim() || null, delivery_target_id: targetId || null,
|
||||
};
|
||||
await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
notify(t('Preset saved.')); loadPresets();
|
||||
};
|
||||
const deletePreset = async (id: string) => {
|
||||
if (!id || !confirm(t('Delete preset?'))) return;
|
||||
await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify(t('Preset deleted.')); loadPresets();
|
||||
};
|
||||
|
||||
// Take a result over from the library: ?reuse=<itemId> loads it as a source.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const reuseId = params.get('reuse');
|
||||
if (!reuseId) return;
|
||||
(async () => {
|
||||
const id = crypto.randomUUID();
|
||||
setPics((p) => [...p, { id, src: `/api/items/${reuseId}/file?thumb=1`, name: 'continue-editing.png', uploading: true }]);
|
||||
try {
|
||||
const j = await fetch(`/api/items/${reuseId}/reuse`, { method: 'POST' }).then((r) => r.json());
|
||||
setPics((p) => p.map((x) => x.id === id
|
||||
? { ...x, uploading: false, source_path: j.source_path, name: j.filename || x.name, error: j.error } : x));
|
||||
} catch {
|
||||
setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: t('Could not load image') } : x));
|
||||
}
|
||||
history.replaceState(null, '', '/');
|
||||
})();
|
||||
}, []);
|
||||
|
||||
function toggleTask(id: string) {
|
||||
setTasks((prev) => {
|
||||
let next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
|
||||
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function applyRecipe(r: any) {
|
||||
if (!r) return;
|
||||
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
|
||||
setTasks(r.tasks || ['clean', 'format']);
|
||||
const of = r.output_format || 'keep';
|
||||
// Get the custom size back out of the recipe (no known key = custom size).
|
||||
if (of !== 'keep' && !FORMATS.some((f) => f.id === of)) {
|
||||
setFormat('custom');
|
||||
setCustomFmt(/^sticker[-_ ]?/i.test(of) ? of.replace(/^sticker[-_ ]?/i, '') : of);
|
||||
} else setFormat(of);
|
||||
setOutputExt(r.output_ext === 'jpg' || r.output_ext === 'png' ? r.output_ext : '');
|
||||
setPortrait((r.orientation || 'portrait') !== 'landscape');
|
||||
setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop');
|
||||
setContourMm(Number(r.contour_mm) || 3);
|
||||
setCustom(r.custom_instruction || '');
|
||||
setDelivery(r.delivery || 'library');
|
||||
setGallery(r.delivery_folder || '');
|
||||
setTargetId(r.delivery_target_id || '');
|
||||
if (r.model_key) setModelKey(r.model_key);
|
||||
}
|
||||
|
||||
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||
// "Custom size" needs a valid entry, otherwise the run cannot start.
|
||||
const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
|
||||
const canRun = !busy && !freeFmtInvalid && (
|
||||
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
|
||||
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
|
||||
: desc.trim().length > 0
|
||||
);
|
||||
|
||||
async function run() {
|
||||
if (!canRun) return;
|
||||
setBusy(true);
|
||||
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||
const tasksOut = mode === 'each'
|
||||
? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||
: (wantsFormat ? ['format'] : []);
|
||||
const recipe = {
|
||||
tasks: tasksOut,
|
||||
output_format: wantsFormat ? effFormat : 'keep',
|
||||
orientation: portrait ? 'portrait' : 'landscape',
|
||||
crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
output_ext: outputExt || null,
|
||||
custom_instruction: mode === 'each' ? (custom || null) : null,
|
||||
delivery, model_key: modelKey || null,
|
||||
delivery_folder: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
|
||||
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
|
||||
};
|
||||
const body: any = { recipe, mode, delivery, private: priv };
|
||||
if (mode !== 'each') body.prompt_text = desc.trim();
|
||||
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
|
||||
try {
|
||||
const res = await fetch('/api/jobs', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.jobId) location.href = `/queue?job=${j.jobId}`;
|
||||
else { notify(j.error || t('Could not create the job.')); setBusy(false); }
|
||||
} catch { notify(t('Network error.')); setBusy(false); }
|
||||
}
|
||||
|
||||
const uploadHint = mode === 'compose'
|
||||
? t('Convert one image or combine several (e.g. subject + a photo of the dog).')
|
||||
: t('Several at once, up to 100. PNG, JPG, WEBP, HEIC.');
|
||||
|
||||
// The key combination keeps its own styling, so the sentence carries a {keys}
|
||||
// placeholder and is split on it instead of being glued together from fragments.
|
||||
const dropText = t('Drag images here, paste with {keys} or tap to choose.').split('{keys}');
|
||||
|
||||
return (
|
||||
<div className="studio">
|
||||
{/* Mode switch */}
|
||||
<div className="mode-bar">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.id} className={`mode-tab ${mode === m.id ? 'on' : ''}`} onClick={() => setMode(m.id)}>
|
||||
<b>{t(m.name)}</b><span>{t(m.hint)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid">
|
||||
{mode !== 'generate' && (
|
||||
<section className="card">
|
||||
<div className="cardHead">
|
||||
<span className="mono-label">{mode === 'compose' ? t('Building blocks') : t('Sources')}{pics.length ? ` · ${pics.length}` : ''}</span>
|
||||
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>{t('remove all')}</button>}
|
||||
</div>
|
||||
<div className="stage">
|
||||
{pics.length === 0 ? (
|
||||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
|
||||
<span className="regmark" />
|
||||
<p>{dropText[0]}<span className="kbd">⌘/Ctrl + V</span>{dropText[1]}</p>
|
||||
<p className="hint">{uploadHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mini-grid">
|
||||
{pics.map((b) => (
|
||||
<div key={b.id} className={`mini-image ${b.error ? 'err' : ''}`}>
|
||||
{b.src && <img src={b.src} alt="" />}
|
||||
{b.uploading && <span className="loading" />}
|
||||
{b.error && <span className="badge">!</span>}
|
||||
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||
onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card">
|
||||
<div className="controls">
|
||||
<div className="field">
|
||||
<label>{t('Presets')}</label>
|
||||
<div className="preset-row">
|
||||
<select className="select" value={presetSel}
|
||||
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
|
||||
<option value="">{t('Choose a preset …')}</option>
|
||||
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
{presetSel && <button className="pbtn" title={t('Delete preset')} onClick={() => { deletePreset(presetSel); setPresetSel(''); }}>✕</button>}
|
||||
<button className="pbtn" title={t('Save the current settings as a preset')} onClick={saveAsPreset}>+ {t('save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mode === 'compose' || mode === 'generate') && (
|
||||
<div className="field">
|
||||
<label>{mode === 'compose' ? t('What should come out of it?') : t('Description of the new image')}</label>
|
||||
<textarea className="area large" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder={mode === 'compose'
|
||||
? t('e.g. “Make the photo look like an oil painting.” or “…with our dog at the edge of the pool.”')
|
||||
: t('e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”')} />
|
||||
<div className="hint">{mode === 'compose'
|
||||
? t('One image = convert (e.g. change the style). Several images = the first is the lead scene, the others supply people and subjects.')
|
||||
: t('The more precise the description, the better the result.')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'each' && (
|
||||
<div className="field">
|
||||
<label>{t('What should happen?')}</label>
|
||||
<div className="modes">
|
||||
{TASKS.map((m) => {
|
||||
const on = tasks.includes(m.id);
|
||||
const disabled = m.id === 'contour' && !hasCutout;
|
||||
return (
|
||||
<button key={m.id} disabled={disabled}
|
||||
className={`mode ${on ? 'on' : ''} ${disabled ? 'off' : ''}`}
|
||||
onClick={() => toggleTask(m.id)}>
|
||||
<b>{t(m.name)}{on ? ' ✓' : ''}</b><span>{t(m.hint)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFormat && (
|
||||
<div className="field">
|
||||
<label>{mode === 'each' ? t('Output format') : t('Format (optional)')}</label>
|
||||
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => <option key={f.id} value={f.id}>{t(f.label)}</option>)}
|
||||
</select>
|
||||
{isFreeFmt && (
|
||||
<>
|
||||
<input className="input" value={customFmt} onChange={(e) => setCustomFmt(e.target.value)}
|
||||
placeholder={format === 'sticker' ? t('e.g. 5 (=5×5 cm) or 5x7') : t('e.g. 25x35, or 5 for 5×5 cm')} />
|
||||
{freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>}
|
||||
</>
|
||||
)}
|
||||
{theframeConflict && <div className="hint warn">{t('Cut out + The Frame makes no sense — pick one.')}</div>}
|
||||
{effCm && (
|
||||
<div className="switch">
|
||||
<button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button>
|
||||
<button className={!portrait ? 'on' : ''} onClick={() => setPortrait(false)}>{t('Landscape')}</button>
|
||||
</div>
|
||||
)}
|
||||
{targetPx && <div className="hint">{t('Gives exactly {w} × {h} pixels at 300 dpi — ready to print.', { w: targetPx[0], h: targetPx[1] })}</div>}
|
||||
{targetPx && mode !== 'generate' && (
|
||||
<div className="switch subtle">
|
||||
<button className={crop === 'crop' ? 'on' : ''} onClick={() => setCrop('crop')}>{t('Crop')}</button>
|
||||
<button className={crop === 'extend' ? 'on' : ''} onClick={() => setCrop('extend')}>{t('Extend')}</button>
|
||||
</div>
|
||||
)}
|
||||
{targetPx && mode !== 'generate' && crop === 'extend' && (
|
||||
<div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>{t('File format')}</label>
|
||||
<div className="switch">
|
||||
<button className={outputExt === '' ? 'on' : ''} onClick={() => setOutputExt('')}>{t('Default')}</button>
|
||||
<button className={outputExt === 'png' ? 'on' : ''} onClick={() => setOutputExt('png')}>PNG</button>
|
||||
<button className={outputExt === 'jpg' ? 'on' : ''} onClick={() => setOutputExt('jpg')}>JPG</button>
|
||||
</div>
|
||||
<div className="hint">
|
||||
{outputExt === 'jpg' ? t('JPG — small and easy on the delivery target. Cut-out subjects stay PNG all the same (transparency).')
|
||||
: outputExt === 'png' ? t('PNG — lossless, supports transparency. Large files.')
|
||||
: t('The default from the admin settings (set globally).')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'each' && tasks.includes('contour') && (
|
||||
<div className="field">
|
||||
<label>{t('Sticker border (mm)')}</label>
|
||||
<input className="input" type="number" min={0} max={20} step={0.5}
|
||||
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="field">
|
||||
<label>{t('Quality')}</label>
|
||||
<div className="switch wrap">
|
||||
{models.map((m) => (
|
||||
<button key={m.model_id} className={modelKey === m.model_id ? 'on' : ''}
|
||||
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'each' && (
|
||||
<div className="field">
|
||||
<label>{t('Own instruction (optional)')}</label>
|
||||
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||
placeholder={t('e.g. “Make the background lighter, leave the rest alone.”')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>{t('Where to?')}</label>
|
||||
<div className="switch">
|
||||
<button className={delivery === 'library' ? 'on' : ''} onClick={() => setDelivery('library')}>{t('Library')}</button>
|
||||
<button className={delivery === 'remote' ? 'on' : ''} onClick={() => setDelivery('remote')}>{t('Delivery target')}</button>
|
||||
<button className={delivery === 'both' ? 'on' : ''} onClick={() => setDelivery('both')}>{t('Both')}</button>
|
||||
</div>
|
||||
{delivery !== 'library' && (
|
||||
<div className="target">
|
||||
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
<option value="">{t('Default delivery target')}</option>
|
||||
<option value="mirror">{t('Backup mirror')}</option>
|
||||
{targets.map((tg) => <option key={tg.id} value={tg.id}>{tg.name}</option>)}
|
||||
</select>
|
||||
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
|
||||
placeholder={t('Folder on the target (empty = default)')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(me.private_allowed || me.private_forced) && (
|
||||
<label className="private">
|
||||
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
|
||||
<span><b>{t('Private session')} {me.private_forced ? t('(enforced)') : ''}</b>
|
||||
<em>{t('Generate only, remember nothing: no entry in the library, no delivery or backup, no stored prompt. The result can be fetched for a short while, then it is deleted.')}</em></span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button className="button" disabled={!canRun} onClick={run}>
|
||||
{busy ? <><span className="spin" />{t('Being created …')}</>
|
||||
: mode === 'each' ? (ready.length === 0 ? t('Start')
|
||||
: ready.length === 1 ? t('Start · {n} image', { n: ready.length })
|
||||
: t('Start · {n} images', { n: ready.length }))
|
||||
: t('Generate image')}
|
||||
</button>
|
||||
<div className="hint center">{t('It keeps running on the server — you can close the window.')}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<StudioStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioStyles() {
|
||||
return <style>{`
|
||||
.studio{display:flex;flex-direction:column;gap:16px;}
|
||||
.mode-bar{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
|
||||
@media(max-width:560px){.mode-bar{grid-template-columns:1fr;}}
|
||||
.mode-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
|
||||
.mode-tab b{display:block;font-size:14px;font-weight:600;}
|
||||
.mode-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
|
||||
.mode-tab.on{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
||||
@media(max-width:820px){.grid{grid-template-columns:1fr;}}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
|
||||
.stage{padding:22px;min-height:290px;display:flex;align-items:center;justify-content:center;}
|
||||
.drop{width:100%;min-height:250px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
|
||||
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
|
||||
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
|
||||
.mini-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:8px;width:100%;}
|
||||
.mini-image{position:relative;aspect-ratio:1;border:1px solid var(--line);border-radius:3px;overflow:hidden;
|
||||
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
|
||||
.mini-image.err{border-color:var(--err);}
|
||||
.mini-image img{width:100%;height:100%;object-fit:contain;display:block;}
|
||||
.mini-image>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
|
||||
.mini-image .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
|
||||
.loading{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||
.mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
||||
.controls{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||
.field label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||||
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||||
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
|
||||
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
|
||||
.area.large{font-size:15px;line-height:1.55;}
|
||||
.hint{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||
.hint.center{text-align:center;}
|
||||
.hint.warn{color:var(--err);}
|
||||
.modes{display:flex;flex-direction:column;gap:6px;}
|
||||
.mode{text-align:left;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;cursor:pointer;font-family:inherit;}
|
||||
.mode b{display:block;font-size:14px;font-weight:600;}
|
||||
.mode span{display:block;font-size:11.5px;color:var(--soft);margin-top:2px;}
|
||||
.mode.on{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.mode.off{opacity:.5;cursor:not-allowed;}
|
||||
.switch{display:flex;gap:6px;margin-top:8px;}
|
||||
.switch button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||||
.switch button.on{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.switch.subtle button{font-size:12.5px;}
|
||||
.target{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
|
||||
.preset-row{display:flex;gap:6px;align-items:center;}
|
||||
.preset-row .select{flex:1;}
|
||||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||
.private{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
|
||||
.private input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
||||
.private b{display:block;font-size:13.5px;}
|
||||
.private em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
|
||||
.switch.wrap{flex-wrap:wrap;}
|
||||
.switch.wrap button{flex:1 1 auto;min-width:110px;}
|
||||
.button{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
.button:disabled{background:#C9C8BF;cursor:not-allowed;}
|
||||
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
|
||||
@keyframes sp{to{transform:rotate(360deg);}}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
`}</style>;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user: import('./lib/auth').SessionUser | null;
|
||||
locale: import('./lib/i18n').Locale;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
import '@fontsource/space-grotesk/400.css';
|
||||
import '@fontsource/space-grotesk/500.css';
|
||||
import '@fontsource/space-grotesk/700.css';
|
||||
import '@fontsource/space-mono/400.css';
|
||||
import '@fontsource/space-mono/700.css';
|
||||
import '../styles/tokens.css';
|
||||
import { LOCALE_NAMES, type Locale } from '../lib/i18n';
|
||||
import { tFor, dictFor } from '../lib/i18n.server';
|
||||
import { has } from '../lib/modules';
|
||||
interface Props { title?: string }
|
||||
const { title = 'Klarbild' } = Astro.props;
|
||||
const user = Astro.locals.user;
|
||||
const locale = Astro.locals.locale ?? 'en';
|
||||
const t = tFor(locale);
|
||||
const dict = dictFor(locale);
|
||||
const path = Astro.url.pathname;
|
||||
const active = (href: string) => href === '/' ? path === '/' : path.startsWith(href);
|
||||
|
||||
// `short` is the label in the bottom bar on a phone: eight entries on a 390 px
|
||||
// screen are about 49 px wide each, which is not enough room for a long word.
|
||||
// Modules that are switched off leave the navigation entirely — a print-only
|
||||
// instance should not advertise a library it does not have.
|
||||
const nav = [
|
||||
...(has('ai') ? [{ href: '/', label: t('Studio'), short: t('Studio'), icon: 'studio' }] : []),
|
||||
...(has('ai') ? [{ href: '/library', label: t('Library'), short: t('Images'), icon: 'library' }] : []),
|
||||
{ href: '/print', label: t('Print'), short: t('Print'), icon: 'print' },
|
||||
...(has('ai') ? [{ href: '/queue', label: t('Queue'), short: t('Running'), icon: 'queue' }] : []),
|
||||
...(has('share') ? [{ href: '/shares', label: t('Shared links'), short: t('Shared'), icon: 'share' }] : []),
|
||||
{ href: '/help', label: t('Help'), short: t('Help'), icon: 'help' },
|
||||
{ href: '/account', label: t('My account'), short: t('Account'), icon: 'account' },
|
||||
...(user?.role === 'admin' ? [{ href: '/admin', label: t('Admin'), short: t('Admin'), icon: 'admin' }] : []),
|
||||
];
|
||||
|
||||
// Language switch: same page, other language. The middleware turns `?lang=` into
|
||||
// a cookie, so one click is enough and the choice sticks.
|
||||
const otherLocale: Locale = locale === 'de' ? 'en' : 'de';
|
||||
const switchHref = (() => {
|
||||
const u = new URL(Astro.url);
|
||||
u.searchParams.set('lang', otherLocale);
|
||||
return u.pathname + u.search;
|
||||
})();
|
||||
|
||||
const S = 'fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"';
|
||||
const ICONS: Record<string, string> = {
|
||||
studio: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="17" height="17" rx="4"/><path d="M12 8.5v7M8.5 12h7"/></svg>`,
|
||||
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
|
||||
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
|
||||
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
|
||||
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
|
||||
share: `<svg viewBox="0 0 24 24" ${S}><circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="M8.4 10.8 15.6 6.9M8.4 13.2l7.2 3.9"/></svg>`,
|
||||
account: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="8.5" r="3.6"/><path d="M4.8 20c.7-3.6 3.7-5.6 7.2-5.6s6.5 2 7.2 5.6"/></svg>`,
|
||||
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
|
||||
};
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#EAEAE3" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
{user && (
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand"><span class="regmark"></span>klarbild</a>
|
||||
<nav class="topnav">
|
||||
{nav.map((n) => (
|
||||
<a href={n.href} class:list={['tnav', { on: active(n.href) }]}>{n.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="logout">
|
||||
<a class="langswitch" href={switchHref} hreflang={otherLocale} title={LOCALE_NAMES[otherLocale]}>{otherLocale.toUpperCase()}</a>
|
||||
<span class="uname">{user.name}</span>
|
||||
<form method="post" action="/api/auth/logout">
|
||||
<button type="submit" aria-label={t('Sign out')}>{t('Sign out')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Signed out there is neither a top bar nor a tab bar, so no frame needs to
|
||||
reserve room for them. The public pages (/s/…, /login, /tour, /password)
|
||||
bring their own layout; a second <main> around them would also be
|
||||
ambiguous for screen readers. */}
|
||||
{user ? <main class="wrap"><slot /></main> : <slot />}
|
||||
|
||||
{user && (
|
||||
<nav class="tabbar" aria-label={t("Main navigation")}>
|
||||
{nav.map((n) => (
|
||||
<a href={n.href} class:list={['tab', { on: active(n.href) }]}>
|
||||
<span class="ticon" set:html={ICONS[n.icon]} />
|
||||
<span class="tlabel">{n.short}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<style is:global>
|
||||
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 20px 16px calc(88px + env(safe-area-inset-bottom)); }
|
||||
@media (min-width: 768px) { .wrap { padding: 26px 22px 80px; } }
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 10px 16px; border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--card) 88%, transparent);
|
||||
backdrop-filter: saturate(1.2) blur(8px);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
padding-top: calc(10px + env(safe-area-inset-top));
|
||||
}
|
||||
.brand { display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 1.2rem;
|
||||
color: var(--ink); text-decoration: none; letter-spacing: -0.02em; }
|
||||
.brand .regmark { transform: translateY(1px); }
|
||||
.topnav { display: none; }
|
||||
@media (min-width: 768px) {
|
||||
/* May shrink and, if it must, scroll: with seven entries the bar no
|
||||
longer fits at 820 px and would push "Sign out" off screen. */
|
||||
.topnav { display: flex; gap: 2px; margin-left: 8px; min-width: 0;
|
||||
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.topnav::-webkit-scrollbar { display: none; }
|
||||
.tnav { padding: 7px 10px; border-radius: 999px; color: var(--ink-soft); white-space: nowrap;
|
||||
text-decoration: none; font-size: 0.88rem; transition: background .15s, color .15s; }
|
||||
.tnav:hover { color: var(--ink); background: var(--surface-2, var(--paper)); }
|
||||
.tnav.on { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
|
||||
}
|
||||
@media (min-width: 1040px) {
|
||||
.topnav { gap: 4px; }
|
||||
.tnav { padding: 7px 12px; font-size: 0.92rem; }
|
||||
}
|
||||
/* Never shrinks — the sign-out button has to stay reachable. */
|
||||
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
|
||||
.langswitch { font-family: var(--font-mono, monospace); font-size: 0.72rem; letter-spacing: .06em;
|
||||
color: var(--ink-mute, var(--soft)); text-decoration: none; border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 5px 9px; line-height: 1; }
|
||||
.langswitch:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.uname { color: var(--ink-mute, var(--soft)); font-size: 0.9rem; display: none; text-decoration: none; }
|
||||
.uname:hover { color: var(--accent); }
|
||||
@media (min-width: 480px) { .uname { display: inline; } }
|
||||
.logout button { background: none; border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 7px 14px; color: var(--ink-soft); cursor: pointer; font-family: inherit; font-size: 0.85rem;
|
||||
transition: transform .06s ease, background .15s; min-height: 36px; }
|
||||
.logout button:active { transform: scale(0.96); }
|
||||
|
||||
/* Bottom tab bar (mobile-first) */
|
||||
.tabbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||
background: color-mix(in oklab, var(--card) 92%, transparent);
|
||||
backdrop-filter: saturate(1.3) blur(12px);
|
||||
border-top: 1px solid var(--line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.tab { display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
padding: 9px 4px 8px; text-decoration: none; color: var(--ink-mute, var(--soft));
|
||||
font-size: 10.5px; letter-spacing: .01em; transition: color .15s, transform .06s; }
|
||||
.tab:active { transform: scale(0.94); }
|
||||
.tab.on { color: var(--accent); }
|
||||
.tab .ticon { width: 22px; height: 22px; display: grid; place-items: center; }
|
||||
.tab .ticon svg { width: 22px; height: 22px; }
|
||||
.tlabel { line-height: 1; white-space: nowrap; }
|
||||
/* Eight entries get tight — label and icon shrink a little, but the tap
|
||||
target stays above 44 px tall. */
|
||||
@media (max-width: 420px) {
|
||||
.tab { padding: 8px 2px 7px; font-size: 9.5px; }
|
||||
.tab .ticon, .tab .ticon svg { width: 20px; height: 20px; }
|
||||
}
|
||||
@media (min-width: 768px) { .tabbar { display: none; } }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import argon2 from 'argon2';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { one, query } from './db';
|
||||
|
||||
export interface SessionUser { uid: string; role: 'user' | 'admin'; name: string; username?: string; }
|
||||
/** What is actually in the cookie — including the fields that make it revocable. */
|
||||
interface SessionPayload extends SessionUser { iat: number; ep: number }
|
||||
|
||||
const COOKIE = 'kb_session';
|
||||
const MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
export const hashPassword = (pw: string) => argon2.hash(pw, { type: argon2.argon2id });
|
||||
export const verifyPassword = (hash: string, pw: string) => argon2.verify(hash, pw).catch(() => false);
|
||||
|
||||
function secret(): string {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s) throw new Error('SESSION_SECRET is missing');
|
||||
return s;
|
||||
}
|
||||
|
||||
function sign(data: string): string {
|
||||
return createHmac('sha256', secret()).update(data).digest('base64url');
|
||||
}
|
||||
|
||||
export function makeSessionCookie(u: SessionUser, epoch = 0): string {
|
||||
const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000), ep: epoch }))
|
||||
.toString('base64url');
|
||||
const value = `${payload}.${sign(payload)}`;
|
||||
return `${COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`;
|
||||
}
|
||||
|
||||
export const clearSessionCookie = () =>
|
||||
`${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
|
||||
|
||||
/**
|
||||
* Check the cookie — signature **and** age.
|
||||
*
|
||||
* The age check is not cosmetics: `Max-Age` is a request to the browser, not a
|
||||
* rule for the server. Without it a cookie that has leaked once stays valid
|
||||
* without limit, years later and via curl as well.
|
||||
*
|
||||
* Whether the cookie is still valid **in substance** (password changed, role
|
||||
* withdrawn, account deleted) cannot be decided here — that needs the database.
|
||||
* `verifySession` in the middleware takes care of it.
|
||||
*/
|
||||
export function readSession(cookieHeader?: string | null): (SessionUser & { ep: number }) | null {
|
||||
if (!cookieHeader) return null;
|
||||
const raw = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
|
||||
if (!raw) return null;
|
||||
const [payload, sig] = raw.split('.');
|
||||
if (!payload || !sig) return null;
|
||||
const expected = sign(payload);
|
||||
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SessionPayload;
|
||||
if (!obj.uid || typeof obj.iat !== 'number') return null;
|
||||
if (Math.floor(Date.now() / 1000) - obj.iat > MAX_AGE) return null; // expired as far as the server is concerned
|
||||
return { uid: obj.uid, role: obj.role, name: obj.name, ep: Number(obj.ep) || 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-check in the database: does the account still exist, is the session
|
||||
* epoch still valid, and which role does it have **now**?
|
||||
*
|
||||
* The role deliberately comes from the database and not from the cookie —
|
||||
* otherwise a downgraded admin would keep their rights until the cookie expires.
|
||||
* It is a point lookup over the primary key, which `tokenUser` does anyway.
|
||||
*/
|
||||
export async function verifySession(s: (SessionUser & { ep: number }) | null): Promise<SessionUser | null> {
|
||||
if (!s?.uid) return null;
|
||||
const u = await one<{ role: 'user' | 'admin'; display_name: string | null; username: string; session_epoch: number }>(
|
||||
'SELECT role, display_name, username, session_epoch FROM users WHERE id = $1', [s.uid]);
|
||||
if (!u) return null; // account deleted
|
||||
if ((u.session_epoch ?? 0) !== s.ep) return null; // password changed or role changed
|
||||
return { uid: s.uid, role: u.role, name: u.display_name || u.username };
|
||||
}
|
||||
|
||||
/** Invalidate all sessions of an account — after a password change or a role change. */
|
||||
export async function bumpSessionEpoch(userId: string): Promise<void> {
|
||||
await query('UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1', [userId]);
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string, password: string,
|
||||
): Promise<{ user: SessionUser; epoch: number } | null> {
|
||||
const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string; username: string; session_epoch: number }>(
|
||||
'SELECT id, password_hash, role, display_name, username, session_epoch FROM users WHERE username=$1', [username]);
|
||||
if (!u) return null;
|
||||
if (!(await verifyPassword(u.password_hash, password))) return null;
|
||||
return {
|
||||
user: { uid: u.id, role: u.role, name: u.display_name || u.username },
|
||||
epoch: u.session_epoch ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- simple in-memory rate limiter: max 5 failed attempts / 15 min per IP ---
|
||||
const attempts = new Map<string, { count: number; until: number }>();
|
||||
export function rateLimited(ip: string): boolean {
|
||||
const e = attempts.get(ip);
|
||||
return !!e && e.count >= 5 && Date.now() < e.until;
|
||||
}
|
||||
export function noteFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
const e = attempts.get(ip);
|
||||
if (!e || now >= e.until) attempts.set(ip, { count: 1, until: now + 15 * 60_000 });
|
||||
else e.count++;
|
||||
}
|
||||
export function clearFailures(ip: string): void { attempts.delete(ip); }
|
||||
@@ -0,0 +1,71 @@
|
||||
// Generic backup: mirrors finished results onto every backup target (the backup
|
||||
// mirror from the settings + every delivery_target with is_backup), optionally
|
||||
// with an accompanying metadata .md file. Structure per target:
|
||||
// <base folder>/YYYY-MM/.
|
||||
import { one, query } from './db';
|
||||
import { getObject } from './storage';
|
||||
import { uploadBuffer, loadTargetConfig, type RemoteTargetCfg } from './remotetarget';
|
||||
import { loadMirrorConfig } from './mirror';
|
||||
import { buildMetadataMd, sidecarName } from './metadata';
|
||||
|
||||
export interface BackupDest { key: string; label: string; cfg: RemoteTargetCfg; sidecar: boolean; }
|
||||
|
||||
/** Collect all active backup targets (including the metadata sidecar flag per target). */
|
||||
export async function backupDestinations(): Promise<BackupDest[]> {
|
||||
const dests: BackupDest[] = [];
|
||||
const mirror = await loadMirrorConfig();
|
||||
if (mirror) {
|
||||
const s = await one<any>('SELECT mirror_metadata_sidecar FROM settings WHERE id=1');
|
||||
dests.push({ key: 'mirror', label: 'Backup mirror', cfg: mirror, sidecar: !!s?.mirror_metadata_sidecar });
|
||||
}
|
||||
const targets = await query<any>(`SELECT id, name, metadata_sidecar FROM delivery_targets WHERE is_backup=true`);
|
||||
for (const t of targets) {
|
||||
const cfg = await loadTargetConfig(t.id);
|
||||
if (cfg) dests.push({ key: t.id, label: t.name, cfg, sidecar: !!t.metadata_sidecar });
|
||||
}
|
||||
return dests;
|
||||
}
|
||||
|
||||
/** Mirror one item onto every backup target. */
|
||||
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
|
||||
const dests = await backupDestinations();
|
||||
if (!dests.length) return { mirrored: 0, failed: 0 };
|
||||
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { mirrored: 0, failed: 0 };
|
||||
|
||||
const buf = await getObject(it.result_path);
|
||||
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
|
||||
let md: Buffer | null = null;
|
||||
const anySidecar = dests.some((d) => d.sidecar);
|
||||
if (anySidecar) md = Buffer.from(await buildMetadataMd(itemId), 'utf8');
|
||||
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const d of dests) {
|
||||
try {
|
||||
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||
if (d.sidecar && md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md);
|
||||
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored')
|
||||
ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]);
|
||||
if (d.key === 'mirror') await query(`UPDATE items SET mirror_status='mirrored' WHERE id=$1`, [itemId]);
|
||||
mirrored++;
|
||||
} catch (e: any) {
|
||||
console.error('[backup]', d.label, itemId, e?.message || e);
|
||||
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'failed')
|
||||
ON CONFLICT (item_id, target) DO UPDATE SET status='failed', at=now()`, [itemId, d.key]);
|
||||
if (d.key === 'mirror') await query(`UPDATE items SET mirror_status='failed' WHERE id=$1`, [itemId]);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { mirrored, failed };
|
||||
}
|
||||
|
||||
/** Back up every finished image onto every backup target (a catch-up run). */
|
||||
export async function backupAll(): Promise<{ items: number; mirrored: number; failed: number }> {
|
||||
const dests = await backupDestinations();
|
||||
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
||||
const rows = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
||||
return { items: rows.length, mirrored, failed };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Crop mathematics — **with no dependencies at all**, like printlayout.ts.
|
||||
//
|
||||
// This file exists because the same calculation used to sit in the project twice:
|
||||
// once in the browser (preview and crop window in PrintApp.tsx) and once on the
|
||||
// server (printrender.ts, for the PDF). Both did the same arithmetic — but
|
||||
// nothing held them against each other. Two copies of the same formula drift
|
||||
// apart sooner or later, and then the preview shows something other than what
|
||||
// gets printed. Now there is only one source, and a test holds the server entry
|
||||
// point against the browser entry point.
|
||||
//
|
||||
// All crops are **relative** (0…1 with respect to the source image) and therefore
|
||||
// independent of resolution: the same crop applies to the small preview image as
|
||||
// to the original.
|
||||
|
||||
export interface CropRel { x: number; y: number; w: number; h: number }
|
||||
export type FitMode = 'cover' | 'contain';
|
||||
|
||||
/** Largest crop in the target ratio that lies **inside** the image. */
|
||||
export function coverSize(natW: number, natH: number, aspect: number): { w: number; h: number } {
|
||||
const imgA = natW / natH;
|
||||
return imgA > aspect ? { w: aspect / imgA, h: 1 } : { w: 1, h: imgA / aspect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest crop in the target ratio that contains the **whole** image.
|
||||
* It deliberately reaches beyond the edge of the image (w or h > 1) — that is
|
||||
* where the letterbox area appears.
|
||||
*/
|
||||
export function containSize(natW: number, natH: number, aspect: number): { w: number; h: number } {
|
||||
const imgA = natW / natH;
|
||||
return imgA > aspect ? { w: 1, h: imgA / aspect } : { w: aspect / imgA, h: 1 };
|
||||
}
|
||||
|
||||
/** Put it around the same centre point as an existing crop (otherwise centred). */
|
||||
export function centred(w: number, h: number, around?: CropRel | null): CropRel {
|
||||
const cx = around ? around.x + around.w / 2 : 0.5;
|
||||
const cy = around ? around.y + around.h / 2 : 0.5;
|
||||
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
||||
}
|
||||
|
||||
/** Limits for zooming in and out. */
|
||||
export interface CropLimits { min?: number; max?: number }
|
||||
|
||||
/**
|
||||
* Keeps the crop within sensible limits.
|
||||
* With `cover` it stays inside the image. With `contain` it may reach beyond —
|
||||
* but not so far that nothing of the subject would be visible any more (at least
|
||||
* 5 % has to stay inside the frame).
|
||||
*
|
||||
* `min`/`max` limit how far the user may zoom in and out. The **initial** crop
|
||||
* has to be exempt from that: a very tall image in a very wide size arithmetically
|
||||
* needs more than eight times — and a very wide size from a very tall image less
|
||||
* than 2 %. If the limit bit there, the aspect ratio would no longer be right, and
|
||||
* the image would be cropped after all even though "letterbox" is selected.
|
||||
* `baseCrop` therefore widens the limits to whatever the size actually needs.
|
||||
*/
|
||||
export function clampCrop(c: CropRel, fit: FitMode = 'cover', limits: CropLimits = {}): CropRel {
|
||||
const lo = limits.min ?? 0.02;
|
||||
const hi = limits.max ?? 8;
|
||||
if (fit === 'contain') {
|
||||
const w = Math.min(hi, Math.max(lo, c.w));
|
||||
const h = Math.min(hi, Math.max(lo, c.h));
|
||||
return {
|
||||
w, h,
|
||||
x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)),
|
||||
y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)),
|
||||
};
|
||||
}
|
||||
const w = Math.min(Math.min(1, hi), Math.max(lo, c.w));
|
||||
const h = Math.min(Math.min(1, hi), Math.max(lo, c.h));
|
||||
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
|
||||
}
|
||||
|
||||
/** Limits that are guaranteed to let a given initial crop through. */
|
||||
export const limitsFor = (c: { w: number; h: number }): CropLimits =>
|
||||
({ min: Math.min(0.02, c.w, c.h), max: Math.max(8, c.w, c.h) });
|
||||
|
||||
/**
|
||||
* The crop an image starts out with in a target size.
|
||||
* `around` preserves the centre point of the image if the user had already
|
||||
* panned and then switches the size.
|
||||
*/
|
||||
export function baseCrop(
|
||||
natW: number, natH: number, aspect: number, fit: FitMode = 'cover', around?: CropRel | null,
|
||||
): CropRel {
|
||||
const { w, h } = fit === 'contain' ? containSize(natW, natH, aspect) : coverSize(natW, natH, aspect);
|
||||
// The zoom limits must never deform the initial crop — otherwise the aspect
|
||||
// ratio would be gone and it would be cropped after all (see clampCrop).
|
||||
return clampCrop(centred(w, h, around), fit, limitsFor({ w, h }));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
// AES-256-GCM. ENCRYPTION_KEY = 64 hex characters (32 bytes).
|
||||
function key(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY || '';
|
||||
if (hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes) long');
|
||||
}
|
||||
return Buffer.from(hex, 'hex');
|
||||
}
|
||||
|
||||
/** Encrypts plain text → "iv.tag.ciphertext" (base64url parts). */
|
||||
export function encrypt(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [iv, tag, enc].map((b) => b.toString('base64url')).join('.');
|
||||
}
|
||||
|
||||
export function decrypt(payload: string): string {
|
||||
const [ivB, tagB, dataB] = payload.split('.');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(dataB, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
/** Masks a key for display: sk-or-…4f2a */
|
||||
export function maskSecret(secret: string): string {
|
||||
if (!secret) return '';
|
||||
const tail = secret.slice(-4);
|
||||
const head = secret.slice(0, Math.min(5, secret.length));
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import pg from 'pg';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||
const res = await pool.query(text, params);
|
||||
return res.rows as T[];
|
||||
}
|
||||
|
||||
export async function one<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||
const rows = await query<T>(text, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Applies migrations/*.sql in order, tracked in schema_migrations. */
|
||||
export async function runMigrations(): Promise<void> {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);
|
||||
// The migrations live in the project root folder (container: /app/migrations).
|
||||
// Do not look for them relative to the compiled module (dist/server/chunks).
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
join(process.cwd(), 'migrations'),
|
||||
join(here, '..', '..', 'migrations'),
|
||||
join(here, '..', '..', '..', 'migrations'),
|
||||
];
|
||||
const dir = candidates.find((d) => existsSync(d));
|
||||
if (!dir) throw new Error(`migrations directory not found (checked: ${candidates.join(', ')})`);
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
||||
for (const file of files) {
|
||||
const done = await one('SELECT name FROM schema_migrations WHERE name=$1', [file]);
|
||||
if (done) continue;
|
||||
const sql = readFileSync(join(dir, file), 'utf8');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(sql);
|
||||
await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [file]);
|
||||
await client.query('COMMIT');
|
||||
console.log(`[migrate] applied ${file}`);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { one, query } from './db';
|
||||
import { getObject } from './storage';
|
||||
import { loadConfig, loadTargetConfig, uploadBuffer, type RemoteTargetCfg } from './remotetarget';
|
||||
|
||||
// Fallback folder on the remote target when nothing else is configured.
|
||||
const DEFAULT_FOLDER = process.env.DELIVERY_DEFAULT_FOLDER || 'klarbild';
|
||||
|
||||
async function folderFor(item: { folder_id: string | null; job_id: string }): Promise<string> {
|
||||
// 1) folder mapping 2) preset recipe 3) configured default folder 4) fallback constant
|
||||
if (item.folder_id) {
|
||||
const f = await one<{ delivery_folder: string | null }>('SELECT delivery_folder FROM folders WHERE id=$1', [item.folder_id]);
|
||||
if (f?.delivery_folder) return f.delivery_folder;
|
||||
}
|
||||
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||
if (j?.recipe_snapshot?.delivery_folder) return j.recipe_snapshot.delivery_folder;
|
||||
const s = await one<{ delivery_default_folder: string | null }>('SELECT delivery_default_folder FROM settings WHERE id=1');
|
||||
return s?.delivery_default_folder || DEFAULT_FOLDER;
|
||||
}
|
||||
|
||||
export async function cfgForKey(key: string | null | undefined): Promise<RemoteTargetCfg | null> {
|
||||
if (!key) return null;
|
||||
if (key === 'mirror') { const { loadMirrorConfig } = await import('./mirror'); return (await loadMirrorConfig()) as unknown as RemoteTargetCfg; }
|
||||
if (key === 'remote') return loadConfig();
|
||||
return loadTargetConfig(key); // UUID of an additional target
|
||||
}
|
||||
|
||||
/** Target credentials including the source key: 1) folder target 2) preset-recipe target 3) the default delivery target. */
|
||||
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<{ cfg: RemoteTargetCfg; key: string } | null> {
|
||||
if (item.folder_id) {
|
||||
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
|
||||
if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return { cfg: c, key: f.delivery_target_id }; }
|
||||
}
|
||||
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||
const tid = j?.recipe_snapshot?.delivery_target_id;
|
||||
if (tid) { const c = await cfgForKey(tid); if (c) return { cfg: c, key: tid }; }
|
||||
const c = await loadConfig();
|
||||
return c ? { cfg: c, key: 'remote' } : null;
|
||||
}
|
||||
|
||||
/** Is the metadata sidecar file switched on for this source? */
|
||||
async function sidecarForKey(key: string): Promise<boolean> {
|
||||
if (key === 'remote') { const s = await one<any>('SELECT delivery_metadata_sidecar FROM settings WHERE id=1'); return !!s?.delivery_metadata_sidecar; }
|
||||
if (key === 'mirror') { const s = await one<any>('SELECT mirror_metadata_sidecar FROM settings WHERE id=1'); return !!s?.mirror_metadata_sidecar; }
|
||||
const t = await one<any>('SELECT metadata_sidecar FROM delivery_targets WHERE id=$1', [key]);
|
||||
return !!t?.metadata_sidecar;
|
||||
}
|
||||
|
||||
/** Delivers a finished item to the matching folder on the delivery target.
|
||||
* What gets delivered is the stored result **in the chosen format** (PNG/JPG per
|
||||
* the global or the preset-recipe setting) — no forced conversion any more. */
|
||||
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
|
||||
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { ok: false, message: 'No result available.' };
|
||||
const target = await targetFor(it);
|
||||
if (!target) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'No delivery target configured.' }; }
|
||||
const { cfg, key } = target;
|
||||
|
||||
await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
|
||||
try {
|
||||
const folder = await folderFor(it);
|
||||
const buf = await getObject(it.result_path);
|
||||
const name = it.filename || `${it.id}.png`;
|
||||
await uploadBuffer(cfg, folder, name, buf);
|
||||
// Optional: metadata as an accompanying .md file — switchable per source.
|
||||
if (await sidecarForKey(key)) {
|
||||
try {
|
||||
const { buildMetadataMd, sidecarName } = await import('./metadata');
|
||||
await uploadBuffer(cfg, folder, sidecarName(name), Buffer.from(await buildMetadataMd(itemId), 'utf8'));
|
||||
} catch (e) { console.error('[delivery] metadata sidecar file failed', e); }
|
||||
}
|
||||
await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
|
||||
return { ok: true, message: `Delivered to "${folder}".` };
|
||||
} catch (e: any) {
|
||||
console.error('[delivery] item', itemId, 'failed:', e?.message || e);
|
||||
await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]);
|
||||
return { ok: false, message: e?.message || 'Delivery failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Work through all deliveries of a job that are still open (pending). */
|
||||
export async function deliverPendingForJob(jobId: string): Promise<void> {
|
||||
const items = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE job_id=$1 AND delivery_status='pending' AND status='done'`, [jobId]);
|
||||
for (const it of items) await deliverItem(it.id);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Target sizes & exact pixel calculation (see 01 §5, 06 §3).
|
||||
// px = round(cm / 2.54 * dpi)
|
||||
|
||||
export type Orientation = 'portrait' | 'landscape';
|
||||
|
||||
export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
|
||||
|
||||
// cm sizes: [width, height] in the portrait convention (short side × long side)
|
||||
const CM: Record<string, [number, number]> = {
|
||||
'9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
|
||||
'20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
|
||||
'40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
|
||||
A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
|
||||
'20x20': [20, 20], '30x30': [30, 30],
|
||||
};
|
||||
|
||||
// Pure screen sizes (fixed pixels, no cm/dpi)
|
||||
const SCREEN: Record<string, [number, number]> = {
|
||||
theframe: [3840, 2160], // 16:9 landscape
|
||||
portrait916: [2160, 3840], // 9:16 portrait
|
||||
};
|
||||
|
||||
export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
|
||||
|
||||
export interface ResolveInput {
|
||||
format: string; // a key from above, 'sticker', 'custom' or 'keep'
|
||||
orientation?: Orientation;
|
||||
dpi?: number;
|
||||
customCm?: [number, number]; // for 'sticker'/'custom'
|
||||
}
|
||||
|
||||
/** Returns exact target pixels + label. `keep` → null (keep the original). */
|
||||
export function resolveDimensions(input: ResolveInput): Dimensions | null {
|
||||
const { format } = input;
|
||||
const dpi = input.dpi ?? 300;
|
||||
const orient = input.orientation ?? 'portrait';
|
||||
|
||||
if (format === 'keep') return null;
|
||||
|
||||
if (format in SCREEN) {
|
||||
const [w, h] = SCREEN[format];
|
||||
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Portrait' };
|
||||
}
|
||||
|
||||
let cm: [number, number] | undefined;
|
||||
if (format in CM) cm = CM[format];
|
||||
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
|
||||
else {
|
||||
// Read freely defined sizes straight out of the key — no database migration needed:
|
||||
// "sticker5" / "sticker-7,5" → square N×N cm; "25x35" / "25×35" → W×H cm.
|
||||
const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
|
||||
const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
|
||||
const num = (s: string) => parseFloat(s.replace(',', '.'));
|
||||
if (sq) { const n = num(sq[1]); cm = [n, n]; }
|
||||
else if (wh) cm = [num(wh[1]), num(wh[2])];
|
||||
}
|
||||
if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
|
||||
throw new Error(`Unknown format: ${format}`);
|
||||
|
||||
// Portrait convention → apply the requested orientation
|
||||
let [wCm, hCm] = cm;
|
||||
if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
|
||||
return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
|
||||
label: `${cm[0]}×${cm[1]} cm` };
|
||||
}
|
||||
|
||||
/** Simplified aspect ratio as "w:h" for the image API. */
|
||||
export function aspectRatio(w: number, h: number): string {
|
||||
const g = gcd(w, h);
|
||||
return `${Math.round(w / g)}:${Math.round(h / g)}`;
|
||||
}
|
||||
function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
|
||||
|
||||
/** Is the native resolution enough for real 300 dpi? (A hint in the UI.) */
|
||||
export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
|
||||
return srcW < target.w * 0.95 || srcH < target.h * 0.95;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import de from '../locales/de.json';
|
||||
import { makeT, type Dict, type Locale, type TFunction } from './i18n';
|
||||
|
||||
/**
|
||||
* Server-side dictionaries. Only this module imports the JSON, so the bundler
|
||||
* keeps it out of the browser bundle; the layout passes the right one down.
|
||||
*/
|
||||
const DICTS: Partial<Record<Locale, Dict>> = { de: de as Dict };
|
||||
|
||||
/** The dictionary for a locale, or `null` for English, which needs none. */
|
||||
export function dictFor(locale: Locale): Dict | null {
|
||||
return DICTS[locale] ?? null;
|
||||
}
|
||||
|
||||
/** Convenience for Astro frontmatter: `const t = tFor(locale)`. */
|
||||
export function tFor(locale: Locale): TFunction {
|
||||
return makeT(dictFor(locale));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Translation.
|
||||
*
|
||||
* The English sentence *is* the key. `t('Sheet layout')` returns "Sheet layout"
|
||||
* in English and looks up "Bogen-Layout" in `locales/de.json` when the instance
|
||||
* runs in German. That has two consequences worth knowing:
|
||||
*
|
||||
* - There is no key file to keep in sync and no `print.sheet.title` indirection
|
||||
* to decode. You read the source and you see the sentence the user sees.
|
||||
* - A missing translation degrades to English rather than to a raw key, so a
|
||||
* half-finished locale is still a usable instance.
|
||||
*
|
||||
* Placeholders are named and written in braces: `t('{done} of {total} done',
|
||||
* { done, total })`. Never build a sentence by concatenation — word order
|
||||
* differs between languages, and a concatenated sentence cannot be translated.
|
||||
*
|
||||
* This module deliberately imports no dictionary. English needs none, and a
|
||||
* static import would ship every other language to every visitor. The server
|
||||
* loads the dictionary from `i18n.server.ts`; the browser picks it up from the
|
||||
* JSON block the layout renders (see `useT.ts`).
|
||||
*/
|
||||
|
||||
export const LOCALES = ['en', 'de'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const LOCALE_NAMES: Record<Locale, string> = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
};
|
||||
|
||||
export type Dict = Record<string, string>;
|
||||
export type TFunction = (text: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
/** A translate function bound to one dictionary. Cheap — make one per render. */
|
||||
export function makeT(dict?: Dict | null): TFunction {
|
||||
return (text, vars) => {
|
||||
let out = dict?.[text] ?? text;
|
||||
if (vars) {
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
out = out.split('{' + key + '}').join(String(value));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which language this request gets.
|
||||
*
|
||||
* Order: an explicit `?lang=` in the URL (which also sets the cookie), then the
|
||||
* cookie, then the instance default from `KLARBILD_LOCALE`, then English. The
|
||||
* browser's `Accept-Language` deliberately does not decide: a self-hosted tool
|
||||
* belongs to its operator, and an instance set up in German should not flip to
|
||||
* English because a visitor's laptop is configured that way.
|
||||
*/
|
||||
export function resolveLocale(opts: { query?: string | null; cookie?: string | null; env?: string | null }): Locale {
|
||||
if (isLocale(opts.query)) return opts.query;
|
||||
if (isLocale(opts.cookie)) return opts.cookie;
|
||||
if (isLocale(opts.env)) return opts.env;
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export const LOCALE_COOKIE = 'klarbild_lang';
|
||||
|
||||
/** Where the layout parks the dictionary for the browser to find. */
|
||||
export const DICT_ELEMENT_ID = 'klarbild-i18n';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { runMigrations } from './db';
|
||||
import { ensureBucket } from './storage';
|
||||
import { seed } from './seed';
|
||||
import { has, activeModules } from './modules';
|
||||
|
||||
let started: Promise<void> | null = null;
|
||||
|
||||
/** One-time server initialisation (idempotent, triggered by the first request). */
|
||||
export function ensureInit(): Promise<void> {
|
||||
if (!started) {
|
||||
started = (async () => {
|
||||
await runMigrations();
|
||||
try { await ensureBucket(); } catch (e) { console.error('[init] object storage not ready:', e); }
|
||||
await seed();
|
||||
|
||||
// The worker only exists to run image jobs through a model. A print-only
|
||||
// instance has no queue to drain, so it does not start one.
|
||||
if (has('ai')) {
|
||||
try { const { startImageWorker } = await import('../worker'); await startImageWorker(); }
|
||||
catch (e) { console.error('[init] worker failed to start:', e); }
|
||||
}
|
||||
if (has('telegram')) {
|
||||
try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); }
|
||||
catch (e) { console.error('[init] Telegram setup failed:', e); }
|
||||
}
|
||||
try { const { startMaintenance } = await import('./maintenance'); startMaintenance(); }
|
||||
catch (e) { console.error('[init] maintenance failed to start:', e); }
|
||||
|
||||
console.log(`[init] Klarbild ready — modules: ${activeModules().join(', ')}`);
|
||||
})().catch((e) => { started = null; throw e; });
|
||||
}
|
||||
return started;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Rate limiting, and the question of whose sender address you believe.
|
||||
//
|
||||
// Deliberately without a dependency and in memory: Klarbild runs as **one** Node
|
||||
// process. If a second one were ever added, this belongs in the database or in
|
||||
// Redis — and then this file is the one place you swap out.
|
||||
|
||||
/**
|
||||
* The caller's actual address.
|
||||
*
|
||||
* Important and easy to overlook: Astro's `clientAddress` takes the **first**
|
||||
* value out of `X-Forwarded-For`. That value is sent by the attacker themselves
|
||||
* — a reverse proxy appends the real address at the end. Any limit that builds
|
||||
* on `clientAddress` can be circumvented by varying the header from request to
|
||||
* request.
|
||||
*
|
||||
* That is why it counts from the right: with `TRUSTED_PROXIES=1` (the default,
|
||||
* one proxy in front such as Traefik) the last entry is the one the proxy set
|
||||
* itself — nobody from outside can forge that. Without a proxy
|
||||
* (`TRUSTED_PROXIES=0`) the header is not considered at all.
|
||||
*/
|
||||
export function realIp(request: Request, fallback?: string | null): string {
|
||||
const trusted = Number(process.env.TRUSTED_PROXIES ?? '1');
|
||||
if (!Number.isFinite(trusted) || trusted <= 0) return fallback || 'unknown';
|
||||
const chain = (request.headers.get('x-forwarded-for') || '')
|
||||
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (!chain.length) return fallback || 'unknown';
|
||||
// From the right, as many hops back as there are trustworthy proxies.
|
||||
const i = Math.max(0, chain.length - trusted);
|
||||
return chain[i] || fallback || 'unknown';
|
||||
}
|
||||
|
||||
interface Entry { n: number; until: number }
|
||||
const buckets = new Map<string, Entry>();
|
||||
let lastCleanup = 0;
|
||||
|
||||
/** Remove expired entries — selectively, not everything at once. */
|
||||
function cleanUp(now: number): void {
|
||||
if (now - lastCleanup < 60_000) return;
|
||||
lastCleanup = now;
|
||||
for (const [k, e] of buckets) if (e.until < now) buckets.delete(k);
|
||||
// Emergency brake in case too much piles up anyway: the oldest ones first.
|
||||
if (buckets.size > 50_000) {
|
||||
const byAge = [...buckets.entries()].sort((a, b) => a[1].until - b[1].until);
|
||||
for (const [k] of byAge.slice(0, buckets.size - 25_000)) buckets.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one attempt. Returns `true` when the limit has been exceeded.
|
||||
*
|
||||
* Unlike before, the whole table is **not** cleared when it grows large — that
|
||||
* would have let an attacker reset everybody else's counters along with theirs.
|
||||
*/
|
||||
export function tooOften(key: string, max: number, windowMs: number): boolean {
|
||||
const now = Date.now();
|
||||
cleanUp(now);
|
||||
const e = buckets.get(key);
|
||||
if (!e || e.until < now) { buckets.set(key, { n: 1, until: now + windowMs }); return false; }
|
||||
e.n++;
|
||||
return e.n > max;
|
||||
}
|
||||
|
||||
/** Reset the counter — after a successful operation. */
|
||||
export const resetPassword = (key: string): void => { buckets.delete(key); };
|
||||
|
||||
/** How many attempts are still free in the running window. */
|
||||
export function attemptsLeft(key: string, max: number): number {
|
||||
const e = buckets.get(key);
|
||||
if (!e || e.until < Date.now()) return max;
|
||||
return Math.max(0, max - e.n);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// Sending email through your own mailbox provider (SMTP).
|
||||
//
|
||||
// Deliberately through the existing outgoing mail server instead of a sending
|
||||
// service: the domain sits there anyway, so SPF and DKIM are right by
|
||||
// themselves, and no further provider comes into play that would get to see the
|
||||
// mails.
|
||||
//
|
||||
// The credentials sit encrypted in `settings` (AES-256-GCM, like the OpenRouter
|
||||
// key and the delivery target password) and **never** in the repository.
|
||||
import nodemailer from 'nodemailer';
|
||||
import { one } from './db.ts';
|
||||
import { decrypt } from './crypto.ts';
|
||||
|
||||
export interface MailCfg {
|
||||
host: string; port: number; secure: boolean;
|
||||
user: string; password: string; from: string;
|
||||
}
|
||||
|
||||
export interface MailSource { cfg: MailCfg; source: 'database' | 'environment' }
|
||||
|
||||
/**
|
||||
* SMTP credentials — from the settings first, otherwise from the environment.
|
||||
*
|
||||
* The same order of precedence as for the OpenRouter key: whatever someone
|
||||
* enters in the admin area wins. The environment variables are the starting
|
||||
* value, so that sending works right after a fresh setup without anyone having
|
||||
* to type credentials in — and so that the password sits **only** there and in
|
||||
* the encrypted column, never in the repository.
|
||||
*/
|
||||
export async function loadMailSource(): Promise<MailSource | null> {
|
||||
const s = await one<any>(
|
||||
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`);
|
||||
|
||||
if (s?.smtp_host && s?.smtp_user && s?.smtp_password) {
|
||||
try {
|
||||
return { source: 'database', cfg: {
|
||||
host: s.smtp_host,
|
||||
port: Number(s.smtp_port) || 465,
|
||||
secure: s.smtp_secure !== false,
|
||||
user: s.smtp_user,
|
||||
password: decrypt(s.smtp_password),
|
||||
from: s.smtp_from || s.smtp_user,
|
||||
} };
|
||||
} catch {
|
||||
// Decryption failed (after a change of the ENCRYPTION_KEY, for example) —
|
||||
// then the environment is better than nothing.
|
||||
console.error('[mail] Stored password not readable, falling back to the environment.');
|
||||
}
|
||||
}
|
||||
|
||||
const e = process.env;
|
||||
if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) {
|
||||
return { source: 'environment', cfg: {
|
||||
host: e.SMTP_HOST,
|
||||
port: Number(e.SMTP_PORT) || 465,
|
||||
secure: e.SMTP_SECURE !== 'false',
|
||||
user: e.SMTP_USER,
|
||||
password: e.SMTP_PASSWORD,
|
||||
from: e.SMTP_FROM || e.SMTP_USER,
|
||||
} };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Only the credentials, without where they came from. */
|
||||
export async function loadMailConfig(): Promise<MailCfg | null> {
|
||||
return (await loadMailSource())?.cfg ?? null;
|
||||
}
|
||||
|
||||
export const mailReady = async (): Promise<boolean> => !!(await loadMailConfig());
|
||||
|
||||
export interface MailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
/** Body text. Is sent as the plain-text version as well. */
|
||||
text: string;
|
||||
/** Optional HTML body; without one the text is simply framed. */
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/**
|
||||
* A plain, readable HTML frame. No external images, no tracking pixels, no
|
||||
* fonts from someone else's servers — that would be blocked in many mailboxes
|
||||
* anyway and would be impolite towards the recipient.
|
||||
*/
|
||||
export function mailLayout(title: string, paragraphs: string[], button?: { text: string; url: string }): string {
|
||||
const p = paragraphs.map((a) => `<p style="margin:0 0 14px;line-height:1.6">${a}</p>`).join('');
|
||||
const btn = button
|
||||
? `<p style="margin:22px 0"><a href="${esc(button.url)}" style="display:inline-block;background:#1B3BE0;color:#fff;
|
||||
text-decoration:none;padding:12px 20px;border-radius:4px;font-weight:600">${esc(button.text)}</a></p>
|
||||
<p style="margin:0 0 14px;font-size:13px;color:#6A685D">If the button does not work:<br>
|
||||
<a href="${esc(button.url)}" style="color:#1B3BE0;word-break:break-all">${esc(button.url)}</a></p>`
|
||||
: '';
|
||||
return `<!doctype html><html lang="en"><body style="margin:0;background:#EAEAE3;padding:24px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#16150F;font-size:15px">
|
||||
<div style="max-width:560px;margin:0 auto;background:#FBFBF7;border:1px solid #D7D6CC;border-radius:4px;padding:28px">
|
||||
<div style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#6A685D;margin-bottom:18px">Klarbild</div>
|
||||
<h1 style="font-size:20px;margin:0 0 16px;line-height:1.3">${esc(title)}</h1>
|
||||
${p}${btn}
|
||||
</div>
|
||||
<div style="max-width:560px;margin:14px auto 0;font-size:12px;color:#6A685D;text-align:center">
|
||||
This message comes from your own Klarbild installation.
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a mail. Throws if no credentials are configured — the callers decide
|
||||
* whether that is an error or only a notice.
|
||||
*/
|
||||
export async function sendMail(m: MailInput): Promise<void> {
|
||||
const cfg = await loadMailConfig();
|
||||
if (!cfg) throw new Error('No email credentials configured.');
|
||||
const tx = nodemailer.createTransport({
|
||||
host: cfg.host, port: cfg.port, secure: cfg.secure,
|
||||
auth: { user: cfg.user, pass: cfg.password },
|
||||
// No endless hanging when the server does not answer.
|
||||
connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000,
|
||||
});
|
||||
try {
|
||||
await tx.sendMail({
|
||||
from: cfg.from, to: m.to, subject: m.subject,
|
||||
text: m.text,
|
||||
html: m.html || mailLayout(m.subject, m.text.split('\n\n').map(esc)),
|
||||
});
|
||||
} finally {
|
||||
tx.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Like `sendMail`, but reports errors only into the log. For notifications. */
|
||||
export async function trySendMail(m: MailInput): Promise<boolean> {
|
||||
try { await sendMail(m); return true; }
|
||||
catch (e: any) { console.error('[mail] sending failed:', e?.message || e); return false; }
|
||||
}
|
||||
|
||||
/** Check the connection without sending anything — for the test button in the admin area. */
|
||||
export async function testMail(): Promise<{ ok: boolean; message: string }> {
|
||||
const cfg = await loadMailConfig();
|
||||
if (!cfg) return { ok: false, message: 'No email credentials configured.' };
|
||||
const tx = nodemailer.createTransport({
|
||||
host: cfg.host, port: cfg.port, secure: cfg.secure,
|
||||
auth: { user: cfg.user, pass: cfg.password },
|
||||
connectionTimeout: 15000, greetingTimeout: 10000,
|
||||
});
|
||||
try {
|
||||
await tx.verify();
|
||||
return { ok: true, message: `Connection to ${cfg.host}:${cfg.port} succeeded.` };
|
||||
} catch (e: any) {
|
||||
// Provider messages can contain the user name — keep it short.
|
||||
const raw = String(e?.message || e);
|
||||
const short = /auth/i.test(raw) ? 'Sign-in refused — check the user name or the password.'
|
||||
: /timeout|ETIMEDOUT|ECONNREFUSED/i.test(raw) ? 'Server not reachable — check the host and the port.'
|
||||
: /certificate|self.signed/i.test(raw) ? 'Certificate error — check the encryption (port 465 = SSL, 587 = STARTTLS).'
|
||||
: 'Connection failed.';
|
||||
return { ok: false, message: short };
|
||||
} finally {
|
||||
tx.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A rough plausibility check of an address.
|
||||
*
|
||||
* Control characters are checked against the **raw** value, not the trimmed
|
||||
* one: in JavaScript `$` without the `m` flag also matches in front of a
|
||||
* trailing line break, and `trim()` would take it away anyway. The two together
|
||||
* would have let `"a@b.de\n"` through — and that is exactly how every header
|
||||
* injection starts.
|
||||
*/
|
||||
export const isEmail = (v: string): boolean => {
|
||||
const raw = String(v ?? '');
|
||||
if (/[\r\n\t\0]/.test(raw)) return false;
|
||||
const s = raw.trim();
|
||||
return !!s && s.length <= 254 && /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// Storage management: show usage, clean up (retention), delete sources.
|
||||
// The aim: do not let the server silt up.
|
||||
import { stat, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { one, query } from './db';
|
||||
import { deleteObject } from './storage';
|
||||
|
||||
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
|
||||
const DIR = process.env.STORAGE_DIR || '/data';
|
||||
|
||||
async function dirBytes(path: string): Promise<{ bytes: number; files: number }> {
|
||||
let bytes = 0, files = 0;
|
||||
let entries: any[] = [];
|
||||
try { entries = await readdir(path, { withFileTypes: true }); } catch { return { bytes, files }; }
|
||||
for (const e of entries) {
|
||||
const p = join(path, e.name);
|
||||
if (e.isDirectory()) { const s = await dirBytes(p); bytes += s.bytes; files += s.files; }
|
||||
else { try { const st = await stat(p); bytes += st.size; files++; } catch { /* ignore */ } }
|
||||
}
|
||||
return { bytes, files };
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
driver: string; bytes: number | null; files: number | null;
|
||||
sources: number; results: number; thumbs: number; items: number;
|
||||
}
|
||||
|
||||
/** Storage figures. Byte accuracy only with the fs driver. */
|
||||
export async function storageStats(): Promise<StorageStats> {
|
||||
const counts = await one<any>(`SELECT
|
||||
count(*) FILTER (WHERE source_path IS NOT NULL)::int AS sources,
|
||||
count(*) FILTER (WHERE result_path IS NOT NULL)::int AS results,
|
||||
count(*) FILTER (WHERE thumb_path IS NOT NULL)::int AS thumbs,
|
||||
count(*)::int AS items FROM items`);
|
||||
let bytes: number | null = null, files: number | null = null;
|
||||
if (DRIVER === 'fs') { const s = await dirBytes(DIR); bytes = s.bytes; files = s.files; }
|
||||
return { driver: DRIVER, bytes, files,
|
||||
sources: counts?.sources || 0, results: counts?.results || 0,
|
||||
thumbs: counts?.thumbs || 0, items: counts?.items || 0 };
|
||||
}
|
||||
|
||||
/** Deletes items (objects included) that are older than N days. */
|
||||
export async function runRetention(days: number): Promise<{ deleted: number }> {
|
||||
if (!days || days <= 0) return { deleted: 0 };
|
||||
const rows = await query<any>(
|
||||
`SELECT id, source_path, source_paths, result_path, thumb_path FROM items
|
||||
WHERE created_at < now() - ($1 || ' days')::interval`, [String(days)]);
|
||||
for (const it of rows) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||
}
|
||||
return { deleted: rows.length };
|
||||
}
|
||||
|
||||
/** Produces missing preview images afterwards (for a fast library view). */
|
||||
export async function rebuildThumbs(): Promise<{ made: number }> {
|
||||
const { getObject, putObject, thumbKey } = await import('./storage');
|
||||
const sharp = (await import('sharp')).default;
|
||||
const rows = await query<any>(
|
||||
`SELECT id, result_path FROM items WHERE status='done' AND result_path IS NOT NULL AND thumb_path IS NULL`);
|
||||
let made = 0;
|
||||
for (const it of rows) {
|
||||
try {
|
||||
const out = await sharp(await getObject(it.result_path), { failOn: 'none' })
|
||||
.resize(600, 600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 72 }).toBuffer();
|
||||
const tk = thumbKey(it.id);
|
||||
await putObject(tk, out, 'image/webp');
|
||||
await query(`UPDATE items SET thumb_path=$2 WHERE id=$1`, [it.id, tk]);
|
||||
made++;
|
||||
} catch (e) { console.error('[maintenance] Thumb', it.id, e); }
|
||||
}
|
||||
return { made };
|
||||
}
|
||||
|
||||
/** Deletes only the source images of finished items (the results stay). */
|
||||
export async function purgeSources(): Promise<{ purged: number }> {
|
||||
const rows = await query<any>(
|
||||
`SELECT id, source_path, source_paths FROM items WHERE status='done'
|
||||
AND (source_path IS NOT NULL OR source_paths IS NOT NULL)`);
|
||||
let purged = 0;
|
||||
for (const it of rows) {
|
||||
const keys = [it.source_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||
await query('UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1', [it.id]);
|
||||
purged += keys.length;
|
||||
}
|
||||
return { purged };
|
||||
}
|
||||
|
||||
/** Mirrors all finished images to all backup targets (backup mirror + is_backup targets). */
|
||||
export async function mirrorAllToBackup(): Promise<{ mirrored: number; failed: number; total: number }> {
|
||||
const { backupAll } = await import('./backup');
|
||||
const r = await backupAll();
|
||||
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
|
||||
}
|
||||
|
||||
/** "Forget" private jobs: delete results + jobs after a short time. */
|
||||
export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number }> {
|
||||
const rows = await query<any>(
|
||||
`SELECT i.id, i.result_path, i.thumb_path FROM items i JOIN jobs j ON j.id=i.job_id
|
||||
WHERE j.private=true AND i.created_at < now() - ($1 || ' minutes')::interval`, [String(olderThanMin)]);
|
||||
for (const it of rows) {
|
||||
for (const k of [it.result_path, it.thumb_path].filter(Boolean)) await deleteObject(k).catch(() => {});
|
||||
await query('DELETE FROM items WHERE id=$1', [it.id]);
|
||||
}
|
||||
// remove empty private jobs
|
||||
await query(`DELETE FROM jobs WHERE private=true AND id NOT IN (SELECT DISTINCT job_id FROM items WHERE job_id IS NOT NULL)`);
|
||||
return { deleted: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear away records and expired reset links.
|
||||
*
|
||||
* Both tables used to grow without bound. With `share_views` that is not only a
|
||||
* question of space: they are records of when somebody looked at an image.
|
||||
* Keeping them longer than you need them would simply be unnecessary — 90 days
|
||||
* are enough for "was that actually looked at?".
|
||||
*/
|
||||
export async function purgeTracking(days = 90): Promise<{ views: number; resets: number }> {
|
||||
const v = await query<{ id: string }>(
|
||||
`DELETE FROM share_views WHERE at < now() - ($1 || ' days')::interval RETURNING id`, [String(days)]);
|
||||
const r = await query<{ token_hash: string }>(
|
||||
`DELETE FROM password_resets
|
||||
WHERE expires_at < now() - interval '7 days' RETURNING token_hash`);
|
||||
return { views: v.length, resets: r.length };
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
/** Maintenance run: retention + forgetting private sessions. */
|
||||
export function startMaintenance(): void {
|
||||
if (timer) return;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||
if (s?.retention_days && s.retention_days > 0) {
|
||||
const r = await runRetention(s.retention_days);
|
||||
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} old items removed.`);
|
||||
}
|
||||
const p = await purgePrivate(60);
|
||||
if (p.deleted) console.log(`[maintenance] Private: ${p.deleted} results forgotten.`);
|
||||
const t = await purgeTracking(90);
|
||||
if (t.views || t.resets) console.log(`[maintenance] Cleaned up: ${t.views} views, ${t.resets} old reset links.`);
|
||||
} catch (e) { console.error('[maintenance]', e); }
|
||||
};
|
||||
timer = setInterval(tick, 30 * 60 * 1000); // every 30 min (also for the private purge)
|
||||
setTimeout(tick, 60 * 1000); // first run after 1 min
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { one } from './db';
|
||||
|
||||
/** Builds an accompanying metadata file (Markdown) for an item. */
|
||||
export async function buildMetadataMd(itemId: string): Promise<string> {
|
||||
const it = await one<any>(
|
||||
`SELECT i.filename, i.output_px, i.has_alpha, i.model_used, i.prompt_used, i.cost,
|
||||
i.created_at, i.delivery_status, i.color_tag, j.mode, j.recipe_snapshot
|
||||
FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [itemId]);
|
||||
if (!it) return '';
|
||||
const r = it.recipe_snapshot || {};
|
||||
const tasks = Array.isArray(r.tasks) ? r.tasks.join(', ') : '';
|
||||
const TAG: Record<string, string> = { red: 'Red', orange: 'Orange', green: 'Green', final: 'Final' };
|
||||
const lines = [
|
||||
`# ${it.filename || itemId}`,
|
||||
'',
|
||||
`- **Created:** ${new Date(it.created_at).toISOString()}`,
|
||||
`- **Mode:** ${it.mode || 'each'}`,
|
||||
tasks ? `- **Tasks:** ${tasks}` : '',
|
||||
r.output_format ? `- **Format:** ${r.output_format}${r.orientation ? ` (${r.orientation})` : ''}` : '',
|
||||
it.output_px ? `- **Resolution:** ${it.output_px} px${r.dpi ? ` @ ${r.dpi} dpi` : ''}` : '',
|
||||
`- **Transparency:** ${it.has_alpha ? 'yes' : 'no'}`,
|
||||
it.model_used ? `- **Model:** ${it.model_used}` : '',
|
||||
it.color_tag ? `- **Tag:** ${TAG[it.color_tag] || it.color_tag}` : '',
|
||||
it.cost != null ? `- **Cost:** $${Number(it.cost).toFixed(4)}` : '',
|
||||
'',
|
||||
'## Prompt',
|
||||
'',
|
||||
'```',
|
||||
(it.prompt_used || '(no prompt stored)'),
|
||||
'```',
|
||||
].filter((l) => l !== '');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** File name of the sidecar file: <result>.md */
|
||||
export function sidecarName(filename: string | null): string {
|
||||
const base = (filename || 'klarbild').replace(/\.[^.]+$/, '');
|
||||
return `${base}.md`;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// A second copy on a backup mirror (SFTP or FTPS, same as the delivery target).
|
||||
// Results are mirrored onto the backup mirror in addition to the library and
|
||||
// the delivery target.
|
||||
import posixpath from 'node:path/posix';
|
||||
import { one, query } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
import { getObject } from './storage';
|
||||
|
||||
export interface MirrorCfg {
|
||||
host: string; protocol: 'ftps' | 'sftp'; port: number;
|
||||
user: string; password: string; basePath: string;
|
||||
}
|
||||
|
||||
export async function loadMirrorConfig(): Promise<MirrorCfg | null> {
|
||||
const s = await one<any>(`SELECT mirror_enabled, mirror_host, mirror_protocol, mirror_port, mirror_user,
|
||||
mirror_password_enc, mirror_base_path FROM settings WHERE id=1`);
|
||||
if (!s?.mirror_enabled || !s?.mirror_host || !s?.mirror_user || !s?.mirror_password_enc) return null;
|
||||
let password = '';
|
||||
try { password = decrypt(s.mirror_password_enc); } catch { return null; }
|
||||
return {
|
||||
host: s.mirror_host, protocol: (s.mirror_protocol || 'sftp'),
|
||||
port: s.mirror_port || (s.mirror_protocol === 'ftps' ? 21 : 22),
|
||||
user: s.mirror_user, password, basePath: s.mirror_base_path || '/',
|
||||
};
|
||||
}
|
||||
|
||||
export async function testMirror(cfg: MirrorCfg): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||
await c.list(cfg.basePath || '/');
|
||||
await c.end();
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(15000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
await c.list(cfg.basePath || '/');
|
||||
c.close();
|
||||
}
|
||||
return { ok: true, message: 'Connection to the backup mirror succeeded.' };
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e?.message || 'Connection to the backup mirror failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToMirror(cfg: MirrorCfg, remoteDir: string, filename: string, buf: Buffer): Promise<void> {
|
||||
// The same check as for the delivery target, for the same reason:
|
||||
// `posixpath.join` is no protection against `..` — the file name comes out of
|
||||
// the database and is therefore checked here, immediately before writing.
|
||||
const { safeSegment } = await import('./remotetarget');
|
||||
const dir = posixpath.join(cfg.basePath || '/', safeSegment(remoteDir, 'folder'));
|
||||
const finalPath = posixpath.join(dir, safeSegment(filename, 'file name'));
|
||||
const { Readable } = await import('node:stream');
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||
try {
|
||||
if (!(await c.exists(dir))) await c.mkdir(dir, true);
|
||||
await c.put(buf, finalPath);
|
||||
} finally { await c.end(); }
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(20000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
try {
|
||||
await c.ensureDir(dir);
|
||||
await c.uploadFrom(Readable.from(buf), finalPath);
|
||||
} finally { c.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors a finished item onto the backup mirror (folder structure klarbild/YYYY-MM/). */
|
||||
export async function mirrorItem(itemId: string): Promise<{ ok: boolean; message: string }> {
|
||||
const cfg = await loadMirrorConfig();
|
||||
if (!cfg) return { ok: false, message: 'Backup mirror not configured.' };
|
||||
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { ok: false, message: 'No result available.' };
|
||||
await query(`UPDATE items SET mirror_status='pending' WHERE id=$1`, [itemId]);
|
||||
try {
|
||||
const buf = await getObject(it.result_path);
|
||||
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); // YYYY-MM
|
||||
// Structure: <base folder>/YYYY-MM/<file> — the base folder is freely choosable.
|
||||
await uploadToMirror(cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||
await query(`UPDATE items SET mirror_status='mirrored' WHERE id=$1`, [itemId]);
|
||||
return { ok: true, message: 'Backed up to the mirror.' };
|
||||
} catch (e: any) {
|
||||
console.error('[mirror] mirror', itemId, 'failed:', e?.message || e);
|
||||
await query(`UPDATE items SET mirror_status='failed' WHERE id=$1`, [itemId]);
|
||||
return { ok: false, message: e?.message || 'Backup to the mirror failed.' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Optional modules.
|
||||
*
|
||||
* Klarbild ships as one codebase but not as one product. The print studio is the
|
||||
* core and is always on; everything that needs an outside account — an image model,
|
||||
* a bot, an SMTP mailbox, a remote server to deliver to — is a module you switch on
|
||||
* only if you want it. A print-only install therefore needs no API key, no bot
|
||||
* token and no mailbox, and the user interface simply does not show those parts.
|
||||
*
|
||||
* Configure with the `KLARBILD_MODULES` environment variable, comma separated:
|
||||
*
|
||||
* KLARBILD_MODULES=print # default: the print studio, nothing else
|
||||
* KLARBILD_MODULES=print,ai,share # add image generation and share links
|
||||
* KLARBILD_MODULES=all # everything
|
||||
*
|
||||
* `print` is implied even if you leave it out — there is no build of Klarbild
|
||||
* without it.
|
||||
*/
|
||||
|
||||
export const MODULE_IDS = ['print', 'ai', 'delivery', 'mirror', 'share', 'mail', 'telegram'] as const;
|
||||
export type ModuleId = (typeof MODULE_IDS)[number];
|
||||
|
||||
/** What each module adds, in one line — used by the admin page and by `--help` style output. */
|
||||
export const MODULE_INFO: Record<ModuleId, { title: string; summary: string; needs: string }> = {
|
||||
print: {
|
||||
title: 'Print studio',
|
||||
summary: 'Exact physical sizes, sheet layout with crop marks, tiling, PDF at 1:1.',
|
||||
needs: 'Nothing beyond the database and a volume.',
|
||||
},
|
||||
ai: {
|
||||
title: 'Image generation',
|
||||
summary: 'Clean up, convert, combine and generate images with an image model.',
|
||||
needs: 'An OpenRouter API key (paid, per image).',
|
||||
},
|
||||
delivery: {
|
||||
title: 'Delivery to a remote target',
|
||||
summary: 'Push finished files to an SFTP or FTPS server, plus extra targets.',
|
||||
needs: 'A server you already have, and its credentials.',
|
||||
},
|
||||
mirror: {
|
||||
title: 'Backup mirror',
|
||||
summary: 'Copy every finished file to a second SFTP/FTPS target, sorted by month.',
|
||||
needs: 'A second server or a network drive that speaks SFTP/FTPS.',
|
||||
},
|
||||
share: {
|
||||
title: 'Share links',
|
||||
summary: 'Public, expiring, revocable links to a single image or a whole set.',
|
||||
needs: 'A publicly reachable URL for this instance.',
|
||||
},
|
||||
mail: {
|
||||
title: 'Email',
|
||||
summary: 'Password reset, job notifications and sending share links by mail.',
|
||||
needs: 'An SMTP mailbox of your own.',
|
||||
},
|
||||
telegram: {
|
||||
title: 'Telegram bot',
|
||||
summary: 'Use Klarbild from a chat instead of the browser.',
|
||||
needs: 'A bot token from @BotFather and a public webhook URL.',
|
||||
},
|
||||
};
|
||||
|
||||
function parse(raw: string | undefined): Set<ModuleId> {
|
||||
const on = new Set<ModuleId>(['print']); // never optional
|
||||
const value = (raw ?? '').trim();
|
||||
if (!value) return on;
|
||||
if (value.toLowerCase() === 'all') return new Set(MODULE_IDS);
|
||||
for (const piece of value.split(',')) {
|
||||
const id = piece.trim().toLowerCase() as ModuleId;
|
||||
if ((MODULE_IDS as readonly string[]).includes(id)) on.add(id);
|
||||
else if (id) console.warn(`[modules] unknown module "${id}" ignored (known: ${MODULE_IDS.join(', ')})`);
|
||||
}
|
||||
return on;
|
||||
}
|
||||
|
||||
const enabled = parse(process.env.KLARBILD_MODULES);
|
||||
|
||||
/** True if the module is switched on for this instance. */
|
||||
export function has(id: ModuleId): boolean {
|
||||
return enabled.has(id);
|
||||
}
|
||||
|
||||
/** The switched-on modules, in declaration order. Safe to send to the browser. */
|
||||
export function activeModules(): ModuleId[] {
|
||||
return MODULE_IDS.filter((id) => enabled.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for API routes belonging to a module that is off.
|
||||
*
|
||||
* Returning 404 rather than 403 is deliberate: to the outside world a module that
|
||||
* is off does not exist, so a disabled instance leaks nothing about what it could
|
||||
* have done.
|
||||
*/
|
||||
export function moduleOff(id: ModuleId): Response {
|
||||
return new Response(JSON.stringify({ error: `Module "${id}" is not enabled on this instance.` }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// File names for Klarbild results — deliberately **with no dependencies at all**,
|
||||
// so that the rules stay testable and are the same everywhere (job processing,
|
||||
// renaming, tests).
|
||||
//
|
||||
// Scheme: `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
||||
// 2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg
|
||||
//
|
||||
// Why: the timestamp comes first, so every file listing — delivery target,
|
||||
// Finder, backup mirror — sorts chronologically by itself. The name becomes
|
||||
// unique through the second **and** a short id derived from the item id. Before,
|
||||
// it was only `YYYY-MM-DD_<motif>_<format>`, and two badges of the same subject
|
||||
// on the same day got the same name — on delivery the second overwrote the first.
|
||||
|
||||
/** Lower case, with hyphens, without umlaut trouble. */
|
||||
export function slugify(s: string): string {
|
||||
return (s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
|
||||
}
|
||||
|
||||
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
|
||||
|
||||
/** A name we produced ourselves — timestamp in front, short id at the back.
|
||||
* Needed in order to strip our own stamp again when processing further, and to
|
||||
* recognise whether a name is already in the new scheme. */
|
||||
// All the parts are slugs (a–z, 0–9, hyphen) and therefore never contain an
|
||||
// underscore — so the pattern splits the name unambiguously into five parts.
|
||||
export const KLARBILD_NAME =
|
||||
/^(\d{4}-\d{2}-\d{2})_(\d{6})_([a-z0-9-]+)_([a-z0-9-]+)_([0-9a-z]{4})(?:-(\d+))?$/;
|
||||
|
||||
/** Peel the motif out of any (possibly already processed) name: remove date
|
||||
* prefixes, UUIDs and long hex strings, so that nothing gets duplicated. */
|
||||
function cleanMotif(name: string | null): string {
|
||||
let s = (name || '').replace(/\.[^.]+$/, '');
|
||||
// First strip our own stamp specifically — only if the name really matches our
|
||||
// pattern. Removing every group of six digits across the board would be too
|
||||
// coarse and would take apart real motifs such as "room104235".
|
||||
const m = KLARBILD_NAME.exec(s);
|
||||
if (m) s = m[3];
|
||||
// Work without \b — word boundaries do not bite at underscores (…_2026-07-24…).
|
||||
s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID
|
||||
s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // date (also more than once)
|
||||
s = s.replace(/[0-9a-f]{16,}/gi, ' '); // long hex strings
|
||||
return slugify(s);
|
||||
}
|
||||
|
||||
/** Time zone for the stamp in the file name. Deliberately the local time of the
|
||||
* installation — an image taken at 0:30 should not be dated to the previous day
|
||||
* (which is what UTC would do). */
|
||||
const TZ = process.env.KLARBILD_TZ || 'Europe/Berlin';
|
||||
const STAMP_FMT = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
});
|
||||
|
||||
/** "2026-08-20_143207" — sorted lexicographically = sorted chronologically. */
|
||||
export function timeStamp(at: Date = new Date()): string {
|
||||
const p: Record<string, string> = {};
|
||||
for (const { type, value } of STAMP_FMT.formatToParts(at)) p[type] = value;
|
||||
// Some Node versions return "24" instead of "00" for midnight.
|
||||
const hh = p.hour === '24' ? '00' : p.hour;
|
||||
return `${p.year}-${p.month}-${p.day}_${hh}${p.minute}${p.second}`;
|
||||
}
|
||||
|
||||
/** Four-character short id, **derived stably from the item id**.
|
||||
* Stable matters: a second run of the renaming has to give the same name,
|
||||
* otherwise the files move again on every run. */
|
||||
export function shortTag(id?: string | null): string {
|
||||
const hex = String(id || '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
||||
if (hex.length >= 4) return hex.slice(0, 4);
|
||||
return Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
||||
}
|
||||
|
||||
export interface NameParts {
|
||||
/** Original file name, out of which the motif is peeled. */
|
||||
originalName?: string | null;
|
||||
formatToken?: string;
|
||||
ext?: string;
|
||||
/** Point in time for the stamp — when renaming, the `created_at` of the image. */
|
||||
at?: Date;
|
||||
/** Item id for the stable short id. */
|
||||
id?: string | null;
|
||||
/** Counter, in case the name is taken after all (2, 3, …). */
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique, chronologically sortable file name:
|
||||
* `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
|
||||
* e.g. `2026-08-20_143207_portrait_the-frame_a3f9.png`
|
||||
*
|
||||
* Why like this: the name begins with the timestamp, so every file listing
|
||||
* (delivery target, Finder, backup mirror) sorts chronologically by itself. It
|
||||
* becomes unique through the second **and** the short id derived from the item
|
||||
* id — two images of the same subject in the same format on the same day no
|
||||
* longer overwrite each other.
|
||||
*/
|
||||
export function buildResultFilename(
|
||||
originalName: string | null, formatToken: string, ext = 'png', opts: Omit<NameParts, 'originalName' | 'formatToken' | 'ext'> = {},
|
||||
): string {
|
||||
const raw = cleanMotif(originalName);
|
||||
const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw;
|
||||
const fmt = slugify(formatToken) || 'original';
|
||||
const seq = opts.seq && opts.seq > 1 ? `-${opts.seq}` : '';
|
||||
return `${timeStamp(opts.at)}_${motif}_${fmt}_${shortTag(opts.id)}${seq}.${ext}`;
|
||||
}
|
||||
|
||||
export interface OldName { motif: string; format: string; ext: string }
|
||||
|
||||
/**
|
||||
* Split an existing name into motif, format and extension — for the renaming, so
|
||||
* that the names already chosen stay and only the timestamp plus short id are
|
||||
* set anew.
|
||||
*/
|
||||
export function splitOldName(filename: string | null): OldName {
|
||||
const raw = String(filename || '');
|
||||
const dot = raw.lastIndexOf('.');
|
||||
const ext = dot > 0 ? raw.slice(dot + 1).toLowerCase() : 'png';
|
||||
const stem = dot > 0 ? raw.slice(0, dot) : raw;
|
||||
|
||||
// Already in the new scheme: take over motif and format unchanged.
|
||||
const already = KLARBILD_NAME.exec(stem);
|
||||
if (already) return { motif: already[3], format: already[4], ext };
|
||||
|
||||
const parts = stem.split('_').filter(Boolean);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(parts[0] || '')) parts.shift(); // old date prefix
|
||||
const format = parts.length > 1 ? slugify(parts.pop()!) : 'original';
|
||||
const motif = slugify(parts.join('-')) || 'klarbild';
|
||||
return { motif: motif || 'klarbild', format: format || 'original', ext };
|
||||
}
|
||||
|
||||
/** The name an existing image would have to carry under the new scheme. */
|
||||
export function newNameFor(it: { id: string; filename: string | null; created_at: Date | string }, seq = 1): string {
|
||||
const { motif, format, ext } = splitOldName(it.filename);
|
||||
const at = it.created_at instanceof Date ? it.created_at : new Date(it.created_at);
|
||||
const s = seq > 1 ? `-${seq}` : '';
|
||||
return `${timeStamp(at)}_${motif}_${format}_${shortTag(it.id)}${s}.${ext}`;
|
||||
}
|
||||
|
||||
/** Format key for the file name (from the output_format). */
|
||||
export function formatToken(outputFormat: string | undefined): string {
|
||||
if (!outputFormat || outputFormat === 'keep') return 'original';
|
||||
if (outputFormat === 'theframe') return 'the-frame';
|
||||
if (outputFormat === 'portrait916') return 'portrait916';
|
||||
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Names for print files
|
||||
*
|
||||
* The same rule as for library results, for the same reason: two print sheets on
|
||||
* the same day were both called `2026-09-01_print-sheet.pdf` — the browser
|
||||
* appended "(1)", and whoever put the file on the backup mirror or into a folder
|
||||
* overwrote the first sheet. Exactly the bug that was fixed for the images in
|
||||
* August.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
/** Short token for the paper: "A4" → `a4`, otherwise the size in mm (`210x297`). */
|
||||
export function paperToken(paperId: string | null | undefined, wMm: number, hMm: number): string {
|
||||
const s = slugify(String(paperId || ''));
|
||||
if (s && s.length <= 12) return s;
|
||||
return `${Math.round(wMm)}x${Math.round(hMm)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name for a print sheet:
|
||||
* `2026-09-01_143207_print-9-pieces_a4_4eca.pdf`
|
||||
*
|
||||
* The short id comes from the id of the history entry — so the file can be
|
||||
* matched to an entry under "Last printed".
|
||||
*/
|
||||
export function buildSheetFilename(opts: {
|
||||
/** Name the caller wants, otherwise one is built from pieces and sheets. */
|
||||
name?: string | null;
|
||||
paperId?: string | null;
|
||||
wMm: number; hMm: number;
|
||||
pieces: number; pages: number;
|
||||
runId?: string | null;
|
||||
at?: Date;
|
||||
}): string {
|
||||
const pieces = opts.pieces === 1 ? 'print-1-piece' : `print-${opts.pieces}-pieces`;
|
||||
const sheets = opts.pages > 1 ? `-${opts.pages}-sheets` : '';
|
||||
const motif = slugify(String(opts.name || '')) || `${pieces}${sheets}`;
|
||||
return buildResultFilename(motif, paperToken(opts.paperId, opts.wMm, opts.hMm), 'pdf',
|
||||
{ id: opts.runId, at: opts.at });
|
||||
}
|
||||
|
||||
/**
|
||||
* Name for a single image brought to size:
|
||||
* `2026-09-01_143207_portrait-badge_13x18_25b3.jpg`
|
||||
* The motif comes from the source file, the short id from the image id.
|
||||
*/
|
||||
export function buildSingleFilename(opts: {
|
||||
name?: string | null;
|
||||
sourceName?: string | null;
|
||||
wMm: number; hMm: number;
|
||||
ext: string;
|
||||
id?: string | null;
|
||||
at?: Date;
|
||||
}): string {
|
||||
const motif = String(opts.name || '') || String(opts.sourceName || '');
|
||||
const size = `${trimNumber(opts.wMm)}x${trimNumber(opts.hMm)}`;
|
||||
return buildResultFilename(motif, size, opts.ext, { id: opts.id, at: opts.at });
|
||||
}
|
||||
|
||||
/** 45 → "45", 112.5 → "112-5" (in a file name a dot means the extension). */
|
||||
function trimNumber(n: number): string {
|
||||
return String(Math.round(n * 10) / 10).replace('.', '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the file name out of a `Content-Disposition` header.
|
||||
*
|
||||
* The browser gets the name from the server — the client should **not invent one
|
||||
* of its own**, otherwise there are two truths. That is exactly what happened:
|
||||
* the server sent a name, and the interface overwrote it with one of its own
|
||||
* that only contained the date.
|
||||
*/
|
||||
export function filenameFromHeader(header: string | null | undefined, fallback: string): string {
|
||||
const h = String(header || '');
|
||||
// RFC 5987 (filename*=UTF-8''…) takes precedence over the plain filename=.
|
||||
const star = /filename\*=UTF-8''([^;]+)/i.exec(h);
|
||||
if (star) { try { return safe(decodeURIComponent(star[1])) || fallback; } catch { /* carry on below */ } }
|
||||
const plain = /filename="?([^";]+)"?/i.exec(h);
|
||||
if (plain) return safe(plain[1]) || fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** A name read out of a header must never contain a path. */
|
||||
function safe(n: string): string {
|
||||
return n.replace(/[\\/\r\n\0]/g, '').replace(/^\.+/, '').trim().slice(0, 120);
|
||||
}
|
||||
|
||||
/**
|
||||
* Name for a ZIP of a selection from the library:
|
||||
* `2026-09-01_143207_selection-12-images_a3f9.zip`
|
||||
*
|
||||
* Before, every one of them was called `klarbild_2026-09-01.zip` — two different
|
||||
* selections on the same day in the same folder, and the second overwrote the
|
||||
* first.
|
||||
*/
|
||||
export function buildSelectionZipName(count: number, at: Date = new Date()): string {
|
||||
const what = count === 1 ? 'selection-1-image' : `selection-${count}-images`;
|
||||
return `${timeStamp(at)}_${what}_${shortTag(null)}.zip`;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { one } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
|
||||
const BASE = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/** Key: the DB value (set in the admin area) takes precedence over the environment variable. */
|
||||
export async function resolveKey(): Promise<string> {
|
||||
const row = await one<{ openrouter_key_enc: string | null }>(
|
||||
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
||||
if (row?.openrouter_key_enc) {
|
||||
try { return decrypt(row.openrouter_key_enc); } catch { /* falls back to ENV */ }
|
||||
}
|
||||
return process.env.OPENROUTER_API_KEY || '';
|
||||
}
|
||||
|
||||
export class OpenRouterError extends Error {
|
||||
constructor(public status: number, public friendly: string, msg?: string) {
|
||||
super(msg || friendly);
|
||||
}
|
||||
}
|
||||
|
||||
function friendlyFor(status: number): string {
|
||||
if (status === 402) return 'Credit used up — not possible right now.';
|
||||
if (status === 429) return 'Service overloaded — will be retried automatically.';
|
||||
if (status >= 500) return 'Service temporarily disrupted.';
|
||||
if (status === 401 || status === 403) return 'No access to the image service.';
|
||||
return 'Image processing failed.';
|
||||
}
|
||||
|
||||
export interface GenerateOpts {
|
||||
model: string;
|
||||
prompt: string;
|
||||
inputUrl?: string; // one reference image (data URL) — short form
|
||||
inputUrls?: string[]; // several reference images (compose) — up to 14/16 depending on the model
|
||||
aspectRatio?: string; // "16:9", "3:4" …
|
||||
resolution?: string; // "2K" | "4K" — NOT together with explicit pixels
|
||||
background?: 'transparent' | 'opaque';
|
||||
outputFormat?: 'png' | 'jpeg' | 'webp';
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
buffer: Buffer;
|
||||
mediaType: string;
|
||||
cost: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
|
||||
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
||||
const key = await resolveKey();
|
||||
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No OpenRouter key stored');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: opts.model,
|
||||
prompt: opts.prompt,
|
||||
n: 1,
|
||||
output_format: opts.outputFormat || 'png',
|
||||
};
|
||||
const refs = opts.inputUrls?.length ? opts.inputUrls : (opts.inputUrl ? [opts.inputUrl] : []);
|
||||
if (refs.length) body.input_references = refs.map((url) => ({ type: 'image_url', image_url: { url } }));
|
||||
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
|
||||
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // never both
|
||||
if (opts.background) body.background = opts.background;
|
||||
if (typeof opts.seed === 'number') body.seed = opts.seed;
|
||||
|
||||
const res = await fetch(`${BASE}/images`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Title': 'Klarbild',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new OpenRouterError(res.status, friendlyFor(res.status),
|
||||
`OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
|
||||
}
|
||||
|
||||
const json: any = await res.json();
|
||||
const b64 = json?.data?.[0]?.b64_json;
|
||||
if (!b64) throw new OpenRouterError(502, 'No image received.', 'Response without b64_json');
|
||||
return {
|
||||
buffer: Buffer.from(b64, 'base64'),
|
||||
mediaType: json.data[0].media_type || 'image/png',
|
||||
cost: Number(json?.usage?.cost ?? 0),
|
||||
model: opts.model,
|
||||
};
|
||||
}
|
||||
|
||||
/** The available image models (for the model list in the admin area). */
|
||||
export async function listImageModels(): Promise<any[]> {
|
||||
const key = await resolveKey();
|
||||
const res = await fetch(`${BASE}/images/models`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
||||
return (await res.json())?.data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Paper and image sizes for the print sheet (the "Print" module, no AI involved).
|
||||
// All dimensions in millimetres — mm is the leading unit of this module, because a
|
||||
// print sheet is a physical thing. Pixels only come into being at render time (mm → dpi).
|
||||
|
||||
export interface PaperSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
/** Sheet sizes (portrait convention: short side × long side). */
|
||||
export const PAPERS: PaperSize[] = [
|
||||
// DIN
|
||||
{ id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' },
|
||||
{ id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' },
|
||||
{ id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' },
|
||||
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
||||
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
|
||||
// Photo paper (cut sheets, as sold over the counter)
|
||||
{ id: 'F9x13', label: 'Photo paper 9 × 13 cm', w: 90, h: 130, group: 'Photo paper' },
|
||||
{ id: 'F10x15', label: 'Photo paper 10 × 15 cm', w: 100, h: 150, group: 'Photo paper' },
|
||||
{ id: 'F13x18', label: 'Photo paper 13 × 18 cm', w: 130, h: 180, group: 'Photo paper' },
|
||||
{ id: 'F15x20', label: 'Photo paper 15 × 20 cm', w: 150, h: 200, group: 'Photo paper' },
|
||||
{ id: 'F20x30', label: 'Photo paper 20 × 30 cm', w: 200, h: 300, group: 'Photo paper' },
|
||||
{ id: 'F10x10', label: 'Photo paper 10 × 10 cm', w: 100, h: 100, group: 'Photo paper' },
|
||||
{ id: 'F13x13', label: 'Photo paper 13 × 13 cm', w: 130, h: 130, group: 'Photo paper' },
|
||||
{ id: 'F4x6', label: 'Photo paper 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Photo paper' },
|
||||
{ id: 'F5x7', label: 'Photo paper 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Photo paper' },
|
||||
{ id: 'F8x10', label: 'Photo paper 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Photo paper' },
|
||||
// US
|
||||
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
||||
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
||||
];
|
||||
|
||||
/** Image sizes (the trim size of a single image on the sheet). */
|
||||
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
export const PHOTO_SIZES: PhotoSize[] = [
|
||||
// --- Passport / ID photo (national requirements) ---
|
||||
{ id: 'P35x45', label: 'Passport photo 35 × 45 mm (DE/EU, biometric)', w: 35, h: 45, group: 'Passport photo' },
|
||||
{ id: 'P33x48', label: 'China visa 33 × 48 mm', w: 33, h: 48, group: 'Passport photo' },
|
||||
{ id: 'P50x50', label: 'US visa 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passport photo' },
|
||||
{ id: 'P50x70', label: 'Canada passport photo 50 × 70 mm', w: 50, h: 70, group: 'Passport photo' },
|
||||
{ id: 'P45x35', label: 'Passport photo landscape 45 × 35 mm', w: 45, h: 35, group: 'Passport photo' },
|
||||
{ id: 'P25x35', label: 'Small ID 25 × 35 mm', w: 25, h: 35, group: 'Passport photo' },
|
||||
|
||||
// --- Small sizes (nursery, school, wallet) ---
|
||||
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Small' },
|
||||
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Small' },
|
||||
{ id: 'K35x50', label: '3.5 × 5 cm', w: 35, h: 50, group: 'Small' },
|
||||
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Small' },
|
||||
{ id: 'K45x60', label: '4.5 × 6 cm', w: 45, h: 60, group: 'Small' },
|
||||
{ id: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, group: 'Small' },
|
||||
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Small' },
|
||||
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Small' },
|
||||
{ id: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Small' },
|
||||
|
||||
// --- Classic photo sizes ---
|
||||
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Photo' },
|
||||
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Photo' },
|
||||
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Photo' },
|
||||
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Photo' },
|
||||
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Photo' },
|
||||
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Photo' },
|
||||
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, group: 'Photo' },
|
||||
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Photo' },
|
||||
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Photo' },
|
||||
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Photo' },
|
||||
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Photo' },
|
||||
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, group: 'Photo' },
|
||||
|
||||
// --- Inch sizes (US photo labs, frames bought abroad) ---
|
||||
{ id: 'Z2_5x3_5', label: 'Wallet 2.5 × 3.5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Inches (US)' },
|
||||
{ id: 'Z3_5x5', label: '3.5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Inches (US)' },
|
||||
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Inches (US)' },
|
||||
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Inches (US)' },
|
||||
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Inches (US)' },
|
||||
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Inches (US)' },
|
||||
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Inches (US)' },
|
||||
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Inches (US)' },
|
||||
|
||||
// --- Instant-film look-alikes (image area, not card size) ---
|
||||
{ id: 'I46x62', label: 'Instax mini · image area 46 × 62 mm', w: 46, h: 62, group: 'Instant photo' },
|
||||
{ id: 'I62x62', label: 'Instax Square · image area 62 × 62 mm', w: 62, h: 62, group: 'Instant photo' },
|
||||
{ id: 'I99x62', label: 'Instax Wide · image area 99 × 62 mm', w: 99, h: 62, group: 'Instant photo' },
|
||||
{ id: 'I79x79', label: 'Polaroid · image area 79 × 79 mm', w: 79, h: 79, group: 'Instant photo' },
|
||||
{ id: 'I88x107', label: 'Polaroid · whole card 88 × 107 mm', w: 88, h: 107, group: 'Instant photo' },
|
||||
|
||||
// --- Poster and frame sizes ---
|
||||
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & frames' },
|
||||
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & frames' },
|
||||
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & frames' },
|
||||
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & frames' },
|
||||
{ id: 'R45x60', label: '45 × 60 cm', w: 450, h: 600, group: 'Poster & frames' },
|
||||
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & frames' },
|
||||
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & frames' },
|
||||
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & frames' },
|
||||
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & frames' },
|
||||
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & frames' },
|
||||
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & frames' },
|
||||
|
||||
// --- Squares ---
|
||||
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Square' },
|
||||
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Square' },
|
||||
{ id: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Square' },
|
||||
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Square' },
|
||||
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Square' },
|
||||
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Square' },
|
||||
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Square' },
|
||||
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Square' },
|
||||
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Square' },
|
||||
|
||||
// --- DIN ---
|
||||
{ id: 'DA7', label: 'DIN A7 (7.4 × 10.5 cm)', w: 74, h: 105, group: 'DIN' },
|
||||
{ id: 'DA6', label: 'DIN A6 (10.5 × 14.8 cm)', w: 105, h: 148, group: 'DIN' },
|
||||
{ id: 'DA5', label: 'DIN A5 (14.8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
|
||||
{ id: 'DA4', label: 'DIN A4 (21 × 29.7 cm)', w: 210, h: 297, group: 'DIN' },
|
||||
{ id: 'DA3', label: 'DIN A3 (29.7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'DA3P', label: 'DIN A3+ (32.9 × 48.3 cm)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'DA2', label: 'DIN A2 (42 × 59.4 cm)', w: 420, h: 594, group: 'DIN' },
|
||||
{ id: 'DA1', label: 'DIN A1 (59.4 × 84.1 cm)', w: 594, h: 841, group: 'DIN' },
|
||||
{ id: 'DA0', label: 'DIN A0 (84.1 × 118.9 cm)', w: 841, h: 1189, group: 'DIN' },
|
||||
|
||||
// --- Cards and printed matter ---
|
||||
{ id: 'C85x55', label: 'Business card 85 × 55 mm', w: 85, h: 55, group: 'Cards' },
|
||||
{ id: 'C148x105', label: 'Postcard 14.8 × 10.5 cm (A6 landscape)', w: 148, h: 105, group: 'Cards' },
|
||||
{ id: 'C99x210', label: 'DL 9.9 × 21 cm', w: 99, h: 210, group: 'Cards' },
|
||||
{ id: 'C210x99', label: 'DL landscape 21 × 9.9 cm', w: 210, h: 99, group: 'Cards' },
|
||||
{ id: 'C54x86', label: 'Credit card 85.6 × 54 mm', w: 85.6, h: 54, group: 'Cards' },
|
||||
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
|
||||
|
||||
// --- Aspect ratios as a print size (worked out on 30 cm) ---
|
||||
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
||||
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
|
||||
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
|
||||
{ id: 'W3x2', label: '3:2 (30 cm wide)', w: 300, h: 200, group: 'Aspect ratio' },
|
||||
{ id: 'W2x3', label: '2:3 (30 cm tall)', w: 200, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W4x3', label: '4:3 (30 cm wide)', w: 300, h: 225, group: 'Aspect ratio' },
|
||||
{ id: 'W3x4', label: '3:4 (30 cm tall)', w: 225, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W5x4', label: '5:4 (30 cm wide)', w: 300, h: 240, group: 'Aspect ratio' },
|
||||
{ id: 'W4x5', label: '4:5 Instagram (30 cm tall)', w: 240, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W1x1', label: '1:1 square (30 cm)', w: 300, h: 300, group: 'Aspect ratio' },
|
||||
];
|
||||
|
||||
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||
export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null;
|
||||
|
||||
/**
|
||||
* Free-text size → mm. Deliberately generous, because people type sizes the way they say them:
|
||||
* "12x15" → 120 × 150 mm (cm is the default unit)
|
||||
* "12 × 15 cm" → 120 × 150 mm
|
||||
* "35x45mm" → 35 × 45 mm
|
||||
* "5" → 50 × 50 mm (square)
|
||||
* "4:3 / 15" → aspect ratio 4:3, longer side 15 cm → 150 × 112.5 mm
|
||||
*/
|
||||
export function parseSizeMm(input: string): { w: number; h: number } | null {
|
||||
const t = (input || '').trim().toLowerCase().replace(/,/g, '.');
|
||||
if (!t) return null;
|
||||
|
||||
// Aspect ratio with a target edge: "4:3 / 15" or "4:3 15cm"
|
||||
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
|
||||
if (ratio && ratio[3]) {
|
||||
// a:b is read as width:height — so "3:4/15" is portrait, "4:3/15" landscape.
|
||||
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
|
||||
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
||||
if (!a || !b || !long) return null;
|
||||
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
|
||||
}
|
||||
|
||||
const unit = /mm\s*$/.test(t) ? 1 : 10; // no unit given: cm
|
||||
const body = t.replace(/\s*(mm|cm)\s*$/, '').trim();
|
||||
|
||||
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body);
|
||||
if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit);
|
||||
|
||||
const sq = /^(\d+(?:\.\d+)?)$/.exec(body);
|
||||
if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function norm(w: number, h: number): { w: number; h: number } | null {
|
||||
const r = (n: number) => Math.round(n * 100) / 100;
|
||||
if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null;
|
||||
return { w: r(w), h: r(h) };
|
||||
}
|
||||
|
||||
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
|
||||
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
|
||||
|
||||
/** Readable caption for a size — without losing the real decimal places. */
|
||||
export function labelMm(w: number, h: number): string {
|
||||
const mm = (n: number) => dec(Math.round(n * 10) / 10);
|
||||
const cm = (n: number) => dec(Math.round(n * 100) / 1000);
|
||||
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
|
||||
}
|
||||
// Decimal separator of the caption. A point, i.e. English notation. The output
|
||||
// of `labelMm` is assembled at run time out of numbers, so it cannot go through
|
||||
// the dictionary — whatever is written here is what every instance shows, and an
|
||||
// English instance must not print "11,25 × 15 cm". `tests/printlayout.test.ts`
|
||||
// pins the spelling.
|
||||
const dec = (n: number) => String(n);
|
||||
@@ -0,0 +1,84 @@
|
||||
import sharp from 'sharp';
|
||||
import type { Dimensions } from './format';
|
||||
|
||||
export type CropMode = 'crop' | 'extend';
|
||||
|
||||
/**
|
||||
* Brings a (model) result exactly onto the target size and writes dpi metadata.
|
||||
* target=null → keep the original (only set the dpi). Keeps alpha if there is any.
|
||||
*/
|
||||
export async function finalizeToFormat(
|
||||
buffer: Buffer,
|
||||
target: Dimensions | null,
|
||||
cropMode: CropMode,
|
||||
dpi: number,
|
||||
): Promise<{ buffer: Buffer; width: number; height: number; hasAlpha: boolean }> {
|
||||
let img = sharp(buffer, { failOn: 'none' });
|
||||
const meta = await img.metadata();
|
||||
const hasAlpha = !!meta.hasAlpha;
|
||||
|
||||
if (target) {
|
||||
img = sharp(buffer, { failOn: 'none' }).resize(target.w, target.h, {
|
||||
fit: 'cover',
|
||||
position: cropMode === 'crop' ? sharp.strategy.attention : 'centre',
|
||||
});
|
||||
}
|
||||
|
||||
const out = await img
|
||||
.withMetadata({ density: dpi })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return { buffer: out.data, width: out.info.width, height: out.info.height, hasAlpha };
|
||||
}
|
||||
|
||||
/**
|
||||
* A white sticker border around the cut-out subject (without the model).
|
||||
* Extract the alpha → dilation (blur+threshold) → white contour layer → the original on top.
|
||||
* Radius = contourMm / 25.4 * dpi.
|
||||
*/
|
||||
export async function stickerContour(
|
||||
buffer: Buffer,
|
||||
contourMm: number,
|
||||
dpi: number,
|
||||
): Promise<Buffer> {
|
||||
const base = sharp(buffer, { failOn: 'none' }).ensureAlpha();
|
||||
const { width, height } = await base.metadata();
|
||||
if (!width || !height) throw new Error('Image dimensions unknown');
|
||||
|
||||
const radiusPx = Math.max(1, Math.round((contourMm / 25.4) * dpi));
|
||||
|
||||
// The alpha as a greyscale mask, widened by blur+threshold (dilation).
|
||||
const mask = await sharp(buffer, { failOn: 'none' })
|
||||
.ensureAlpha()
|
||||
.extractChannel('alpha')
|
||||
.blur(Math.max(0.3, radiusPx / 2))
|
||||
.threshold(1)
|
||||
.toColourspace('b-w')
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// A white layer with the widened mask as its alpha.
|
||||
const whiteLayer = await sharp({
|
||||
create: { width, height, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
||||
})
|
||||
.joinChannel(mask, { raw: { width, height, channels: 1 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Lay the original (RGBA) over the contour layer.
|
||||
const original = await base.png().toBuffer();
|
||||
return sharp(whiteLayer)
|
||||
.composite([{ input: original }])
|
||||
.withMetadata({ density: dpi })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** HEIC/HEIF → PNG buffer (in a standard build sharp usually cannot read HEIC). */
|
||||
export async function heicToPng(buffer: Buffer): Promise<Buffer> {
|
||||
const convert = (await import('heic-convert')).default as any;
|
||||
const out = await convert({ buffer, format: 'PNG' });
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// Pure layout mathematics for the print sheet — mm, no I/O, no dependencies.
|
||||
// Used identically by the preview in the browser and by the PDF renderer on the
|
||||
// server, so that "what I see" and "what gets printed" are the same calculation.
|
||||
//
|
||||
// Coordinates: origin at the top left, x to the right, y downwards (as on screen/SVG).
|
||||
// The PDF renderer mirrors y, because PDF counts from the bottom left.
|
||||
|
||||
export interface PlaceSpec {
|
||||
id: string; // points at an image
|
||||
wMm: number; // trim size (trim box) width
|
||||
hMm: number; // trim size height
|
||||
count: number; // how many times onto the paper
|
||||
allowRotate?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetSpec {
|
||||
wMm: number;
|
||||
hMm: number;
|
||||
marginMm: number; // the printer's non-printable margin (safety margin)
|
||||
gapMm: number; // distance between two trim sizes
|
||||
bleedMm?: number; // bleed per side (the image reaches beyond)
|
||||
center?: boolean; // centre the block on the sheet (default: true)
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
specId: string;
|
||||
copy: number; // 1-based
|
||||
x: number; y: number; // top left corner of the trim box
|
||||
w: number; h: number; // trim box
|
||||
rotated: boolean; // image placed rotated by 90°
|
||||
}
|
||||
|
||||
export interface Line { x1: number; y1: number; x2: number; y2: number }
|
||||
|
||||
export interface Page { placements: Placement[] }
|
||||
|
||||
export interface LayoutResult {
|
||||
pages: Page[];
|
||||
/** What does not fit on any sheet (e.g. image larger than the paper). */
|
||||
unplaced: { specId: string; count: number; reason: string }[];
|
||||
perSheet: number; // pieces on the first sheet (a figure for the UI)
|
||||
}
|
||||
|
||||
export type MarkMode = 'none' | 'corner' | 'grid';
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Effective gap: the bleeds of two neighbours must not overlap. */
|
||||
export function effectiveGap(sheet: SheetSpec): number {
|
||||
return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs the trim sizes onto as few sheets as possible.
|
||||
*
|
||||
* Two routes, deliberately kept apart:
|
||||
* 1. All images the same size → exact grid (columns × rows). Perfectly
|
||||
* aligned, so that continuous cut lines really do run through.
|
||||
* 2. Mixed sizes → MaxRects (best-short-side-fit) with optional
|
||||
* 90° rotation. This is the case "one large image plus eight passport
|
||||
* photos": the small ones move into the leftover area next to the large one.
|
||||
*
|
||||
* The gap is carried along as an "inflated" rectangle (image + gap), which is
|
||||
* why the spacing is right everywhere, including at the edge.
|
||||
*/
|
||||
export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||
const gap = effectiveGap(sheet);
|
||||
const bleed = sheet.bleedMm || 0;
|
||||
const inset = (sheet.marginMm || 0) + bleed;
|
||||
const availW = sheet.wMm - 2 * inset;
|
||||
const availH = sheet.hMm - 2 * inset;
|
||||
|
||||
const unplaced: LayoutResult['unplaced'] = [];
|
||||
const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = [];
|
||||
|
||||
for (const s of specs) {
|
||||
const n = Math.max(0, Math.floor(s.count || 0));
|
||||
if (!n) continue;
|
||||
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
|
||||
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
|
||||
if (!fitsPlain && !fitsRot) {
|
||||
unplaced.push({ specId: s.id, count: n, reason: 'larger than the usable area of the sheet' });
|
||||
continue;
|
||||
}
|
||||
for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false });
|
||||
}
|
||||
if (!units.length) return { pages: [], unplaced, perSheet: 0 };
|
||||
|
||||
const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS);
|
||||
let pages: Page[];
|
||||
|
||||
if (uniform) {
|
||||
pages = gridPack(units, specs, availW, availH, gap);
|
||||
} else {
|
||||
// Decide the orientation per image size (not per copy) — otherwise identical
|
||||
// images end up lying every which way on the sheet. Try all sensible
|
||||
// combinations and take the best one.
|
||||
const ids = [...new Set(units.map((u) => u.specId))];
|
||||
const rotatable = ids.filter((id) => {
|
||||
const sp = specs.find((s) => s.id === id)!;
|
||||
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
||||
});
|
||||
const tooBig = ids.length > 5 || units.length > 150;
|
||||
// With very many sizes/pieces, do not try every combination — but still give
|
||||
// every size an orientation in which it fits at all.
|
||||
const variants: Record<string, boolean>[] = tooBig
|
||||
? [Object.fromEntries(ids.map((id) => {
|
||||
const sp = specs.find((x) => x.id === id)!;
|
||||
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
|
||||
return [id, !plainFits && sp.allowRotate !== false];
|
||||
}))]
|
||||
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
|
||||
|
||||
let best: Page[] | null = null, bestScore = -Infinity;
|
||||
for (const [vi, rotMap] of variants.entries()) {
|
||||
const oriented = units.map((u) => {
|
||||
const r = !!rotMap[u.specId];
|
||||
const sp = specs.find((s) => s.id === u.specId)!;
|
||||
// Only allow orientations that fit onto the sheet at all.
|
||||
const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm;
|
||||
return { ...u, w, h, rot: r };
|
||||
});
|
||||
if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue;
|
||||
const cand = maxRectsPack(oriented, availW, availH, gap);
|
||||
const placed = cand.reduce((n, p) => n + p.placements.length, 0);
|
||||
if (placed < units.length) continue;
|
||||
// Few sheets counts for most, then a full first page, then "as little
|
||||
// rotated as possible" (the orientation the user asked for is more likely
|
||||
// to survive).
|
||||
const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi
|
||||
- 0.1 * Object.values(rotMap).filter(Boolean).length;
|
||||
if (score > bestScore) { bestScore = score; best = cand; }
|
||||
}
|
||||
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
||||
// Count the missing pieces per image size — do not blame them all on the first one.
|
||||
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
|
||||
const missing = new Map<string, number>();
|
||||
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
|
||||
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'no room on the sheet' });
|
||||
}
|
||||
|
||||
// Align on the sheet: centre of the block, or top left corner.
|
||||
for (const pg of pages) {
|
||||
let offX = inset, offY = inset;
|
||||
if (sheet.center !== false && pg.placements.length) {
|
||||
const minX = Math.min(...pg.placements.map((p) => p.x));
|
||||
const maxX = Math.max(...pg.placements.map((p) => p.x + p.w));
|
||||
const minY = Math.min(...pg.placements.map((p) => p.y));
|
||||
const maxY = Math.max(...pg.placements.map((p) => p.y + p.h));
|
||||
offX = inset + (availW - (maxX - minX)) / 2 - minX;
|
||||
offY = inset + (availH - (maxY - minY)) / 2 - minY;
|
||||
}
|
||||
for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); }
|
||||
}
|
||||
|
||||
return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 };
|
||||
}
|
||||
|
||||
/** Images of equal size: a clean grid, row by row. */
|
||||
function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[],
|
||||
availW: number, availH: number, gap: number): Page[] {
|
||||
const spec = specs.find((s) => s.id === units[0].specId);
|
||||
let { w, h } = units[0];
|
||||
let rot = false;
|
||||
const fit = (a: number, b: number) => ({
|
||||
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
|
||||
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
|
||||
});
|
||||
// The orientation chosen by the user takes precedence. It is rotated only if
|
||||
// that actually lowers the number of sheets (or if nothing fits unrotated).
|
||||
let best = fit(w, h);
|
||||
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
||||
const alt = fit(h, w);
|
||||
const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
|
||||
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
|
||||
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
|
||||
}
|
||||
const perPage = best.cols * best.rows;
|
||||
if (!perPage) return [];
|
||||
|
||||
const pages: Page[] = [];
|
||||
for (let i = 0; i < units.length; i += perPage) {
|
||||
const chunk = units.slice(i, i + perPage);
|
||||
const placements: Placement[] = chunk.map((u, k) => {
|
||||
const c = k % best.cols, r = Math.floor(k / best.cols);
|
||||
return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot };
|
||||
});
|
||||
pages.push({ placements });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
interface FreeRect { x: number; y: number; w: number; h: number }
|
||||
|
||||
/** Mixed sizes: MaxRects with best-short-side-fit, orientation already decided. */
|
||||
function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[],
|
||||
availW: number, availH: number, gap: number): Page[] {
|
||||
// Large ones first — otherwise the small pieces block the good areas.
|
||||
const todo = [...units].sort((a, b) =>
|
||||
(b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) ||
|
||||
a.specId.localeCompare(b.specId) || a.copy - b.copy);
|
||||
|
||||
const W = availW + gap, H = availH + gap; // inflated area
|
||||
const pages: Page[] = [];
|
||||
// Emergency brake against degenerate input (many sizes × many pieces): the
|
||||
// packer runs synchronously in the server process and must not block it.
|
||||
let steps = 0;
|
||||
const MAX_STEPS = 2_000_000;
|
||||
|
||||
while (todo.length) {
|
||||
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
|
||||
const placements: Placement[] = [];
|
||||
|
||||
for (;;) {
|
||||
let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity;
|
||||
let bestRect: FreeRect | null = null;
|
||||
|
||||
for (let i = 0; i < todo.length; i++) {
|
||||
const u = todo[i];
|
||||
const cw = u.w + gap, ch = u.h + gap;
|
||||
if ((steps += free.length) > MAX_STEPS) break;
|
||||
for (const r of free) {
|
||||
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
||||
const short = Math.min(r.w - cw, r.h - ch);
|
||||
// On a tie, take the area furthest to the top/left — that gives a
|
||||
// calm, readable sheet instead of scattered subjects.
|
||||
const pos = r.y * 10000 + r.x;
|
||||
if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) {
|
||||
bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
|
||||
|
||||
const u = todo.splice(bestIdx, 1)[0];
|
||||
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
|
||||
free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap });
|
||||
}
|
||||
|
||||
if (!placements.length) break; // nothing placeable → stop
|
||||
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
pages.push({ placements });
|
||||
if (pages.length > 200 || steps > MAX_STEPS) break; // emergency brake
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** All subsets (orientation combinations), stably sorted. */
|
||||
function combos(ids: string[]): Set<string>[] {
|
||||
const out: Set<string>[] = [];
|
||||
const n = Math.min(ids.length, 8);
|
||||
for (let m = 0; m < (1 << n); m++) {
|
||||
const s = new Set<string>();
|
||||
for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]);
|
||||
out.push(s);
|
||||
}
|
||||
// First "nothing rotated", then ascending by number of rotations.
|
||||
return out.sort((a, b) => a.size - b.size);
|
||||
}
|
||||
|
||||
function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
|
||||
const out: FreeRect[] = [];
|
||||
for (const r of free) {
|
||||
const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS
|
||||
&& used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS;
|
||||
if (!overlap) { out.push(r); continue; }
|
||||
if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h });
|
||||
if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h });
|
||||
if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y });
|
||||
if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) });
|
||||
}
|
||||
// Throw away rectangles contained in others (otherwise the list grows without bound).
|
||||
return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) =>
|
||||
j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS &&
|
||||
b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS &&
|
||||
(b.w > a.w + EPS || b.h > a.h + EPS || j < i)));
|
||||
}
|
||||
|
||||
/** How many trim sizes fit onto one sheet at most? (A figure for the UI.) */
|
||||
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
||||
// Calculate directly instead of packing — otherwise the trial run caps the answer.
|
||||
const gap = effectiveGap(sheet);
|
||||
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||
const grid = (w: number, h: number) =>
|
||||
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
|
||||
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
|
||||
const plain = grid(spec.wMm, spec.hMm);
|
||||
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
|
||||
return Math.max(plain, rot);
|
||||
}
|
||||
|
||||
export interface MarkOptions {
|
||||
mode: MarkMode;
|
||||
lengthMm?: number; // length of one mark (standard: 4–10 mm)
|
||||
offsetMm?: number; // distance outwards from the trim size
|
||||
bleedMm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop marks as line segments.
|
||||
* - `corner`: four pairs of corner marks per image, outside the trim size (the
|
||||
* Adobe / print-shop convention).
|
||||
* - `grid`: continuous lines across the whole sheet at every cut edge — for the
|
||||
* cutting rule or the guillotine, when there are many identical
|
||||
* images on the sheet.
|
||||
*/
|
||||
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||
if (opt.mode === 'none') return [];
|
||||
const len = opt.lengthMm ?? 4;
|
||||
// The offset has to clear the printed bleed — otherwise the mark lands on the
|
||||
// subject. A requested value that is too small is therefore raised.
|
||||
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
|
||||
const lines: Line[] = [];
|
||||
|
||||
if (opt.mode === 'corner') {
|
||||
for (const p of page.placements) {
|
||||
const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h;
|
||||
// per corner: one horizontal and one vertical mark, pointing outwards
|
||||
for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) {
|
||||
lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y });
|
||||
lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) });
|
||||
}
|
||||
}
|
||||
return clip(lines, sheet);
|
||||
}
|
||||
|
||||
// grid: draw the cut edges across the whole sheet — but only where no other
|
||||
// image is in the way. Otherwise the line would run right through the
|
||||
// neighbouring subject.
|
||||
const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w]));
|
||||
const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h]));
|
||||
for (const x of xs) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.x + EPS < x && x < p.x + p.w - EPS)
|
||||
.map((p) => [p.y, p.y + p.h] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b });
|
||||
}
|
||||
for (const y of ys) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.y + EPS < y && y < p.y + p.h - EPS)
|
||||
.map((p) => [p.x, p.x + p.w] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y });
|
||||
}
|
||||
return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b);
|
||||
|
||||
/** Free stretches in [from,to] after subtracting the occupied intervals. */
|
||||
function complement(blocked: [number, number][], from: number, to: number): [number, number][] {
|
||||
const sorted = [...blocked].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [];
|
||||
let cur = from;
|
||||
for (const [a, b] of sorted) {
|
||||
if (a > cur) out.push([cur, Math.min(a, to)]);
|
||||
cur = Math.max(cur, b);
|
||||
if (cur >= to) break;
|
||||
}
|
||||
if (cur < to) out.push([cur, to]);
|
||||
return out.filter(([a, b]) => b - a > 0.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest gap at which the chosen marks still make sense:
|
||||
* the corner marks of two neighbours each need (offset + length) of room, and
|
||||
* bleeds must not overlap.
|
||||
*/
|
||||
export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number {
|
||||
const off = offsetMm ?? Math.max(2, bleedMm + 1);
|
||||
const base = 2 * bleedMm;
|
||||
if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm));
|
||||
if (mode === 'grid') return Math.max(base, 0);
|
||||
return base;
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
/** Cut back, or drop, marks that would reach beyond the edge of the sheet. */
|
||||
function clip(lines: Line[], sheet: SheetSpec): Line[] {
|
||||
const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v));
|
||||
const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v));
|
||||
return lines
|
||||
.map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) }))
|
||||
.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
/** Is the source resolution enough for the trim size at the wanted dpi? */
|
||||
export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } {
|
||||
const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0;
|
||||
return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Oversize images: what to do when the trim size is larger than the sheet?
|
||||
// 20 × 30 cm on A4 photo paper is the everyday case — exactly 3 mm are missing.
|
||||
// Pure mathematics, no dependencies (like printlayout.ts), so that browser and
|
||||
// server use the same calculation.
|
||||
import type { Placement, SheetSpec } from './printlayout.ts';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
export type Oversize =
|
||||
| 'fit' // do nothing, report it as "does not fit" (default)
|
||||
| 'overflow' // place it centred on the sheet, the oversize is cut off in printing
|
||||
| 'tile'; // spread it over several sheets (poster print, to be glued together)
|
||||
|
||||
/** Does the trim size fit onto the usable area of the sheet — rotated if need be? */
|
||||
export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean {
|
||||
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||
const plain = wMm <= availW + EPS && hMm <= availH + EPS;
|
||||
const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS;
|
||||
return plain || rot;
|
||||
}
|
||||
|
||||
export interface OverflowInfo {
|
||||
/** Placement on the sheet — may be negative, in which case the image reaches beyond. */
|
||||
placement: Placement;
|
||||
rotated: boolean;
|
||||
/** How much is lost per side (mm), worked out after rotating. */
|
||||
lostLeft: number; lostRight: number; lostTop: number; lostBottom: number;
|
||||
lostMm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place an image that is too large centred on the **whole sheet** — deliberately
|
||||
* not on the usable area, because otherwise you give away the margin that a
|
||||
* borderless printer can very well print on. Whatever reaches beyond the sheet is
|
||||
* missing afterwards; how much, is in `lost*`.
|
||||
*/
|
||||
export function overflowPlacement(
|
||||
specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true,
|
||||
): OverflowInfo {
|
||||
const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm);
|
||||
const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS;
|
||||
const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm;
|
||||
|
||||
const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2);
|
||||
const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y));
|
||||
const lostRight = round(Math.max(0, x + w - sheet.wMm));
|
||||
const lostBottom = round(Math.max(0, y + h - sheet.hMm));
|
||||
return {
|
||||
placement: { specId, copy, x, y, w, h, rotated },
|
||||
rotated, lostLeft, lostRight, lostTop, lostBottom,
|
||||
lostMm: round(lostLeft + lostRight + lostTop + lostBottom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In a poster print every sheet carries "Sheet 2/4 · row 1, column 2" — that is
|
||||
* the assembly instruction and is therefore always printed (8 pt from 3 mm below
|
||||
* the top edge, needs about 6 mm). If the paper margin is tighter than that, the
|
||||
* tile has to move down accordingly; so that it does not stick out at the bottom,
|
||||
* the sheet is calculated correspondingly lower. Browser and server use the same
|
||||
* function, otherwise the preview would show a different number of sheets than
|
||||
* the finished PDF.
|
||||
*/
|
||||
export const NOTE_MM = 6;
|
||||
export function noteInset(sheet: SheetSpec): number {
|
||||
return Math.max(0, NOTE_MM - (sheet.marginMm || 0));
|
||||
}
|
||||
export function tileSheet(sheet: SheetSpec): SheetSpec {
|
||||
const n = noteInset(sheet);
|
||||
return n ? { ...sheet, hMm: sheet.hMm - n } : sheet;
|
||||
}
|
||||
|
||||
export interface Tile {
|
||||
col: number; row: number; // 0-based
|
||||
xMm: number; yMm: number; // section of the target image, from the top left corner
|
||||
wMm: number; hMm: number;
|
||||
glueRight: boolean; glueBottom: boolean; // a neighbouring tile joins on there
|
||||
}
|
||||
|
||||
export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number }
|
||||
|
||||
/**
|
||||
* Poster print: spread one image over several sheets.
|
||||
* The tiles overlap by `overlapMm` — that strip is the glue flap. It is printed
|
||||
* on both neighbouring sheets; when assembling, you lay the one sheet onto the
|
||||
* other along the dashed line.
|
||||
*/
|
||||
export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan {
|
||||
const inset = sheet.marginMm || 0;
|
||||
const availW = Math.max(10, sheet.wMm - 2 * inset);
|
||||
const availH = Math.max(10, sheet.hMm - 2 * inset);
|
||||
const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3));
|
||||
|
||||
const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o);
|
||||
const cols = Math.max(1, Math.ceil((wMm - o) / stepX));
|
||||
const rows = Math.max(1, Math.ceil((hMm - o) / stepY));
|
||||
|
||||
const tiles: Tile[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * stepX, y = r * stepY;
|
||||
const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y);
|
||||
if (w <= 0.5 || h <= 0.5) continue;
|
||||
tiles.push({
|
||||
col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h),
|
||||
glueRight: c < cols - 1, glueBottom: r < rows - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { cols, rows, tiles, overlapMm: round(o) };
|
||||
}
|
||||
|
||||
/** The section of one tile, nested inside the user's crop. */
|
||||
export function tileCrop(
|
||||
crop: { x: number; y: number; w: number; h: number },
|
||||
tile: Tile, wMm: number, hMm: number,
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
return {
|
||||
x: crop.x + (tile.xMm / wMm) * crop.w,
|
||||
y: crop.y + (tile.yMm / hMm) * crop.h,
|
||||
w: (tile.wMm / wMm) * crop.w,
|
||||
h: (tile.hMm / hMm) * crop.h,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
|
||||
// Deliberately without AI — plain geometry and scaling, so that the result is
|
||||
// predictable and repeatable.
|
||||
import sharp from 'sharp';
|
||||
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
||||
import { mmToPt, mmToPx } from './paper.ts';
|
||||
import type { Line, Page, SheetSpec } from './printlayout.ts';
|
||||
import { baseCrop, type CropRel, type FitMode } from './cropmath.ts';
|
||||
|
||||
/** Crop relative to the source (0..1) — storable independently of resolution. */
|
||||
export type { CropRel, FitMode };
|
||||
|
||||
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
|
||||
|
||||
/* `FitMode`: `cover` = crop until filled (something is missing at the outside),
|
||||
`contain` = fit the whole image in (the letterbox colour stays at the outside).
|
||||
Never distort. */
|
||||
|
||||
/** Largest centred crop in the target ratio — fills it out, cuts something off. */
|
||||
// Both entry points calculate via `src/lib/cropmath.ts` — the same file the
|
||||
// browser preview uses. `tests/cropmath.test.ts` holds the two routes against
|
||||
// each other, so that preview and PDF cannot drift apart.
|
||||
export const coverCrop = (natW: number, natH: number, aspect: number): CropRel =>
|
||||
baseCrop(natW, natH, aspect, 'cover');
|
||||
|
||||
/**
|
||||
* Smallest crop in the target ratio that contains the **whole** image.
|
||||
* It deliberately reaches beyond the edge of the image (w or h > 1) — that is
|
||||
* where the letterbox area appears.
|
||||
*/
|
||||
export const containCrop = (natW: number, natH: number, aspect: number): CropRel =>
|
||||
baseCrop(natW, natH, aspect, 'contain');
|
||||
|
||||
/**
|
||||
* Brings a section of an image to an exact physical size.
|
||||
* `bleedMm` enlarges the rendered field outwards; the trim box stays exactly the
|
||||
* chosen section. Missing edge pixels are continued from the edge
|
||||
* (extendWith 'copy') instead of squashing the subject.
|
||||
*/
|
||||
export async function renderCell(
|
||||
input: Buffer,
|
||||
crop: CropRel | null,
|
||||
wMm: number,
|
||||
hMm: number,
|
||||
dpi: number,
|
||||
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
|
||||
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
|
||||
const bleed = Math.max(0, opt.bleedMm || 0);
|
||||
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
|
||||
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
|
||||
|
||||
// Apply the EXIF orientation BEFORE anything is calculated. Phone photos carry
|
||||
// the rotation only as metadata; browsers and the iPhone show them rotated,
|
||||
// while sharp without this step calculates on the unrotated raster — and then
|
||||
// the image comes out of the printer sideways and with the wrong crop.
|
||||
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
|
||||
const src = (meta0.orientation ?? 1) > 1
|
||||
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
|
||||
: input;
|
||||
|
||||
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
|
||||
const nw = meta.width || 0, nh = meta.height || 0;
|
||||
if (!nw || !nh) throw new Error('Image dimensions unknown.');
|
||||
|
||||
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
|
||||
? containCrop(nw, nh, wMm / hMm)
|
||||
: coverCrop(nw, nh, wMm / hMm));
|
||||
|
||||
// Bleed: widen the crop from the centre, so that the trim box stays exactly
|
||||
// the framed area.
|
||||
const fx = (wMm + 2 * bleed) / wMm;
|
||||
const fy = (hMm + 2 * bleed) / hMm;
|
||||
const cw = c.w * fx, ch = c.h * fy;
|
||||
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
|
||||
|
||||
const targetW = mmToPx(wMm + 2 * bleed, dpi);
|
||||
const targetH = mmToPx(hMm + 2 * bleed, dpi);
|
||||
if (targetW * targetH > MAX_TARGET_PX)
|
||||
throw new Error(`Target image too large (${targetW}×${targetH} px). Please reduce the size or the resolution.`);
|
||||
|
||||
// Visible part of the crop (the rest is filled, not rendered).
|
||||
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
|
||||
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
|
||||
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Crop lies outside the image.');
|
||||
|
||||
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
|
||||
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
|
||||
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
|
||||
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
|
||||
|
||||
// Mapping crop → target image (scale in px per fraction of the crop).
|
||||
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
|
||||
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
|
||||
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
|
||||
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
|
||||
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
|
||||
const right = targetW - dx - dw, bottom = targetH - dy - dh;
|
||||
|
||||
// A single sharp chain: extract → resize → extend. That order is sharp's own
|
||||
// internal order, which is why the numbers add up. Important: only pad after
|
||||
// scaling down, otherwise the intermediate image grows beyond all measure.
|
||||
let img = sharp(src, { failOn: 'none' })
|
||||
.extract({ left: L, top: T, width: W, height: H })
|
||||
.resize(dw, dh, { fit: 'fill' });
|
||||
if (dx || dy || right || bottom) {
|
||||
img = img.extend(fit === 'contain'
|
||||
? { top: dy, left: dx, bottom, right, background: bg }
|
||||
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
|
||||
}
|
||||
|
||||
const hasAlpha = !!meta.hasAlpha;
|
||||
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
||||
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
|
||||
|
||||
const encode = (pipe: sharp.Sharp) => (ext === 'png'
|
||||
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
|
||||
|
||||
// Rotate only in the second pass: otherwise sharp would apply a rotation
|
||||
// before the padding and put the margins on the wrong sides.
|
||||
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
|
||||
}
|
||||
|
||||
/** Upper bound for the target image — guards against a memory explosion from extreme size/dpi combinations. */
|
||||
const MAX_TARGET_PX = 300_000_000;
|
||||
|
||||
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
|
||||
|
||||
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
|
||||
if (!c) return FULL_CROP;
|
||||
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
|
||||
// When letterboxing, the crop may be larger than the image (that is where the
|
||||
// letterbox area appears); when cropping it has to lie inside the image.
|
||||
const maxSide = fit === 'contain' ? 8 : 1;
|
||||
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
|
||||
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
|
||||
if (fit === 'contain') {
|
||||
// Only prevent the image from being pushed completely out of the crop.
|
||||
const x = Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, num(c.x)));
|
||||
const y = Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, num(c.y)));
|
||||
return { x, y, w, h };
|
||||
}
|
||||
const x = Math.min(1 - w, Math.max(0, num(c.x)));
|
||||
const y = Math.min(1 - h, Math.max(0, num(c.y)));
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
|
||||
|
||||
/** Where the footer sits. The edge first, the alignment second.
|
||||
* The values stay as they are — they cross the wire in the API request body. */
|
||||
export type FooterPos =
|
||||
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
||||
| 'top-left' | 'top-center' | 'top-rechts';
|
||||
|
||||
export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number }
|
||||
|
||||
/** Extras per page — for the poster print: sheet number and glue flap. */
|
||||
export interface PageExtras { note?: string | null; dashed?: Line[] }
|
||||
|
||||
export interface SheetPdfInput {
|
||||
sheet: SheetSpec;
|
||||
pages: Page[];
|
||||
marksPerPage: Line[][];
|
||||
/** specId → finished rendered image (bleed included, rotated if applicable). */
|
||||
images: Record<string, SheetCellImage>;
|
||||
title?: string;
|
||||
footer?: FooterSpec | null;
|
||||
extrasPerPage?: PageExtras[];
|
||||
markWidthPt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the print PDF. Page size = sheet size in mm, 1:1 — when printing, be
|
||||
* sure to choose "Actual size / 100 %", not "fit to page".
|
||||
*/
|
||||
export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
|
||||
const { sheet, pages, marksPerPage, images } = input;
|
||||
const pdf = await PDFDocument.create();
|
||||
pdf.setTitle(input.title || 'Klarbild print sheet');
|
||||
pdf.setProducer('Klarbild');
|
||||
pdf.setCreator('Klarbild — print sheet');
|
||||
|
||||
const embedded: Record<string, any> = {};
|
||||
for (const [id, img] of Object.entries(images)) {
|
||||
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
|
||||
}
|
||||
const needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note);
|
||||
const font = needsFont ? await pdf.embedFont(StandardFonts.Helvetica) : null;
|
||||
const bleed = sheet.bleedMm || 0;
|
||||
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
|
||||
|
||||
pages.forEach((pg, i) => {
|
||||
const page = pdf.addPage([W, H]);
|
||||
for (const p of pg.placements) {
|
||||
const im = embedded[p.specId];
|
||||
if (!im) continue;
|
||||
// Image including bleed: reaches beyond the trim box by `bleed` per side.
|
||||
const x = mmToPt(p.x - bleed);
|
||||
const yTop = p.y - bleed; // counted from the top
|
||||
const w = mmToPt(p.w + 2 * bleed);
|
||||
const h = mmToPt(p.h + 2 * bleed);
|
||||
page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h });
|
||||
}
|
||||
for (const l of marksPerPage[i] || []) {
|
||||
page.drawLine({
|
||||
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
|
||||
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
|
||||
thickness: input.markWidthPt ?? 0.25,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
// Glue flaps in the poster print: dashed, so that nobody mistakes them for a
|
||||
// cut edge.
|
||||
const extras = input.extrasPerPage?.[i];
|
||||
for (const l of extras?.dashed || []) {
|
||||
page.drawLine({
|
||||
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
|
||||
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
|
||||
thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3],
|
||||
});
|
||||
}
|
||||
|
||||
if (font) {
|
||||
const put = (text: string, pos: FooterPos, size: number) => {
|
||||
const w = font.widthOfTextAtSize(text, size);
|
||||
const top = pos.startsWith('top');
|
||||
const y = top ? H - mmToPt(3) - size : mmToPt(3);
|
||||
const x = pos.endsWith('center') ? (W - w) / 2
|
||||
: pos.endsWith('rechts') ? W - mmToPt(4) - w
|
||||
: mmToPt(4);
|
||||
page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) });
|
||||
};
|
||||
|
||||
// The footer only if there really is room at the chosen edge —
|
||||
// otherwise it would sit in the middle of the subject.
|
||||
if (input.footer?.text) {
|
||||
const pos = input.footer.position || 'bottom-left';
|
||||
const size = input.footer.sizePt ?? 6;
|
||||
const top = pos.startsWith('top');
|
||||
const free = pg.placements.length
|
||||
? (top ? Math.min(...pg.placements.map((p) => p.y - bleed))
|
||||
: sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)))
|
||||
: sheet.hMm;
|
||||
if (free >= size * 0.353 + 4) put(input.footer.text, pos, size);
|
||||
}
|
||||
// The sheet number in a poster print is always there — it is the assembly instruction.
|
||||
if (extras?.note) put(extras.note, 'top-left', 8);
|
||||
}
|
||||
});
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Print history — what was printed last, and how to repeat it.
|
||||
//
|
||||
// The crucial point: what gets stored is the **instruction**, not the result.
|
||||
// An A3 sheet as a PDF is easily 20–80 MB; a few dozen of those fill the disk
|
||||
// with files that can be recalculated in seconds. So what lies here is the
|
||||
// request body with which `/api/print/sheet` produces the same sheet again
|
||||
// — around 1–3 KB per entry.
|
||||
//
|
||||
// That only works because the print module is **deterministic**: the same
|
||||
// sources and the same settings give the same PDF. For the AI jobs it would be
|
||||
// nonsense, there something different would come out every time.
|
||||
import { one, query } from './db.ts';
|
||||
|
||||
export const MAX_RUNS_PER_USER = 100;
|
||||
|
||||
export interface PrintRun {
|
||||
id: string;
|
||||
created_by: string | null;
|
||||
origin: string;
|
||||
summary: string | null;
|
||||
paper: string | null;
|
||||
pages: number;
|
||||
pieces: number;
|
||||
bytes: number | null;
|
||||
config: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RunInput {
|
||||
/** Predetermined id — the file name of the PDF carries its short id. */
|
||||
id?: string | null;
|
||||
userId: string | null;
|
||||
origin?: string;
|
||||
summary: string;
|
||||
paper: string;
|
||||
pages: number;
|
||||
pieces: number;
|
||||
bytes: number;
|
||||
/** The request body, unchanged. */
|
||||
config: any;
|
||||
/** Ids of the library images that were used. */
|
||||
itemIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What was on the sheet, in one sentence.
|
||||
*
|
||||
* The list comes in cell by cell — two cells with the same size would otherwise
|
||||
* give "10 × 15 cm, 10 × 15 cm". Identical sizes are therefore pulled together
|
||||
* and their piece counts added up; an existing "×3" is counted along, so that
|
||||
* "×3 ×2" does not appear.
|
||||
*/
|
||||
export function summarise(kinds: string[]): string {
|
||||
if (!kinds.length) return 'Print sheet';
|
||||
|
||||
// Only the **trailing** piece count counts: "35 × 45 mm ×8" has two × in it,
|
||||
// and a greedy expression promptly turned "13 × 18 cm" into "13 cm ×18".
|
||||
// That is why it is anchored to the end, with an optional addition in
|
||||
// parentheses behind it.
|
||||
const COUNT = /^(.*?)\s*×\s*(\d+)(\s*\([^)]*\))?$/;
|
||||
|
||||
const sums = new Map<string, number>();
|
||||
for (const k of kinds) {
|
||||
const m = COUNT.exec(k);
|
||||
const base = m ? `${m[1]}${m[3] || ''}` : k;
|
||||
const n = m ? Number(m[2]) : 1;
|
||||
sums.set(base, (sums.get(base) || 0) + n);
|
||||
}
|
||||
|
||||
// "30 × 40 cm (poster) ×3" would read awkwardly — the piece count belongs in
|
||||
// front of the addition, where it came in.
|
||||
const parts = [...sums].map(([base, n]) => {
|
||||
if (n <= 1) return base;
|
||||
const z = /^(.*?)(\s*\([^)]*\))$/.exec(base);
|
||||
return z ? `${z[1]} ×${n}${z[2]}` : `${base} ×${n}`;
|
||||
});
|
||||
const s = parts.join(', ');
|
||||
return s.length <= 120 ? s : `${s.slice(0, 117)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a print.
|
||||
*
|
||||
* An error here must **never** make the print fail — by that point the PDF has
|
||||
* long been finished and is on its way to the user. That is why the caller
|
||||
* catches, and this function only reports to the log.
|
||||
*/
|
||||
export async function recordRun(e: RunInput): Promise<string | null> {
|
||||
const run = await one<{ id: string }>(
|
||||
`INSERT INTO print_runs (id, created_by, origin, summary, paper, pages, pieces, bytes, config)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||
[e.id || null, e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes,
|
||||
JSON.stringify(e.config)]);
|
||||
if (!run) return null;
|
||||
|
||||
const ids = [...new Set(e.itemIds.filter(Boolean))];
|
||||
if (ids.length) {
|
||||
// Images may have been deleted in the meantime — then the link falls away
|
||||
// and the entry stays.
|
||||
await query(
|
||||
`INSERT INTO print_run_items (run_id, item_id)
|
||||
SELECT $1, x FROM unnest($2::uuid[]) AS x
|
||||
WHERE EXISTS (SELECT 1 FROM items WHERE id = x)
|
||||
ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
|
||||
console.error('[printruns] image references not stored:', err?.message || err));
|
||||
}
|
||||
|
||||
// Clear away older entries of the same user. Without this the list grows
|
||||
// without bound, and nobody scrolls back to a print from two years ago.
|
||||
if (e.userId) {
|
||||
await query(
|
||||
`DELETE FROM print_runs
|
||||
WHERE created_by = $1 AND id NOT IN (
|
||||
SELECT id FROM print_runs WHERE created_by = $1
|
||||
ORDER BY created_at DESC LIMIT $2)`,
|
||||
[e.userId, MAX_RUNS_PER_USER]).catch(() => {});
|
||||
}
|
||||
return run.id;
|
||||
}
|
||||
|
||||
export interface RunRow extends PrintRun {
|
||||
/** Images that still exist — only with those can the print be repeated. */
|
||||
images: { id: string; filename: string | null }[];
|
||||
/** How many **library images** have been deleted since. */
|
||||
missing: number;
|
||||
/** How many cells came from an uploaded file — those are gone. */
|
||||
uploaded: number;
|
||||
/** Can the sheet be produced again unchanged? */
|
||||
repeatable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent prints. Admins see all of them, everybody else only their
|
||||
* own — the same rule as for the share links.
|
||||
*/
|
||||
export async function listRuns(userId: string | null, isAdmin: boolean, limit = 30): Promise<RunRow[]> {
|
||||
const runs = await query<PrintRun>(
|
||||
`SELECT * FROM print_runs
|
||||
WHERE $2::boolean OR created_by = $1
|
||||
ORDER BY created_at DESC LIMIT $3`, [userId, isAdmin, Math.min(limit, 100)]);
|
||||
if (!runs.length) return [];
|
||||
|
||||
const images = await query<{ run_id: string; id: string; filename: string | null }>(
|
||||
`SELECT ri.run_id, i.id, i.filename
|
||||
FROM print_run_items ri JOIN items i ON i.id = ri.item_id
|
||||
WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`,
|
||||
[runs.map((r) => r.id)]);
|
||||
|
||||
const byRun = new Map<string, { id: string; filename: string | null }[]>();
|
||||
for (const b of images) {
|
||||
const l = byRun.get(b.run_id) || [];
|
||||
l.push({ id: b.id, filename: b.filename });
|
||||
byRun.set(b.run_id, l);
|
||||
}
|
||||
|
||||
return runs.map((r) => {
|
||||
const present = byRun.get(r.id) || [];
|
||||
const cells: any[] = Array.isArray(r.config?.cells) ? r.config.cells : [];
|
||||
// Only cells from the library can be repeated. Uploaded files lie under
|
||||
// `sources/…` and are cleaned up after processing depending on the settings
|
||||
// — then the source is missing.
|
||||
const fromLibrary = cells.filter((c) => c?.src?.kind === 'item').length;
|
||||
// Different reasons, explained differently: a deleted library image is
|
||||
// something other than a file that was never in the library. Throwing both
|
||||
// into one counter led to "1 image is no longer there" for a sheet whose
|
||||
// source was simply an upload.
|
||||
const distinct = new Set(cells.filter((c) => c?.src?.kind === 'item')
|
||||
.map((c) => String(c.src.id))).size;
|
||||
return {
|
||||
...r,
|
||||
images: present,
|
||||
missing: Math.max(0, distinct - present.length),
|
||||
uploaded: cells.length - fromLibrary,
|
||||
// Repeatable only if **all** cells come from the library and all images
|
||||
// are still there. Half will not do: a sheet with a missing image would
|
||||
// not be the same sheet.
|
||||
repeatable: cells.length > 0 && fromLibrary === cells.length && present.length === cells.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single print — for producing it again. */
|
||||
export async function getRun(id: string, userId: string | null, isAdmin: boolean): Promise<PrintRun | null> {
|
||||
return one<PrintRun>(
|
||||
`SELECT * FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2)`,
|
||||
[id, userId, isAdmin]);
|
||||
}
|
||||
|
||||
export async function deleteRun(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||
const r = await query<{ id: string }>(
|
||||
`DELETE FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`,
|
||||
[id, userId, isAdmin]);
|
||||
return r.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Resolving the image sources for the print sheet: freshly uploaded or from the library.
|
||||
import { one } from './db';
|
||||
import { getObject } from './storage';
|
||||
|
||||
export type PrintSource =
|
||||
| { kind: 'upload'; path: string }
|
||||
| { kind: 'item'; id: string };
|
||||
|
||||
export interface SessionUser { uid: string | null; role: string }
|
||||
|
||||
/** Loads the image data and checks the access rights while doing so, like /api/items/:id/file. */
|
||||
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
|
||||
return (await loadSourceNamed(src, user)).buffer;
|
||||
}
|
||||
|
||||
export interface LoadedSource {
|
||||
buffer: Buffer;
|
||||
/** Original file name, as far as it is known — for the name of the output file. */
|
||||
filename: string | null;
|
||||
/** Id of the library image, otherwise null — supplies the short id in the name. */
|
||||
itemId: string | null;
|
||||
}
|
||||
|
||||
/** Like `loadSource`, but also returns the name of the source. */
|
||||
export async function loadSourceNamed(src: PrintSource, user: SessionUser): Promise<LoadedSource> {
|
||||
if (!src || typeof src !== 'object') throw new Error('Source missing.');
|
||||
|
||||
if (src.kind === 'upload') {
|
||||
// Only the upload area can be reached — no route to results/ or /etc.
|
||||
const p = String(src.path || '');
|
||||
// \n deliberately excluded: JS "$" also matches before a trailing line break.
|
||||
if (/[\r\n]/.test(p) || !/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p))
|
||||
throw new Error('Invalid source.');
|
||||
return { buffer: await getObject(p), filename: p.split('/').pop() || null, itemId: null };
|
||||
}
|
||||
|
||||
if (src.kind === 'item') {
|
||||
const item = await one<any>(
|
||||
`SELECT i.result_path, i.filename, j.created_by, j.private
|
||||
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
|
||||
if (!item?.result_path) throw new Error('Image not found.');
|
||||
const isAdmin = user.role === 'admin';
|
||||
const own = item.created_by === user.uid;
|
||||
if (!isAdmin && !own) {
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
if (item.private || s?.library_visibility !== 'shared') throw new Error('No access to this image.');
|
||||
}
|
||||
return { buffer: await getObject(item.result_path), filename: item.filename ?? null, itemId: String(src.id) };
|
||||
}
|
||||
|
||||
throw new Error('Unknown kind of source.');
|
||||
}
|
||||
|
||||
// `sheetFilename` is gone: all it did was put the date in front of the name, so
|
||||
// two sheets from the same day were called the same. The naming rules now live
|
||||
// in full in `naming.ts` — the same ones as for library results.
|
||||
@@ -0,0 +1,230 @@
|
||||
import { one, query } from './db';
|
||||
import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage';
|
||||
import { generateImage } from './openrouter';
|
||||
import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
|
||||
import { resolveDimensions, aspectRatio, type Orientation } from './format';
|
||||
import { finalizeToFormat, stickerContour, type CropMode } from './pipeline';
|
||||
import { formatToken } from './naming';
|
||||
import { uniqueFilename } from './renames';
|
||||
import { embedProvenance, TRAINED_ALGORITHMIC_MEDIA, COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA } from './provenance';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
||||
|
||||
type Mode = 'each' | 'compose' | 'generate';
|
||||
|
||||
interface RecipeSnapshot {
|
||||
tasks: Task[];
|
||||
output_format?: string;
|
||||
orientation?: Orientation;
|
||||
crop_mode?: CropMode;
|
||||
dpi?: number;
|
||||
contour_mm?: number | null;
|
||||
model_key?: string | null;
|
||||
custom_instruction?: string | null;
|
||||
prompt_text?: string | null; // description for compose/generate
|
||||
delivery?: 'library' | 'remote' | 'both';
|
||||
delivery_folder?: string | null;
|
||||
output_ext?: 'png' | 'jpg' | null; // file-format override; otherwise the global setting
|
||||
}
|
||||
|
||||
/** Which model id + alpha capability? From the models table, otherwise the default. */
|
||||
async function pickModel(modelKey?: string | null): Promise<{ id: string; alpha: boolean }> {
|
||||
if (modelKey) {
|
||||
const m = await one<{ model_id: string; supports_alpha: boolean }>(
|
||||
'SELECT model_id, supports_alpha FROM models WHERE label=$1 OR model_id=$1 LIMIT 1', [modelKey]);
|
||||
if (m) return { id: m.model_id, alpha: m.supports_alpha };
|
||||
}
|
||||
const def = await one<{ model_id: string; supports_alpha: boolean }>(
|
||||
'SELECT model_id, supports_alpha FROM models WHERE active AND is_default ORDER BY sort LIMIT 1');
|
||||
if (def) return { id: def.model_id, alpha: def.supports_alpha };
|
||||
return { id: DEFAULT_MODEL, alpha: true };
|
||||
}
|
||||
|
||||
async function toDataUrl(buf: Buffer): Promise<string> {
|
||||
const small = await sharp(buf, { failOn: 'none' })
|
||||
.resize(1536, 1536, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
return `data:image/jpeg;base64,${small.toString('base64')}`;
|
||||
}
|
||||
|
||||
/** A small preview image (webp) for the library — saves bandwidth and storage. */
|
||||
async function makeThumb(buf: Buffer): Promise<Buffer> {
|
||||
return sharp(buf, { failOn: 'none' })
|
||||
.resize(600, 600, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 72 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export interface ProcessResult { ok: boolean; cost: number; error?: string; }
|
||||
|
||||
/** Processes one item completely and updates its record. */
|
||||
export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||
const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
|
||||
'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
|
||||
if (!item) return { ok: false, cost: 0, error: 'Item not found' };
|
||||
|
||||
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
|
||||
'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
|
||||
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
||||
const mode: Mode = job?.mode || 'each';
|
||||
const isPrivate = !!job?.private;
|
||||
const tasks = r.tasks || [];
|
||||
const dpi = r.dpi ?? 300;
|
||||
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
||||
const wantCutout = tasks.includes('cutout');
|
||||
const wantContour = tasks.includes('contour');
|
||||
const description = (r.prompt_text || '').trim();
|
||||
|
||||
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
|
||||
|
||||
try {
|
||||
// Collect the sources per mode
|
||||
const sourceKeys: string[] =
|
||||
mode === 'compose' ? (item.source_paths || []).filter(Boolean)
|
||||
: mode === 'generate' ? []
|
||||
: (item.source_path ? [item.source_path] : []);
|
||||
// Does this mode need a source but there is none (any more)? A clear message instead of a raw fs error.
|
||||
if (mode !== 'generate' && sourceKeys.length === 0) {
|
||||
const e: any = new Error('Source no longer available — please upload the image again.');
|
||||
e.friendly = 'Source no longer available — please upload the image again.';
|
||||
throw e;
|
||||
}
|
||||
const sources = await Promise.all(sourceKeys.map(async (k) => {
|
||||
try { return await getObject(k); }
|
||||
catch {
|
||||
const e: any = new Error('Source file no longer available — please upload the image again.');
|
||||
e.friendly = 'Source file no longer available — please upload the image again.';
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
|
||||
const target = tasks.includes('format') && r.output_format
|
||||
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
|
||||
: null;
|
||||
|
||||
const model = await pickModel(r.model_key);
|
||||
|
||||
// The prompt + whether the model is needed at all, per mode.
|
||||
let prompt: string;
|
||||
let needsModel: boolean;
|
||||
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; }
|
||||
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
|
||||
else {
|
||||
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
|
||||
needsModel = tasks.includes('clean') || wantCutout ||
|
||||
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
|
||||
}
|
||||
|
||||
let working = sources[0] || Buffer.alloc(0);
|
||||
let modelUsed: string | null = null;
|
||||
let cost = 0;
|
||||
|
||||
if (needsModel) {
|
||||
const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined;
|
||||
const gen = await generateImage({
|
||||
model: model.id,
|
||||
prompt,
|
||||
inputUrls,
|
||||
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
|
||||
background: wantCutout ? 'transparent' : undefined,
|
||||
outputFormat: 'png',
|
||||
});
|
||||
working = gen.buffer;
|
||||
modelUsed = gen.model;
|
||||
cost = gen.cost;
|
||||
}
|
||||
if (!working.length) throw new Error('No image data produced');
|
||||
|
||||
// Local post-processing: exactly onto the target size + dpi
|
||||
const fin = await finalizeToFormat(working, target, cropMode, dpi);
|
||||
let outBuf = fin.buffer;
|
||||
let hasAlpha = fin.hasAlpha || wantCutout;
|
||||
|
||||
if (wantContour && r.contour_mm) {
|
||||
outBuf = await stickerContour(outBuf, Number(r.contour_mm), dpi);
|
||||
hasAlpha = true;
|
||||
}
|
||||
|
||||
// Output format: the preset recipe's override, otherwise the global setting. JPG only without transparency.
|
||||
const gset = await one<{ output_ext: string | null }>('SELECT output_ext FROM settings WHERE id=1');
|
||||
const wantExt = (r.output_ext || gset?.output_ext || 'png').toLowerCase();
|
||||
let ext = 'png';
|
||||
let contentType = 'image/png';
|
||||
if (wantExt === 'jpg' && !hasAlpha) {
|
||||
outBuf = await sharp(outBuf, { failOn: 'none' })
|
||||
.flatten({ background: '#ffffff' })
|
||||
.withMetadata({ density: dpi })
|
||||
.jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
||||
.toBuffer();
|
||||
ext = 'jpg';
|
||||
contentType = 'image/jpeg';
|
||||
}
|
||||
|
||||
// Write the AI provenance into the file in machine-readable form (IPTC DigitalSourceType).
|
||||
// "generate" is fully generated, everything else is an altered photo —
|
||||
// the IPTC list has a code of its own for that.
|
||||
outBuf = embedProvenance(outBuf, {
|
||||
sourceType: mode === 'generate' ? TRAINED_ALGORITHMIC_MEDIA : COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
|
||||
model: modelUsed,
|
||||
description: isPrivate ? null : prompt.slice(0, 300),
|
||||
});
|
||||
|
||||
const key = resultKey(item.id);
|
||||
await putObject(key, outBuf, contentType);
|
||||
|
||||
// Preview image (optional, per the setting)
|
||||
const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; mirror_enabled: boolean }>(
|
||||
'SELECT make_thumbnails, keep_sources, mirror_enabled FROM settings WHERE id=1');
|
||||
let thumbPath: string | null = null;
|
||||
if (cfg?.make_thumbnails !== false) {
|
||||
try {
|
||||
const tk = thumbKey(item.id);
|
||||
await putObject(tk, await makeThumb(outBuf), 'image/webp');
|
||||
thumbPath = tk;
|
||||
} catch (e) { console.error('[process] thumbnail failed', e); }
|
||||
}
|
||||
|
||||
// Unique per image: the second plus the item's short id in the name, plus a
|
||||
// cross-check in the database. Before this, two similar images from the same
|
||||
// day could get the same name and overwrite each other on the delivery target.
|
||||
const filename = await uniqueFilename(itemId, item.filename, formatToken(r.output_format), ext);
|
||||
const outputPx = `${fin.width}x${fin.height}`;
|
||||
// Private jobs: no delivery, no backup, no stored prompt.
|
||||
const deliveryStatus = (!isPrivate && (r.delivery === 'remote' || r.delivery === 'both')) ? 'pending' : 'none';
|
||||
const mirrorStatus = (!isPrivate && cfg?.mirror_enabled) ? 'pending' : 'none';
|
||||
|
||||
await query(
|
||||
`UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
|
||||
has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
|
||||
delivery_status=$11, mirror_status=$12 WHERE id=$1`,
|
||||
[itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed,
|
||||
isPrivate ? null : prompt.slice(0, 1000), cost, deliveryStatus, mirrorStatus]);
|
||||
|
||||
// Delete the source images unless they are kept — for private jobs always.
|
||||
if (isPrivate || cfg?.keep_sources === false) {
|
||||
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
|
||||
if (isPrivate) await query(`UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1`, [itemId]);
|
||||
}
|
||||
|
||||
// A second copy on every backup target — not for private jobs.
|
||||
if (!isPrivate) {
|
||||
try {
|
||||
const { backupItem } = await import('./backup');
|
||||
await backupItem(itemId);
|
||||
} catch (e) { console.error('[process] backup failed', e); }
|
||||
}
|
||||
|
||||
return { ok: true, cost };
|
||||
} catch (e: any) {
|
||||
const real = e?.message || String(e);
|
||||
const status = e?.status ? ` [status ${e.status}]` : '';
|
||||
console.error(`[process] item ${itemId} failed${status}: ${real}`, e?.stack || '');
|
||||
const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : '');
|
||||
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
|
||||
[itemId, String(msg).slice(0, 500)]);
|
||||
if (e?.status === 402) throw e;
|
||||
return { ok: false, cost: 0, error: msg };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Task prompts for the image API. Guiding principle (01 §1): reproduce and extend,
|
||||
// do not reinterpret. Leave colours, composition, textures and above all the
|
||||
// lettering faithful.
|
||||
//
|
||||
// The prompt text stays in English on purpose: it is a functional instruction to
|
||||
// the image model, not something a person reads, so it is not translated.
|
||||
|
||||
export type Task = 'clean' | 'cutout' | 'format' | 'contour' | 'deliver';
|
||||
|
||||
const CLEAN =
|
||||
'Recreate ONLY the actual artwork/motif as a clean, complete, high-resolution image. ' +
|
||||
'Remove everything around it that is not part of the motif: picture frames, walls, ' +
|
||||
'phone/app UI bars, shop overlays, cart icons, price tags, watermarks from your own ' +
|
||||
'screenshot, timestamps and reflections. Reconstruct edges that were cropped or hidden. ' +
|
||||
'Keep the original style, colors, textures, composition and ESPECIALLY any lettering ' +
|
||||
'pixel-faithful. Do not reinterpret, do not add new elements.';
|
||||
|
||||
const CUTOUT =
|
||||
'Isolate only the main subject. The background must be fully transparent (alpha).';
|
||||
|
||||
const FORMAT_EXTEND =
|
||||
'Extend the scene naturally to fill the requested aspect ratio (outpainting), keeping ' +
|
||||
'the existing composition centered and consistent in style.';
|
||||
|
||||
const FORMAT_KEEP_RATIO =
|
||||
'Produce the motif in the requested aspect ratio without distorting it.';
|
||||
|
||||
export interface PromptOpts {
|
||||
tasks: Task[];
|
||||
cropMode?: 'crop' | 'extend';
|
||||
customInstruction?: string | null;
|
||||
}
|
||||
|
||||
/** Builds the combined prompt out of the preset recipe's tasks (in a fixed order). */
|
||||
export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts): string {
|
||||
const parts: string[] = [];
|
||||
if (tasks.includes('clean')) parts.push(CLEAN);
|
||||
if (tasks.includes('cutout')) parts.push(CUTOUT);
|
||||
if (tasks.includes('format')) {
|
||||
parts.push(cropMode === 'extend' ? FORMAT_EXTEND : FORMAT_KEEP_RATIO);
|
||||
}
|
||||
if (!tasks.includes('clean') && !tasks.includes('cutout') && parts.length === 0) {
|
||||
// format only, without cleaning up: just reproduce
|
||||
parts.push('Reproduce the image faithfully, no stylistic changes.');
|
||||
}
|
||||
if (customInstruction?.trim()) parts.push(`Additional instruction: ${customInstruction.trim()}`);
|
||||
parts.push('Output only the resulting image.');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Combine/convert: one OR several reference images + a description → ONE new image.
|
||||
* With one reference image = convert the image per the instruction (a photo → an oil painting, say).
|
||||
* With several = the first one leads, the others supply elements/people/subjects. */
|
||||
export function buildComposePrompt(description: string, extend = false, count = 2): string {
|
||||
const parts: string[] = [];
|
||||
if (count <= 1) {
|
||||
parts.push(
|
||||
'Transform the given image according to the instruction below into ONE new, ' +
|
||||
'high-resolution image. Keep the subject, composition and important details recognizable ' +
|
||||
'unless the instruction explicitly asks to change them.');
|
||||
} else {
|
||||
parts.push(
|
||||
'You are given several reference images. Combine them into ONE new, coherent, ' +
|
||||
'high-resolution image that follows the instruction below. Treat the first image as ' +
|
||||
'the main scene/style reference and use the other images as elements to integrate ' +
|
||||
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
|
||||
'they look naturally part of the same photo.');
|
||||
}
|
||||
if (description.trim()) parts.push(`Instruction: ${description.trim()}`);
|
||||
if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).');
|
||||
parts.push('Output only the resulting image.');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Free text: no reference image at all → a completely new image out of the description. */
|
||||
export function buildGeneratePrompt(description: string): string {
|
||||
const d = description.trim() || 'A clean, high-quality image.';
|
||||
return `Create a new, high-resolution image. ${d} Output only the resulting image.`;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Machine-readable provenance marking for AI-generated images.
|
||||
//
|
||||
// Why: the EU AI Act (Art. 50) requires AI-generated content to be marked as
|
||||
// such **in machine-readable form**. The `.md` sidecar file that Klarbild ships
|
||||
// along is good documentation for people — but it is not a marking in the sense
|
||||
// of the regulation. The established route is the IPTC field `DigitalSourceType`
|
||||
// with the value `trainedAlgorithmicMedia`, embedded as XMP directly in the file.
|
||||
// (This does not replace legal advice; it implements what is standard and common
|
||||
// practice.)
|
||||
//
|
||||
// Deliberately **without a new dependency**: sharp 0.33 cannot write XMP, and
|
||||
// upgrading the native library just for that would be a deployment risk (see the
|
||||
// learnings about `tsx`). Hooking XMP into PNG and JPEG is cleanly specified in
|
||||
// both cases and done in a few lines — and therefore testable as well.
|
||||
|
||||
// The identifier ends with a null byte, as the standard requires. Written as an
|
||||
// escape: as a raw control character in the source file a formatter or an editor
|
||||
// would turn it into a space — and then no tool would recognise the segment as
|
||||
// XMP any more, and no test would catch it.
|
||||
const XMP_NS = 'http://ns.adobe.com/xap/1.0/\u0000';
|
||||
const BOM = '';
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/** The IPTC code for "created by a trained algorithm". */
|
||||
export const TRAINED_ALGORITHMIC_MEDIA =
|
||||
'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia';
|
||||
|
||||
/** For images that a model only *changed* (clean up, cut out). */
|
||||
export const COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA =
|
||||
'http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia';
|
||||
|
||||
export interface Provenance {
|
||||
/** IPTC code, see the constants above. */
|
||||
sourceType: string;
|
||||
/** Model used, e.g. `google/gemini-3-pro-image`. */
|
||||
model?: string | null;
|
||||
/** Short description of what was done. */
|
||||
description?: string | null;
|
||||
/** Time of creation. */
|
||||
created?: Date;
|
||||
}
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/** The XMP packet as text — what is about to go into the file. */
|
||||
export function buildXmp(p: Provenance): string {
|
||||
const created = (p.created ?? new Date()).toISOString();
|
||||
const tool = p.model ? `Klarbild (${p.model})` : 'Klarbild';
|
||||
const desc = p.description
|
||||
? `\n <dc:description><rdf:Alt><rdf:li xml:lang="x-default">${esc(p.description)}</rdf:li></rdf:Alt></dc:description>`
|
||||
: '';
|
||||
return `<?xpacket begin="${BOM}" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Klarbild">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
Iptc4xmpExt:DigitalSourceType="${esc(p.sourceType)}"
|
||||
xmp:CreatorTool="${esc(tool)}"
|
||||
xmp:CreateDate="${created}">${desc}
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>`;
|
||||
}
|
||||
|
||||
/* ---------------- PNG ---------------- */
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/** Build a PNG chunk: length · type · data · CRC over type+data. */
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang the XMP in as an `iTXt` chunk behind the IHDR chunk. The PNG
|
||||
* specification requires text chunks to stand before the first `IDAT` — directly
|
||||
* behind IHDR is the safe spot.
|
||||
*/
|
||||
function embedPng(buf: Buffer, xmp: string): Buffer {
|
||||
// 8 bytes of signature, then IHDR: 4 length + 4 type + data + 4 CRC
|
||||
const ihdrLen = buf.readUInt32BE(8);
|
||||
const insertAt = 8 + 4 + 4 + ihdrLen + 4;
|
||||
const data = Buffer.concat([
|
||||
Buffer.from('XML:com.adobe.xmp', 'latin1'),
|
||||
// null byte · compression 0 · method 0 · language "" · translated keyword ""
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
Buffer.from(xmp, 'utf8'),
|
||||
]);
|
||||
return Buffer.concat([buf.subarray(0, insertAt), pngChunk('iTXt', data), buf.subarray(insertAt)]);
|
||||
}
|
||||
|
||||
/* ---------------- JPEG ---------------- */
|
||||
|
||||
/**
|
||||
* Hook the XMP in as an `APP1` segment. An existing APP0 (JFIF) and an existing
|
||||
* APP1 (Exif, written by sharp for the dpi) stay in front of it — the two are
|
||||
* told apart by their identifier, they do not get in each other's way.
|
||||
*/
|
||||
function embedJpeg(buf: Buffer, xmp: string): Buffer {
|
||||
let pos = 2; // behind SOI (FFD8)
|
||||
while (pos + 4 <= buf.length && buf[pos] === 0xff) {
|
||||
const marker = buf[pos + 1];
|
||||
if (marker !== 0xe0 && marker !== 0xe1) break; // only skip APP0/APP1
|
||||
pos += 2 + buf.readUInt16BE(pos + 2);
|
||||
}
|
||||
const payload = Buffer.concat([Buffer.from(XMP_NS, 'latin1'), Buffer.from(xmp, 'utf8')]);
|
||||
const len = payload.length + 2;
|
||||
if (len > 0xffff) return buf; // does not fit into one segment — then rather nothing at all
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUInt16BE(0xffe1, 0);
|
||||
head.writeUInt16BE(len, 2);
|
||||
return Buffer.concat([buf.subarray(0, pos), head, payload, buf.subarray(pos)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the marking into the image file. Recognises PNG and JPEG by itself.
|
||||
* For anything else (or on an error) the buffer comes back unchanged — a missing
|
||||
* marking is annoying, a broken image would be worse.
|
||||
*/
|
||||
export function embedProvenance(buf: Buffer, p: Provenance): Buffer {
|
||||
try {
|
||||
// Remove a marking that is already there instead of appending a second one:
|
||||
// otherwise the **older** model entry would stand in front, and reading
|
||||
// tools would take exactly that one. For a marking under Art. 50 AI Act,
|
||||
// though, the model actually used last is what should stand there.
|
||||
const clean = stripXmp(buf);
|
||||
const xmp = buildXmp(p);
|
||||
if (clean.subarray(0, 8).equals(PNG_MAGIC)) return embedPng(clean, xmp);
|
||||
if (clean[0] === 0xff && clean[1] === 0xd8) return embedJpeg(clean, xmp);
|
||||
return clean;
|
||||
} catch {
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove existing XMP packets (PNG iTXt or JPEG APP1). */
|
||||
export function stripXmp(buf: Buffer): Buffer {
|
||||
if (buf.subarray(0, 8).equals(PNG_MAGIC)) {
|
||||
const parts: Buffer[] = [buf.subarray(0, 8)];
|
||||
let pos = 8;
|
||||
while (pos + 12 <= buf.length) {
|
||||
const len = buf.readUInt32BE(pos);
|
||||
const type = buf.toString('latin1', pos + 4, pos + 8);
|
||||
const end = pos + 12 + len;
|
||||
if (end > buf.length) break;
|
||||
const isXmp = type === 'iTXt'
|
||||
&& buf.toString('latin1', pos + 8, pos + 8 + 17) === 'XML:com.adobe.xmp';
|
||||
if (!isXmp) parts.push(buf.subarray(pos, end));
|
||||
pos = end;
|
||||
if (type === 'IEND') break;
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) {
|
||||
const parts: Buffer[] = [buf.subarray(0, 2)];
|
||||
let pos = 2;
|
||||
while (pos + 4 <= buf.length && buf[pos] === 0xff) {
|
||||
const marker = buf[pos + 1];
|
||||
if (marker === 0xda) break; // from here on come the image data
|
||||
const len = buf.readUInt16BE(pos + 2);
|
||||
const end = pos + 2 + len;
|
||||
if (end > buf.length) break;
|
||||
const isXmp = marker === 0xe1
|
||||
&& buf.toString('latin1', pos + 4, pos + 4 + XMP_NS.length) === XMP_NS;
|
||||
if (!isXmp) parts.push(buf.subarray(pos, end));
|
||||
pos = end;
|
||||
}
|
||||
parts.push(buf.subarray(pos));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Reads the marking back out — for tests and for checking. */
|
||||
export function readXmp(buf: Buffer): string | null {
|
||||
const i = buf.indexOf('<x:xmpmeta');
|
||||
if (i < 0) return null;
|
||||
const j = buf.indexOf('</x:xmpmeta>', i);
|
||||
return j < 0 ? null : buf.subarray(i, j + 12).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import PgBoss from 'pg-boss';
|
||||
|
||||
export const QUEUE = 'generate';
|
||||
export interface GenerateJob { itemId: string; jobId: string; }
|
||||
|
||||
let boss: PgBoss | null = null;
|
||||
|
||||
export async function getBoss(): Promise<PgBoss> {
|
||||
if (!boss) {
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||
boss.on('error', (e) => console.error('[pg-boss]', e));
|
||||
await boss.start();
|
||||
await boss.createQueue(QUEUE);
|
||||
}
|
||||
return boss;
|
||||
}
|
||||
|
||||
export async function enqueue(job: GenerateJob): Promise<void> {
|
||||
const b = await getBoss();
|
||||
await b.send(QUEUE, job, { retryLimit: 2, retryBackoff: true });
|
||||
}
|
||||
|
||||
export async function startWorker(
|
||||
concurrency: number,
|
||||
handler: (job: GenerateJob) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const b = await getBoss();
|
||||
await b.work<GenerateJob>(
|
||||
QUEUE,
|
||||
{ batchSize: Math.max(1, concurrency), pollingIntervalSeconds: 2 },
|
||||
async (jobs) => { await Promise.all(jobs.map((j) => handler(j.data))); },
|
||||
);
|
||||
console.log(`[queue] Worker active (concurrency ${concurrency}).`);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Delivery to a remote target via SFTP (ssh2-sftp-client) or FTPS (basic-ftp).
|
||||
// The configuration comes from settings (with an encrypted password).
|
||||
import posixpath from 'node:path/posix';
|
||||
import { one } from './db.ts';
|
||||
import { decrypt } from './crypto.ts';
|
||||
|
||||
export interface RemoteTargetCfg {
|
||||
host: string; protocol: 'ftps' | 'sftp'; port: number;
|
||||
user: string; password: string; basePath: string;
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<RemoteTargetCfg | null> {
|
||||
const s = await one<any>(`SELECT delivery_host, delivery_protocol, delivery_port, delivery_user,
|
||||
delivery_password_enc, delivery_base_path FROM settings WHERE id=1`);
|
||||
if (!s?.delivery_host || !s?.delivery_user || !s?.delivery_password_enc) return null;
|
||||
let password = '';
|
||||
try { password = decrypt(s.delivery_password_enc); } catch { return null; }
|
||||
return {
|
||||
host: s.delivery_host, protocol: (s.delivery_protocol || 'sftp'),
|
||||
port: s.delivery_port || (s.delivery_protocol === 'ftps' ? 21 : 22),
|
||||
user: s.delivery_user, password, basePath: s.delivery_base_path || '/',
|
||||
};
|
||||
}
|
||||
|
||||
/** An additional delivery target from delivery_targets. */
|
||||
export async function loadTargetConfig(id: string): Promise<RemoteTargetCfg | null> {
|
||||
const s = await one<any>(`SELECT protocol, host, port, username, password_enc, base_path
|
||||
FROM delivery_targets WHERE id=$1`, [id]);
|
||||
if (!s?.host || !s?.username || !s?.password_enc) return null;
|
||||
let password = '';
|
||||
try { password = decrypt(s.password_enc); } catch { return null; }
|
||||
return {
|
||||
host: s.host, protocol: (s.protocol || 'sftp'),
|
||||
port: s.port || (s.protocol === 'ftps' ? 21 : 22),
|
||||
user: s.username, password, basePath: s.base_path || '/',
|
||||
};
|
||||
}
|
||||
|
||||
/** Test the connection: connect + list the base folder. */
|
||||
export async function testConnection(cfg: RemoteTargetCfg): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||
await c.list(cfg.basePath || '/');
|
||||
await c.end();
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(15000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
await c.list(cfg.basePath || '/');
|
||||
c.close();
|
||||
}
|
||||
return { ok: true, message: 'Connection succeeded.' };
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e?.message || 'Connection failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A path segment that can be appended to the base folder without danger.
|
||||
*
|
||||
* `posixpath.join` is **no** protection: `join('/customers/acme', '../../../etc/cron.d')`
|
||||
* yields `/etc/cron.d`, and the upload creates the directory beforehand. Folder
|
||||
* names and file names come from the database (preset recipes, folders,
|
||||
* settings) and therefore ultimately from user input — so they are checked
|
||||
* **immediately before writing**, not somewhere further up. Nobody who delivers
|
||||
* can get around this point.
|
||||
*/
|
||||
export function safeSegment(value: string, what: 'folder' | 'file name'): string {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
// Deliberately a block list of the *dangerous* constructs rather than a narrow
|
||||
// allow list: a customer folder is allowed to be called "Søren & Co. (2026)" —
|
||||
// that is harmless. Only separators, control characters and names made purely
|
||||
// of dots are dangerous, because those alone are enough to leave the base folder.
|
||||
if (/[\/\\]/.test(v)) throw new Error(`Invalid ${what}: no slashes allowed.`);
|
||||
if (/[\u0000-\u001f\u007f]/.test(v)) throw new Error(`Invalid ${what}: control characters.`);
|
||||
if (/^\.+$/.test(v)) throw new Error(`Invalid ${what}: "." and ".." are not names.`);
|
||||
if (v.length > 128) throw new Error(`Invalid ${what}: too long.`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Uploads a buffer straight under its final name.
|
||||
* (The delivery target does not cope with the temp-then-rename pattern — `.tmp-…`
|
||||
* files were left lying around.) */
|
||||
export async function uploadBuffer(cfg: RemoteTargetCfg, folder: string, filename: string, buf: Buffer): Promise<void> {
|
||||
const dir = posixpath.join(cfg.basePath || '/', safeSegment(folder, 'folder'));
|
||||
const finalPath = posixpath.join(dir, safeSegment(filename, 'file name'));
|
||||
const { Readable } = await import('node:stream');
|
||||
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||
try {
|
||||
if (!(await c.exists(dir))) await c.mkdir(dir, true);
|
||||
await c.put(buf, finalPath);
|
||||
} finally { await c.end(); }
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(20000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
try {
|
||||
await c.ensureDir(dir);
|
||||
await c.uploadFrom(Readable.from(buf), finalPath);
|
||||
} finally { c.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes `.tmp-…` leftovers from a folder (a one-off cleanup helper). */
|
||||
export async function cleanupTmp(cfg: RemoteTargetCfg, folder: string): Promise<number> {
|
||||
const dir = posixpath.join(cfg.basePath || '/', folder || '');
|
||||
let n = 0;
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
|
||||
try {
|
||||
const list = await c.list(dir);
|
||||
for (const e of list) if (e.name.startsWith('.tmp-')) { await c.delete(posixpath.join(dir, e.name)); n++; }
|
||||
} finally { await c.end(); }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Unique file names — assigning them for new images and renaming the library.
|
||||
//
|
||||
// Background: until 2026-08-20 a result was called `YYYY-MM-DD_<motif>_<format>.<ext>`.
|
||||
// Whoever produced the same motif in the same format twice on the same day — two
|
||||
// badges from one shoot, say — got the same name twice. On the delivery target
|
||||
// the second image overwrote the first. Since then the name also carries the
|
||||
// time of day to the second and a short id derived from the item id.
|
||||
import { query, one } from './db.ts';
|
||||
import { buildResultFilename, newNameFor } from './naming.ts';
|
||||
|
||||
/** Is this name already taken by a *different* image? */
|
||||
async function taken(name: string, exceptId: string): Promise<boolean> {
|
||||
const row = await one<{ id: string }>(
|
||||
'SELECT id FROM items WHERE filename = $1 AND id <> $2 LIMIT 1', [name, exceptId]);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name is practically unique already through second + item short id. This
|
||||
* loop is the belt to go with the braces: it appends `-2`, `-3` … in case the
|
||||
* name is taken against all expectation. Deliberately as a loop in code and
|
||||
* **not** as a UNIQUE constraint in the database — a rejected write would make
|
||||
* an already paid-for AI job fail at the very last step.
|
||||
*/
|
||||
export async function uniqueFilename(
|
||||
itemId: string, originalName: string | null, formatToken: string, ext: string, at?: Date,
|
||||
/** For tests only: otherwise the library is asked. */
|
||||
isTaken: (name: string) => Promise<boolean> = (n) => taken(n, itemId),
|
||||
): Promise<string> {
|
||||
for (let seq = 1; seq <= 50; seq++) {
|
||||
const name = buildResultFilename(originalName, formatToken, ext, { at, id: itemId, seq });
|
||||
if (!(await isTaken(name))) return name;
|
||||
}
|
||||
// Unreachable as long as item ids are unique — but an ugly name is preferable
|
||||
// to an overwritten image.
|
||||
return buildResultFilename(originalName, formatToken, ext, { at, id: `${itemId}${Date.now()}` });
|
||||
}
|
||||
|
||||
export interface RenameRow { id: string; before: string; after: string }
|
||||
export interface RenameResult {
|
||||
checked: number;
|
||||
renamed: number;
|
||||
unchanged: number;
|
||||
/** Names that were assigned twice before — exactly the reported bug. */
|
||||
wereDuplicates: number;
|
||||
dryRun: boolean;
|
||||
examples: RenameRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring all library images onto the new naming scheme.
|
||||
* Idempotent: the timestamp comes from `created_at`, the short id from the item
|
||||
* id — a second run changes nothing any more. The files themselves stay
|
||||
* untouched, they lie in storage under the item id, not under the name.
|
||||
*/
|
||||
export async function renameLibrary(dryRun = true): Promise<RenameResult> {
|
||||
const items = await query<{ id: string; filename: string | null; created_at: Date }>(
|
||||
`SELECT id, filename, created_at FROM items
|
||||
WHERE status='done' AND filename IS NOT NULL ORDER BY created_at`);
|
||||
|
||||
// How many names were assigned more than once before?
|
||||
const counts = new Map<string, number>();
|
||||
for (const it of items) counts.set(it.filename!, (counts.get(it.filename!) || 0) + 1);
|
||||
const wereDuplicates = [...counts.values()].filter((n) => n > 1).reduce((a, n) => a + n - 1, 0);
|
||||
|
||||
const used = new Set<string>();
|
||||
const rows: RenameRow[] = [];
|
||||
let renamed = 0, unchanged = 0;
|
||||
|
||||
for (const it of items) {
|
||||
let next = newNameFor(it);
|
||||
for (let seq = 2; used.has(next) && seq <= 50; seq++) next = newNameFor(it, seq);
|
||||
used.add(next);
|
||||
if (next === it.filename) { unchanged++; continue; }
|
||||
renamed++;
|
||||
if (rows.length < 25) rows.push({ id: it.id, before: it.filename!, after: next });
|
||||
if (!dryRun) await query('UPDATE items SET filename=$2 WHERE id=$1', [it.id, next]);
|
||||
}
|
||||
|
||||
return { checked: items.length, renamed, unchanged, wereDuplicates, dryRun: dryRun, examples: rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver all images that were already delivered once again — under the new
|
||||
* name. Necessary because the renaming only affects the library: whatever
|
||||
* already lies on the delivery target is still called by its old name there. The
|
||||
* old files stay put and have to be removed by hand if wanted; deleting
|
||||
* something at the customer's end automatically would be the wrong reflex.
|
||||
*/
|
||||
export async function redeliverAll(): Promise<{ attempted: number; ok: number; failed: number }> {
|
||||
const items = await query<{ id: string }>(
|
||||
`SELECT i.id FROM items i JOIN jobs j ON j.id = i.job_id
|
||||
WHERE i.status='done' AND i.result_path IS NOT NULL
|
||||
AND i.delivery_status IN ('delivered','failed') AND j.private = false
|
||||
ORDER BY i.created_at`);
|
||||
const { deliverItem } = await import('./delivery.ts');
|
||||
let ok = 0, failed = 0;
|
||||
for (const it of items) {
|
||||
const r = await deliverItem(it.id).catch(() => ({ ok: false }));
|
||||
r.ok ? ok++ : failed++;
|
||||
}
|
||||
return { attempted: items.length, ok, failed };
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { one, query } from './db';
|
||||
import { hashPassword } from './auth';
|
||||
import { has } from './modules';
|
||||
|
||||
/**
|
||||
* First start: create the administrator, the settings row, and a few example
|
||||
* presets. Idempotent — it runs on every boot and does nothing once the
|
||||
* instance has been set up.
|
||||
*
|
||||
* There are no built-in accounts. The very first boot creates exactly one
|
||||
* administrator, from `ADMIN_USERNAME` / `ADMIN_PASSWORD` if you set them, and
|
||||
* otherwise with a random password printed once to the server log. Nothing here
|
||||
* carries a name, a folder or a mailbox belonging to whoever built this.
|
||||
*/
|
||||
export async function seed(): Promise<void> {
|
||||
await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
|
||||
|
||||
if (has('ai')) await seedModels();
|
||||
await seedAdmin();
|
||||
await seedPrintPresets();
|
||||
if (has('ai')) await seedRecipes();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ admin */
|
||||
|
||||
async function seedAdmin(): Promise<void> {
|
||||
const existing = await one<{ n: string }>(`SELECT count(*)::text AS n FROM users`);
|
||||
if (existing && Number(existing.n) > 0) return;
|
||||
|
||||
const username = (process.env.ADMIN_USERNAME || 'admin').trim().toLowerCase();
|
||||
const given = process.env.ADMIN_PASSWORD;
|
||||
// 24 bytes of base64url is 32 characters — long enough that nobody is tempted
|
||||
// to leave it in place, short enough to copy out of a log by hand.
|
||||
const password = given || randomBytes(24).toString('base64url');
|
||||
|
||||
await query(
|
||||
`INSERT INTO users (username, role, display_name, password_hash) VALUES ($1,'admin',$2,$3)`,
|
||||
[username, username, await hashPassword(password)],
|
||||
);
|
||||
|
||||
if (given) {
|
||||
console.log(`[setup] Administrator "${username}" created with the password from ADMIN_PASSWORD.`);
|
||||
} else {
|
||||
// Printed once, on the first boot only. There is no way to recover it later;
|
||||
// the reset flow (module "mail") or a fresh volume is the way back.
|
||||
console.log(
|
||||
'\n' +
|
||||
' ┌────────────────────────────────────────────────────────────┐\n' +
|
||||
' │ Klarbild is set up. Sign in with: │\n' +
|
||||
` │ user ${username.padEnd(46)}│\n` +
|
||||
` │ password ${password.padEnd(46)}│\n` +
|
||||
' │ This is shown once. Change it under Account after login. │\n' +
|
||||
' └────────────────────────────────────────────────────────────┘\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- models */
|
||||
|
||||
async function seedModels(): Promise<void> {
|
||||
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM models`);
|
||||
if (count && Number(count.n) === 0) {
|
||||
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort) VALUES
|
||||
('google/gemini-3.1-flash-image','Fast','Ready in seconds. The everyday choice.',true,true,true,0),
|
||||
('google/gemini-3-pro-image','Best quality','Slower, but truer to text and fine detail.',true,false,true,1),
|
||||
('black-forest-labs/flux.2-flex','Open weights (FLUX.2)','Open model, good quality. Cannot cut out to transparency.',true,false,false,2)`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- recipes */
|
||||
|
||||
async function seedRecipes(): Promise<void> {
|
||||
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM recipes`);
|
||||
if (!count || Number(count.n) > 0) return;
|
||||
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||
const by = admin?.id ?? null;
|
||||
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
||||
query(
|
||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
|
||||
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]);
|
||||
|
||||
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
|
||||
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
||||
await R('TV frame (16:9)', ['clean', 'format'], 'theframe', 'landscape');
|
||||
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- print presets */
|
||||
|
||||
async function seedPrintPresets(): Promise<void> {
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||
const by = admin?.id ?? null;
|
||||
const P = async (name: string, config: Record<string, unknown>) => {
|
||||
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
|
||||
if (exists) return;
|
||||
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
|
||||
[name, by, JSON.stringify(config)]);
|
||||
};
|
||||
const sheet = {
|
||||
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
|
||||
autoGap: true, gapMm: 14, bleedMm: 0,
|
||||
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
|
||||
};
|
||||
const F = (sizeId: string, count: number, landscape = false) =>
|
||||
({ sizeId, customSize: '', landscape, count, allowRotate: true });
|
||||
|
||||
// The classic school-photo set from a single portrait.
|
||||
await P('School photo set (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
|
||||
// Just the small prints, for when the large one is already done.
|
||||
await P('Small prints (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
|
||||
// A full sheet of passport photos — continuous lines, because every cell is identical.
|
||||
await P('Passport sheet 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
|
||||
// Two images at true size on one sheet of 10×15 photo paper.
|
||||
await P('2 × 10×7.5 on 10×15 photo paper', {
|
||||
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
|
||||
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Share links: make one image or a collection publicly reachable without the
|
||||
// recipient needing an account.
|
||||
//
|
||||
// Ground rule: there are children in these pictures. A link that is valid
|
||||
// forever and that anyone can forward is therefore the wrong default. So every
|
||||
// link gets an **expiry date** of its own accord, can be **revoked** at any
|
||||
// time, can be given a **passphrase**, and the page forbids search engines from
|
||||
// indexing it.
|
||||
import { randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import { one, query } from './db.ts';
|
||||
|
||||
/** Without i, l, 0, O, 1 — so a link read out loud does not arrive wrong. */
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
|
||||
/** 12 characters out of 31 = around 59 bits. Not guessable, still readable aloud. */
|
||||
export function neuerSlug(length = 12): string {
|
||||
const bytes = randomBytes(length * 2);
|
||||
let s = '';
|
||||
for (let i = 0; s.length < length && i < bytes.length; i++) {
|
||||
const v = bytes[i];
|
||||
// Discard the leftover values, otherwise the earlier characters would come up more often.
|
||||
if (v < 256 - (256 % ALPHABET.length)) s += ALPHABET[v % ALPHABET.length];
|
||||
}
|
||||
return s.length === length ? s : neuerSlug(length);
|
||||
}
|
||||
|
||||
export type ShareKind = 'item' | 'collection';
|
||||
|
||||
export interface Share {
|
||||
id: string; slug: string; kind: ShareKind;
|
||||
title: string | null; note: string | null;
|
||||
created_by: string | null;
|
||||
expires_at: string | null; revoked_at: string | null;
|
||||
allow_download: boolean; password_hash: string | null;
|
||||
views: number; downloads: number;
|
||||
last_seen_at: string | null; created_at: string;
|
||||
}
|
||||
|
||||
export interface ShareErstellen {
|
||||
kind?: ShareKind;
|
||||
itemIds: string[];
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
/** Days until expiry; 0 or null = unlimited. */
|
||||
days?: number | null;
|
||||
allowDownload?: boolean;
|
||||
password?: string | null;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export const MAX_ITEMS = 500;
|
||||
export const MAX_TITEL = 200;
|
||||
export const MAX_NOTIZ = 2000;
|
||||
const MAX_DAYS = 3650;
|
||||
|
||||
/**
|
||||
* Read the days until expiry properly.
|
||||
*
|
||||
* `Number('abc')` is `NaN`, and `NaN > 0` is `false` — with the naive spelling
|
||||
* every nonsensical input ended up in the "unlimited" branch. Exactly the
|
||||
* default this file calls wrong at the top. So: anything that is not a sensible
|
||||
* number is rejected instead of being silently interpreted.
|
||||
*/
|
||||
export function tageLesen(v: unknown, fallback: number): number {
|
||||
if (v === undefined || v === null || v === '') return fallback;
|
||||
// Accept only a number or a string: `Number([])` is 0 and `Number([7])` is 7 —
|
||||
// an array sent along by accident would otherwise silently turn into
|
||||
// "unlimited" or into an invented deadline.
|
||||
if (typeof v !== 'number' && typeof v !== 'string')
|
||||
throw new Error('Validity must be a number of days.');
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) throw new Error('Validity must be a number of days.');
|
||||
const t = Math.trunc(n);
|
||||
if (t < 0 || t > MAX_DAYS) throw new Error(`Validity must be between 0 and ${MAX_DAYS} days.`);
|
||||
return t;
|
||||
}
|
||||
|
||||
const short = (v: unknown, max: number): string | null => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t ? t.slice(0, max) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a link. Without an explicit value the default from the settings
|
||||
* applies (30 days) — not "unlimited".
|
||||
*/
|
||||
export async function createShare(input: ShareErstellen): Promise<Share> {
|
||||
const ids = [...new Set(input.itemIds.filter(Boolean))].slice(0, MAX_ITEMS);
|
||||
if (!ids.length) throw new Error('No images selected.');
|
||||
|
||||
const s = await one<{ share_default_days: number }>('SELECT share_default_days FROM settings WHERE id=1');
|
||||
const days = tageLesen(input.days, s?.share_default_days ?? 30);
|
||||
const expires = days > 0 ? new Date(Date.now() + days * 86400_000) : null;
|
||||
const kind: ShareKind = input.kind === 'collection' || ids.length > 1 ? 'collection' : 'item';
|
||||
const pw = input.password?.trim() ? await argon2.hash(input.password.trim()) : null;
|
||||
|
||||
const share = await one<Share>(
|
||||
`INSERT INTO shares (slug, kind, title, note, created_by, expires_at, allow_download, password_hash)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[neuerSlug(), kind, short(input.title, MAX_TITEL), short(input.note, MAX_NOTIZ),
|
||||
input.userId, expires, input.allowDownload !== false, pw]);
|
||||
|
||||
// Insert in **one** step: before it was up to 500 separate queries, and if one
|
||||
// of them failed (because an image had been deleted in the meantime, say), a
|
||||
// half-filled link was left behind while the interface reported an error.
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO share_items (share_id, item_id, position)
|
||||
SELECT $1, x.id, x.pos FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id, pos)
|
||||
ON CONFLICT DO NOTHING`, [share!.id, ids]);
|
||||
} catch (e) {
|
||||
// No half link: whatever could not come into being completely is removed.
|
||||
await query('DELETE FROM shares WHERE id = $1', [share!.id]).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
return share!;
|
||||
}
|
||||
|
||||
export type ShareStatus = 'ok' | 'not-found' | 'revoked' | 'expired' | 'passphrase';
|
||||
|
||||
/**
|
||||
* Resolve a link and check whether it is valid right now.
|
||||
* Revoked and expired are kept **apart** — the recipient should understand why
|
||||
* nothing shows up, instead of suspecting a typo.
|
||||
*/
|
||||
export async function resolveShare(slug: string, passphrase?: string | null): Promise<{
|
||||
status: ShareStatus; share?: Share;
|
||||
}> {
|
||||
if (!/^[a-z0-9]{6,32}$/.test(String(slug || ''))) return { status: 'not-found' };
|
||||
const share = await one<Share>('SELECT * FROM shares WHERE slug=$1', [slug]);
|
||||
if (!share) return { status: 'not-found' };
|
||||
if (share.revoked_at) return { status: 'revoked', share };
|
||||
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now()) return { status: 'expired', share };
|
||||
if (share.password_hash) {
|
||||
if (!passphrase) return { status: 'passphrase', share };
|
||||
let ok = false;
|
||||
try { ok = await argon2.verify(share.password_hash, passphrase); } catch { ok = false; }
|
||||
if (!ok) return { status: 'passphrase', share };
|
||||
}
|
||||
return { status: 'ok', share };
|
||||
}
|
||||
|
||||
export interface ShareBild {
|
||||
id: string; filename: string | null; output_px: string | null; created_at: string;
|
||||
}
|
||||
|
||||
/** The images behind a link, in the stored order. */
|
||||
export async function shareItems(shareId: string): Promise<ShareBild[]> {
|
||||
return query<ShareBild>(
|
||||
// No join on `jobs`: per the schema `items.job_id` is nullable, so an INNER
|
||||
// JOIN could swallow rows — and nothing from it was needed anyway.
|
||||
`SELECT i.id, i.filename, i.output_px, i.created_at
|
||||
FROM share_items si
|
||||
JOIN items i ON i.id = si.item_id
|
||||
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL
|
||||
ORDER BY si.position, i.created_at`, [shareId]);
|
||||
}
|
||||
|
||||
/** Does this image belong to this link? Cross-check before every delivery. */
|
||||
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
|
||||
const r = await one<{ item_id: string }>(
|
||||
'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]);
|
||||
return !!r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an access. The IP is stored **hashed only**, salted with the server
|
||||
* secret: that makes it possible to count returning visitors, but the address
|
||||
* cannot be computed back and nobody is identifiable.
|
||||
*/
|
||||
export async function trackShare(
|
||||
shareId: string, kind: 'view' | 'download', ip?: string | null, ua?: string | null,
|
||||
): Promise<void> {
|
||||
const salt = process.env.SESSION_SECRET || 'klarbild';
|
||||
// The hash takes the link's id into account: that way the same visitor can be
|
||||
// recognised **within** one link, but not followed across several links.
|
||||
// (Honestly: this is pseudonymisation, not anonymisation — whoever has the
|
||||
// server key can work through the IPv4 space. Which is why it is only kept
|
||||
// for 90 days.)
|
||||
const ipHash = ip
|
||||
? createHash('sha256').update(`${salt}|${shareId}|${ip}`).digest('base64url').slice(0, 22)
|
||||
: null;
|
||||
const hour = new Date().toISOString().slice(0, 13); // "2026-09-01T14"
|
||||
try {
|
||||
// At most one row per visitor and hour — otherwise a single recipient (or an
|
||||
// <img> tag on someone else's page) fills up the table.
|
||||
const fresh = await query<{ id: string }>(
|
||||
`INSERT INTO share_views (share_id, kind, ip_hash, user_agent, hour)
|
||||
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING id`,
|
||||
[shareId, kind, ipHash, geraet(ua), hour]);
|
||||
|
||||
// The counter counts every call, the visitor number only new ids.
|
||||
await query(
|
||||
`UPDATE shares SET last_seen_at = now(),
|
||||
views = views + CASE WHEN $2='view' THEN 1 ELSE 0 END,
|
||||
downloads = downloads + CASE WHEN $2='download' THEN 1 ELSE 0 END,
|
||||
visitor = visitor + CASE WHEN $3 AND $2='view' THEN 1 ELSE 0 END
|
||||
WHERE id = $1`, [shareId, kind, fresh.length > 0]);
|
||||
} catch (e: any) {
|
||||
// Counting is a side matter — it must never stop the delivery.
|
||||
console.error('[share] access not counted:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the browser and the system from the user agent.
|
||||
* The full string is a usable fingerprint; for "what was it opened with"
|
||||
* "Safari · iPhone" is plenty.
|
||||
*/
|
||||
export function geraet(ua?: string | null): string | null {
|
||||
const s = String(ua || '');
|
||||
if (!s) return null;
|
||||
const browser = /Edg\//.test(s) ? 'Edge' : /OPR\//.test(s) ? 'Opera'
|
||||
: /Chrome\//.test(s) ? 'Chrome' : /Firefox\//.test(s) ? 'Firefox'
|
||||
: /Safari\//.test(s) ? 'Safari' : 'Browser';
|
||||
const system = /iPhone/.test(s) ? 'iPhone' : /iPad/.test(s) ? 'iPad'
|
||||
: /Android/.test(s) ? 'Android' : /Macintosh|Mac OS/.test(s) ? 'Mac'
|
||||
: /Windows/.test(s) ? 'Windows' : /Linux/.test(s) ? 'Linux' : '';
|
||||
return system ? `${browser} · ${system}` : browser;
|
||||
}
|
||||
|
||||
export interface ShareStat extends Share {
|
||||
count: number; visitor: number;
|
||||
}
|
||||
|
||||
/** Overview for the owner (admins see all of them). */
|
||||
export async function listShares(userId: string | null, isAdmin: boolean): Promise<ShareStat[]> {
|
||||
return query<ShareStat>(
|
||||
// `count` counts the same conditions as `shareItems` — otherwise the
|
||||
// management view names a different number than the page the recipient sees.
|
||||
// `visitor` comes from the maintained column instead of an aggregation over
|
||||
// share_views: with many accesses this page turned sluggish otherwise.
|
||||
`SELECT s.*,
|
||||
(SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
|
||||
WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS count,
|
||||
s.visitor
|
||||
FROM shares s
|
||||
WHERE $2::boolean OR s.created_by = $1
|
||||
ORDER BY s.created_at DESC LIMIT 200`, [userId, isAdmin]);
|
||||
}
|
||||
|
||||
/** Revoke — the link stays in place, but delivers nothing any more. */
|
||||
export async function revokeShare(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||
const r = await query<{ id: string }>(
|
||||
`UPDATE shares SET revoked_at = now()
|
||||
WHERE id = $1 AND ($3::boolean OR created_by = $2) AND revoked_at IS NULL RETURNING id`,
|
||||
[id, userId, isAdmin]);
|
||||
return r.length > 0;
|
||||
}
|
||||
|
||||
/** Release it again — handy when you clicked the wrong thing. */
|
||||
export async function unrevokeShare(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||
const r = await query<{ id: string }>(
|
||||
`UPDATE shares SET revoked_at = NULL
|
||||
WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`, [id, userId, isAdmin]);
|
||||
return r.length > 0;
|
||||
}
|
||||
|
||||
export interface ShareAendern {
|
||||
title?: string | null; note?: string | null;
|
||||
days?: number | null; allowDownload?: boolean;
|
||||
/** Empty text removes the passphrase. */
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
export async function updateShare(
|
||||
id: string, userId: string | null, isAdmin: boolean, a: ShareAendern,
|
||||
): Promise<boolean> {
|
||||
const own = await one<{ id: string }>(
|
||||
'SELECT id FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2)', [id, userId, isAdmin]);
|
||||
if (!own) return false;
|
||||
|
||||
const sets: string[] = []; const args: any[] = [id];
|
||||
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
|
||||
if (a.title !== undefined) set('title', short(a.title, MAX_TITEL));
|
||||
if (a.note !== undefined) set('note', short(a.note, MAX_NOTIZ));
|
||||
if (a.allowDownload !== undefined) set('allow_download', !!a.allowDownload);
|
||||
if (a.days !== undefined) {
|
||||
const t = tageLesen(a.days, 0);
|
||||
set('expires_at', t > 0 ? new Date(Date.now() + t * 86400_000) : null);
|
||||
}
|
||||
if (a.password !== undefined) {
|
||||
set('password_hash', a.password?.trim() ? await argon2.hash(a.password.trim()) : null);
|
||||
}
|
||||
if (!sets.length) return true;
|
||||
await query(`UPDATE shares SET ${sets.join(',')} WHERE id=$1`, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteShare(id: string, userId: string | null, isAdmin: boolean): Promise<boolean> {
|
||||
const r = await query<{ id: string }>(
|
||||
'DELETE FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2) RETURNING id', [id, userId, isAdmin]);
|
||||
return r.length > 0;
|
||||
}
|
||||
|
||||
/** Constant-time comparison — its duration does not give away how close you were. */
|
||||
export function gleich(a: string, b: string): boolean {
|
||||
const x = Buffer.from(String(a)); const y = Buffer.from(String(b));
|
||||
if (x.length !== y.length) return false;
|
||||
return timingSafeEqual(x, y);
|
||||
}
|
||||
|
||||
/* ---------------- Names inside the collected package ---------------- */
|
||||
|
||||
/**
|
||||
* Unique file names within one ZIP.
|
||||
*
|
||||
* Two identical names in the same archive are not an error while packing — they
|
||||
* only show up when **unpacking**, where the second overwrites the first or the
|
||||
* unpacker asks back. Both annoying, both avoidable.
|
||||
*
|
||||
* `taken` is written on as it goes; the function is therefore deliberately not
|
||||
* pure but works on the state of the running archive.
|
||||
*/
|
||||
export function archiveName(
|
||||
raw: string | null | undefined, taken: Map<string, number>, defaultExt = 'jpg',
|
||||
): string {
|
||||
let name = String(raw || '').replace(/[/\\]/g, '_').replace(/[\u0000-\u001f]/g, '').trim()
|
||||
|| `image.${defaultExt}`;
|
||||
if (!/\.[a-z0-9]{1,8}$/i.test(name)) name += `.${defaultExt}`;
|
||||
const n = taken.get(name);
|
||||
if (n == null) { taken.set(name, 0); return name; }
|
||||
taken.set(name, n + 1);
|
||||
return name.replace(/(\.[^.]+)$/, `_${n + 1}$1`);
|
||||
}
|
||||
|
||||
/** File name for the collected package: date and title, reduced to file-system characters. */
|
||||
export function packageName(title: string | null | undefined, on = new Date()): string {
|
||||
const slug = String(title || '').toLowerCase()
|
||||
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
|
||||
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'klarbild';
|
||||
return `${on.toISOString().slice(0, 10)}_${slug}.zip`;
|
||||
}
|
||||
|
||||
/* ---------------- Proof of a checked passphrase ----------------
|
||||
* `argon2.verify` costs around 130 ms and 64 MiB — and used to run on **every**
|
||||
* request, so on every single image of a folder too. A page with 40 images
|
||||
* would have driven the process into the memory limit, and a few dozen requests
|
||||
* with the wrong word would have brought the whole application to a halt.
|
||||
*
|
||||
* So: check once, then carry along a short-lived, signed proof that is valid
|
||||
* **for this one link only**. */
|
||||
|
||||
const PROOF_LIFETIME = 6 * 60 * 60; // 6 hours
|
||||
|
||||
export const proofName = (slug: string) => `kb_s_${slug}`;
|
||||
|
||||
export function proofBuild(shareId: string): string {
|
||||
const until = Math.floor(Date.now() / 1000) + PROOF_LIFETIME;
|
||||
const sig = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
|
||||
.update(`${shareId}.${until}`).digest('base64url');
|
||||
return `${until}.${sig}`;
|
||||
}
|
||||
|
||||
export function proofValid(shareId: string, value?: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const [untilRaw, sig] = String(value).split('.');
|
||||
const until = Number(untilRaw);
|
||||
if (!Number.isFinite(until) || until < Math.floor(Date.now() / 1000)) return false;
|
||||
const expected = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
|
||||
.update(`${shareId}.${until}`).digest('base64url');
|
||||
return !!sig && gleich(sig, expected);
|
||||
}
|
||||
|
||||
/** Pull the proof out of the cookie header. */
|
||||
export function proofFromCookie(cookieHeader: string | null | undefined, slug: string): string | null {
|
||||
const name = proofName(slug);
|
||||
return cookieHeader?.split(/;\s*/).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1) || null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Object storage abstraction. STORAGE_DRIVER=fs (volume, default) | s3 (MinIO/S3).
|
||||
// Presigned URLs are not needed — originals go to the image API as a base64 data URL.
|
||||
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
|
||||
const DIR = process.env.STORAGE_DIR || '/data';
|
||||
const bucket = process.env.S3_BUCKET || 'klarbild';
|
||||
|
||||
// --- S3 (lazy, only when used) ----------------------------------------------
|
||||
let _s3: any = null;
|
||||
async function s3client() {
|
||||
if (!_s3) {
|
||||
const { S3Client } = await import('@aws-sdk/client-s3');
|
||||
_s3 = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
return _s3;
|
||||
}
|
||||
|
||||
// --- API --------------------------------------------------------------------
|
||||
export async function ensureBucket(): Promise<void> {
|
||||
if (DRIVER === 's3') {
|
||||
const { CreateBucketCommand, HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
||||
const s3 = await s3client();
|
||||
try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); }
|
||||
catch { await s3.send(new CreateBucketCommand({ Bucket: bucket })); }
|
||||
} else {
|
||||
await mkdir(DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function putObject(key: string, body: Buffer, contentType: string): Promise<string> {
|
||||
if (DRIVER === 's3') {
|
||||
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
await (await s3client()).send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
|
||||
} else {
|
||||
const p = join(DIR, key);
|
||||
await mkdir(dirname(p), { recursive: true });
|
||||
await writeFile(p, body);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function getObject(key: string): Promise<Buffer> {
|
||||
if (DRIVER === 's3') {
|
||||
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
const res = await (await s3client()).send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of res.Body as any) chunks.push(Buffer.from(c));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
return readFile(join(DIR, key));
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
if (DRIVER === 's3') {
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
await (await s3client()).send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||
} else {
|
||||
await unlink(join(DIR, key)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Storage health check (directory writable, or bucket reachable). */
|
||||
export async function storageOk(): Promise<boolean> {
|
||||
try {
|
||||
if (DRIVER === 's3') {
|
||||
const { HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
||||
await (await s3client()).send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
} else {
|
||||
await mkdir(DIR, { recursive: true });
|
||||
const probe = join(DIR, '.health');
|
||||
await writeFile(probe, 'ok'); await unlink(probe).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function sourceKey(uuid: string, ext: string): string {
|
||||
return `sources/${new Date().getFullYear()}/${uuid}.${ext}`;
|
||||
}
|
||||
export function resultKey(uuid: string): string {
|
||||
return `results/${new Date().getFullYear()}/${uuid}.png`;
|
||||
}
|
||||
export function thumbKey(uuid: string): string {
|
||||
return `thumbs/${new Date().getFullYear()}/${uuid}.webp`;
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Telegram bot (grammY, webhook) — a full input and control channel.
|
||||
// Pairing, bundling of forwarded images, preset recipe choice, processing, feedback.
|
||||
import { Bot, InputFile, InlineKeyboard, Keyboard } from 'grammy';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { one, query } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
import { getObject, putObject, sourceKey } from './storage';
|
||||
import { enqueue } from './queue';
|
||||
import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout';
|
||||
import { renderCell, buildSheetPdf } from './printrender';
|
||||
import { photoById, labelMm, paperById } from './paper';
|
||||
|
||||
const QUIET_MS = 4000; // quiet window for the bundling
|
||||
const BASE = process.env.PUBLIC_BASE_URL || '';
|
||||
|
||||
let bot: Bot | null = null;
|
||||
let webhookSecret = '';
|
||||
// Scratch space: free text the user can turn into an image with one button.
|
||||
const pendingGenerate = new Map<number, string>();
|
||||
// Chats that are typing an image description right now (after tapping “New image”).
|
||||
const awaitingGenerate = new Set<number>();
|
||||
|
||||
// Menu labels (persistent keyboard) — everything by fingertip, without commands.
|
||||
const BTN_NEW = '✨ New image', BTN_RECIPES = '📁 Choose recipe', BTN_HELP = '❓ Help';
|
||||
function mainKeyboard() {
|
||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||
}
|
||||
// Print sheet via Telegram: a fixed, tappable choice — no free text needed.
|
||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40'];
|
||||
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = “fill the sheet”
|
||||
|
||||
const HELP_TEXT =
|
||||
'How it works — all by button, without commands:\n\n' +
|
||||
'🖼 *Edit:* send the image or images here (best as a *file*), then tap the preset recipe at the bottom.\n' +
|
||||
'🔀 *Combine:* send 2+ images, put a description such as “with our dog” in the image caption field — then tap “🔀 Combine”.\n' +
|
||||
'✨ *New image:* tap “✨ New image” at the bottom and describe what should come out.\n' +
|
||||
'♻️ *Keep editing:* simply send a finished image from me back to me.\n' +
|
||||
'🖨 *Print (without AI):* send an image → “🖨 Print” → tap the size and the quantity. ' +
|
||||
'You get a PDF at 100 % size with crop marks.\n\n' +
|
||||
'I report back once, when everything is done — with the result and a link.';
|
||||
|
||||
export async function getToken(): Promise<string> {
|
||||
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
|
||||
if (s?.telegram_bot_token_enc) { try { return decrypt(s.telegram_bot_token_enc); } catch { /* ENV */ } }
|
||||
return process.env.TELEGRAM_BOT_TOKEN || '';
|
||||
}
|
||||
|
||||
export async function getBot(): Promise<Bot | null> {
|
||||
if (bot) return bot;
|
||||
const token = await getToken();
|
||||
if (!token) return null;
|
||||
bot = new Bot(token);
|
||||
register(bot);
|
||||
await bot.init();
|
||||
return bot;
|
||||
}
|
||||
|
||||
export function getWebhookSecret(): string { return webhookSecret; }
|
||||
|
||||
/** At startup: register the webhook (secret from settings) + the bundling sweeper. */
|
||||
export async function setupTelegram(): Promise<void> {
|
||||
const b = await getBot();
|
||||
if (!b) { console.log('[telegram] no token — bot inactive'); return; }
|
||||
const s = await one<{ telegram_webhook_secret: string | null }>('SELECT telegram_webhook_secret FROM settings WHERE id=1');
|
||||
webhookSecret = s?.telegram_webhook_secret || randomUUID().replace(/-/g, '');
|
||||
if (!s?.telegram_webhook_secret) await query('UPDATE settings SET telegram_webhook_secret=$1 WHERE id=1', [webhookSecret]);
|
||||
if (BASE) {
|
||||
try {
|
||||
await b.api.setWebhook(`${BASE}/api/telegram/webhook`, {
|
||||
secret_token: webhookSecret, allowed_updates: ['message', 'callback_query'], drop_pending_updates: true,
|
||||
});
|
||||
console.log('[telegram] webhook set:', `${BASE}/api/telegram/webhook`);
|
||||
} catch (e) { console.error('[telegram] setWebhook failed:', e); }
|
||||
}
|
||||
// Command menu (the “/” menu in Telegram) — as an additional shortcut.
|
||||
try {
|
||||
await b.api.setMyCommands([
|
||||
{ command: 'new', description: '✨ Generate a new image from text' },
|
||||
{ command: 'presets', description: '📁 Choose the default preset recipe' },
|
||||
{ command: 'status', description: '⏳ Show the last job' },
|
||||
{ command: 'help', description: '❓ Help & guide' },
|
||||
{ command: 'start', description: '🏠 Show the menu' },
|
||||
]);
|
||||
} catch (e) { console.error('[telegram] setMyCommands:', e); }
|
||||
setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000);
|
||||
}
|
||||
|
||||
// --- Pairing ----------------------------------------------------------------
|
||||
async function linkedUser(chatId: number) {
|
||||
return one<{ user_id: string; default_recipe_id: string | null }>(
|
||||
'SELECT user_id, default_recipe_id FROM telegram_links WHERE chat_id=$1 AND active', [chatId]);
|
||||
}
|
||||
|
||||
async function tryPair(chatId: number, code: string): Promise<boolean> {
|
||||
const row = await one<{ user_id: string }>(
|
||||
`SELECT user_id FROM telegram_pairing_codes WHERE code=$1 AND used_at IS NULL AND expires_at > now()`, [code.trim()]);
|
||||
if (!row) return false;
|
||||
await query(`UPDATE telegram_pairing_codes SET used_at=now() WHERE code=$1`, [code.trim()]);
|
||||
await query(`INSERT INTO telegram_links (chat_id, user_id, active) VALUES ($1,$2,true)
|
||||
ON CONFLICT (chat_id) DO UPDATE SET user_id=$2, active=true`, [chatId, row.user_id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Bundling (drafts) ------------------------------------------------------
|
||||
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, caption?: string | null) {
|
||||
const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>(
|
||||
`SELECT id, file_refs, caption FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
|
||||
if (existing) {
|
||||
const refs = [...(existing.file_refs || []), fileRef];
|
||||
// Keep the first caption that came in as the description.
|
||||
const cap = existing.caption || (caption?.trim() || null);
|
||||
await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`,
|
||||
[existing.id, JSON.stringify(refs), cap]);
|
||||
return existing.id;
|
||||
}
|
||||
const row = await one<{ id: string }>(
|
||||
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status)
|
||||
VALUES ($1,$2,$3,$4,'collecting') RETURNING id`,
|
||||
[chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || null]);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/** Sweeper: drafts that finished collecting (quiet window over) → ask for the preset recipe. */
|
||||
export async function sweepDrafts(): Promise<void> {
|
||||
const b = await getBot(); if (!b) return;
|
||||
const drafts = await query<any>(
|
||||
`SELECT id, chat_id, file_refs, caption FROM telegram_drafts
|
||||
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
|
||||
for (const d of drafts) {
|
||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 6');
|
||||
const kb = new InlineKeyboard();
|
||||
// Callback data <64 bytes: only the recipe UUID; the draft is found via the chat on click.
|
||||
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||
const n = (d.file_refs || []).length;
|
||||
// From 2 images on, additionally offer “Combine”.
|
||||
if (n >= 2) { kb.row(); kb.text('🔀 Combine into one image', 'c:x'); }
|
||||
kb.row(); kb.text('🖨 Print (without AI)', 'p:menu');
|
||||
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||
const capNote = d.caption ? `\n📝 Description recognised: “${d.caption}” — for “Combine”.` : '';
|
||||
try {
|
||||
await b.api.sendMessage(d.chat_id,
|
||||
`${n} image${n > 1 ? 's' : ''} received. What would you like to do?` + capNote +
|
||||
(compressed ? '\n⚠️ Sent as a photo (compressed). For full quality send as a *file*.' : ''),
|
||||
{ reply_markup: kb, parse_mode: 'Markdown' });
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
|
||||
} catch (e) { console.error('[telegram] sweep send:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Draft -> job -----------------------------------------------------------
|
||||
|
||||
/** Loads all images of a draft from Telegram and puts them into the object store. */
|
||||
async function refsToSources(refs: any[], b: Bot): Promise<any[]> {
|
||||
const sources: any[] = [];
|
||||
const token = await getToken();
|
||||
for (const ref of refs || []) {
|
||||
try {
|
||||
const file = await b.api.getFile(ref.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
|
||||
const key = sourceKey(randomUUID(), 'jpg');
|
||||
await putObject(key, buf, 'image/jpeg');
|
||||
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
|
||||
} catch (e) { console.error('[telegram] loading the file failed', e); }
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** Base snapshot from the chat's default preset recipe (format/model/delivery). */
|
||||
async function chatRecipeDefaults(chatId: number): Promise<any> {
|
||||
const link = await linkedUser(chatId);
|
||||
if (link?.default_recipe_id) {
|
||||
const r = await one<any>('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]);
|
||||
if (r) return {
|
||||
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
|
||||
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
|
||||
delivery_folder: r.delivery_folder, output_ext: r.output_ext, delivery_target_id: r.delivery_target_id,
|
||||
};
|
||||
}
|
||||
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library', output_ext: null };
|
||||
}
|
||||
|
||||
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft || draft.status === 'dispatched') return;
|
||||
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
|
||||
if (!recipe) { await b.api.sendMessage(chatId, 'Preset recipe not found.'); return; }
|
||||
const link = await linkedUser(chatId);
|
||||
|
||||
await b.api.sendMessage(chatId, `⏳ Processing ${(draft.file_refs || []).length} image(s) …`);
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (!sources.length) { await b.api.sendMessage(chatId, '❌ No images could be loaded.'); return; }
|
||||
|
||||
const snap = {
|
||||
tasks: recipe.tasks, output_format: recipe.output_format, orientation: recipe.orientation,
|
||||
crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm,
|
||||
model_key: recipe.model_key, custom_instruction: recipe.custom_instruction,
|
||||
delivery: recipe.delivery || 'library', delivery_folder: recipe.delivery_folder,
|
||||
output_ext: recipe.output_ext, delivery_target_id: recipe.delivery_target_id,
|
||||
};
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram','each',$2,'queued',$3,$4) RETURNING id`,
|
||||
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
|
||||
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
|
||||
[job!.id, i, sources[i].source_path, sources[i].filename, sources[i].quality]);
|
||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||
}
|
||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||
}
|
||||
|
||||
/** Combine: all images of the draft + the description → one new image. */
|
||||
async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft || draft.status === 'dispatched') return;
|
||||
const link = await linkedUser(chatId);
|
||||
await b.api.sendMessage(chatId, '⏳ Combining the images …');
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ To combine I need at least 2 images.'); return; }
|
||||
|
||||
const d = await chatRecipeDefaults(chatId);
|
||||
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||
const snap = {
|
||||
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||
prompt_text: description, delivery: d.delivery || 'library', delivery_folder: d.delivery_folder,
|
||||
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
|
||||
};
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`,
|
||||
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
|
||||
VALUES ($1,0,'queued',$2,'combined','original') RETURNING id`,
|
||||
[job!.id, JSON.stringify(sources.map((s) => s.source_path))]);
|
||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||
}
|
||||
|
||||
/** Free text: a completely new image from the description alone. */
|
||||
async function dispatchGenerate(chatId: number, description: string, b: Bot) {
|
||||
const link = await linkedUser(chatId);
|
||||
await b.api.sendMessage(chatId, '⏳ Generating a new image …');
|
||||
const d = await chatRecipeDefaults(chatId);
|
||||
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||
const snap = {
|
||||
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||
prompt_text: description, delivery: d.delivery || 'library', delivery_folder: d.delivery_folder,
|
||||
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
|
||||
};
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||
VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`,
|
||||
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, filename, source_quality)
|
||||
VALUES ($1,0,'queued','new','original') RETURNING id`, [job!.id]);
|
||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Print sheet without AI: bring the draft's images to a size, place them on A4,
|
||||
* add crop marks, send it back as a PDF. Runs directly (no queue, no model,
|
||||
* no cost).
|
||||
*/
|
||||
async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft) { await b.api.sendMessage(chatId, 'No open image batch found.'); return; }
|
||||
const size = photoById(sizeId);
|
||||
if (!size) { await b.api.sendMessage(chatId, 'Unknown size.'); return; }
|
||||
|
||||
await b.api.sendMessage(chatId, '📐 Building the print sheet …');
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (!sources.length) { await b.api.sendMessage(chatId, '❌ No images could be loaded.'); return; }
|
||||
|
||||
const a4 = paperById('A4')!;
|
||||
const sheet: SheetSpec = {
|
||||
wMm: a4.w, hMm: a4.h, marginMm: 5,
|
||||
gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true,
|
||||
};
|
||||
// “Fill the sheet”: as often as possible, spread evenly over the images.
|
||||
let per = count;
|
||||
if (!per) {
|
||||
const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet);
|
||||
per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length));
|
||||
}
|
||||
const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true }));
|
||||
const plan = layout(specs, sheet);
|
||||
if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ The size does not fit on A4.'); return; }
|
||||
|
||||
const images: Record<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
|
||||
for (const [i, src] of sources.entries()) {
|
||||
const buf = await getObject(src.source_path);
|
||||
const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated)));
|
||||
for (const rot of rots) {
|
||||
const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot });
|
||||
images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
}
|
||||
const pages = plan.pages.map((pg) => ({
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
|
||||
}));
|
||||
const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 }));
|
||||
const label = labelMm(size.w, size.h);
|
||||
const bytes = await buildSheetPdf({
|
||||
sheet, pages, marksPerPage: marks, images, title: `Klarbild print sheet ${label}`,
|
||||
footer: {
|
||||
text: `Klarbild · DIN A4 · ${label} · 300 dpi · print at 100 % (not “fit to page”)`,
|
||||
position: 'bottom-left', sizePt: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const file = new InputFile(Buffer.from(bytes), `klarbild-print-sheet-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
|
||||
await b.api.sendDocument(chatId, file, {
|
||||
caption: `📐 ${plan.pages.length} sheet(s) · ${plan.pages[0].placements.length} image(s) on sheet 1 · ${label}\n` +
|
||||
'When printing choose “Actual size / 100 %” — otherwise the size is wrong.',
|
||||
});
|
||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||
}
|
||||
|
||||
/** Called by the worker: one message back to the chat when the job is done. */
|
||||
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
||||
const b = await getBot(); if (!b) return;
|
||||
const items = await query<any>(
|
||||
`SELECT id, filename, result_path, status, delivery_status FROM items WHERE job_id=$1 ORDER BY position`, [jobId]);
|
||||
const done = items.filter((i) => i.status === 'done');
|
||||
const failed = items.filter((i) => i.status === 'failed');
|
||||
const delivered = items.filter((i) => i.delivery_status === 'delivered').length;
|
||||
|
||||
// Send the results back as files (as long as the number is manageable)
|
||||
if (done.length <= 10) {
|
||||
for (const it of done) {
|
||||
try {
|
||||
const buf = await getObject(it.result_path);
|
||||
await b.api.sendDocument(chatId, new InputFile(buf, it.filename || 'klarbild.png'));
|
||||
} catch (e) { console.error('[telegram] sending', e); }
|
||||
}
|
||||
}
|
||||
let msg = `✅ Done: ${done.length} image(s)${failed.length ? `, ${failed.length} failed` : ''}.`;
|
||||
if (delivered) msg += `\n📁 ${delivered} delivered to the folder on the target.`;
|
||||
if (BASE) msg += `\n🔗 Library: ${BASE}/library`;
|
||||
if (done.length) msg += `\n♻️ Keep editing? Just send a result back.`;
|
||||
await b.api.sendMessage(chatId, msg, { reply_markup: mainKeyboard() });
|
||||
}
|
||||
|
||||
// --- Handler ----------------------------------------------------------------
|
||||
function register(b: Bot) {
|
||||
const sendRecipeMenu = async (ctx: any) => {
|
||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
|
||||
const kb = new InlineKeyboard();
|
||||
recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||
return ctx.reply('Which preset recipe should apply to new images by default?', { reply_markup: kb });
|
||||
};
|
||||
|
||||
b.command('start', async (ctx) => {
|
||||
const link = await linkedUser(ctx.chat.id);
|
||||
if (link) return ctx.reply('👋 Klarbild is connected. Send me images to edit/combine, or tap “✨ New image” at the bottom.', { reply_markup: mainKeyboard() });
|
||||
return ctx.reply('👋 Welcome to Klarbild. To pair, enter the *pairing code* from the admin area here.', { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
const sendHelp = (ctx: any) => ctx.reply(HELP_TEXT, { parse_mode: 'Markdown', reply_markup: mainKeyboard() });
|
||||
b.command('help', sendHelp);
|
||||
|
||||
b.command('new', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Please pair first (code from the admin area).');
|
||||
const text = (ctx.match || '').toString().trim();
|
||||
if (!text) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Describe your image briefly — what should come out?'); }
|
||||
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||
});
|
||||
|
||||
b.command('presets', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Please pair first (code from the admin area).');
|
||||
return sendRecipeMenu(ctx);
|
||||
});
|
||||
|
||||
b.command('status', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Please pair first.');
|
||||
const j = await one<any>(`SELECT status, done_count, failed_count, total FROM jobs
|
||||
WHERE telegram_chat_id=$1 ORDER BY created_at DESC LIMIT 1`, [ctx.chat.id]);
|
||||
if (!j) return ctx.reply('No jobs yet.');
|
||||
return ctx.reply(`Last job: ${j.status} — ${j.done_count}/${j.total} done${j.failed_count ? `, ${j.failed_count} failed` : ''}.`);
|
||||
});
|
||||
|
||||
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Not paired. Please enter the pairing code from the admin area.');
|
||||
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null,
|
||||
{ file_id: fileId, name, quality }, ctx.message?.caption || null);
|
||||
};
|
||||
b.on('message:photo', async (ctx) => {
|
||||
const p = ctx.message.photo[ctx.message.photo.length - 1];
|
||||
await onImage(ctx, p.file_id, 'photo.jpg', 'compressed');
|
||||
});
|
||||
b.on('message:document', async (ctx) => {
|
||||
const d = ctx.message.document;
|
||||
if (!(d.mime_type || '').startsWith('image/')) return;
|
||||
await onImage(ctx, d.file_id, d.file_name || 'image', 'original');
|
||||
});
|
||||
|
||||
b.on('message:text', async (ctx) => {
|
||||
if (ctx.message.text.startsWith('/')) return;
|
||||
const text = ctx.message.text.trim();
|
||||
const linked = await linkedUser(ctx.chat.id);
|
||||
if (linked) {
|
||||
// Buttons of the persistent keyboard
|
||||
if (text === BTN_HELP) return sendHelp(ctx);
|
||||
if (text === BTN_RECIPES) return sendRecipeMenu(ctx);
|
||||
if (text === BTN_NEW) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Describe your image briefly — what should come out?'); }
|
||||
|
||||
// Is a combine draft waiting for the description? → take the text as the description.
|
||||
const waiting = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
|
||||
if (waiting) {
|
||||
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, text, b2);
|
||||
return;
|
||||
}
|
||||
// The user tapped “New image” and is typing the description now.
|
||||
if (awaitingGenerate.has(ctx.chat.id)) {
|
||||
awaitingGenerate.delete(ctx.chat.id);
|
||||
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||
return;
|
||||
}
|
||||
// Otherwise: offer the free text as something to generate an image from.
|
||||
const kb = new InlineKeyboard().text('✨ Yes, generate an image from it', 'g:x');
|
||||
pendingGenerate.set(ctx.chat.id, text);
|
||||
return ctx.reply('Should I generate a new image from that?', { reply_markup: kb });
|
||||
}
|
||||
if (await tryPair(ctx.chat.id, text)) {
|
||||
return ctx.reply('✅ Connected. Send me images or tap “✨ New image” at the bottom.', { reply_markup: mainKeyboard() });
|
||||
}
|
||||
return ctx.reply('Code invalid or expired. Generate a new code in the admin area.');
|
||||
});
|
||||
|
||||
b.on('callback_query:data', async (ctx) => {
|
||||
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
||||
await ctx.answerCallbackQuery();
|
||||
// Buttons need an active pairing too — otherwise an old chat keeps working.
|
||||
if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Not (any longer) paired. Please enter a new pairing code from the admin area.');
|
||||
if (kind === 'p') { // Print sheet without AI
|
||||
const draft = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||
if (!draft) return ctx.editMessageText('No open image batch found — please send the images again.');
|
||||
if (recipeId === 'menu') {
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]);
|
||||
const kb = new InlineKeyboard();
|
||||
PRINT_SIZES.forEach((id, i) => {
|
||||
const s = photoById(id); if (!s) return;
|
||||
kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return ctx.editMessageText('📐 What final size should the image have?', { reply_markup: kb });
|
||||
}
|
||||
if (extra === undefined) {
|
||||
const s = photoById(recipeId);
|
||||
if (!s) return ctx.editMessageText('Unknown size.');
|
||||
const kb = new InlineKeyboard();
|
||||
PRINT_COUNTS.forEach((n, i) => {
|
||||
kb.text(n ? `${n}×` : 'Fill the sheet', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — how many times on the A4 sheet?`, { reply_markup: kb });
|
||||
}
|
||||
await ctx.editMessageText('Got it — the sheet is being built.');
|
||||
const b2 = await getBot();
|
||||
if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2)
|
||||
.catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Print sheet failed: ${e?.message || e}`));
|
||||
return;
|
||||
}
|
||||
if (kind === 'd') { // Set the default preset recipe
|
||||
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
|
||||
return ctx.editMessageText('Default preset recipe set.');
|
||||
}
|
||||
if (kind === 'r') { // Preset recipe chosen → process this chat's open draft
|
||||
const draft = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||
if (!draft) return ctx.editMessageText('No open image batch found — please send the images again.');
|
||||
await ctx.editMessageText('Got it, here we go.');
|
||||
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
|
||||
}
|
||||
if (kind === 'c') { // Combine chosen
|
||||
const draft = await one<{ id: string; caption: string | null }>(
|
||||
`SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
|
||||
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||
if (!draft) return ctx.editMessageText('No open image batch found — please send the images again.');
|
||||
if (draft.caption?.trim()) {
|
||||
await ctx.editMessageText(`Got it — combining with: “${draft.caption.trim()}”.`);
|
||||
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2);
|
||||
} else {
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]);
|
||||
await ctx.editMessageText('Describe briefly what should come out (for example “the image, but with our dog”):');
|
||||
}
|
||||
}
|
||||
if (kind === 'g') { // Free text → new image
|
||||
const text = pendingGenerate.get(ctx.chat!.id);
|
||||
if (!text) return ctx.editMessageText('Text no longer available — please send it again or use /new.');
|
||||
pendingGenerate.delete(ctx.chat!.id);
|
||||
await ctx.editMessageText('Got it, generating a new image …');
|
||||
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from 'react';
|
||||
import { makeT, DICT_ELEMENT_ID, type Dict, type Locale, type TFunction } from './i18n';
|
||||
|
||||
/**
|
||||
* Translation inside a React island.
|
||||
*
|
||||
* The dictionary is not bundled. The layout renders it once per page as an inert
|
||||
* `<script type="application/json">` block, and this reads it from there — so an
|
||||
* English instance downloads no dictionary at all, and a German one downloads it
|
||||
* once, as part of the HTML it was already fetching. Reading it is synchronous,
|
||||
* which matters: an island must not render English and then flicker into German.
|
||||
*
|
||||
* Server-side rendering has no `document`, so the island also accepts the
|
||||
* dictionary as a prop. English is the floor in every case, which means no
|
||||
* component is ever left without a working `t`.
|
||||
*/
|
||||
let cached: Dict | null | undefined;
|
||||
|
||||
function fromDocument(): Dict | null {
|
||||
if (cached !== undefined) return cached;
|
||||
cached = null;
|
||||
if (typeof document !== 'undefined') {
|
||||
const el = document.getElementById(DICT_ELEMENT_ID);
|
||||
if (el?.textContent) {
|
||||
try { cached = JSON.parse(el.textContent) as Dict; } catch { cached = null; }
|
||||
}
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function useT(dict?: Dict | Locale | null): TFunction {
|
||||
return useMemo(() => {
|
||||
// Pages pass the dictionary; older call sites pass a locale string, which
|
||||
// carries no dictionary and simply falls through to the document.
|
||||
const d = dict && typeof dict === 'object' ? dict : fromDocument();
|
||||
return makeT(d);
|
||||
}, [dict]);
|
||||
}
|
||||
|
||||
export type { Locale, TFunction, Dict };
|
||||
+1041
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { ensureInit } from './lib/init';
|
||||
import { readSession, verifySession } from './lib/auth';
|
||||
import { one } from './lib/db';
|
||||
import { LOCALE_COOKIE, resolveLocale, isLocale } from './lib/i18n';
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
/^\/login/, /^\/tour/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//,
|
||||
/^\/api\/telegram\/webhook/, /^\/llms\.txt/,
|
||||
// Forgot password — must be reachable without being signed in, otherwise it's pointless.
|
||||
/^\/password/, /^\/api\/auth\/reset/,
|
||||
// Share links: whoever has the link sees the images — that's what they're for.
|
||||
// The check lives in the link itself (expiry, revocation, passphrase).
|
||||
/^\/s\//, /^\/api\/s\//,
|
||||
];
|
||||
|
||||
/** API token (Authorization: Bearer …) → a synthetic admin user for /api/*. */
|
||||
async function tokenUser(header: string | null): Promise<any | null> {
|
||||
const m = /^Bearer\s+(.+)$/i.exec(header || '');
|
||||
if (!m) return null;
|
||||
const s = await one<{ api_token: string | null }>('SELECT api_token FROM settings WHERE id=1');
|
||||
if (!s?.api_token || s.api_token !== m[1].trim()) return null;
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||
return { uid: admin?.id || null, role: 'admin', name: 'API', username: 'api' };
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||
// Health/webhook may run even if init is still stuck — otherwise nothing works.
|
||||
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
|
||||
|
||||
// `readSession` checks the signature and age; whether the account still exists,
|
||||
// the role still matches and the session state is still valid is only decided
|
||||
// by the database — otherwise a cookie would survive every password change.
|
||||
let user = await verifySession(readSession(ctx.request.headers.get('cookie')));
|
||||
// Programmatic access via API token (only for /api/*, except the admin area).
|
||||
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
|
||||
user = await tokenUser(ctx.request.headers.get('authorization'));
|
||||
}
|
||||
ctx.locals.user = user;
|
||||
|
||||
// Language for this request (see lib/i18n.ts for the order of precedence).
|
||||
const wish = ctx.url.searchParams.get('lang');
|
||||
const cookieLang = ctx.cookies.get(LOCALE_COOKIE)?.value ?? null;
|
||||
const locale = resolveLocale({ query: wish, cookie: cookieLang, env: process.env.KLARBILD_LOCALE ?? null });
|
||||
ctx.locals.locale = locale;
|
||||
if (isLocale(wish) && wish !== cookieLang) {
|
||||
ctx.cookies.set(LOCALE_COOKIE, wish, { path: '/', sameSite: 'lax', httpOnly: false, maxAge: 60 * 60 * 24 * 365 });
|
||||
}
|
||||
|
||||
const path = ctx.url.pathname;
|
||||
const isPublic = PUBLIC_PATHS.some((r) => r.test(path));
|
||||
if (!isPublic && !user) {
|
||||
if (path.startsWith('/api/')) return new Response('Unauthorized', { status: 401 });
|
||||
return ctx.redirect('/login');
|
||||
}
|
||||
if (path.startsWith('/api/admin') || path.startsWith('/admin')) {
|
||||
if (user?.role !== 'admin') {
|
||||
if (path.startsWith('/api/')) return new Response('Forbidden', { status: 403 });
|
||||
return ctx.redirect('/');
|
||||
}
|
||||
}
|
||||
return withSecurityHeaders(await next());
|
||||
});
|
||||
|
||||
/**
|
||||
* Security headers. Astro itself builds the Content-Security-Policy at build time
|
||||
* (`experimental.csp` in `astro.config.mjs`) and attaches real **hashes** for every
|
||||
* inline script of ours — so no `'unsafe-inline'` for scripts, which is the
|
||||
* important part against injected code.
|
||||
*
|
||||
* `style-src` is deliberately set back to `'unsafe-inline'` here: React sets
|
||||
* styles as an attribute on the element (`style={{…}}`), and attributes cannot be
|
||||
* hashed. Measured: with a pure hash policy the browser blocks them, and the
|
||||
* preview in the print module loses its layout. What remains exposed — CSS-based
|
||||
* exfiltration — is a much narrower class of attack than script injection, and
|
||||
* that one stays closed.
|
||||
*/
|
||||
function withSecurityHeaders(res: Response): Response {
|
||||
const csp = res.headers.get('content-security-policy');
|
||||
if (csp) {
|
||||
res.headers.set('content-security-policy',
|
||||
csp.replace(/style-src[^;]*/i, "style-src 'self' 'unsafe-inline'"));
|
||||
}
|
||||
// No MIME sniffing: result files are served with a detected type, the
|
||||
// browser should not guess on its own and run something as a script.
|
||||
res.headers.set('x-content-type-options', 'nosniff');
|
||||
// Don't send the path along when navigating to another site (folder names!).
|
||||
res.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
||||
// Older browsers without CSP frame-ancestors.
|
||||
res.headers.set('x-frame-options', 'DENY');
|
||||
// None of these interfaces are needed.
|
||||
res.headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import AccountApp from '../components/AccountApp';
|
||||
import { tFor, dictFor } from '../lib/i18n.server';
|
||||
const locale = Astro.locals.locale ?? 'en';
|
||||
const t = tFor(locale);
|
||||
const dict = dictFor(locale);
|
||||
---
|
||||
<Base title={t('My account · Klarbild')}>
|
||||
<section class="page">
|
||||
<header class="pagehead">
|
||||
<h1>{t('My account')}</h1>
|
||||
<p class="lead">{t('Email address and notifications.')}</p>
|
||||
</header>
|
||||
<AccountApp client:load locale={locale} dict={dict} />
|
||||
</section>
|
||||
<style>
|
||||
.page { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
|
||||
.pagehead { margin-bottom: 18px; }
|
||||
.pagehead h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
|
||||
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; font-size: 0.95rem; }
|
||||
@media (max-width: 640px) { .page { padding: 18px 14px 90px; } }
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import AdminApp from '../../components/AdminApp.tsx';
|
||||
import { tFor, dictFor } from '../../lib/i18n.server';
|
||||
if (Astro.locals.user?.role !== 'admin') return Astro.redirect('/');
|
||||
const locale = Astro.locals.locale ?? 'en';
|
||||
const t = tFor(locale);
|
||||
const dict = dictFor(locale);
|
||||
---
|
||||
<Base title={t('Admin · Klarbild')}>
|
||||
<h1 class="page-title">{t('Admin')}</h1>
|
||||
<AdminApp client:load locale={locale} dict={dict} />
|
||||
<style>.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import posixpath from 'node:path/posix';
|
||||
import { loadConfig, cleanupTmp } from '../../../lib/remotetarget';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Diagnostics: lists a folder on the delivery target (default: the base folder). Body { path? }
|
||||
* Clean up: body { action:'cleanup_tmp', gallery } removes .tmp files left behind. */
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
const cfg = await loadConfig();
|
||||
if (!cfg) return json({ error: 'Delivery target is not configured.' }, 400);
|
||||
if (b.action === 'cleanup_tmp') {
|
||||
try {
|
||||
const removed = await cleanupTmp(cfg, b.gallery || '');
|
||||
return json({ cleaned: removed });
|
||||
} catch (e: any) { return json({ error: e?.message || 'Clean-up failed.' }, 500); }
|
||||
}
|
||||
const path = b.path || cfg.basePath || '/';
|
||||
try {
|
||||
if (cfg.protocol === 'sftp') {
|
||||
const SftpClient = (await import('ssh2-sftp-client')).default;
|
||||
const c = new SftpClient();
|
||||
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
|
||||
try {
|
||||
const list = await c.list(path);
|
||||
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
|
||||
} finally { await c.end(); }
|
||||
} else {
|
||||
const { Client } = await import('basic-ftp');
|
||||
const c = new Client(15000);
|
||||
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
|
||||
try {
|
||||
const list = await c.list(path);
|
||||
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
|
||||
} finally { c.close(); }
|
||||
}
|
||||
} catch (e: any) {
|
||||
return json({ path, error: e?.message || 'Listing failed.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { encrypt } from '../../../lib/crypto';
|
||||
import { loadTargetConfig, testConnection } from '../../../lib/remotetarget';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup, metadata_sidecar,
|
||||
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
|
||||
return json({ targets: rows });
|
||||
};
|
||||
|
||||
// POST: create | { action:'test', id } | { action:'backup_all' }
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
const b = await request.json();
|
||||
if (b.action === 'test') {
|
||||
const cfg = await loadTargetConfig(b.id);
|
||||
if (!cfg) return json({ ok: false, message: 'Target is not fully configured.' });
|
||||
return json(await testConnection(cfg));
|
||||
}
|
||||
if (b.action === 'backup_all') {
|
||||
const { backupAll } = await import('../../../lib/backup');
|
||||
return json(await backupAll());
|
||||
}
|
||||
if (!b.name?.trim()) return json({ error: 'Name is missing.' }, 400);
|
||||
const row = await one<any>(
|
||||
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup, metadata_sidecar)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
|
||||
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup, !!b.metadata_sidecar]);
|
||||
return json({ id: row!.id });
|
||||
};
|
||||
|
||||
// PATCH: edit
|
||||
export const PATCH: APIRoute = async ({ request }) => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
const b = await request.json();
|
||||
if (!b.id) return json({ error: 'No id.' }, 400);
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
|
||||
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
|
||||
if (c in b) set(c, b[c] === '' ? null : b[c]);
|
||||
}
|
||||
if ('is_backup' in b) set('is_backup', !!b.is_backup);
|
||||
if ('metadata_sidecar' in b) set('metadata_sidecar', !!b.metadata_sidecar);
|
||||
if (b.password) set('password_enc', encrypt(String(b.password)));
|
||||
if (!sets.length) return json({ ok: true });
|
||||
args.push(b.id);
|
||||
await query(`UPDATE delivery_targets SET ${sets.join(',')} WHERE id=$${args.length}`, args);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ request, url }) => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
let id = url.searchParams.get('id');
|
||||
if (!id) { try { id = (await request.json())?.id; } catch { /* no matter */ } }
|
||||
if (!id) return json({ error: 'No id.' }, 400);
|
||||
await query('DELETE FROM delivery_targets WHERE id=$1', [id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { listImageModels } from '../../../lib/openrouter';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// GET: the stored models; ?available=1 adds the ones OpenRouter offers.
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
const models = await query('SELECT * FROM models ORDER BY sort, label');
|
||||
const out: any = { models };
|
||||
if (url.searchParams.get('available') === '1') {
|
||||
try {
|
||||
const avail = await listImageModels();
|
||||
out.available = avail.map((m: any) => ({
|
||||
model_id: m.id, name: m.name,
|
||||
supports_alpha: (m.supported_parameters?.background?.values || []).includes('transparent')
|
||||
|| !!m.supported_parameters?.background,
|
||||
}));
|
||||
} catch (e: any) { out.available_error = e?.friendly || 'Could not fetch the model list.'; }
|
||||
}
|
||||
return json(out);
|
||||
};
|
||||
|
||||
// POST: create/update a model (upsert by model_id) or run an action (toggle/default/delete).
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
const b = await request.json();
|
||||
if (b.action === 'delete' && b.id) { await query('DELETE FROM models WHERE id=$1', [b.id]); return json({ ok: true }); }
|
||||
if (b.action === 'default' && b.id) {
|
||||
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (b.action === 'nsfw' && b.id) {
|
||||
await query('UPDATE models SET nsfw = NOT nsfw WHERE id=$1', [b.id]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (!b.model_id || !b.label) return json({ error: 'model_id and label are required.' }, 400);
|
||||
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
|
||||
if (existing) {
|
||||
await query(`UPDATE models SET label=$2, description=$3, active=$4, supports_alpha=$5, sort=$6 WHERE id=$1`,
|
||||
[existing.id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
|
||||
return json({ id: existing.id });
|
||||
}
|
||||
const row = await one(`INSERT INTO models (model_id, label, description, active, supports_alpha, sort)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[b.model_id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
|
||||
return json({ model: row });
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import net from 'node:net';
|
||||
import dns from 'node:dns/promises';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** TCP connection test from inside the container (with a timeout). */
|
||||
function tcpProbe(host: string, port: number, timeoutMs = 6000): Promise<{ ok: boolean; ms: number; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const sock = new net.Socket();
|
||||
let done = false;
|
||||
const finish = (ok: boolean, error?: string) => {
|
||||
if (done) return; done = true;
|
||||
try { sock.destroy(); } catch { /* no matter */ }
|
||||
resolve({ ok, ms: Date.now() - start, error });
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.once('connect', () => finish(true));
|
||||
sock.once('timeout', () => finish(false, 'Timeout — no answer (firewall or routing?)'));
|
||||
sock.once('error', (e: any) => finish(false, e?.code || e?.message || 'Connection error'));
|
||||
sock.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/** Checks whether a host:port can be reached from the container. Body {host,port}, or
|
||||
* the configured backup mirror host if nothing is given. Also resolves DNS. */
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
let host = (b.host || '').trim();
|
||||
let port = Number(b.port) || 0;
|
||||
if (!host) {
|
||||
const s = await one<{ mirror_host: string | null; mirror_port: number | null; mirror_protocol: string | null }>(
|
||||
'SELECT mirror_host, mirror_port, mirror_protocol FROM settings WHERE id=1');
|
||||
host = (s?.mirror_host || '').trim();
|
||||
port = s?.mirror_port || (s?.mirror_protocol === 'ftps' ? 21 : 22);
|
||||
}
|
||||
if (!host) return json({ error: 'No host given.' }, 400);
|
||||
if (!port) port = 22;
|
||||
|
||||
const out: any = { host, port };
|
||||
// DNS: only if this is not a plain IP.
|
||||
const isIp = net.isIP(host) !== 0;
|
||||
if (!isIp) {
|
||||
try { const a = await dns.lookup(host); out.resolve = { ok: true, address: a.address }; }
|
||||
catch (e: any) { out.resolve = { ok: false, error: e?.code || 'DNS lookup failed' }; }
|
||||
} else {
|
||||
out.resolve = { ok: true, address: host, note: 'IP address' };
|
||||
}
|
||||
out.tcp = await tcpProbe(host, port);
|
||||
out.reachable = out.tcp.ok;
|
||||
return json(out);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { deleteObject } from '../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Resets the content: deletes all images, jobs and folders plus their files.
|
||||
* Users, settings, models and preset recipes are kept.
|
||||
* Requires { confirm: 'RESET' }. */
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
if (b.confirm !== 'RESET') return json({ error: 'Confirmation is missing (confirm=RESET).' }, 400);
|
||||
|
||||
// Remove every object from storage first.
|
||||
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
|
||||
let objects = 0;
|
||||
for (const it of items) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) { await deleteObject(k).catch(() => {}); objects++; }
|
||||
}
|
||||
|
||||
// Empty the content tables (the configuration stays).
|
||||
await query('DELETE FROM print_job_items');
|
||||
await query('DELETE FROM print_jobs');
|
||||
await query('DELETE FROM share_links');
|
||||
await query('DELETE FROM telegram_drafts');
|
||||
await query('DELETE FROM items');
|
||||
await query('DELETE FROM jobs');
|
||||
await query('DELETE FROM folders');
|
||||
|
||||
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
|
||||
import { loadMailSource } from '../../../lib/mail';
|
||||
|
||||
export const prerender = false;
|
||||
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const s = await one<any>('SELECT * FROM settings WHERE id=1');
|
||||
const mail = await loadMailSource();
|
||||
if (!s) return json({ settings: null });
|
||||
let orMask = '';
|
||||
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'set'; } }
|
||||
return json({ settings: {
|
||||
openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc,
|
||||
delivery_host: s.delivery_host, delivery_protocol: s.delivery_protocol, delivery_port: s.delivery_port,
|
||||
delivery_user: s.delivery_user, delivery_base_path: s.delivery_base_path,
|
||||
delivery_default_folder: s.delivery_default_folder,
|
||||
delivery_password_set: !!s.delivery_password_enc,
|
||||
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||
// Storage housekeeping
|
||||
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
|
||||
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
|
||||
delivery_metadata_sidecar: s.delivery_metadata_sidecar, mirror_metadata_sidecar: s.mirror_metadata_sidecar,
|
||||
output_ext: s.output_ext,
|
||||
// Permissions / privacy / NSFW
|
||||
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
|
||||
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw,
|
||||
// Backup mirror
|
||||
mirror_enabled: s.mirror_enabled, mirror_host: s.mirror_host, mirror_protocol: s.mirror_protocol, mirror_port: s.mirror_port,
|
||||
mirror_user: s.mirror_user, mirror_base_path: s.mirror_base_path, mirror_password_set: !!s.mirror_password_enc,
|
||||
// Outgoing mail (the password is never returned, only whether it is set)
|
||||
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
|
||||
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
|
||||
// Where the credentials in effect come from: 'database' | 'environment' | null
|
||||
smtp_source: mail?.source ?? null,
|
||||
smtp_effective: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
|
||||
// Sharing
|
||||
share_default_days: s.share_default_days,
|
||||
} });
|
||||
};
|
||||
|
||||
export const PATCH: APIRoute = async ({ request }) => {
|
||||
const b = await request.json();
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
|
||||
|
||||
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
|
||||
if (b.delivery_password) set('delivery_password_enc', encrypt(String(b.delivery_password)));
|
||||
if (b.mirror_password) set('mirror_password_enc', encrypt(String(b.mirror_password)));
|
||||
if (b.smtp_password) set('smtp_password', encrypt(String(b.smtp_password)));
|
||||
if (b.generate_api_token) set('api_token', randomToken());
|
||||
const boolCols = ['keep_sources', 'make_thumbnails', 'mirror_enabled', 'metadata_sidecar',
|
||||
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||
'smtp_secure'];
|
||||
for (const col of ['delivery_host', 'delivery_protocol', 'delivery_port', 'delivery_user', 'delivery_base_path',
|
||||
'delivery_default_folder', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
|
||||
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
||||
'delivery_metadata_sidecar', 'mirror_metadata_sidecar', 'output_ext',
|
||||
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
|
||||
'mirror_enabled', 'mirror_host', 'mirror_protocol', 'mirror_port', 'mirror_user', 'mirror_base_path',
|
||||
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
|
||||
if (col in b) {
|
||||
const raw = b[col];
|
||||
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
|
||||
set(col, val);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return json({ ok: true });
|
||||
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query, one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`);
|
||||
const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
|
||||
const cost = async (days: number | null) => (await one<{ c: string }>(
|
||||
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
|
||||
(days ? ` AND created_at > now() - interval '${days} days'` : ''))
|
||||
)?.c || '0';
|
||||
const byModel = await query(`SELECT COALESCE(model_used,'—') m, count(*)::int n, COALESCE(sum(cost),0)::float c
|
||||
FROM items WHERE status='done' GROUP BY 1 ORDER BY n DESC`);
|
||||
const last = await one<{ t: string }>(`SELECT max(created_at)::text t FROM items`);
|
||||
|
||||
const totalN = Number(total?.n || 0), failedN = Number(failed?.n || 0);
|
||||
return new Response(JSON.stringify({
|
||||
total: totalN, failed: failedN,
|
||||
error_rate: totalN + failedN ? Math.round((failedN / (totalN + failedN)) * 100) : 0,
|
||||
cost_today: Number(await cost(1)), cost_7: Number(await cost(7)),
|
||||
cost_30: Number(await cost(30)), cost_all: Number(await cost(null)),
|
||||
by_model: byModel, last_activity: last?.t || null,
|
||||
}), { headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { storageStats, runRetention, purgeSources, mirrorAllToBackup, rebuildThumbs } from '../../../lib/maintenance';
|
||||
import { renameLibrary, redeliverAll } from '../../../lib/renames';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => json({ stats: await storageStats() });
|
||||
|
||||
// POST { action: 'retention' | 'purge_sources' | 'rename_preview' | 'rename_apply' | 'redeliver_all' | … }
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
if (b.action === 'purge_sources') return json(await purgeSources());
|
||||
if (b.action === 'mirror_all') return json(await mirrorAllToBackup());
|
||||
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
|
||||
// Unique file names: first show what would happen, then apply it.
|
||||
if (b.action === 'rename_preview') return json(await renameLibrary(true));
|
||||
if (b.action === 'rename_apply') return json(await renameLibrary(false));
|
||||
if (b.action === 'redeliver_all') return json(await redeliverAll());
|
||||
if (b.action === 'retention') {
|
||||
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||
if (!s?.retention_days) return json({ error: 'No retention period is set.' }, 400);
|
||||
return json(await runRetention(s.retention_days));
|
||||
}
|
||||
return json({ error: 'Unknown action.' }, 400);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../../lib/db';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
if (!has('telegram')) return moduleOff('telegram');
|
||||
const links = await query(`SELECT l.chat_id, l.active, l.linked_at, u.display_name AS user_name
|
||||
FROM telegram_links l LEFT JOIN users u ON u.id=l.user_id ORDER BY l.linked_at DESC`);
|
||||
return json({ links });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ request }) => {
|
||||
if (!has('telegram')) return moduleOff('telegram');
|
||||
const b = await request.json().catch(() => ({}));
|
||||
if (!b.chat_id) return json({ error: 'chat_id is required.' }, 400);
|
||||
await query('DELETE FROM telegram_links WHERE chat_id=$1', [b.chat_id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// Creates a six-digit pairing code (valid for 15 minutes) for a user.
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!has('telegram')) return moduleOff('telegram');
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const userId = b.userId || locals.user?.uid;
|
||||
if (!userId) return json({ error: 'userId is required.' }, 400);
|
||||
// Six-digit code (Math.random is fine here — no cryptographic secret, and short-lived)
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
await one(`INSERT INTO telegram_pairing_codes (code, user_id, expires_at)
|
||||
VALUES ($1,$2, now() + interval '15 minutes')
|
||||
ON CONFLICT (code) DO UPDATE SET user_id=$2, expires_at=now()+interval '15 minutes', used_at=NULL`,
|
||||
[code, userId]);
|
||||
return json({ code, expires_minutes: 15 });
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { loadConfig, testConnection } from '../../../lib/remotetarget';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async () => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
const cfg = await loadConfig();
|
||||
if (!cfg) return json({ ok: false, message: 'The delivery target is not fully configured.' });
|
||||
return json(await testConnection(cfg));
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { testMail, sendMail, mailLayout, isEmail } from '../../../lib/mail';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Without `to`: only check the connection (nothing is sent).
|
||||
* With `to`: send a real test message — only that proves the mail actually
|
||||
* arrives and does not end up in the spam folder.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (!has('mail')) return moduleOff('mail');
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const to = String(b.to || '').trim();
|
||||
if (!to) return json(await testMail());
|
||||
if (!isEmail(to)) return json({ ok: false, message: 'That is not a valid address.' }, 400);
|
||||
try {
|
||||
await sendMail({
|
||||
to,
|
||||
subject: 'Klarbild — test message',
|
||||
text: 'If you are reading this, sending mail from Klarbild works.\n\n'
|
||||
+ 'Which means these now work: password resets, notices about finished jobs, and share links by mail.',
|
||||
html: mailLayout('Sending works', [
|
||||
'If you are reading this, mail from your Klarbild installation arrives.',
|
||||
'Which means these now work: resetting a password, notices about finished jobs, and share links by mail.',
|
||||
]),
|
||||
});
|
||||
return json({ ok: true, message: `Test message sent to ${to}. Check the spam folder too.` });
|
||||
} catch (e: any) {
|
||||
console.error('[test-mail]', e?.message || e);
|
||||
return json({ ok: false, message: 'Sending failed — check the credentials and the port.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { loadMirrorConfig, testMirror } from '../../../lib/mirror';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async () => {
|
||||
if (!has('mirror')) return moduleOff('mirror');
|
||||
const cfg = await loadMirrorConfig();
|
||||
if (!cfg) return json({ ok: false, message: 'The backup mirror is not fully configured (or switched off).' });
|
||||
return json(await testMirror(cfg));
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const users = await query('SELECT id, username, role, display_name, private_forced, created_at FROM users ORDER BY username');
|
||||
return json({ users });
|
||||
};
|
||||
|
||||
// POST: create a new user OR set a password/attribute (when username or id already exists).
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json();
|
||||
if (b.id && b.password) {
|
||||
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
|
||||
// A new password means old sessions no longer count.
|
||||
await bumpSessionEpoch(b.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (b.id && 'private_forced' in b) {
|
||||
await query('UPDATE users SET private_forced=$2 WHERE id=$1', [b.id, !!b.private_forced]);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (b.id && b.role) {
|
||||
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
|
||||
// Role changed, so invalidate the session: otherwise a demoted admin would
|
||||
// keep their rights until the cookie expires.
|
||||
await bumpSessionEpoch(b.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (!b.username || !b.password) return json({ error: 'username and password are required.' }, 400);
|
||||
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
|
||||
if (exists) return json({ error: 'That user name is taken.' }, 400);
|
||||
const row = await one(`INSERT INTO users (username, role, display_name, password_hash)
|
||||
VALUES ($1,$2,$3,$4) RETURNING id, username, role, display_name`,
|
||||
[b.username.trim(), b.role === 'admin' ? 'admin' : 'user', b.display_name || b.username.trim(), await hashPassword(b.password)]);
|
||||
return json({ user: row });
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { login, makeSessionCookie, rateLimited, noteFailure, clearFailures } from '../../../lib/auth';
|
||||
import { realIp, tooOften } from '../../../lib/limit';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
// Not `clientAddress` directly: that is the **first** value out of
|
||||
// X-Forwarded-For and therefore set by the caller itself — the lock after
|
||||
// five failed attempts could otherwise be sidestepped with a changing header.
|
||||
const ip = realIp(request, clientAddress);
|
||||
if (rateLimited(ip)) {
|
||||
return new Response(JSON.stringify({ error: 'Too many attempts. Please try again in 15 minutes.' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
let username = '', password = '';
|
||||
const ct = request.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
username = b.username || ''; password = b.password || '';
|
||||
} else {
|
||||
const f = await request.formData();
|
||||
username = String(f.get('username') || ''); password = String(f.get('password') || '');
|
||||
}
|
||||
|
||||
// Count per user name as well — otherwise the per-IP lock does nothing when
|
||||
// the attacker comes from many networks.
|
||||
if (tooOften('login-user:' + username.trim().toLowerCase(), 10, 15 * 60_000)) {
|
||||
return new Response(JSON.stringify({ error: 'Too many attempts. Please try again in 15 minutes.' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const session = await login(username.trim(), password);
|
||||
if (!session) {
|
||||
noteFailure(ip);
|
||||
return new Response(JSON.stringify({ error: 'Wrong user name or password.' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
clearFailures(ip);
|
||||
return new Response(JSON.stringify({ ok: true, role: session.user.role }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(session.user, session.epoch) },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { clearSessionCookie } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() },
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
|
||||
import { sendMail, mailLayout, mailReady, isEmail, esc } from '../../../lib/mail';
|
||||
import { tooOften, realIp } from '../../../lib/limit';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const VALID_MINUTES = 60;
|
||||
const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
|
||||
const baseUrl = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Request a reset link (`{ email }`) or set a new password
|
||||
* (`{ token, password }`).
|
||||
*
|
||||
* Important: when a link is requested the route **always answers the same way**,
|
||||
* whether or not the address exists. Otherwise it would be possible to read off
|
||||
* who has an account.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
if (!has('mail')) return moduleOff('mail');
|
||||
const ip = realIp(request, clientAddress);
|
||||
const b = await request.json().catch(() => ({}));
|
||||
|
||||
// --- Set a new password ----------------------------------------------------
|
||||
if (b.token) {
|
||||
const token = String(b.token);
|
||||
const pw = String(b.password || '');
|
||||
if (pw.length < 10) return json({ error: 'The password must be at least 10 characters long.' }, 400);
|
||||
if (tooOften('reset-redeem:' + ip, 20, 15 * 60_000)) return json({ error: 'Too many attempts. Please try again later.' }, 429);
|
||||
|
||||
const r = await one<{ user_id: string }>(
|
||||
`SELECT user_id FROM password_resets
|
||||
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hashToken(token)]);
|
||||
if (!r) return json({ error: 'This link has expired or has already been used.' }, 400);
|
||||
|
||||
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [r.user_id, await hashPassword(pw)]);
|
||||
// Single use, and every other open link for the same account expires with it.
|
||||
await query('UPDATE password_resets SET used_at = now() WHERE user_id=$1 AND used_at IS NULL', [r.user_id]);
|
||||
// The decisive part: **invalidate every existing session**. Someone who
|
||||
// resets their password usually does so because someone else got in — without
|
||||
// this step that person's cookie would stay valid for up to 30 days and the
|
||||
// whole exercise would have no effect.
|
||||
await bumpSessionEpoch(r.user_id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// --- Request a reset link --------------------------------------------------
|
||||
const email = String(b.email || '').trim().toLowerCase();
|
||||
if (!isEmail(email)) return json({ error: 'Please enter a valid email address.' }, 400);
|
||||
// Two limits: per sender **and** per target address. Without the second one a
|
||||
// particular mailbox could be flooded from changing addresses — at the cost of
|
||||
// your own domain's deliverability.
|
||||
if (tooOften('reset-ip:' + ip, 5, 15 * 60_000)) return json({ ok: true });
|
||||
if (tooOften('reset-mail:' + email, 3, 60 * 60_000)) return json({ ok: true });
|
||||
if (!(await mailReady())) return json({ error: 'This server has no outgoing mail configured.' }, 503);
|
||||
|
||||
const u = await one<{ id: string; display_name: string | null; username: string }>(
|
||||
'SELECT id, display_name, username FROM users WHERE lower(email) = $1', [email]);
|
||||
|
||||
if (u) {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await query(
|
||||
`INSERT INTO password_resets (token_hash, user_id, expires_at)
|
||||
VALUES ($1, $2, now() + ($3 || ' minutes')::interval)`,
|
||||
[hashToken(token), u.id, String(VALID_MINUTES)]);
|
||||
|
||||
const url = `${baseUrl()}/password?token=${token}`;
|
||||
const name = u.display_name || u.username;
|
||||
// Do not await: an SMTP send takes hundreds of milliseconds. If the response
|
||||
// waited for it, the timing alone would reveal whether the account exists —
|
||||
// and the identical answer below would be worthless.
|
||||
void sendMail({
|
||||
to: email,
|
||||
subject: 'Reset your Klarbild password',
|
||||
text: `Hello ${name},\n\nuse the link below to set a new password for Klarbild. `
|
||||
+ `The link is valid for ${VALID_MINUTES} minutes and can only be used once.\n\n${url}\n\n`
|
||||
+ `If you did not ask for this, you can ignore this message — nothing will change.`,
|
||||
html: mailLayout('Set a new password', [
|
||||
`Hello ${esc(name)},`,
|
||||
`use the button below to set a new password for Klarbild. The link is valid for <b>${VALID_MINUTES} minutes</b> and can only be used <b>once</b>.`,
|
||||
`If you did not ask for this, you can ignore this message — nothing will change.`,
|
||||
], { text: 'Set a new password', url }),
|
||||
}).catch((e) => console.error('[reset] mail failed:', e?.message || e));
|
||||
}
|
||||
|
||||
// Always the same answer — otherwise it would reveal which addresses have an account.
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { deliverItem } from '../../lib/delivery';
|
||||
import { has, moduleOff } from '../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// Body: { itemIds: [...] } — send a selection to the delivery target after the fact.
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const ids: string[] = b.itemIds || [];
|
||||
if (!ids.length) return json({ error: 'Nothing selected.' }, 400);
|
||||
const results = [];
|
||||
for (const id of ids) results.push({ id, ...(await deliverItem(id)) });
|
||||
const ok = results.filter((r) => r.ok).length;
|
||||
return json({ delivered: ok, total: ids.length, results });
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../lib/db';
|
||||
import { has, moduleOff } from '../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// Names of the extra targets for the studio dropdown (no credentials).
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!has('delivery')) return moduleOff('delivery');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const rows = await query('SELECT id, name FROM delivery_targets ORDER BY name');
|
||||
return json({ targets: rows });
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const row = await one(`UPDATE folders SET name=COALESCE($2,name), delivery_folder=$3 WHERE id=$1 RETURNING *`,
|
||||
[params.id, b.name?.trim() || null, b.delivery_folder ?? null]);
|
||||
return json({ folder: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
await query('DELETE FROM folders WHERE id=$1', [params.id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id)::int AS count
|
||||
FROM folders f ORDER BY f.name`);
|
||||
return json({ folders: rows });
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
if (!b.name?.trim()) return json({ error: 'Name is missing.' }, 400);
|
||||
const row = await one(`INSERT INTO folders (name, delivery_folder) VALUES ($1,$2) RETURNING *`,
|
||||
[b.name.trim(), b.delivery_folder || null]);
|
||||
return json({ folder: row });
|
||||
};
|
||||
|
||||
// Rename a folder / change the folder on the target. Body { id, name?, delivery_folder? }
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
if (!b.id) return json({ error: 'No id.' }, 400);
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
if (typeof b.name === 'string' && b.name.trim()) { args.push(b.name.trim()); sets.push(`name=$${args.length}`); }
|
||||
if ('delivery_folder' in b) { args.push(b.delivery_folder?.trim() || null); sets.push(`delivery_folder=$${args.length}`); }
|
||||
if (!sets.length) return json({ error: 'Nothing to change.' }, 400);
|
||||
args.push(b.id);
|
||||
const row = await one(`UPDATE folders SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
|
||||
return json({ folder: row });
|
||||
};
|
||||
|
||||
// Delete a folder (the images stay, they only lose the assignment). ?id=…
|
||||
export const DELETE: APIRoute = async ({ request, url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
let id = url.searchParams.get('id');
|
||||
if (!id) { try { id = (await request.json())?.id; } catch { /* no matter */ } }
|
||||
if (!id) return json({ error: 'No id.' }, 400);
|
||||
await query('DELETE FROM folders WHERE id=$1', [id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { pool } from '../../lib/db';
|
||||
import { storageOk } from '../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const out: Record<string, string> = { service: 'klarbild' };
|
||||
let ok = true;
|
||||
|
||||
try { await pool.query('SELECT 1'); out.db = 'ok'; }
|
||||
catch { out.db = 'error'; ok = false; }
|
||||
|
||||
out.storage = (await storageOk()) ? 'ok' : 'error';
|
||||
if (out.storage !== 'ok') ok = false;
|
||||
|
||||
out.status = ok ? 'ok' : 'degraded';
|
||||
return new Response(JSON.stringify(out), {
|
||||
status: ok ? 200 : 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { deleteObject } from '../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
|
||||
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
|
||||
if ('color_tag' in b) {
|
||||
const t = ['red', 'orange', 'green', 'final'].includes(b.color_tag) ? b.color_tag : null;
|
||||
args.push(t); sets.push(`color_tag=$${args.length}`);
|
||||
}
|
||||
if (!sets.length) return json({ error: 'Nothing to change.' }, 400);
|
||||
args.push(params.id);
|
||||
const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id, color_tag`, args);
|
||||
return json({ item: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const it = await one<{ result_path: string | null }>('SELECT result_path FROM items WHERE id=$1', [params.id]);
|
||||
if (it?.result_path) await deleteObject(it.result_path).catch(() => {});
|
||||
await query('DELETE FROM items WHERE id=$1', [params.id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../../lib/db';
|
||||
import { enqueue } from '../../../../lib/queue';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Creates another take (an alternative) from the same source, with the same preset recipe. */
|
||||
export const POST: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const item = await one<any>(
|
||||
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
|
||||
if (!item) return json({ error: 'Not found.' }, 404);
|
||||
// Without a source and without a model call there is nothing to vary.
|
||||
const root = item.variant_of || item.id;
|
||||
|
||||
const it = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_path, source_paths, filename, source_quality, variant_of)
|
||||
VALUES ($1, (SELECT COALESCE(max(position),0)+1 FROM items WHERE job_id=$1), 'queued', $2, $3, $4, 'original', $5)
|
||||
RETURNING id`,
|
||||
[item.job_id, item.source_path, item.source_paths ? JSON.stringify(item.source_paths) : null,
|
||||
item.filename, root]);
|
||||
// The job counts one more position and starts running again.
|
||||
await query(`UPDATE jobs SET total=total+1, status='queued', finished_at=NULL
|
||||
WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
|
||||
await enqueue({ itemId: it!.id, jobId: item.job_id });
|
||||
return json({ ok: true, itemId: it!.id });
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import sharp from 'sharp';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Streams the result (or source) image out of object storage.
|
||||
export const GET: APIRoute = async ({ params, url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const q = url.searchParams;
|
||||
const wantThumb = q.get('thumb') === '1';
|
||||
const wantSrc = q.get('src') === '1';
|
||||
const item = await one<any>(
|
||||
`SELECT i.source_path, i.source_paths, i.result_path, i.thumb_path, i.filename, i.has_alpha,
|
||||
j.created_by, j.private FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [params.id]);
|
||||
if (item) {
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const own = item.created_by === locals.user.uid;
|
||||
if (!isAdmin && !own) {
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
if (item.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
// Source: the single source, otherwise the first combine source (for before/after).
|
||||
const firstSource = item?.source_path || (Array.isArray(item?.source_paths) ? item.source_paths[0] : null);
|
||||
const path = wantSrc ? firstSource
|
||||
: wantThumb ? (item?.thumb_path || item?.result_path)
|
||||
: item?.result_path;
|
||||
if (!path) return new Response('Not found', { status: 404 });
|
||||
try {
|
||||
let buf = await getObject(path);
|
||||
const download = q.get('download') === '1';
|
||||
// Derive the content type from the magic bytes — results can be PNG or JPG.
|
||||
const sniff = (b: Buffer): string =>
|
||||
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
|
||||
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
|
||||
: 'image/png';
|
||||
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
|
||||
// Fast full-screen preview: non-transparent results as JPEG (much smaller and
|
||||
// faster on a phone) — enough for "save to photos". The PNG download is unchanged.
|
||||
if (q.get('preview') === '1' && !download && !item?.has_alpha) {
|
||||
try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
|
||||
catch { /* fall back to the original */ }
|
||||
}
|
||||
return new Response(new Uint8Array(buf), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=600',
|
||||
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response('File not available', { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../../lib/db';
|
||||
import { enqueue } from '../../../../lib/queue';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const item = await one<{ id: string; job_id: string }>(
|
||||
'SELECT id, job_id FROM items WHERE id=$1', [params.id]);
|
||||
if (!item) return json({ error: 'Not found.' }, 404);
|
||||
await query(`UPDATE items SET status='queued', error_message=NULL WHERE id=$1`, [item.id]);
|
||||
await query(`UPDATE jobs SET status='queued' WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
|
||||
await enqueue({ itemId: item.id, jobId: item.job_id });
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Copies an item's result into a new source, so it can be worked on further in
|
||||
* the studio as a starting point. */
|
||||
export const POST: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const it = await one<{ result_path: string | null; filename: string | null }>(
|
||||
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
|
||||
if (!it?.result_path) return json({ error: 'There is no result.' }, 404);
|
||||
try {
|
||||
const buf = await getObject(it.result_path);
|
||||
const key = sourceKey(randomUUID(), 'png');
|
||||
await putObject(key, buf, 'image/png');
|
||||
const name = (it.filename || 'image').replace(/\.[^.]+$/, '') + '.png';
|
||||
return json({ source_path: key, filename: name });
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Could not load the result.' }, 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// The library with filters: ?task=&format=&folder=&delivery=&by=
|
||||
export const GET: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const q = url.searchParams;
|
||||
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const shared = s?.library_visibility === 'shared';
|
||||
// Private jobs never show up in the library; visibility follows the setting.
|
||||
const where: string[] = [`i.status='done'`, `j.private=false`];
|
||||
const args: any[] = [];
|
||||
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
|
||||
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
|
||||
|
||||
if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
|
||||
if (q.get('tag')) add('i.color_tag = ?', q.get('tag'));
|
||||
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
|
||||
if (q.get('by')) add('j.created_by = ?', q.get('by'));
|
||||
if (q.get('format')) add('i.output_px = ?', q.get('format'));
|
||||
if (q.get('task')) {
|
||||
args.push(JSON.stringify([q.get('task')]));
|
||||
where.push(`j.recipe_snapshot->'tasks' @> $${args.length}::jsonb`);
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id, i.color_tag,
|
||||
i.delivery_status, i.mirror_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode,
|
||||
${(s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name'} AS by_name,
|
||||
j.recipe_snapshot->'tasks' AS tasks
|
||||
FROM items i
|
||||
JOIN jobs j ON j.id = i.job_id
|
||||
LEFT JOIN users u ON u.id = j.created_by
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY i.created_at DESC LIMIT 400`, args);
|
||||
return json({ items: rows });
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { buildSelectionZipName } from '../../../lib/naming';
|
||||
import archiver from 'archiver';
|
||||
import { query } from '../../../lib/db';
|
||||
import { getObject } from '../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/** Downloads several results as a ZIP. Body { itemIds: [...] } */
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
const ids: string[] = b.itemIds || [];
|
||||
if (!ids.length) return new Response('Nothing selected.', { status: 400 });
|
||||
|
||||
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
|
||||
`SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]);
|
||||
if (!rows.length) return new Response('No results.', { status: 404 });
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
const seen: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const buf = await getObject(r.result_path!);
|
||||
let name = r.filename || `${r.id}.png`;
|
||||
if (!/\.[a-z0-9]+$/i.test(name)) name += '.png';
|
||||
// Avoid name collisions
|
||||
if (seen[name] != null) { const n = ++seen[name]; name = name.replace(/(\.[^.]+)$/, `_${n}$1`); }
|
||||
else seen[name] = 0;
|
||||
archive.append(buf, { name });
|
||||
} catch { /* skip the file */ }
|
||||
}
|
||||
const done = archive.finalize();
|
||||
|
||||
// Turn the archiver stream (Node) into a web ReadableStream.
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
archive.on('data', (c: Buffer) => controller.enqueue(new Uint8Array(c)));
|
||||
archive.on('end', () => controller.close());
|
||||
archive.on('error', (e: any) => controller.error(e));
|
||||
done.catch(() => {});
|
||||
},
|
||||
});
|
||||
// Unique rather than only dated: two selections on the same day had the same name.
|
||||
const name = buildSelectionZipName(rows.length);
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${name}"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const job = await one<any>('SELECT * FROM jobs WHERE id=$1', [params.id]);
|
||||
if (!job) return json({ error: 'Not found.' }, 404);
|
||||
// Access: your own job, an admin, or a shared (non-private) one.
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const own = job.created_by === locals.user.uid;
|
||||
if (!isAdmin && !own) {
|
||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||
if (job.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
const items = await query(
|
||||
`SELECT id, position, status, filename, output_px, has_alpha, error_message,
|
||||
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
|
||||
[params.id]);
|
||||
return json({ job, items });
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../../lib/db';
|
||||
import { enqueue } from '../../../../lib/queue';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const id = params.id!;
|
||||
const job = await one<{ id: string }>('SELECT id FROM jobs WHERE id=$1', [id]);
|
||||
if (!job) return json({ error: 'Not found.' }, 404);
|
||||
|
||||
switch (params.action) {
|
||||
case 'pause':
|
||||
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [id]);
|
||||
break;
|
||||
case 'resume': {
|
||||
await query(`UPDATE jobs SET status='queued' WHERE id=$1`, [id]);
|
||||
const items = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE job_id=$1 AND status IN ('queued','running')`, [id]);
|
||||
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
|
||||
break;
|
||||
}
|
||||
case 'cancel':
|
||||
await query(`UPDATE jobs SET status='cancelled', finished_at=now() WHERE id=$1`, [id]);
|
||||
await query(`UPDATE items SET status='skipped' WHERE job_id=$1 AND status IN ('queued','running')`, [id]);
|
||||
break;
|
||||
case 'retry-failed': {
|
||||
const items = await query<{ id: string }>(
|
||||
`UPDATE items SET status='queued', error_message=NULL WHERE job_id=$1 AND status='failed' RETURNING id`, [id]);
|
||||
await query(`UPDATE jobs SET status='queued', failed_count=0 WHERE id=$1`, [id]);
|
||||
for (const it of items) await enqueue({ itemId: it.id, jobId: id });
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Remove the job with its positions and their objects (for old or failed jobs).
|
||||
const { deleteObject } = await import('../../../../lib/storage');
|
||||
const items = await query<any>(
|
||||
`SELECT source_path, source_paths, result_path, thumb_path FROM items WHERE job_id=$1`, [id]);
|
||||
for (const it of items) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) await deleteObject(k).catch(() => {});
|
||||
}
|
||||
await query(`DELETE FROM items WHERE job_id=$1`, [id]);
|
||||
await query(`DELETE FROM jobs WHERE id=$1`, [id]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return json({ error: 'Unknown action.' }, 400);
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { enqueue } from '../../../lib/queue';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const shared = s?.library_visibility === 'shared';
|
||||
const where: string[] = ['j.private=false']; const args: any[] = [];
|
||||
if (!isAdmin && !shared) { args.push(locals.user.uid); where.push(`j.created_by=$${args.length}`); }
|
||||
const nameCol = (s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name';
|
||||
const rows = await query(
|
||||
`SELECT j.*, ${nameCol} AS by_name FROM jobs j
|
||||
LEFT JOIN users u ON u.id=j.created_by
|
||||
WHERE ${where.join(' AND ')} ORDER BY j.created_at DESC LIMIT 50`, args);
|
||||
return json({ jobs: rows });
|
||||
};
|
||||
|
||||
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}],
|
||||
// mode?: 'each'|'compose'|'generate', prompt_text?, origin? }
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each';
|
||||
const sources: any[] = b.sources || [];
|
||||
const promptText: string = (b.prompt_text || '').trim();
|
||||
|
||||
if (mode === 'each' && !sources.length) return json({ error: 'No images.' }, 400);
|
||||
if (mode === 'compose' && sources.length < 1) return json({ error: 'At least 1 image is required.' }, 400);
|
||||
if (mode === 'compose' && !promptText) return json({ error: 'Please describe what should come out.' }, 400);
|
||||
if (mode === 'generate' && !promptText) return json({ error: 'Please enter some text.' }, 400);
|
||||
|
||||
let snapshot: any = b.recipe;
|
||||
if (b.recipeId) {
|
||||
const r = await one('SELECT * FROM recipes WHERE id=$1', [b.recipeId]);
|
||||
if (!r) return json({ error: 'Preset recipe not found.' }, 404);
|
||||
snapshot = r;
|
||||
}
|
||||
snapshot = snapshot || {};
|
||||
|
||||
// Freeze the preset recipe as a snapshot
|
||||
const snap = {
|
||||
tasks: snapshot.tasks || (mode === 'each' ? [] : []),
|
||||
output_format: snapshot.output_format,
|
||||
orientation: snapshot.orientation,
|
||||
crop_mode: snapshot.crop_mode || 'crop',
|
||||
dpi: snapshot.dpi || 300,
|
||||
contour_mm: snapshot.contour_mm ?? null,
|
||||
model_key: snapshot.model_key ?? null,
|
||||
custom_instruction: snapshot.custom_instruction ?? null,
|
||||
prompt_text: promptText || null,
|
||||
delivery: snapshot.delivery || 'library',
|
||||
delivery_folder: snapshot.delivery_folder ?? null,
|
||||
delivery_target_id: snapshot.delivery_target_id ?? null,
|
||||
output_ext: (snapshot.output_ext === 'jpg' || snapshot.output_ext === 'png') ? snapshot.output_ext : null,
|
||||
};
|
||||
|
||||
// Private session: only if allowed globally or forced for the user.
|
||||
const acc = await one<any>(`SELECT s.private_allowed, u.private_forced
|
||||
FROM settings s, users u WHERE s.id=1 AND u.id=$1`, [locals.user.uid]);
|
||||
const isPrivate = !!(acc?.private_forced || (acc?.private_allowed && b.private));
|
||||
if (isPrivate) snap.delivery = 'library'; // no external delivery for private jobs
|
||||
|
||||
const total = (mode === 'each') ? sources.length : 1;
|
||||
const job = await one<{ id: string }>(
|
||||
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, private)
|
||||
VALUES ($1,$2,$3,$4,'queued',$5,$6) RETURNING id`,
|
||||
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total, isPrivate]);
|
||||
|
||||
if (mode === 'each') {
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
const item = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
|
||||
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
|
||||
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
|
||||
await enqueue({ itemId: item!.id, jobId: job!.id });
|
||||
}
|
||||
} else {
|
||||
const srcPaths = sources.map((s) => s.source_path).filter(Boolean);
|
||||
// File name stem, also written by src/lib/telegram.ts — kept identical there.
|
||||
const base = mode === 'generate' ? 'new' : 'combined';
|
||||
const item = await one<{ id: string }>(
|
||||
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
|
||||
VALUES ($1,0,'queued',$2,$3,'original') RETURNING id`,
|
||||
[job!.id, srcPaths.length ? JSON.stringify(srcPaths) : null, base]);
|
||||
await enqueue({ itemId: item!.id, jobId: job!.id });
|
||||
}
|
||||
|
||||
return json({ jobId: job!.id });
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../lib/db';
|
||||
import { isEmail, mailReady } from '../../lib/mail';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<{ private_allowed: boolean; share_default_days: number }>(
|
||||
'SELECT private_allowed, share_default_days FROM settings WHERE id=1');
|
||||
const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
|
||||
'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
|
||||
return json({
|
||||
role: locals.user.role,
|
||||
private_allowed: !!s?.private_allowed,
|
||||
private_forced: !!u?.private_forced,
|
||||
// My account: email address and notifications
|
||||
username: u?.username ?? null,
|
||||
display_name: u?.display_name ?? null,
|
||||
email: u?.email ?? null,
|
||||
notify_jobs: !!u?.notify_jobs,
|
||||
mail_ready: await mailReady(),
|
||||
share_default_days: s?.share_default_days ?? 30,
|
||||
});
|
||||
};
|
||||
|
||||
/** Maintain your own account: store an email address and switch notifications. */
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user?.uid) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({}));
|
||||
|
||||
if ('email' in b) {
|
||||
const raw = String(b.email ?? '').trim().toLowerCase();
|
||||
if (raw && !isEmail(raw)) return json({ error: 'That does not look like a valid address.' }, 400);
|
||||
// The address is the way to reset a password — so it must not be handed out
|
||||
// twice.
|
||||
if (raw) {
|
||||
const taken = await one<{ id: string }>(
|
||||
'SELECT id FROM users WHERE lower(email)=$1 AND id<>$2', [raw, locals.user.uid]);
|
||||
if (taken) return json({ error: 'That address already belongs to another account.' }, 409);
|
||||
}
|
||||
try {
|
||||
await query('UPDATE users SET email=$2 WHERE id=$1', [locals.user.uid, raw || null]);
|
||||
} catch (e: any) {
|
||||
// Two simultaneous requests can slip past the check above — the unique
|
||||
// index catches it. Then give the same understandable answer as above
|
||||
// instead of an HTML error page.
|
||||
if (e?.code === '23505') return json({ error: 'That address already belongs to another account.' }, 409);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if ('notify_jobs' in b) {
|
||||
await query('UPDATE users SET notify_jobs=$2 WHERE id=$1', [locals.user.uid, !!b.notify_jobs]);
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../lib/db';
|
||||
import { has, moduleOff } from '../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Active models with friendly names (for the studio picker and the result view).
|
||||
// NSFW-capable models only if allow_nsfw is switched on in the settings.
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<{ allow_nsfw: boolean }>('SELECT allow_nsfw FROM settings WHERE id=1');
|
||||
const models = await query(
|
||||
`SELECT model_id, label, description, is_default, supports_alpha, nsfw FROM models
|
||||
WHERE active AND (nsfw=false OR $1=true) ORDER BY sort, label`, [!!s?.allow_nsfw]);
|
||||
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query, one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Sheet presets: paper, marks, image sizes and quantities — without the images. */
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<any>('SELECT anonymous_generations FROM settings WHERE id=1');
|
||||
const hideName = !!s?.anonymous_generations && locals.user.role !== 'admin';
|
||||
const rows = await query(
|
||||
`SELECT p.id, p.name, p.config, p.created_at, p.created_by,
|
||||
${hideName ? 'NULL' : 'u.display_name'} AS by_name
|
||||
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
|
||||
ORDER BY p.name ASC LIMIT 200`);
|
||||
return json({ presets: rows });
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const body = await request.json().catch(() => null) as any;
|
||||
const name = String(body?.name || '').trim();
|
||||
if (!name) return json({ error: 'Name is missing.' }, 400);
|
||||
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Configuration is missing.' }, 400);
|
||||
// Presets are served to every user — hence a hard size limit.
|
||||
const cfg = JSON.stringify(body.config);
|
||||
if (cfg.length > 20000) return json({ error: 'Preset is too large.' }, 400);
|
||||
const mine = await one<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM print_presets WHERE created_by IS NOT DISTINCT FROM $1`, [locals.user.uid]);
|
||||
if (Number(mine?.n || 0) >= 100) return json({ error: 'Too many presets — please clean up first.' }, 400);
|
||||
const row = await one(
|
||||
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
|
||||
[name.slice(0, 80), locals.user.uid, cfg]);
|
||||
return json({ preset: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) return json({ error: 'id is missing.' }, 400);
|
||||
// Only your own presets (admins may do anything) — otherwise anyone could
|
||||
// delete someone else's nursery set.
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const del = await query(
|
||||
`DELETE FROM print_presets WHERE id=$1 AND ($2::bool OR created_by IS NOT DISTINCT FROM $3) RETURNING id`,
|
||||
[id, isAdmin, locals.user.uid]);
|
||||
if (!del.length) return json({ error: 'Preset not found, or not yours.' }, 404);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { listRuns, deleteRun } from '../../../lib/printruns';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** The most recent prints — the settings and the sources, never the PDF itself. */
|
||||
export const GET: APIRoute = async ({ locals, url }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const limit = Number(url.searchParams.get('limit')) || 30;
|
||||
const runs = await listRuns(locals.user.uid, locals.user.role === 'admin', limit);
|
||||
return json({ runs });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ locals, url }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const id = url.searchParams.get('id') || '';
|
||||
if (!UUID.test(id)) return json({ error: 'Unknown entry.' }, 404);
|
||||
const ok = await deleteRun(id, locals.user.uid, locals.user.role === 'admin');
|
||||
return ok ? json({ ok: true }) : json({ error: 'Unknown entry.' }, 404);
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
|
||||
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize';
|
||||
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
|
||||
import { loadSource, type PrintSource } from '../../../lib/printsource';
|
||||
import { buildSheetFilename } from '../../../lib/naming';
|
||||
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||
import { cfgForKey } from '../../../lib/delivery';
|
||||
import { uploadBuffer } from '../../../lib/remotetarget';
|
||||
import { one } from '../../../lib/db';
|
||||
import { recordRun, summarise } from '../../../lib/printruns';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const MAX_CELLS = 40; // different images per sheet
|
||||
const MAX_COPIES = 200; // copies per image
|
||||
const MAX_PAGES = 60; // poster printing needs more pages than a passport photo sheet
|
||||
const MAX_PIECES = 500; // sum of all copies — keeps the packer from degenerating
|
||||
|
||||
interface CellIn {
|
||||
id: string;
|
||||
src: PrintSource;
|
||||
crop?: CropRel | null;
|
||||
wMm?: number; hMm?: number;
|
||||
size?: string; // free text ("12x15", "35x45mm") as an alternative to wMm/hMm
|
||||
count?: number;
|
||||
allowRotate?: boolean;
|
||||
landscape?: boolean; // image size landscape instead of portrait
|
||||
fit?: 'cover' | 'contain';// differing aspect ratio: crop or letterbox
|
||||
bg?: string; // letterbox colour
|
||||
oversize?: Oversize; // larger than the sheet: report, let it run over, or tile
|
||||
overlapMm?: number; // glue flap when tiling
|
||||
}
|
||||
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
let body: any;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Invalid request.' }, 400); }
|
||||
|
||||
try {
|
||||
const sheet = readSheet(body);
|
||||
const marksMode: MarkMode = ['none', 'corner', 'grid'].includes(body?.marks?.mode) ? body.marks.mode : 'corner';
|
||||
const dpi = clamp(Number(body?.dpi) || 300, 72, 1200);
|
||||
const wantExt: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
||||
|
||||
const cellsIn: CellIn[] = Array.isArray(body?.cells) ? body.cells.slice(0, MAX_CELLS) : [];
|
||||
if (!cellsIn.length) return json({ error: 'No images given.' }, 400);
|
||||
|
||||
const specs: PlaceSpec[] = [];
|
||||
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
|
||||
// Cells that do not fit on the sheet and take their own route instead.
|
||||
const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = [];
|
||||
|
||||
for (const [i, c] of cellsIn.entries()) {
|
||||
const size = cellSize(c);
|
||||
if (!size) return json({ error: `Image ${i + 1}: size is missing or invalid.` }, 400);
|
||||
const id = String(c.id || `c${i}`);
|
||||
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
|
||||
const allowRotate = c.allowRotate !== false;
|
||||
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
|
||||
|
||||
if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) {
|
||||
big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode });
|
||||
continue;
|
||||
}
|
||||
specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate });
|
||||
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
|
||||
}
|
||||
if (!specs.length && !big.length) return json({ error: 'No images given.' }, 400);
|
||||
|
||||
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
|
||||
if (totalPieces > MAX_PIECES) return json({ error: `Too many individual images (${totalPieces}). At most ${MAX_PIECES} per sheet job.` }, 422);
|
||||
|
||||
const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 };
|
||||
if (!plan.pages.length && !big.length) {
|
||||
return json({ error: 'Nothing could be placed — the image size is larger than the usable area of the sheet.', unplaced: plan.unplaced }, 422);
|
||||
}
|
||||
|
||||
// Render each image exactly once — even for 24 copies.
|
||||
// Rotated placements need their own take.
|
||||
// The image key is deliberately NOT derived from the cell id — a cell named
|
||||
// "x::rot" would otherwise overwrite the rotated take of "x".
|
||||
const keyOf = (id: string, rot: boolean) => `${specs.findIndex((s) => s.id === id)}${rot ? 'r' : 'p'}`;
|
||||
const images: Record<string, SheetCellImage> = {};
|
||||
const needRotated = new Set<string>();
|
||||
const needPlain = new Set<string>();
|
||||
for (const pg of plan.pages) for (const p of pg.placements) (p.rotated ? needRotated : needPlain).add(p.specId);
|
||||
|
||||
for (const r of resolved) {
|
||||
const buf = await loadSource(r.cell.src, locals.user as any);
|
||||
if (needPlain.has(r.cell.id)) {
|
||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
|
||||
images[keyOf(r.cell.id, false)] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
if (needRotated.has(r.cell.id)) {
|
||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
|
||||
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
}
|
||||
|
||||
// Point the placements at the matching take of the image.
|
||||
const pages = plan.pages.map((pg) => ({
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: keyOf(p.specId, p.rotated) })),
|
||||
}));
|
||||
|
||||
const marks = plan.pages.map((pg) =>
|
||||
cutMarks(pg, sheet, {
|
||||
mode: marksMode,
|
||||
lengthMm: numOr(body?.marks?.lengthMm, 4, 1, 20),
|
||||
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
|
||||
bleedMm: sheet.bleedMm,
|
||||
}));
|
||||
const extras: PageExtras[] = plan.pages.map(() => ({}));
|
||||
|
||||
// --- Oversize images: let them run over, or tile them across several sheets ---
|
||||
const notes: string[] = [];
|
||||
for (const [bi, b] of big.entries()) {
|
||||
const buf = await loadSource(b.cell.src, locals.user as any);
|
||||
const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) };
|
||||
|
||||
if (b.mode === 'overflow') {
|
||||
const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false);
|
||||
const key = `o${bi}`;
|
||||
const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated });
|
||||
images[key] = { bytes: out.buffer, ext: out.ext };
|
||||
for (let c = 0; c < b.count; c++) {
|
||||
pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] });
|
||||
marks.push([]); // marks would be pointless here, the edges lie outside the sheet
|
||||
extras.push({});
|
||||
}
|
||||
if (info.lostMm > 0.05)
|
||||
notes.push(`${labelMm(b.wMm, b.hMm)} runs over the sheet: ${fmtMm(info.lostLeft + info.lostRight)} is missing in width and ${fmtMm(info.lostTop + info.lostBottom)} in height.`);
|
||||
} else {
|
||||
const noteMm = noteInset(sheet);
|
||||
const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40));
|
||||
if (tp.tiles.length * b.count > MAX_PAGES)
|
||||
return json({ error: `The poster print would need ${tp.tiles.length * b.count} sheets — please choose a smaller size or larger paper.` }, 422);
|
||||
|
||||
const inset = sheet.marginMm || 0;
|
||||
for (const [ti, t] of tp.tiles.entries()) {
|
||||
const key = `t${bi}_${ti}`;
|
||||
const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm);
|
||||
const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false });
|
||||
images[key] = { bytes: out.buffer, ext: out.ext };
|
||||
|
||||
// Make the glue flap visible: dashed where the neighbouring sheet joins.
|
||||
const dashed: Line[] = [];
|
||||
const top = inset + noteMm;
|
||||
if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); }
|
||||
if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); }
|
||||
|
||||
for (let c = 0; c < b.count; c++) {
|
||||
pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] });
|
||||
marks.push([]);
|
||||
extras.push({
|
||||
note: `Sheet ${ti + 1}/${tp.tiles.length} · row ${t.row + 1}, column ${t.col + 1}` +
|
||||
(tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} glue flap (dashed)` : ''),
|
||||
dashed,
|
||||
});
|
||||
}
|
||||
}
|
||||
notes.push(`${labelMm(b.wMm, b.hMm)} spread over ${tp.cols} × ${tp.rows} sheets, ${fmtMm(tp.overlapMm)} overlap for gluing.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length > MAX_PAGES) return json({ error: `Too many sheets (${pages.length}). Please reduce the quantity.` }, 422);
|
||||
|
||||
const kinds = [
|
||||
...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`),
|
||||
...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (poster)' : ' (oversize)'}`),
|
||||
];
|
||||
const footer = readFooter(body, sheet, kinds, dpi);
|
||||
|
||||
const bytes = await buildSheetPdf({
|
||||
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
|
||||
title: String(body?.title || 'Klarbild print sheet'),
|
||||
footer,
|
||||
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
|
||||
});
|
||||
|
||||
// The id of the history entry is created **before** the file name so that both
|
||||
// carry the same short id: whoever holds a PDF file can find the matching
|
||||
// entry again under "Last printed".
|
||||
const runId = randomUUID();
|
||||
const filename = buildSheetFilename({
|
||||
name: body?.name, paperId: typeof body?.paper === 'string' ? body.paper : body?.paper?.id,
|
||||
wMm: sheet.wMm, hMm: sheet.hMm, pieces: totalPieces, pages: pages.length, runId,
|
||||
});
|
||||
|
||||
// Optional: also push the finished sheet to the usual target (the default
|
||||
// delivery target, the backup mirror or an extra target) — as with the images.
|
||||
let delivered = '0', deliveryMsg = '';
|
||||
if (body?.deliver) {
|
||||
const key = body.deliver.target === '' || body.deliver.target == null ? 'remote' : String(body.deliver.target);
|
||||
try {
|
||||
// Check the folder name first: no slashes, no "..", otherwise posixpath.join
|
||||
// could be used to break out of the target's base folder.
|
||||
const wish = String(body.deliver.gallery || '').trim();
|
||||
if (wish && (wish.includes('..') || !/^[\p{L}\p{N} _.\-]{1,64}$/u.test(wish)))
|
||||
throw new Error('Invalid folder name.');
|
||||
const cfg = await cfgForKey(key);
|
||||
if (!cfg) throw new Error('Target is not configured.');
|
||||
const s = await one<{ delivery_default_folder: string | null }>('SELECT delivery_default_folder FROM settings WHERE id=1');
|
||||
const gallery = wish || s?.delivery_default_folder || 'Gallery A';
|
||||
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
|
||||
delivered = '1';
|
||||
deliveryMsg = `Sheet delivered to "${gallery}".`;
|
||||
} catch (e: any) {
|
||||
deliveryMsg = `Delivery failed: ${e?.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Record the print — only the instructions, not the PDF. A failure here must
|
||||
// never cost the sheet: it is finished and about to go out.
|
||||
try {
|
||||
await recordRun({
|
||||
id: runId,
|
||||
userId: locals.user.uid ?? null,
|
||||
origin: String(body?.origin || 'web'),
|
||||
summary: summarise(kinds),
|
||||
paper: `${labelMm(sheet.wMm, sheet.hMm)}`,
|
||||
pages: pages.length,
|
||||
pieces: totalPieces,
|
||||
bytes: bytes.length,
|
||||
config: body,
|
||||
itemIds: [...cellsIn, ...big.map((b) => b.cell)]
|
||||
.map((c: any) => (c?.src?.kind === 'item' ? String(c.src.id) : null))
|
||||
.filter(Boolean) as string[],
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('[print] print history not saved:', e?.message || e);
|
||||
}
|
||||
|
||||
return new Response(Buffer.from(bytes), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'X-Klarbild-Pages': String(pages.length),
|
||||
'X-Klarbild-Per-Sheet': String(plan.perSheet),
|
||||
'X-Klarbild-Delivered': delivered,
|
||||
...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}),
|
||||
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Internal details (paths, host names of delivery targets) stay in the log.
|
||||
console.error('[print/sheet]', e?.message || e);
|
||||
return json({ error: userMessage(e) }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
/** Only pass on messages we wrote ourselves, nothing from deeper down. */
|
||||
const SAFE = /^(Paper size|Image \d|Size|Invalid|Unknown|Image not|No access|Source|Crop|Target image|Too many)/;
|
||||
function userMessage(e: any): string {
|
||||
const m = String(e?.message || '');
|
||||
return SAFE.test(m) ? m : 'The print sheet could not be created.';
|
||||
}
|
||||
|
||||
function readSheet(body: any): SheetSpec {
|
||||
let w: number | null = null, h: number | null = null;
|
||||
const p = body?.paper;
|
||||
if (typeof p === 'string') { const pp = paperById(p); if (pp) { w = pp.w; h = pp.h; } }
|
||||
else if (p && typeof p === 'object') {
|
||||
if (p.id) { const pp = paperById(String(p.id)); if (pp) { w = pp.w; h = pp.h; } }
|
||||
if (p.wMm && p.hMm) { w = Number(p.wMm); h = Number(p.hMm); }
|
||||
if (!w && p.size) { const s = parseSizeMm(String(p.size)); if (s) { w = s.w; h = s.h; } }
|
||||
}
|
||||
if (!w || !h) throw new Error('Paper size is missing or unknown.');
|
||||
if (body?.landscape) [w, h] = [h, w];
|
||||
if (w < 20 || h < 20 || w > 2000 || h > 2000) throw new Error('Paper size is outside the allowed range.');
|
||||
|
||||
const bleedMm = numOr(body?.bleedMm, 0, 0, 10);
|
||||
const sheet: SheetSpec = {
|
||||
wMm: w, hMm: h,
|
||||
marginMm: numOr(body?.marginMm, 5, 0, 50),
|
||||
gapMm: numOr(body?.gapMm, 4, 0, 100),
|
||||
bleedMm,
|
||||
center: body?.center !== false,
|
||||
};
|
||||
sheet.gapMm = effectiveGap(sheet);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function cellSize(c: CellIn): { w: number; h: number } | null {
|
||||
let s: { w: number; h: number } | null = null;
|
||||
if (Number(c.wMm) > 0 && Number(c.hMm) > 0) s = { w: Number(c.wMm), h: Number(c.hMm) };
|
||||
else if (c.size) s = parseSizeMm(String(c.size));
|
||||
if (!s || s.w < 5 || s.h < 5 || s.w > 2000 || s.h > 2000) return null;
|
||||
return c.landscape ? { w: s.h, h: s.w } : s;
|
||||
}
|
||||
|
||||
/** Millimetres in human-readable form: "3 mm", "1.5 mm", "0 mm". */
|
||||
const fmtMm = (n: number) => `${Math.round(n * 10) / 10} mm`;
|
||||
|
||||
const FOOTER_POS: FooterPos[] = ['bottom-left', 'bottom-center', 'bottom-right', 'top-left', 'top-center', 'top-rechts'];
|
||||
|
||||
/** Caption: off, automatic, or your own text at a position you choose. */
|
||||
function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) {
|
||||
const f = body?.footer;
|
||||
if (f === false || f === null) return null;
|
||||
const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · print at 100% (not "fit to page")`;
|
||||
if (f === true || f == null) return { text: auto, position: 'bottom-left' as FooterPos, sizePt: 6 };
|
||||
const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto;
|
||||
const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'bottom-left';
|
||||
return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) };
|
||||
}
|
||||
|
||||
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
|
||||
/** Only allow a safe hex value as the letterbox colour. */
|
||||
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');
|
||||
|
||||
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
||||
const numOr = (v: any, def: number, lo: number, hi: number) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? clamp(n, lo, hi) : def;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { renderCell, type CropRel } from '../../../lib/printrender';
|
||||
import { loadSourceNamed, type PrintSource } from '../../../lib/printsource';
|
||||
import { buildSingleFilename } from '../../../lib/naming';
|
||||
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
|
||||
import { dpiCheck } from '../../../lib/printlayout';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/**
|
||||
* Bring a single image to an exact size — no AI, no sheet.
|
||||
* Response: the finished image file (PNG/JPG) with dpi metadata.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
let body: any;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Invalid request.' }, 400); }
|
||||
|
||||
try {
|
||||
const src: PrintSource = body?.src;
|
||||
const crop: CropRel | null = body?.crop ?? null;
|
||||
let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0)
|
||||
? { w: Number(body.wMm), h: Number(body.hMm) }
|
||||
: parseSizeMm(String(body?.size || ''));
|
||||
if (!size) return json({ error: 'Size is missing or invalid.' }, 400);
|
||||
if (body?.landscape) size = { w: size.h, h: size.w };
|
||||
if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Size is outside the allowed range.' }, 400);
|
||||
|
||||
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
|
||||
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
||||
const source = await loadSourceNamed(src, locals.user as any);
|
||||
const buf = source.buffer;
|
||||
const fit = body?.fit === 'contain' ? 'contain' : 'cover';
|
||||
const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff';
|
||||
const out = await renderCell(buf, crop, size.w, size.h, dpi,
|
||||
{ ext, bleedMm: Math.min(10, Math.max(0, Number(body?.bleedMm) || 0)), fit, background });
|
||||
|
||||
// A hint in case the source is too small for real 300 dpi.
|
||||
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
||||
const check = dpiCheck(cropW, size.w, dpi);
|
||||
|
||||
return new Response(new Uint8Array(out.buffer), {
|
||||
headers: {
|
||||
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
|
||||
'Content-Disposition': `attachment; filename="${buildSingleFilename({
|
||||
name: body?.name, sourceName: source.filename,
|
||||
wMm: size.w, hMm: size.h, ext: out.ext, id: source.itemId,
|
||||
})}"`,
|
||||
'X-Klarbild-Px': `${out.width}x${out.height}`,
|
||||
'X-Klarbild-Real-Dpi': String(check.dpi),
|
||||
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('[print/single]', e?.message || e);
|
||||
const m = String(e?.message || '');
|
||||
const safe = /^(Size|Invalid|Unknown|Image not|No access|Source|Crop|Target image)/.test(m);
|
||||
return json({ error: safe ? m : 'The image could not be created.' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
/** A small helper for scripts and MCP: size → pixels at a given dpi. */
|
||||
export const GET: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const size = parseSizeMm(url.searchParams.get('size') || '');
|
||||
const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300));
|
||||
if (!size) return json({ error: 'The size parameter is missing (e.g. 12x15 or 35x45mm).' }, 400);
|
||||
return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) });
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query, one } from '../../lib/db';
|
||||
import { has, moduleOff } from '../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const rows = await query('SELECT * FROM recipes ORDER BY is_default DESC, name');
|
||||
return json({ recipes: rows });
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const row = await one(
|
||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
|
||||
model_key, delivery, delivery_folder, delivery_target_id, custom_instruction, mode, output_ext, is_default, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,false,$15) RETURNING *`,
|
||||
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
|
||||
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
|
||||
b.delivery_folder ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null,
|
||||
['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each',
|
||||
(b.output_ext === 'jpg' || b.output_ext === 'png') ? b.output_ext : null, locals.user.uid]);
|
||||
return json({ recipe: row });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm',
|
||||
'model_key', 'delivery', 'delivery_folder', 'delivery_target_id', 'custom_instruction', 'mode', 'output_ext'];
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
|
||||
for (const c of COLS) {
|
||||
if (c in b) set(c, c === 'tasks' ? JSON.stringify(b[c] || []) : (b[c] === '' ? null : b[c]));
|
||||
}
|
||||
if (!sets.length) return json({ error: 'Nothing to change.' }, 400);
|
||||
args.push(params.id);
|
||||
const row = await one(`UPDATE recipes SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
|
||||
return json({ recipe: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('ai')) return moduleOff('ai');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
await query('DELETE FROM recipes WHERE id=$1', [params.id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import sharp from 'sharp';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
import { resolveShare, shareHasItem, trackShare, proofValid, proofFromCookie } from '../../../../lib/shares';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/**
|
||||
* Serve an image behind a share link — **without a sign-in**.
|
||||
*
|
||||
* The access check hangs on the link alone, not on the image: only what is in
|
||||
* `share_items` comes out. So nobody can fetch someone else's images through a
|
||||
* valid link by substituting a different image id.
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
const q = url.searchParams;
|
||||
// No `?pw=` any more: for protected links only the proof counts that the page
|
||||
// set as a cookie after checking once. Otherwise argon2 (130 ms, 64 MiB) would
|
||||
// run again for every single image — with a gallery of 40 images that is enough
|
||||
// to drive the process into the memory limit.
|
||||
let { status, share } = await resolveShare(params.slug!, null);
|
||||
if (status === 'passphrase' && share
|
||||
&& proofValid(share.id, proofFromCookie(request.headers.get('cookie'), params.slug!))) {
|
||||
status = 'ok';
|
||||
}
|
||||
if (status !== 'ok' || !share) {
|
||||
return new Response(
|
||||
status === 'expired' ? 'This link has expired.'
|
||||
: status === 'revoked' ? 'This link has been switched off.'
|
||||
: status === 'passphrase' ? 'A passphrase is required.'
|
||||
: 'Not found.',
|
||||
{ status: status === 'passphrase' ? 401 : status === 'not-found' ? 404 : 410 });
|
||||
}
|
||||
|
||||
const itemId = q.get('item');
|
||||
if (!itemId || !/^[0-9a-f-]{36}$/i.test(itemId)) return new Response('Not found.', { status: 404 });
|
||||
if (!(await shareHasItem(share.id, itemId))) return new Response('Not found.', { status: 404 });
|
||||
|
||||
const item = await one<any>(
|
||||
'SELECT result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1', [itemId]);
|
||||
if (!item?.result_path) return new Response('Not found.', { status: 404 });
|
||||
|
||||
const download = q.get('download') === '1';
|
||||
const wantThumb = q.get('thumb') === '1';
|
||||
// Whoever switched the download off does not hand out the original file either —
|
||||
// otherwise the setting would be decoration.
|
||||
if (download && !share.allow_download) return new Response('Downloading is off for this link.', { status: 403 });
|
||||
|
||||
const path = wantThumb ? (item.thumb_path || item.result_path) : item.result_path;
|
||||
try {
|
||||
let buf = await getObject(path);
|
||||
const sniff = (b: Buffer): string =>
|
||||
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
|
||||
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
|
||||
: 'image/png';
|
||||
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
|
||||
|
||||
// Viewing take: smaller and faster, above all on a phone. The download still
|
||||
// delivers the untouched original file at full resolution.
|
||||
//
|
||||
// Important: this did **not** used to apply to cut-out motifs — they went
|
||||
// through unfiltered, at full resolution, saveable with a right click. That
|
||||
// made "downloading off" useless for exactly stickers and cut-outs. Alpha
|
||||
// images are therefore scaled down as PNG instead of being skipped.
|
||||
if (!download && !wantThumb) {
|
||||
try {
|
||||
const small = sharp(buf, { failOn: 'none', limitInputPixels: 400_000_000 })
|
||||
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true });
|
||||
if (item.has_alpha) { buf = await small.png({ compressionLevel: 8 }).toBuffer(); contentType = 'image/png'; }
|
||||
else { buf = await small.jpeg({ quality: 88, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
|
||||
} catch (e: any) {
|
||||
// Only when the download is blocked is the original off limits — then it
|
||||
// is better to serve nothing than to serve full resolution by accident.
|
||||
console.error('[share] viewing take failed:', e?.message || e);
|
||||
if (!share.allow_download) return new Response('Preview not available.', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
if (download) {
|
||||
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(buf.length),
|
||||
// Not into search engines and not into anyone else's cache.
|
||||
'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
};
|
||||
if (download) {
|
||||
const name = (item.filename || 'klarbild.jpg').replace(/[^\w.\-]/g, '_');
|
||||
headers['Content-Disposition'] = `attachment; filename="${name}"`;
|
||||
}
|
||||
return new Response(new Uint8Array(buf), { headers });
|
||||
} catch (e: any) {
|
||||
// The cause goes into the log — otherwise, during a storage outage, the
|
||||
// operator searches in vain while every recipient sees "image no longer there".
|
||||
console.error('[share] delivery failed', path, e?.message || e);
|
||||
return new Response('This image is no longer there.', { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import archiver from 'archiver';
|
||||
import { Readable } from 'node:stream';
|
||||
import { once } from 'node:events';
|
||||
import { query } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
import { resolveShare, shareItems, trackShare, proofValid, proofFromCookie,
|
||||
archiveName, packageName } from '../../../../lib/shares';
|
||||
import { tooOften, realIp } from '../../../../lib/limit';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/** Upper limits. A ZIP is the most expensive way to pull data out of this server. */
|
||||
const MAX_FILES = 200;
|
||||
const MAX_BYTES = 500 * 1024 * 1024; // 500 MB unpacked
|
||||
|
||||
/**
|
||||
* All images of a share link as a ZIP.
|
||||
*
|
||||
* Deliberately **GET**: that makes it a plain link on the page which works even
|
||||
* without JavaScript — and the recipient is often on a phone.
|
||||
*
|
||||
* Unlike `/api/items/zip` (for signed-in users), here **one file after another**
|
||||
* is read and passed on: after every `append` the code waits for the `entry`
|
||||
* event. Otherwise twenty posters would quickly put a few gigabytes into memory
|
||||
* at the same time — and `mem_limit` is 3 GB.
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, request, clientAddress }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
const slug = params.slug!;
|
||||
let { status, share } = await resolveShare(slug, null);
|
||||
if (status === 'passphrase' && share
|
||||
&& proofValid(share.id, proofFromCookie(request.headers.get('cookie'), slug))) {
|
||||
status = 'ok';
|
||||
}
|
||||
if (status !== 'ok' || !share) {
|
||||
return new Response(
|
||||
status === 'expired' ? 'This link has expired.'
|
||||
: status === 'revoked' ? 'This link has been switched off.'
|
||||
: status === 'passphrase' ? 'A passphrase is required.'
|
||||
: 'Not found.',
|
||||
{ status: status === 'passphrase' ? 401 : status === 'not-found' ? 404 : 410 });
|
||||
}
|
||||
|
||||
// Whoever blocked the download means the bundle too.
|
||||
if (!share.allow_download) {
|
||||
return new Response('Downloading is switched off for this link.', { status: 403 });
|
||||
}
|
||||
|
||||
// A ZIP costs processing time and bandwidth — not once a second.
|
||||
const ip = realIp(request, clientAddress);
|
||||
if (tooOften(`zip:${share.id}:${ip}`, 5, 10 * 60_000)) {
|
||||
return new Response('Just downloaded a moment ago. Please wait a little.', { status: 429 });
|
||||
}
|
||||
|
||||
const images = await shareItems(share.id);
|
||||
if (!images.length) return new Response('There are no images for this link.', { status: 404 });
|
||||
if (images.length > MAX_FILES) {
|
||||
return new Response(`This bundle would be too large (${images.length} images). Please download them one by one.`, { status: 413 });
|
||||
}
|
||||
|
||||
const paths = await query<{ id: string; result_path: string; filename: string | null }>(
|
||||
`SELECT id, result_path, filename FROM items
|
||||
WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`,
|
||||
[images.map((b) => b.id)]);
|
||||
const byId = new Map(paths.map((p) => [p.id, p]));
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
// Errors must not take the process with them; the stream then breaks off.
|
||||
archive.on('error', (e: any) => console.error('[share/zip]', e?.message || e));
|
||||
archive.on('warning', (e: any) => console.error('[share/zip] warning:', e?.message || e));
|
||||
|
||||
// The handlers are in place **before** anything is written into it.
|
||||
const web = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
|
||||
|
||||
(async () => {
|
||||
const taken = new Map<string, number>();
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (const b of images) {
|
||||
const p = byId.get(b.id);
|
||||
if (!p) continue;
|
||||
let buf: Buffer;
|
||||
try { buf = await getObject(p.result_path); }
|
||||
catch (e: any) { console.error('[share/zip] file missing', p.result_path, e?.message || e); continue; }
|
||||
|
||||
bytes += buf.length;
|
||||
if (bytes > MAX_BYTES) {
|
||||
archive.append(
|
||||
Buffer.from(
|
||||
'This bundle was cut off at 500 MB.\r\n'
|
||||
+ 'The remaining images can be downloaded one by one on the page.\r\n', 'utf8'),
|
||||
{ name: 'NOTE.txt' });
|
||||
break;
|
||||
}
|
||||
|
||||
archive.append(buf, { name: archiveName(p.filename, taken) });
|
||||
// One file after another: only read on once this one is in the stream.
|
||||
await once(archive, 'entry');
|
||||
}
|
||||
} finally {
|
||||
archive.finalize().catch(() => {});
|
||||
}
|
||||
})();
|
||||
|
||||
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
|
||||
|
||||
return new Response(web, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${packageName(share.title)}"`,
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { updateShare, revokeShare, unrevokeShare, deleteShare } from '../../../lib/shares';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** The most recent views of a link — for the report in the frontend. */
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!UUID.test(params.id || '')) return json({ error: 'Unknown link.' }, 404);
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const rows = await query<any>(
|
||||
`SELECT v.kind, v.at, v.user_agent
|
||||
FROM share_views v JOIN shares s ON s.id = v.share_id
|
||||
WHERE v.share_id = $1 AND ($3::boolean OR s.created_by = $2)
|
||||
ORDER BY v.at DESC LIMIT 100`, [params.id, locals.user.uid, isAdmin]);
|
||||
return json({ visits: rows });
|
||||
};
|
||||
|
||||
/** Change the title, expiry, passphrase or download permission; revoke or release. */
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!UUID.test(params.id || '')) return json({ error: 'Unknown link.' }, 404);
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
|
||||
if (b.action === 'revoke') {
|
||||
return json({ ok: await revokeShare(params.id!, locals.user.uid, isAdmin) });
|
||||
}
|
||||
if (b.action === 'unrevoke') {
|
||||
return json({ ok: await unrevokeShare(params.id!, locals.user.uid, isAdmin) });
|
||||
}
|
||||
const ok = await updateShare(params.id!, locals.user.uid, isAdmin, {
|
||||
title: b.title, note: b.note, days: b.days,
|
||||
allowDownload: b.allowDownload, password: b.password,
|
||||
});
|
||||
return ok ? json({ ok: true }) : json({ error: 'Unknown link.' }, 404);
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!UUID.test(params.id || '')) return json({ error: 'Unknown link.' }, 404);
|
||||
const ok = await deleteShare(params.id!, locals.user.uid, locals.user.role === 'admin');
|
||||
return ok ? json({ ok: true }) : json({ error: 'Unknown link.' }, 404);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { createShare, listShares, MAX_ITEMS } from '../../../lib/shares';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** List your own share links (admins see all of them). */
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const shares = await listShares(locals.user.uid, locals.user.role === 'admin');
|
||||
return json({ shares });
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new link.
|
||||
* Only images you are allowed to see may be shared — otherwise the share button
|
||||
* would be a comfortable way around the visibility rule.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({}));
|
||||
const ids: string[] = Array.isArray(b.itemIds) ? b.itemIds.filter((x: any) => typeof x === 'string') : [];
|
||||
if (!ids.length) return json({ error: 'No images selected.' }, 400);
|
||||
if (ids.length > MAX_ITEMS) return json({ error: `At most ${MAX_ITEMS} images per link.` }, 422);
|
||||
|
||||
// Check the ids first — an invalid one would otherwise make Postgres abort with
|
||||
// "invalid input syntax for type uuid" and the caller would get a 500 instead
|
||||
// of a clear answer.
|
||||
if (ids.some((id) => !UUID.test(id))) return json({ error: 'Invalid image id.' }, 400);
|
||||
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
// One query instead of one per image: with 500 images that was 500 round trips.
|
||||
const found = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
|
||||
`SELECT i.id, i.status, i.result_path, j.created_by, j.private,
|
||||
(SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
|
||||
FROM items i JOIN jobs j ON j.id = i.job_id
|
||||
WHERE i.id = ANY($1::uuid[])`, [ids]);
|
||||
if (found.length !== ids.length) return json({ error: 'One of the images is no longer available.' }, 404);
|
||||
|
||||
for (const it of found) {
|
||||
if (!it.result_path || it.status !== 'done') return json({ error: 'One of the images is no longer available.' }, 404);
|
||||
// Private jobs are deliberately never shared — the whole point is that they
|
||||
// leave no trace.
|
||||
if (it.private) return json({ error: 'Images from private sessions cannot be shared.' }, 403);
|
||||
const own = it.created_by === locals.user.uid;
|
||||
if (!isAdmin && !own && it.library_visibility !== 'shared')
|
||||
return json({ error: 'No access to one of the images.' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const share = await createShare({
|
||||
itemIds: ids,
|
||||
kind: b.kind === 'collection' ? 'collection' : undefined,
|
||||
title: b.title, note: b.note,
|
||||
days: b.days, allowDownload: b.allowDownload, password: b.password,
|
||||
userId: locals.user.uid,
|
||||
});
|
||||
return json({ share });
|
||||
} catch (e: any) {
|
||||
// Pass on our own messages (validity, empty selection), not database
|
||||
// internals — those give away table and column names.
|
||||
const own = /Validity|selected|images per link/.test(String(e?.message || ''));
|
||||
if (!own) console.error('[shares] creating failed:', e?.message || e);
|
||||
return json({ error: own ? e.message : 'The link could not be created.' }, 400);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one } from '../../../lib/db';
|
||||
import { sendMail, mailLayout, mailReady, isEmail, esc } from '../../../lib/mail';
|
||||
import { tooOften } from '../../../lib/limit';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const baseUrl = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
||||
const MAX_RECIPIENTS = 10;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Send a share link by email.
|
||||
*
|
||||
* Deliberately **without an image attachment**: a poster at full resolution
|
||||
* bursts any mailbox, and an attachment cannot be withdrawn afterwards. A link
|
||||
* can be — it has an expiry date and can be switched off at any time.
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
if (!has('mail')) return moduleOff('mail');
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json().catch(() => ({}));
|
||||
|
||||
const recipients: string[] = (Array.isArray(b.to) ? b.to : String(b.to || '').split(/[,;\s]+/))
|
||||
.map((x: string) => String(x).trim().toLowerCase()).filter(Boolean);
|
||||
if (!recipients.length) return json({ error: 'No recipient address given.' }, 400);
|
||||
if (recipients.length > MAX_RECIPIENTS) return json({ error: `At most ${MAX_RECIPIENTS} recipients at once.` }, 422);
|
||||
const bad = recipients.filter((e) => !isEmail(e));
|
||||
if (bad.length) return json({ error: `Not a valid address: ${bad[0]}` }, 400);
|
||||
|
||||
if (!(await mailReady())) return json({ error: 'No outgoing mail is configured (Admin → Email).' }, 503);
|
||||
// Without an absolute base address the mail would contain a relative path that
|
||||
// no mailbox makes clickable — then it is better not to send at all.
|
||||
if (!/^https?:\/\//.test(baseUrl()))
|
||||
return json({ error: 'The base address of the server is missing (PUBLIC_BASE_URL) — the link in the mail would be useless.' }, 503);
|
||||
|
||||
// Limit per sender, not per IP: whoever is signed in is identified.
|
||||
if (tooOften('mailversand:' + locals.user.uid, 50, 60 * 60_000))
|
||||
return json({ error: 'Enough has been sent for this hour. Please carry on later.' }, 429);
|
||||
|
||||
if (!UUID.test(String(b.shareId || ''))) return json({ error: 'Unknown link.' }, 404);
|
||||
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const share = await one<any>(
|
||||
`SELECT s.*, (SELECT count(*)::int FROM share_items si WHERE si.share_id = s.id) AS item_count
|
||||
FROM shares s WHERE s.id = $1 AND ($3::boolean OR s.created_by = $2)`,
|
||||
[b.shareId, locals.user.uid, isAdmin]);
|
||||
if (!share) return json({ error: 'Unknown link.' }, 404);
|
||||
if (share.revoked_at) return json({ error: 'This link is switched off — release it again first.' }, 409);
|
||||
// The expiry counts too: otherwise grandma gets a mail pointing at a page that
|
||||
// tells her "This link has expired".
|
||||
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now())
|
||||
return json({ error: 'This link has expired — extend its validity first.' }, 409);
|
||||
|
||||
const url = `${baseUrl()}/s/${share.slug}`;
|
||||
const sender = locals.user.name || locals.user.username || 'Someone';
|
||||
const title = share.title || (share.item_count > 1 ? `${share.item_count} images` : 'An image');
|
||||
const message = String(b.message || '').trim().slice(0, 1000);
|
||||
const validity = share.expires_at
|
||||
? `The link is valid until ${new Date(share.expires_at).toLocaleDateString('en-GB')}.`
|
||||
: 'The link does not expire.';
|
||||
const passphrase = share.password_hash
|
||||
? 'To open it you also need the passphrase — you will get that separately.'
|
||||
: '';
|
||||
|
||||
// `mailLayout` deliberately puts paragraphs into the HTML raw so that <b>
|
||||
// works — everything that comes from user input therefore has to be escaped
|
||||
// **here**. Previously only the message was escaped, not the title: that made
|
||||
// it possible to send a foreign link in familiar clothing through our own mail
|
||||
// server, signed with SPF and DKIM.
|
||||
const paragraphs = [
|
||||
`${esc(sender)} has sent you something via Klarbild: <b>${esc(title)}</b>.`,
|
||||
...(message ? [esc(message).replace(/\n/g, '<br>')] : []),
|
||||
[validity, passphrase].filter(Boolean).join(' '),
|
||||
];
|
||||
|
||||
let ok = 0; const failed: string[] = [];
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendMail({
|
||||
to,
|
||||
subject: `${sender} shares: ${title}`,
|
||||
text: `${sender} has sent you something via Klarbild: ${title}.\n\n`
|
||||
+ (message ? `${message}\n\n` : '')
|
||||
+ `${url}\n\n${validity}${passphrase ? ' ' + passphrase : ''}`,
|
||||
html: mailLayout(title, paragraphs, { text: 'View the images', url }),
|
||||
});
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
console.error('[share/send] to', to, 'failed:', e?.message || e);
|
||||
failed.push(to);
|
||||
}
|
||||
}
|
||||
return json({ ok, failed, url });
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { webhookCallback } from 'grammy';
|
||||
import { getBot, getWebhookSecret } from '../../../lib/telegram';
|
||||
import { has, moduleOff } from '../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
let handler: ((req: Request) => Promise<Response>) | null = null;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (!has('telegram')) return moduleOff('telegram');
|
||||
const bot = await getBot();
|
||||
if (!bot) return new Response('Bot inactive', { status: 503 });
|
||||
if (!handler) {
|
||||
handler = webhookCallback(bot, 'std/http', { secretToken: getWebhookSecret() || undefined });
|
||||
}
|
||||
try {
|
||||
return await handler(request);
|
||||
} catch (e) {
|
||||
console.error('[telegram] webhook', e);
|
||||
return new Response('ok'); // do not flood Telegram with 5xx
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import sharp from 'sharp';
|
||||
import { putObject, sourceKey } from '../../lib/storage';
|
||||
import { heicToPng } from '../../lib/pipeline';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const MAX_BYTES = 25 * 1024 * 1024;
|
||||
const OK_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/heic', 'image/heif'];
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const form = await request.formData();
|
||||
const files = form.getAll('files').filter((f): f is File => f instanceof File);
|
||||
if (!files.length) return json({ error: 'No files.' }, 400);
|
||||
|
||||
const out: any[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_BYTES) { out.push({ filename: file.name, error: 'Larger than 25 MB.' }); continue; }
|
||||
const isImg = OK_TYPES.includes(file.type) || /\.(png|jpe?g|webp|heic|heif)$/i.test(file.name);
|
||||
if (!isImg) { out.push({ filename: file.name, error: 'Unsupported format.' }); continue; }
|
||||
|
||||
try {
|
||||
let buf: Buffer = Buffer.from(await file.arrayBuffer());
|
||||
let ext = 'png';
|
||||
const heic = /heic|heif/i.test(file.type) || /\.(heic|heif)$/i.test(file.name);
|
||||
if (heic) buf = await heicToPng(buf);
|
||||
else {
|
||||
const meta = await sharp(buf, { failOn: 'none' }).metadata();
|
||||
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
|
||||
}
|
||||
const meta = await sharp(buf, { failOn: 'none' }).metadata();
|
||||
// Take the EXIF rotation into account: the browser shows the image oriented,
|
||||
// so the reported dimensions have to be oriented too (cropping in the print module).
|
||||
const turned = (meta.orientation ?? 1) >= 5;
|
||||
const key = sourceKey(randomUUID(), ext);
|
||||
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
|
||||
out.push({
|
||||
source_path: key, filename: file.name,
|
||||
width: turned ? meta.height : meta.width,
|
||||
height: turned ? meta.width : meta.height,
|
||||
source_quality: 'original',
|
||||
});
|
||||
} catch (e: any) {
|
||||
out.push({ filename: file.name, error: 'Could not be read.' });
|
||||
}
|
||||
}
|
||||
return json({ files: out });
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { tFor, dictFor } from '../lib/i18n.server';
|
||||
const locale = Astro.locals.locale ?? 'en';
|
||||
const t = tFor(locale);
|
||||
const dict = dictFor(locale);
|
||||
---
|
||||
<Base title={t('Changelog · Klarbild')}>
|
||||
<div class="clog">
|
||||
<div class="chead">
|
||||
<h1>{t('Changelog')}</h1>
|
||||
<a class="back" href="/help">{t('← Back to help')}</a>
|
||||
</div>
|
||||
<p class="lead">{t('What is new in Klarbild. Newest changes at the top.')}</p>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('1 Sep 2026')}</span><h2>{t('Print: images from the library, and what you printed last')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>Images from the library into the print area.</b> Until now you had to upload a file even when Klarbild had it already. The print area now offers <b>“Choose from the library”</b> — with search, by folder, and several at once.')} />
|
||||
<li set:html={t('<b>Printed recently.</b> At the very bottom of the print area you can see what you printed before: sizes, quantity, the images. <b>Print again</b> produces exactly the same sheet a second time, without you setting anything up again. <b>Reuse settings</b> brings back only the paper, the marks and the margins — for “like last time, but with different photos”.')} />
|
||||
<li set:html={t('<b>Without filling up the disk:</b> what is kept is not the PDFs — an A3 sheet is quickly 20 to 80 MB — but only the settings, a few kilobytes. Because the print area computes without AI, the same job yields exactly the same sheet again. That is why “Print again” only appears when all the images came from the library and are still there; otherwise the entry says what is in the way. The last 100 prints are kept.')} />
|
||||
<li set:html={t('<b>On a phone</b> the four cards now come in their own order — the preview used to slide to the top, and you scrolled past everything before you could add an image.')} />
|
||||
<li set:html={t('<b>Email runs by itself.</b> The account for the sending mailbox is configured in place; nothing has to be entered under Admin → Email any more. That page now also shows which account is actually in use.')} />
|
||||
<li set:html={t('<b>Fixed: print files were all called the same.</b> Two sheets on the same day were both <code>2026-09-01_print-sheet.pdf</code> — in the same folder the second overwrote the first. Now it is <code>2026-09-01_143207_print-9-pieces_a4_4eca.pdf</code>: date, time to the second, what is on the sheet, the paper and a short id. The short id belongs to the history entry — so you can see which print was which file. Single images are now named after their subject.')} />
|
||||
<li set:html={t('<b>Fixed:</b> the same went for ZIPs from the library — every one of them was called <code>klarbild_2026-09-01.zip</code>.')} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('1 Sep 2026')}</span><h2>{t('Sharing images, email, forgotten passwords')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>Share links.</b> In the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.')} />
|
||||
<li set:html={t('<b>Pass it on via WhatsApp</b> (opens WhatsApp with the text ready), through your own mail program, or by email straight out of Klarbild to several recipients at once.')} />
|
||||
<li set:html={t('<b>Safe by default:</b> every link <b>expires by itself</b> (30 days by default, changeable in Admin), can be <b>switched off</b> and released again at any time, can be protected with a <b>passphrase</b> if you want, and downloading can be blocked entirely. Search engines never get to see the pages. Images from private sessions cannot be shared at all.')} />
|
||||
<li set:html={t('<b>All images at once:</b> with more than one image there is a button above the page, <b>“Download all N images”</b> — a ZIP at full resolution. With twenty images the recipient previously had to tap twenty times.')} />
|
||||
<li set:html={t('<b>An overview under <a href="/shares">Shared links</a>:</b> views, downloads and the number of visitors per link. What is recorded is <b>when</b> a link was opened and with what kind of device — not <b>who</b> opened it. It is deleted after 90 days.')} />
|
||||
<li set:html={t('<b>Sending email</b> through your own mailbox (Admin → Email). That makes these work: <b>forgotten passwords</b>, a <b>notice when a job is finished</b> (switch it on under <a href="/account">My account</a>) and sending share links.')} />
|
||||
<li set:html={t('<b>Forgot your password?</b> At the bottom of the sign-in page. The link from the mail is valid for one hour and can only be used once. It needs an email address stored under <b>My account</b>.')} />
|
||||
<li set:html={t('<b>Fixed:</b> a new password now also ends every sign-in that is still open. An existing session used to stay valid for up to 30 days — so resetting your password because somebody else had got in did nothing for you.')} />
|
||||
<li set:html={t('<b>Fixed:</b> “Block downloads” had no effect on cut-out subjects.')} />
|
||||
<li set:html={t('<b>AI images now carry a machine-readable marker</b> (IPTC/XMP) including the model used — right inside the image file.')} />
|
||||
<li>{t('Small things: the colour flag no longer sits on top of the selection circle; the top bar fits on a tablet again; the admin area can be operated on a phone.')}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('21 Aug 2026')}</span><h2>{t('Unique file names — images no longer overwrite each other')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>Fixed: images overwrote each other on the delivery target.</b> Generating the same subject in the same format twice on one day — two badges, say — produced the same file name twice (<code>2026-07-23_portrait_the-frame.png</code>). On delivery the second image then silently replaced the first. That can no longer happen.')} />
|
||||
<li set:html={t("<b>The new name:</b> <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code> — date, time to the second, subject, format and a short id. Because the timestamp comes first, <b>every file listing sorts chronologically by itself</b> — on the delivery target, in the file manager, on the backup mirror. The short id comes from the image's id and is therefore guaranteed to be unique.")} />
|
||||
<li set:html={t("<b>The time is local time</b>, not UTC. An image made at 00:30 now carries the right date instead of the previous day's.")} />
|
||||
<li set:html={t('<b>Retroactively for the whole library:</b> under <b>Admin → File names</b>, “Check library” first shows what would change (including the number of names currently used twice); “Rename now” carries it out. The image files themselves are left untouched. A second run changes nothing.')} />
|
||||
<li set:html={t("<b>“Deliver again”</b> uploads every already delivered image once more under its new name. The old files stay behind on the target — you clear those away there yourself, so that nothing disappears from the client's view by accident.")} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('20 Aug 2026')}</span><h2>{t('Oversize images, duplicating an image, free caption & a new layout')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>20 × 30 on DIN A4 paper — now that works.</b> When the size you want is larger than the sheet, the tool no longer just says “does not fit”, it offers two ways out: <b>Oversize</b> places the image centred on the whole sheet — whatever hangs over is missing afterwards, and how much that is is stated beforehand (for 20 × 30 cm on A4 it is exactly 3 mm in height). <b>Poster print</b> spreads the image over several sheets; the tiles overlap by a glue flap that is marked with a dashed line, and every sheet carries its number with row and column. 30 × 40 cm on A4 gives 2 × 2 sheets.')} />
|
||||
<li set:html={t('<b>Duplicate an image.</b> One button copies the same image so it can go onto the same sheet at a second size — a portrait once at 10 × 15 and once as a passport photo on one sheet of photo paper, say. No more renaming and uploading again.')} />
|
||||
<li set:html={t('<b>Decide the caption yourself.</b> The small line under the sheet can be switched off, given your own text and placed in one of six spots (top or bottom, left, centre or right). If there is no room free at the chosen edge it is dropped automatically — nothing is ever printed over an image.')} />
|
||||
<li set:html={t('<b>A new layout on the desktop:</b> work on the left, the preview on the right, following along as you scroll. The setting groups sit next to each other collapsed and open across the full width. No more endless column beside an empty one.')} />
|
||||
<li set:html={t('<b>Like an app on a phone:</b> the preview is at the top, “Create print PDF” sits as a fixed bar above the navigation, every image row shows preview and sliders side by side, and every button is thumb-sized. Tablet widths were checked separately.')} />
|
||||
<li set:html={t('<b>A public tour page</b> at <code>/tour</code> — it describes the print module without a login and without any controls, meant for showing the tool and for a possible open-source release.')} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('19 Aug 2026')}</span><h2>{t('“Passport photos” becomes “Print” — 86 image sizes, crop repaired')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>The area is now called “Print”</b> (it was “Passport photos”) — it does far more than passport photos, after all. Old links redirect automatically.')} />
|
||||
<li set:html={t('<b>86 image sizes in 10 groups:</b> passport, small, photo, inches (US), instant, poster & frame, square, DIN, cards and plain aspect ratios — plus 19 paper sizes and a custom size at any time.')} />
|
||||
<li set:html={t('<b>Fixed: the crop showed the image distorted.</b> The frame in the crop window did not have the target aspect ratio, which squeezed the image into it (measured: 8.3% too wide). Frame and image now both compute from the same ratio.')} />
|
||||
<li set:html={t('<b>Fixed: “I cannot move the image.”</b> Technically you could, but with the frame fully filled the crop sits against the stop in one direction — dragging did nothing there. It now says which way the image can still move; double-tap zooms, the arrow keys nudge, and the zoom slider shows its factor.')} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('18 Aug 2026')}</span><h2>{t('New: Print — bring images to exact sizes, entirely without AI')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>Exact sizes instead of “roughly”.</b> A passport photo is 35 × 45 mm, a frame is 13 × 18 cm. The tool computes in millimetres and produces a PDF whose page is exactly the sheet size and in which every image is exactly its target size. When printing, choose “Actual size / 100%” — not “fit to page”.')} />
|
||||
<li set:html={t('<b>Several images on one sheet.</b> Images of the same size go into an exact grid; with mixed formats a packer arranges them so that as few sheets as possible are needed. One 13 × 18, one 9 × 13 and six passport photos fit together on a single DIN A4 sheet.')} />
|
||||
<li set:html={t('<b>Crop marks as in prepress:</b> fine corner marks outside each image, or continuous lines across the whole sheet — broken where another subject lies. Length, offset and bleed can be set.')} />
|
||||
<li set:html={t('<b>Crop or letterbox.</b> If the aspect ratio does not fit, you decide: crop until the format is filled, or fit the whole image and leave a border colour around it. Nothing is ever distorted. The crop can be moved and zoomed in the crop window; the rotation of phone photos (EXIF) is taken into account beforehand.')} />
|
||||
<li set:html={t('<b>Any paper you like:</b> DIN A7 to A0, photo paper 9 × 13 to 20 × 30, US sizes — or a paper size of your own. Borderless, 5 mm or 10 mm margin.')} />
|
||||
<li set:html={t('<b>Presets</b> for recurring sheets (school photo set, small prints, passport sheet, 2 × 10×7.5 on photo paper) — and your own can be saved.')} />
|
||||
<li set:html={t('<b>Reachable through Telegram and the MCP server</b> as well, and the finished sheet can be delivered straight to the delivery target or to a backup target.')} />
|
||||
<li set:html={t('<b>No AI, no cost:</b> plain geometry, in house. No model, no key, no images that leave the server.')} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('24 Jul 2026')}</span><h2>{t('JPG delivery, custom sizes, failed jobs & AI-readable docs')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>The delivery target gets exactly your format:</b> what is delivered is now exactly the file format you set — globally in the admin settings <i>or</i> per job in the studio (PNG or JPG). No more forced conversion. Cut-out subjects always stay PNG. (The images that had “disappeared” in the meantime were there the whole time, by the way — it was the sort order in the target’s web interface.)')} />
|
||||
<li set:html={t('<b>Telegram & MCP keep up:</b> the Telegram bot and the MCP server (for assistants) now know the selectable file format and the custom sizes too — whichever way you start a job.')} />
|
||||
<li set:html={t('<b>Delete a job</b> straight from the queue (for old or failed jobs). Missing sources now report a clear notice instead of a cryptic technical error.')} />
|
||||
<li set:html={t('<b>AI-readable docs</b> at <code>/llms.txt</code> — describing purpose, formats, API and MCP tools for assistants.')} />
|
||||
<li set:html={t('<b>The file format can be chosen:</b> globally in the admin settings (default file format PNG/JPG) <i>and</i> per conversion in the studio (default / PNG / JPG). JPG only without transparency.')} />
|
||||
<li set:html={t('<b>Custom sizes:</b> in the studio, “Custom size (cm)” — just type <code>25x35</code>, say; for stickers one number is enough (<code>5</code> = 5×5 cm). Preset recipes store that too.')} />
|
||||
<li set:html={t('<b>Failed jobs are now red</b> in the overview (they used to be green) and carry the reason. Fixed: the “Sticker 5 cm” preset recipe led nowhere (“Unknown format: sticker5”).')} />
|
||||
<li set:html={t('<b>Finish time in the job:</b> the queue and the job show when it was done.')} />
|
||||
<li set:html={t('<b>Clean file names:</b> no more doubled date and UUID chains in the name.')} />
|
||||
<li set:html={t('<b>Metadata sidecar per target:</b> whether the <b>.md</b> file is sent along can be switched separately for the delivery target, the backup mirror and every extra target.')} />
|
||||
<li set:html={t('<b>Faster loading:</b> the full view and the preview come as a small JPG (about 5× smaller), tiles load a thumbnail first; missing thumbnails can be generated afterwards with a button.')} />
|
||||
<li set:html={t('<b>Delivery-target tools in Admin:</b> “Show folders” (what sits where over SFTP) and “Clean up leftovers” (remove .tmp files left behind).')} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">{t('23 Jul 2026')}</span><h2>{t('Combine, Generate & a second backup')}</h2></div>
|
||||
<ul>
|
||||
<li set:html={t('<b>Combine:</b> several images plus a description into one new image (for example “… with our dog”).')} />
|
||||
<li set:html={t('<b>Generate:</b> images from text alone, with no source image at all.')} />
|
||||
<li set:html={t('<b>The Frame “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.')} />
|
||||
<li set:html={t('<b>Keep editing:</b> take a finished image as a new source and edit it again.')} />
|
||||
<li set:html={t('<b>Alternatives:</b> generate further versions of the same image — grouped, with a switcher.')} />
|
||||
<li set:html={t('<b>Folders:</b> filter, rename and delete in the library; each folder can have its own folder on the target.')} />
|
||||
<li set:html={t('<b>Bulk actions:</b> download several images as a ZIP and move them into a folder in one go.')} />
|
||||
<li set:html={t('<b>Thumbnails</b> for the library (fast & frugal).')} />
|
||||
<li set:html={t('<b>Models:</b> quality levels including open source (FLUX.2); the model used is shown.')} />
|
||||
<li set:html={t('<b>Colour flags</b> like the ones on the delivery target (red / orange / green / final): set them per image and filter by them afterwards — the flag is also in the accompanying .md file.')} />
|
||||
<li set:html={t('<b>“Download” opens large:</b> a tap on “Download” shows the image filling the screen — on an iPhone or iPad, press and hold, then <b>“Save to Photos”</b> (the classic file download is there as well).')} />
|
||||
<li set:html={t('<b>Permissions & privacy:</b> visibility can be set (everyone sees only their own images, or “everyone sees everything”), generations optionally anonymous, the admin sees everything.')} />
|
||||
<li set:html={t('<b>Private session:</b> generate only, then forget — no library entry, no delivery or backup, no stored prompt. The admin can allow it globally or enforce it per user.')} />
|
||||
<li set:html={t('<b>NSFW switch:</b> models marked NSFW are only selectable and visible when the admin setting is on.')} />
|
||||
<li set:html={t('<b>Convert (one image + text):</b> convert a single image with a description — “photo → oil painting”, say — and bring it to size right away. No more having to upload two images.')} />
|
||||
<li set:html={t('<b>Several output targets:</b> any number of further FTP/SFTP targets besides the standard delivery target; under “Where to?” in the studio the <b>backup mirror</b> and individual FTP targets can be chosen too; a preset recipe or folder can point at one target permanently.')} />
|
||||
<li set:html={t('<b>Backup to every target:</b> not just the mirror — any target can be marked as “backup”; “Back up all images” mirrors to every backup target.')} />
|
||||
<li set:html={t('<b>Metadata sidecar:</b> optionally an <b>.md file</b> (prompt, model, format …) alongside every image on the delivery target, the mirror or FTP.')} />
|
||||
<li set:html={t('<b>Prompt display:</b> every generated image shows its prompt in the library (with a copy button).')} />
|
||||
<li set:html={t('<b>Manage preset recipes yourself:</b> save the current settings as a recipe, load it, delete it.')} />
|
||||
<li set:html={t('<b>API token & MCP server:</b> external access — hand images from a folder or from iMessage to Klarbild through an assistant.')} />
|
||||
<li set:html={t('<b>The Frame</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.')} />
|
||||
<li set:html={t('<b>A second backup (the mirror):</b> every result additionally goes to the backup mirror over SFTP/FTPS (Tailscale works too) — with a “Mirror ✓” marker and “Back up all images to the mirror”.')} />
|
||||
<li set:html={t('<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.')} />
|
||||
<li set:html={t('<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.')} />
|
||||
<li set:html={t('<b>Reset:</b> empty the library & jobs cleanly (with a confirmation).')} />
|
||||
<li set:html={t('<b>A help page</b> and this changelog.')} />
|
||||
<li>{t('Under the hood: a real cost cap, reliable job completion, a reachability check for the mirror.')}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clog{max-width:760px;margin:0 auto;}
|
||||
.chead{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.clog h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
|
||||
.back{color:var(--accent);text-decoration:none;font-size:.9rem;}
|
||||
.lead{color:var(--soft);margin:4px 0 20px;}
|
||||
.clog section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
|
||||
.ver{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.ver h2{font-size:1.05rem;margin:0;}
|
||||
.tag{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;background:var(--accent-bg);color:var(--accent);padding:3px 9px;border-radius:20px;}
|
||||
.clog ul{margin:0;padding-left:20px;line-height:1.65;}
|
||||
.clog li{margin-bottom:6px;}
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { tFor, dictFor } from '../lib/i18n.server';
|
||||
const locale = Astro.locals.locale ?? 'en';
|
||||
const t = tFor(locale);
|
||||
const dict = dictFor(locale);
|
||||
---
|
||||
<Base title={t('Help · Klarbild')}>
|
||||
<div class="howto">
|
||||
<div class="hhead">
|
||||
<h1>{t('How Klarbild works')}</h1>
|
||||
<div class="hhead-actions">
|
||||
<a class="print-btn" href="/changelog">{t('Changelog')}</a>
|
||||
<button class="print-btn" onclick="window.print()">{t('Save as PDF / print')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">{t('Screenshots and things you found turn into print-ready images — cleaned up, cut out, in the right format, optionally delivered straight to the delivery target. Everything works through the website and through Telegram.')}</p>
|
||||
|
||||
<section>
|
||||
<h2>{t('1 · Edit an image')}</h2>
|
||||
<ol>
|
||||
<li set:html={t('Choose <b>Edit</b> at the top of the <b>studio</b>.')} />
|
||||
<li>{t('Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).')}</li>
|
||||
<li set:html={t('Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or The Frame, for instance), <b>Contour</b> (white sticker edge).')} />
|
||||
<li set:html={t('For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for The Frame.')} />
|
||||
<li set:html={t('Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.')} />
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('2 · Combine several images')}</h2>
|
||||
<p set:html={t('<b>Combine</b> mode: upload at least two images and describe what should come out of them — “the pool picture, but with our dog at the edge”, say. The first image is the lead scene, the others supply people or objects. The result is a new, composed image.')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('3 · A new image from text')}</h2>
|
||||
<p set:html={t('<b>Generate</b> mode: no source image at all, just a description. The more precise the text, the better the result.')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('4 · Library, folders, alternatives & keep editing')}</h2>
|
||||
<p set:html={t('Every result lands in the <b>library</b>. The folder bar at the top filters by folder; each folder can be given its own folder on the delivery target (an arrow on the folder means it has one).')} />
|
||||
<ul>
|
||||
<li set:html={t('<b>+ Alternative</b>: generates another version of the same image. The versions sit together as a group and can be switched with the thumbnail strip (badge “Version 2/3”).')} />
|
||||
<li set:html={t('<b>Keep editing</b>: takes a finished image into the studio as a new source — edit it again there, bring it to the next format, or mix it with further images in Combine mode.')} />
|
||||
<li set:html={t('<b>Before/after</b>: shows the comparison source ↔ result (not available for images made from text alone).')} />
|
||||
<li set:html={t('<b>Manage folders</b>: tap the folder chip, then ✎ to rename it (including its folder on the target) or ✕ to delete it (the images are kept).')} />
|
||||
<li set:html={t('<b>Several at once</b>: select images, then <b>download them as a ZIP</b> or move them <b>into a folder</b> together.')} />
|
||||
</ul>
|
||||
<p class="notice" set:html={t("<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)")} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('5 · Print — exact sizes and crop marks (without AI)')}</h2>
|
||||
<p class="lead" set:html={t('The <b>Print</b> tab is the photo-lab part of Klarbild: no model, no cost, no waiting. Only cropping, scaling and geometry — from the biometric passport photo to the 30 × 40 poster.')} />
|
||||
<ol>
|
||||
<li set:html={t('Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.')} />
|
||||
<li set:html={t('Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 “The Frame”, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).')} />
|
||||
<li set:html={t('<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>')} />
|
||||
<li set:html={t('Tap the preview image → <b>Crop</b>: pan and zoom. The aspect ratio stays locked to the target format, and nothing is ever distorted.')} />
|
||||
<li set:html={t('Set the <b>quantity</b> per image — that is how the passport-photo or school photo set comes about.')} />
|
||||
<li set:html={t('<b>The same image at two sizes?</b> Tap <b>Duplicate</b> — the image appears a second time in the list and gets its own size there. No more renaming and uploading again.')} />
|
||||
<li set:html={t('Choose the <b>paper size</b>: DIN A6–A2 including <b>A3+ (329 × 483 mm)</b>, photo paper cuts 9 × 13 to 20 × 30, US Letter/Legal — or a paper size of your own.')} />
|
||||
<li set:html={t('<b>Cutting guides</b>: <b>corner marks</b> (fine marks outside the final format, as in Photoshop and InDesign) or <b>continuous lines</b> across the whole sheet for the cutting ruler. The continuous lines never run through another subject.')} />
|
||||
<li set:html={t('<b>If the final size is larger than the sheet</b> — 20 × 30 cm on DIN A4 photo paper, say — the question of how to go on appears right at the image: <b>Oversize</b> places the image centred on the whole sheet and what hangs over is cut off (for 20 × 30 on A4 exactly 3 mm in height are missing, and that is exactly what it says), or <b>Poster print</b> spreads it across several sheets to be glued together.')} />
|
||||
<li set:html={t('<b>Create the print PDF</b>. The PDF page is exactly the sheet size.')} />
|
||||
</ol>
|
||||
<p set:html={t('<b>Printed recently.</b> At the very bottom you can see what you printed before — with sizes, quantity and the images. <b>Print again</b> produces exactly the same sheet a second time, without you setting anything up again; <b>Reuse settings</b> brings back only the paper, the marks and the margins, for “like last time, but with different photos”.')} />
|
||||
<p class="mini" set:html={t('What is kept is <b>not the PDFs</b> — those are huge, an A3 sheet is quickly 20 to 80 MB — but only the settings, a few kilobytes. Because the print module computes without AI, the same job yields exactly the same sheet again. That is why “Print again” only appears when all the images came from the library and are still there; with uploaded files the source is gone once it has been cleaned up, and the entry says so.')} />
|
||||
<p class="notice" set:html={t('<b>Important when printing:</b> in the print dialog choose “Actual size” or “100%” — <b>not</b> “fit to page”. Otherwise the size no longer holds. If you can print borderless, set the margin to 0; otherwise 3–5 mm, or the printer cuts the marks away.')} />
|
||||
<ul>
|
||||
<li set:html={t('<b>Bleed</b> (0–10 mm): the image extends past the cut edge so that a slightly crooked cut leaves no white border. The gap between two images grows with it automatically.')} />
|
||||
<li set:html={t('<b>Poster print</b>: when the image is spread across several sheets, the tiles overlap by a <b>glue flap</b> (10 mm by default, freely adjustable). The flap is printed on both neighbouring sheets and marked with a dashed line — when assembling, you lay one sheet onto the other along that line. Every sheet carries its number with row and column at the top. 30 × 40 cm on A4 gives 2 × 2 sheets.')} />
|
||||
<li set:html={t('<b>Caption</b>: by default a small line under the sheet carries the sizes, the dpi and the printing note. It can be <b>switched off</b>, given <b>your own text</b> (“Ada · August 2026”, say) and placed in one of six spots — top or bottom, each of them left, centre or right. If there is no room free at the chosen edge it is dropped automatically; it is never printed over an image.')} />
|
||||
<li set:html={t('<b>Download singly</b>: one image at exactly its size as PNG/JPG with dpi metadata — without a sheet.')} />
|
||||
<li set:html={t('<b>Resolution warning</b>: if the source is not enough for the chosen dpi, it says so in red under the image.')} />
|
||||
<li set:html={t('<b>Presets</b>: save the whole sheet setup (paper, marks, formats, quantities). Bundled are <b>School photo set (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × passport photo — deliberately more than fits on one sheet; the preview tells you how many sheets it becomes), <b>Small prints (A4)</b>, <b>Passport sheet 35 × 45 (A4)</b> and <b>2 × 10×7.5 on 10×15 photo paper</b>. If you load a multi-format preset with only <i>one</i> image, that image is taken over for every format automatically — one click, a finished set.')} />
|
||||
<li set:html={t('<b>Margin profiles</b>: <b>Borderless</b> (0 mm), <b>Standard</b> (5 mm) or <b>Safe</b> (10 mm) at the press of a button; the exact value stays freely editable.')} />
|
||||
<li set:html={t('<b>Where to?</b>: the finished sheet can also be put on the delivery target, on the backup mirror or on an extra target — like the images from the studio.')} />
|
||||
<li set:html={t('<b>Automatic arrangement</b>: images of the same size make a clean grid; with mixed sizes the small ones are placed automatically into the space left beside the large ones.')} />
|
||||
</ul>
|
||||
<p class="mini" set:html={t('Through Telegram as well: send an image → “🖨 Print” → tap size and quantity → the PDF comes back. On a phone the preview is at the top and “Create print PDF” sits within reach as a fixed bar above the navigation; on a computer you work on the left and see on the right what comes out of it. To show someone the tool without anything being uploadable, send them the link to <a href="/tour">the tour page</a>.')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('6 · Everything through Telegram — without commands')}</h2>
|
||||
<p>{t('Pair it once: generate a code in Admin and enter it in the chat with the bot. After that everything runs through the buttons at the bottom of the chat — no need to type commands.')}</p>
|
||||
<ul>
|
||||
<li set:html={t('<b>Edit:</b> send the image or images (best as a <i>file</i> for full quality) → tap the preset recipe at the bottom.')} />
|
||||
<li set:html={t('<b>Combine:</b> send two or more images, put a description such as “with our dog” in the image caption field — then tap “🔀 Combine”. (Without a description the bot asks for one.)')} />
|
||||
<li set:html={t('<b>Generate:</b> tap “✨ New image” at the bottom and describe what should come out.')} />
|
||||
<li set:html={t('<b>Keep editing:</b> simply send a finished image from the bot back to it.')} />
|
||||
<li set:html={t('<b>Print sheet (without AI):</b> send an image → “📐 Print sheet” → tap the final size and the quantity. Back comes an A4 PDF with crop marks at 100% size.')} />
|
||||
<li set:html={t('“❓ Help” gives the short guide, “📁 Choose recipe” sets the default preset recipe.')} />
|
||||
</ul>
|
||||
<p class="mini">{t('For power users there is also the “/” command menu (/new, /presets, /status) — it is not needed.')}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('7 · Backup & a clean restart (Admin)')}</h2>
|
||||
<p set:html={t('In the admin area a <b>second backup to the mirror</b> can be set up (SFTP/FTPS, Tailscale works too) — every finished image is then mirrored as well and marked <b>“Mirror ✓”</b> in the library. <b>“Back up all existing images to the mirror”</b> catches up on older images.')} />
|
||||
<p set:html={t('Under <b>“File names”</b> the existing library can be brought onto the unique naming scheme. <b>“Check library”</b> first shows what would change and how many names are currently used twice; <b>“Rename now”</b> carries it out. The image files themselves are left untouched — they sit in storage under their id, not under their name. A second run changes nothing. <b>“Deliver again”</b> then uploads every already delivered image under its new name; the old files stay on the delivery target and you clear them away there yourself.')} />
|
||||
<p set:html={t('For clearing out there is <b>“Reset library & jobs”</b>: it deletes all images, jobs and folders (users, settings, models and preset recipes remain) — with a confirmation. Best to back up to the mirror once beforehand.')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('My account, sharing & email')}</h2>
|
||||
<ul>
|
||||
<li set:html={t('<b><a href="/account">My account</a></b>: store an email address — that lets you have a new password sent to you if you forget yours. You can also switch on a short mail as soon as a job is finished.')} />
|
||||
<li set:html={t('<b>Share</b>: in the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.')} />
|
||||
<li set:html={t('<b>Pass it on</b> via <b>WhatsApp</b> (opens WhatsApp with the text ready), through your own mail program, or by email straight out of Klarbild to several recipients.')} />
|
||||
<li set:html={t('<b>All at once</b>: if a link holds several images, the top of the page offers <b>“Download all N images”</b> — a ZIP at full resolution. Very large collections are cut off at 500 MB; a note about that is then in the package.')} />
|
||||
<li set:html={t('<b>Staying safe</b>: every link expires by itself (30 days by default), can be <b>switched off</b> at any time and protected with a <b>passphrase</b> if you want. Send the passphrase separately from the link — otherwise it is of no use.')} />
|
||||
<li set:html={t('<b><a href="/shares">Shared links</a></b>: an overview of everything that is open right now — with views and downloads. What is recorded is <b>when</b> and <b>with what</b> a link was opened, not <b>who</b> opened it: the IP address is only kept hashed and cannot be computed back.')} />
|
||||
<li set:html={t('<b>Forgot your password?</b> At the bottom of the sign-in page, on “Forgot your password?” — the link from the mail is valid for one hour and can only be used once.')} />
|
||||
</ul>
|
||||
<p class="mini" set:html={t('Images from a <b>private session</b> deliberately cannot be shared and do not trigger a notification either.')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{t('Tips')}</h2>
|
||||
<ul>
|
||||
<li>{t('For printing always choose 300 dpi and a cm format — the pixel dimensions are right there next to it.')}</li>
|
||||
<li set:html={t('In Telegram send images as a <i>file</i>, not as a photo — otherwise Telegram compresses them.')} />
|
||||
<li>{t('Stickers: choose Cut out + Contour together.')}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.howto{max-width:760px;margin:0 auto;}
|
||||
.hhead{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
|
||||
.hhead-actions{display:flex;gap:8px;align-items:center;}
|
||||
.print-btn{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);text-decoration:none;}
|
||||
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
|
||||
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
|
||||
.howto h2{font-size:1.05rem;margin:0 0 10px;}
|
||||
.howto ol,.howto ul{margin:0;padding-left:20px;line-height:1.65;}
|
||||
.howto li{margin-bottom:6px;}
|
||||
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
.howto .mini{font-size:.82rem;color:var(--soft);margin:8px 0 0;}
|
||||
.howto .notice{background:var(--accent-bg);border-left:3px solid var(--accent);padding:10px 13px;margin:12px 0;font-size:.92rem;line-height:1.55;}
|
||||
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
@media print{.topbar,.tabbar,.print-btn{display:none !important;}.howto section{break-inside:avoid;}}
|
||||
</style>
|
||||
</Base>
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user