/ and /de/ are now two pages from one shared component and one copy file, with hreflang between them and a language button in the header. Separate URLs rather than a runtime switch: each language is linkable and indexable, and the page still works without JavaScript. The sheet playground takes its labels from the page, so its readout says "29,7 cm" in German and "29.7 cm" in English. New section for the `ai` module, because the page so far read as if Klarbild were print-only. It says plainly what the module costs and that the endpoint is configurable, and it carries a preset builder: pick tasks and a target size, see the exact pixels and the recipe that gets saved. It composes; it does not generate. A generator here would need a key on a public page and would show a picture from a model rather than anything about Klarbild — the part worth showing is the recipe. The target pixels come from engine/format.ts, copied verbatim from the application like the layout files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
/**
|
||
* The preset builder.
|
||
*
|
||
* This composes a Klarbild recipe and shows what it would run — it does not
|
||
* run anything. Generating on a public page would mean putting an API key on
|
||
* that page and paying for every curious visitor, and the picture it produced
|
||
* would tell you about the model rather than about Klarbild. What Klarbild
|
||
* decides is the part this does show: which tasks, which exact target size,
|
||
* and the preset you save once and reuse.
|
||
*
|
||
* The target pixels come from `engine/format.ts`, copied verbatim from the
|
||
* application, so the numbers here are the numbers it would produce.
|
||
*/
|
||
|
||
import { resolveDimensions } from '../engine/format';
|
||
|
||
export interface PresetExample {
|
||
name: string;
|
||
tasks: string[];
|
||
format: string;
|
||
orientation: 'portrait' | 'landscape';
|
||
ext?: string;
|
||
note: string;
|
||
}
|
||
|
||
interface Labels {
|
||
resultKeep: string;
|
||
contourNeedsCutout: string;
|
||
dpiLine: string; // "{w} × {h} px at {dpi} dpi"
|
||
}
|
||
|
||
const ALL_TASKS = ['clean', 'cutout', 'format', 'contour'];
|
||
|
||
export function mountRecipe(root: HTMLElement, presets: PresetExample[], labels: Labels) {
|
||
const state = {
|
||
tasks: new Set(presets[0]?.tasks ?? ['clean']),
|
||
format: presets[0]?.format ?? 'keep',
|
||
orientation: (presets[0]?.orientation ?? 'landscape') as 'portrait' | 'landscape',
|
||
ext: presets[0]?.ext ?? '',
|
||
name: presets[0]?.name ?? '',
|
||
note: presets[0]?.note ?? '',
|
||
};
|
||
|
||
const out = {
|
||
result: root.querySelector<HTMLElement>('[data-r=result]')!,
|
||
note: root.querySelector<HTMLElement>('[data-r=note]')!,
|
||
recipe: root.querySelector<HTMLElement>('[data-r=recipe]')!,
|
||
};
|
||
|
||
function dims() {
|
||
if (state.format === 'keep') return null;
|
||
const customCm: [number, number] | undefined = state.format === 'sticker' ? [5, 5] : undefined;
|
||
try {
|
||
return resolveDimensions({ format: state.format, orientation: state.orientation, dpi: 300, customCm });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
// Contour without Cut out is refused by the application, so the builder
|
||
// refuses it too rather than composing a recipe that would be rejected.
|
||
if (state.tasks.has('contour') && !state.tasks.has('cutout')) state.tasks.delete('contour');
|
||
|
||
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-task]')) {
|
||
const id = b.dataset.task!;
|
||
b.setAttribute('aria-pressed', String(state.tasks.has(id)));
|
||
b.disabled = id === 'contour' && !state.tasks.has('cutout');
|
||
}
|
||
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-orient]')) {
|
||
b.setAttribute('aria-pressed', String(b.dataset.orient === state.orientation));
|
||
}
|
||
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-ext]')) {
|
||
b.setAttribute('aria-pressed', String((b.dataset.ext || '') === state.ext));
|
||
}
|
||
const sel = root.querySelector<HTMLSelectElement>('#recipe-format');
|
||
if (sel && sel.value !== state.format) sel.value = state.format;
|
||
|
||
const d = dims();
|
||
out.result.textContent = d
|
||
? labels.dpiLine.replace('{w}', String(d.w)).replace('{h}', String(d.h)).replace('{dpi}', '300')
|
||
: labels.resultKeep;
|
||
|
||
out.note.textContent = state.note;
|
||
|
||
const recipe: Record<string, unknown> = {
|
||
name: state.name || '…',
|
||
tasks: ALL_TASKS.filter((t) => state.tasks.has(t)),
|
||
output_format: state.format,
|
||
orientation: state.orientation,
|
||
crop_mode: 'crop',
|
||
dpi: 300,
|
||
};
|
||
if (state.ext) recipe.output_ext = state.ext;
|
||
if (state.tasks.has('contour')) recipe.contour_mm = 3;
|
||
out.recipe.textContent = JSON.stringify(recipe, null, 2);
|
||
}
|
||
|
||
root.addEventListener('click', (e) => {
|
||
const el = e.target as HTMLElement;
|
||
const task = el.closest<HTMLButtonElement>('button[data-task]');
|
||
if (task && !task.disabled) {
|
||
const id = task.dataset.task!;
|
||
state.tasks.has(id) ? state.tasks.delete(id) : state.tasks.add(id);
|
||
state.name = '';
|
||
state.note = '';
|
||
return render();
|
||
}
|
||
const orient = el.closest<HTMLButtonElement>('button[data-orient]');
|
||
if (orient) {
|
||
state.orientation = orient.dataset.orient as 'portrait' | 'landscape';
|
||
return render();
|
||
}
|
||
const ext = el.closest<HTMLButtonElement>('button[data-ext]');
|
||
if (ext) {
|
||
state.ext = ext.dataset.ext || '';
|
||
return render();
|
||
}
|
||
const preset = el.closest<HTMLButtonElement>('button[data-preset]');
|
||
if (preset) {
|
||
const p = presets[Number(preset.dataset.preset)];
|
||
state.tasks = new Set(p.tasks);
|
||
state.format = p.format;
|
||
state.orientation = p.orientation;
|
||
state.ext = p.ext ?? '';
|
||
state.name = p.name;
|
||
state.note = p.note;
|
||
for (const sib of root.querySelectorAll<HTMLButtonElement>('button[data-preset]')) {
|
||
sib.setAttribute('aria-pressed', String(sib === preset));
|
||
}
|
||
return render();
|
||
}
|
||
});
|
||
|
||
root.addEventListener('change', (e) => {
|
||
const sel = e.target as HTMLSelectElement;
|
||
if (sel.id === 'recipe-format') {
|
||
state.format = sel.value;
|
||
state.name = '';
|
||
state.note = '';
|
||
render();
|
||
}
|
||
});
|
||
|
||
const first = root.querySelector<HTMLButtonElement>('button[data-preset="0"]');
|
||
first?.setAttribute('aria-pressed', 'true');
|
||
render();
|
||
}
|