openvibers/openvibe.tools Commits
Tools: the shared tool frame on every satellite tool page (#157)
Changes
nnew file mode 100644@@@ -0,0 +1,229 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the satellites (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// The network and code tools on openvibe.tools got the shared frame in #155. The satellites that draw++// their own pages — img, text, audio, docs, maps, yt, food — get the same frame here: the head (crumb,++// name, one sentence, terms), the box, the answer, the family, the folds and "People also use".++//++// The markup is openvibe-shared/kit's; this module only chooses the copy and the links. It takes the++// kit and the server from the app that requires it (apps/_shared has no dependencies of its own).++//++// The terms come from the app's real configuration and from nowhere else:++// • where the work happens, and how long the file is kept, from the app's own cleanup config;++// • the file's size and count limits, from the app's upload limits;++// • the anonymous rate, from the guard's quota table (apps/_shared/guard/limits.js) and the tool's++// own cost in it.++// Nothing here promises a limit the configuration does not have.++// ═══════════════════════════════════════════════════════════════++++const { quotas } = require('./guard/limits');++++const SITE = 'https://openvibe.tools';++++/**++ * The frame's builders, for one app.++ * @param {object} deps { kit, serve } — openvibe-shared/kit and openvibe-shared/serve++ */++module.exports = function createFrame({ kit, serve }) {++ const esc = kit.esc;++++ /** A file size in the words a person reads: "50 MB", "1.5 GB". */++ function size(bytes) {++ const b = Number(bytes) || 0;++ if (b >= 1024 * 1024 * 1024) return `${Math.round(b / (1024 * 1024 * 1024))} GB`;++ if (b >= 1024 * 1024) return `${Math.round(b / (1024 * 1024))} MB`;++ return `${Math.round(b / 1024)} KB`;++ }++++ /**++ * The terms of a tool, from its real configuration.++ * @param {object} s++ * work 'server' (the file goes to our server) or 'browser' (nothing leaves the page)++ * ttlMs how long the server keeps the file (the app's cleanup config)++ * maxBytes the largest file the app takes (the app's upload limits)++ * maxFiles how many files at once (the tool's own descriptor)++ * quotaClass the guard's quota class for the tool++ * cost what one run costs in quota units (the tool's own descriptor)++ * unit what one run is called: 'conversions', 'downloads', 'lookups'++ * needsAccount the tool refuses anonymous callers++ * @returns {Array<{text: string, icon: string}>} the head's terms++ */++ function terms(s) {++ const facts = [{ text: 'Free', icon: 'gift' }];++ if (s.work === 'server') {++ if (s.ttlMs) {++ const minutes = Math.max(1, Math.round((Number(s.ttlMs) || 0) / 60000));++ facts.push({ text: `Runs on our server; your file is deleted after ${minutes} minutes`, icon: 'server' });++ } else {++ // A tool that keeps no file: the app says where its work happens, in its own words.++ facts.push({ text: s.serverNote || 'Runs on our server', icon: 'server' });++ }++ } else {++ facts.push({ text: 'Runs in your browser: nothing is sent', icon: 'computer' });++ }++ if (s.maxBytes) {++ facts.push({++ text: s.maxFiles && s.maxFiles > 1++ ? `Up to ${s.maxFiles} files, ${size(s.maxBytes)} each`++ : `One file up to ${size(s.maxBytes)}`,++ icon: 'file',++ });++ }++ const q = quotas()[s.quotaClass];++ // One run is "1 download", two are "2 downloads": the unit follows the number.++ const unit = (n) => {++ const u = s.unit || 'runs';++ return n === 1 ? u.replace(/s$/, '') : u;++ };++ if (s.needsAccount) {++ // The tool refuses anonymous callers: the page still works, because the browser gets a++ // session, and that session's rate is the one the guard's table holds.++ const tier = q && q.session ? q.session : null;++ if (tier && tier.perMinute) {++ const n = Math.max(1, Math.floor(tier.perMinute / (s.cost || 1)));++ facts.push({ text: `${n} ${unit(n)} a minute in a browser session`, icon: 'clock' });++ }++ facts.push({ text: 'A browser session: no account needed', icon: 'user' });++ } else {++ if (q && q.anonymous && q.anonymous.perMinute) {++ const n = Math.max(1, Math.floor(q.anonymous.perMinute / (s.cost || 1)));++ facts.push({ text: `${n} ${unit(n)} a minute without an account`, icon: 'clock' });++ }++ facts.push({ text: 'No sign-up', icon: 'user' });++ }++ return facts;++ }++++ /** A field's label, from its name: "sampleRate" is "Sample rate". */++ function labelOf(name, labels = {}) {++ if (labels[name]) return labels[name];++ const words = String(name).replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();++ return words.charAt(0).toUpperCase() + words.slice(1);++ }++++ /** An enum's value, in the words a person reads: "bass_boost" is "Bass boost". */++ function humanValue(value) {++ const s = String(value).replace(/[_-]+/g, ' ').trim();++ return s.charAt(0).toUpperCase() + s.slice(1);++ }++++ /**++ * One field of a box, from a tool's own descriptor: its type, its range and its default. A field++ * the descriptor does not have is not on the page, and the field reads what the tool reads.++ * @param {string} name the field's name++ * @param {object} s the descriptor's field++ * @param {object} [labels] the field's name in the words a person reads++ * @param {object} [values] the field's values in the words a person reads++ * @returns {string} HTML++ */++ function field(name, s, labels = {}, values = {}) {++ const label = labelOf(name, labels);++ if (s.enum) {++ const value = s.default !== undefined ? s.default : s.enum[0];++ return kit.selectRow({ name, label, value: String(value), options: s.enum.map((v) => [String(v), values[String(v)] || humanValue(v)]) });++ }++ if (s.type === 'boolean') {++ return kit.selectRow({ name, label, value: String(s.default === true), options: [['true', 'Yes'], ['false', 'No']] });++ }++ const min = s.minimum != null ? ` min="${esc(s.minimum)}"` : '';++ const max = s.maximum != null ? ` max="${esc(s.maximum)}"` : '';++ const step = s.type === 'integer' ? ' step="1"' : '';++ if (s.type === 'integer' || s.type === 'number') {++ const value = s.default !== undefined ? ` value="${esc(s.default)}"` : ' value=""';++ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`++ + `<input type="number" class="num-input num-input-sm" name="${esc(name)}"${value}${min}${max}${step}></label>`;++ }++ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`++ + `<input type="text" class="num-input num-input-sm" name="${esc(name)}" value=""${s.maxLength ? ` maxlength="${esc(s.maxLength)}"` : ''}></label>`;++ }++++ /**++ * The tool's head: the crumb, the name, the one sentence and the terms.++ * @param {object} rec { icon, hue, family, crumb: { href, label }, title, task, facts }++ */++ function head(rec) {++ return kit.toolHead({++ icon: rec.icon || 'grid', hue: rec.hue, family: rec.family || '',++ crumb: rec.crumb || null, title: rec.title || '', task: rec.task || '', facts: rec.facts || [],++ });++ }++++ /** The one button of a tool page: it says the verb, never "Run". */++ function verbButton(label, { id = '', icon = null, disabled = false, extra = '' } = {}) {++ return `<button type="button"${id ? ` id="${esc(id)}"` : ''} class="ovk-btn ovk-btn--primary ovk-tool-go"${disabled ? ' disabled' : ''}${extra}>`++ + `${icon ? kit.icon(icon) : ''}<span>${esc(label)}</span></button>`;++ }++++ /**++ * The folds: About the tool, Questions, and the real call from code.++ * @param {object} rec { name, about, bullets, faq: [[q, a]], api: { path, fields, needsAccount, capability, input } }++ */++ function folds(rec) {++ const out = [];++ const name = rec.name || rec.title || 'this tool';++ if (rec.about) {++ const bullets = (rec.bullets || []).length ? `<ul>${rec.bullets.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>` : '';++ out.push(kit.fold({ id: 'about-this-tool', title: `About ${name}`, body: `<p>${esc(rec.about)}</p>${bullets}` }));++ }++ if ((rec.faq || []).length) {++ const body = rec.faq.map(([q, a]) => `<p><strong>${esc(q)}</strong></p><p>${esc(a)}</p>`).join('');++ out.push(kit.fold({ title: 'Questions', meta: String(rec.faq.length), body }));++ }++ if (rec.api) {++ const a = rec.api;++ const lines = [`curl -X POST ${SITE}${a.path}`];++ if (a.needsAccount) lines.push(`-H 'Authorization: Bearer $OPENVIBE_TOKEN'`);++ lines.push(`-H 'Content-Type: application/json'`, `-d ${shq(JSON.stringify(a.payload || {}))}`);++ const who = a.needsAccount++ ? 'Needs a signed-in browser session or a token with <code>tools.tool.run</code>.'++ : 'No account needed within the free limit; a token with <code>tools.tool.run</code> raises it.';++ out.push(kit.fold({++ title: 'Use it from code', meta: 'API',++ body: `<p>Input: <code>${esc(a.fields || 'none')}</code>. ${who}</p>${kit.snippet({ code: lines.join(' \\\n ') })}`++ + `<p><a href="${SITE}/api/v1/tools/${esc(a.id)}/schema">JSON Schema</a> · <a href="${SITE}/api/v1/openapi.json">OpenAPI</a> · <a href="${SITE}/developers">API docs</a></p>`,++ }));++ }++ return out.filter(Boolean);++ }++++ // curl quoting for a value with single quotes (the same helper the gateway's frame uses).++ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;++++ /**++ * The whole frame for one tool page.++ * @param {object} rec {++ * id, icon, hue, family, crumb, title, task, facts, → the head++ * box, → the card's markup++ * familyItems, familyMore, familyTitle, → the family row++ * about, bullets, faq, api, → the folds++ * related: [{ href, title, sub }] → "People also use"++ * }++ * @returns {string} HTML++ */++ function page(rec) {++ const related = (rec.related || []).filter(Boolean);++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="${esc(rec.id || '')}">${kit.tool({++ head: head(rec),++ input: rec.box || '',++ answer: '',++ family: kit.family({++ title: rec.familyTitle || '', more: rec.familyMore || null,++ items: rec.familyItems || [], label: rec.familyLabel || 'More tools',++ }),++ folds: folds(rec),++ related: related.length ? kit.list(related, { title: 'People also use' }) : '',++ })}</div>`;++ }++++ /** The browser files a framed page loads (the kit and its behaviour, from this app's own pin). */++ function assets() {++ return {++ css: [serve.url('kit.css'), serve.url('kit-data.css')],++ js: [serve.url('kit.js'), serve.url('kit-web.js'), serve.url('kit-data-web.js')],++ };++ }++++ return { terms, field, labelOf, head, verbButton, folds, page, assets, size, SITE };++};nnew file mode 100644@@@ -0,0 +1,126 @@++/* The tool frame's browser glue (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++ *++ * The satellite apps keep their own upload, processing and download code; this only fills the frame:++ * the answer a file tool draws in the frame's answer slot, and the sample a "Try" link drops into the++ * drop zone. The markup comes from openvibe-shared/kit, the same builders the server used.++ *++ * Served by each app at /js/tool-frame.js (the file lives in apps/_shared/public, so the three file++ * apps share one copy instead of three).++ */++(function () {++ 'use strict';++++ var kit = window.OpenVibeKit;++ if (!kit) return;++++ /** A file size in the words a person reads: "1.4 MB", "812 KB". */++ function size(bytes) {++ var b = Number(bytes) || 0;++ if (b >= 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB';++ if (b >= 1024) return Math.round(b / 1024) + ' KB';++ return b + ' B';++ }++++ /**++ * The answer of a file tool: the file it made, its size before and after, and Download.++ * @param {object} o++ * title the answer's heading++ * before the size of the file that went in (bytes)++ * after the size of the file that came out (bytes)++ * savedPercent how much smaller it is (a number, or nothing)++ * rows extra rows: [[label, value]]++ * downloadUrl where the file is++ * filename the name it downloads under++ * body markup under the rows++ * tone 'bad' for a failure++ */++ function fileAnswer(o) {++ var rows = [];++ if (o.before != null) rows.push(['Before', size(o.before)]);++ if (o.after != null) rows.push(['After', size(o.after), o.savedPercent ? { text: o.savedPercent + '% smaller', tone: 'ok' } : null]);++ (o.rows || []).forEach(function (r) { rows.push(r); });++ var actions = o.downloadUrl++ ? [kit.button({ href: o.downloadUrl, label: 'Download', icon: 'download', kind: 'primary', attrs: { download: o.filename || '' } })]++ : [];++ return kit.answer({++ title: o.title || '', badge: o.badge || null, rows: rows, actions: actions,++ body: o.body || '', tone: o.tone || '', live: true,++ });++ }++++ /** Put an answer in the frame's answer slot. */++ function show(html) {++ var out = document.querySelector('.ovk-tool-out');++ if (out) out.innerHTML = html;++ return out;++ }++++ /**++ * Load a sample file into the drop zone, as if the person had chosen it.++ * @param {string} url the sample's address++ * @param {function} onFile called with the File++ */++ function sample(url, onFile) {++ if (!url || typeof fetch !== 'function') return;++ fetch(url, { credentials: 'same-origin' }).then(function (r) {++ if (!r.ok) throw new Error('sample ' + r.status);++ return r.blob();++ }).then(function (blob) {++ var name = String(url).split('/').pop() || 'sample';++ var file = new File([blob], name, { type: blob.type || 'application/octet-stream' });++ var input = document.querySelector('.ovk-drop-input');++ if (input && window.DataTransfer) {++ var dt = new DataTransfer();++ dt.items.add(file);++ input.files = dt.files;++ input.dispatchEvent(new Event('change', { bubbles: true }));++ }++ if (onFile) onFile(file);++ }).catch(function () { /* the sample is a convenience, not a promise */ });++ }++++ /**++ * A text tool's live output belongs in the frame's answer slot. Only an output that sits directly++ * in the box moves: one inside a two-pane editor (the Markdown preview) keeps its place.++ */++ function moveAnswer() {++ var card = document.querySelector('.ovk-tool-card');++ var out = document.querySelector('.ovk-tool-out');++ if (!card || !out) return;++ var live = card.querySelector('#output, #result, #results, #grid');++ // A two-pane editor (the Markdown preview) keeps its place: moving it would break the grid.++ if (live && !live.closest('.md-pane, .md-editor, .pane')) {++ out.appendChild(live);++ out.classList.add('ovk-tool-out--live');++ }++ }++++ /**++ * The navbar sits over the page, so the frame starts under it: its height is measured and put in++ * --ovk-nav-h, which the app's own stylesheet uses for the frame's top padding.++ */++ function underNavbar() {++ var nav = document.querySelector('.openvibe-navbar');++ if (!nav) return;++ var h = Math.round(nav.getBoundingClientRect().height);++ if (h > 0) document.documentElement.style.setProperty('--ovk-nav-h', h + 'px');++ }++++ function boot() {++ moveAnswer();++ underNavbar();++ if (typeof ResizeObserver === 'function') {++ var nav = document.querySelector('.openvibe-navbar');++ if (nav) new ResizeObserver(underNavbar).observe(nav);++ }++ // The navbar renders after the page: measure again once it is there.++ setTimeout(underNavbar, 400);++ setTimeout(underNavbar, 1500);++ window.addEventListener('resize', underNavbar);++ }++++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);++ else boot();++++ window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, moveAnswer: moveAnswer };++})();@@@ -46,14 +46,17 @@ a { color: var(--accent); text-decoration: none; }a:hover { color: var(--accent-hover); }/* ---------- App Container ---------- */++/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */#app {width: 100%;-- max-width: var(--max-width);-- margin: 0 auto;-- padding: 0 20px 60px;flex: 1;}++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++/* ---------- Hero ---------- */.hero {text-align: center;@@@ -311,6 +314,8 @@ a:hover { color: var(--accent-hover); }transition: var(--transition);}.text-input:focus, .num-input:focus { border-color: var(--accent); }++/* The frame's option rows: the field sits beside its label and keeps its own width. */++.num-input-sm { width: 120px; flex: none; }/* Slider */.slider {@@@ -583,4 +588,7 @@ a:hover { color: var(--accent-hover); }.file-details .file-row + .file-row { border-top: 1px solid var(--border); }.file-details .btn-sm { padding: 4px 10px; font-size: 12px; margin-top: 4px; }++++/* The page is the shared tool frame: it starts under the navbar, not behind it. */++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }@media (prefers-reduced-motion: reduce) { .option-group, .result-panel, .toast { animation: none; } .spinner { animation: none; opacity: .7; } }@@@ -26,324 +26,9 @@<!-- Navbar injected by OpenVibeNavbar.init() --><div id="app">-- <!-- Hero -->-- <section class="hero" id="hero">-- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-headphones"></i></div>-- <h1 id="hero-title">Audio.OpenVibe</h1>-- <p class="hero-subtitle" id="hero-subtitle">Online Audio Converter & Tools</p>-- </section>---- <!-- Tool Selector (visible on hub domain) -->-- <nav class="tool-tabs" id="tool-tabs">-- <button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>-- <button class="tool-tab" data-tool="trim"><i class="fa-solid fa-scissors"></i> Trim</button>-- <button class="tool-tab" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>-- <button class="tool-tab" data-tool="pitch"><i class="fa-solid fa-wave-square"></i> Pitch</button>-- <button class="tool-tab" data-tool="speed"><i class="fa-solid fa-gauge-high"></i> Speed</button>-- <button class="tool-tab" data-tool="reverse"><i class="fa-solid fa-backward"></i> Reverse</button>-- <button class="tool-tab" data-tool="normalize"><i class="fa-solid fa-sliders"></i> Normalize</button>-- <button class="tool-tab" data-tool="fade"><i class="fa-solid fa-volume-low"></i> Fade</button>-- <button class="tool-tab" data-tool="bass"><i class="fa-solid fa-volume-high"></i> Bass</button>-- <button class="tool-tab" data-tool="equalizer"><i class="fa-solid fa-sliders"></i> EQ</button>-- <button class="tool-tab" data-tool="voice"><i class="fa-solid fa-user-astronaut"></i> Voice FX</button>-- <button class="tool-tab" data-tool="echo"><i class="fa-solid fa-tower-broadcast"></i> Echo</button>-- <button class="tool-tab" data-tool="reverb"><i class="fa-solid fa-house-chimney"></i> Reverb</button>-- <button class="tool-tab" data-tool="extract"><i class="fa-solid fa-music"></i> Extract</button>-- <button class="tool-tab" data-tool="ringtone"><i class="fa-solid fa-bell"></i> Ringtone</button>-- </nav>---- <!-- Upload Zone -->-- <section class="upload-zone" id="upload-zone">-- <div class="upload-area" id="upload-area">-- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>-- <p class="upload-text">Drag & drop an audio file here</p>-- <p class="upload-hint">or click to browse — MP3, WAV, FLAC, OGG, M4A, AAC, OPUS, WMA, AIFF, video files</p>-- <input type="file" id="file-input" accept="audio/*,video/*" hidden>-- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose File</button>-- </div>-- <div class="upload-info" id="upload-info" style="display:none">-- <div class="file-icon"><i class="fa-solid fa-file-audio"></i></div>-- <div class="file-details" id="file-details"></div>-- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>-- </div>-- </section>---- <!-- Tool Options -->-- <section class="options-panel" id="options-panel" style="display:none">-- <!-- Convert Options -->-- <div class="option-group" id="opt-convert">-- <label class="option-label">Output Format</label>-- <div class="format-grid" id="format-grid">-- <button class="format-btn active" data-format="mp3">MP3</button>-- <button class="format-btn" data-format="wav">WAV</button>-- <button class="format-btn" data-format="flac">FLAC</button>-- <button class="format-btn" data-format="ogg">OGG</button>-- <button class="format-btn" data-format="m4a">M4A</button>-- <button class="format-btn" data-format="aac">AAC</button>-- <button class="format-btn" data-format="opus">OPUS</button>-- <button class="format-btn" data-format="wma">WMA</button>-- <button class="format-btn" data-format="aiff">AIFF</button>-- <button class="format-btn" data-format="ac3">AC3</button>-- </div>-- <label class="option-label">Bitrate <span id="bitrate-val">192</span> kbps</label>-- <input type="range" id="bitrate-slider" min="64" max="320" value="192" step="32" class="slider">-- <p class="option-hint">Higher = better quality, larger file. Lossless formats (WAV, FLAC, AIFF) ignore bitrate.</p>-- </div>---- <!-- Merge Options -->-- <div class="option-group" id="opt-merge" style="display:none">-- <p class="option-hint">The files are joined in the order listed above (2 to 5 files, up to 3 hours in total). Different formats can be mixed; the result is re-encoded.</p>-- <label class="option-label">Output Format</label>-- <div class="format-grid" id="merge-format-grid">-- <button class="format-btn active" data-format="mp3">MP3</button>-- <button class="format-btn" data-format="wav">WAV</button>-- <button class="format-btn" data-format="flac">FLAC</button>-- <button class="format-btn" data-format="ogg">OGG</button>-- <button class="format-btn" data-format="m4a">M4A</button>-- <button class="format-btn" data-format="opus">OPUS</button>-- </div>-- <label class="option-label">Bitrate <span id="merge-bitrate-val">192</span> kbps</label>-- <input type="range" id="merge-bitrate-slider" min="64" max="320" value="192" step="32" class="slider">-- </div>---- <!-- Trim Options -->-- <div class="option-group" id="opt-trim" style="display:none">-- <div class="time-inputs">-- <div class="time-field">-- <label class="option-label">Start Time</label>-- <input type="text" id="trim-start" class="text-input" placeholder="0:00" value="0">-- </div>-- <span class="time-arrow"><i class="fa-solid fa-arrow-right"></i></span>-- <div class="time-field">-- <label class="option-label">End Time</label>-- <input type="text" id="trim-end" class="text-input" placeholder="0:30">-- </div>-- </div>-- <p class="option-hint">Accepts seconds (30) or time format (1:30 or 0:01:30)</p>-- </div>---- <!-- Pitch Options -->-- <div class="option-group" id="opt-pitch" style="display:none">-- <label class="option-label">Pitch Shift: <span id="pitch-val">0</span> semitones</label>-- <input type="range" id="pitch-slider" min="-12" max="12" value="0" class="slider">-- <div class="pitch-presets">-- <button class="preset-btn" data-val="-12">-1 Octave</button>-- <button class="preset-btn" data-val="-5">-5 Semi</button>-- <button class="preset-btn" data-val="-2">-2 Semi</button>-- <button class="preset-btn" data-val="2">+2 Semi</button>-- <button class="preset-btn" data-val="5">+5 Semi</button>-- <button class="preset-btn" data-val="12">+1 Octave</button>-- </div>-- </div>---- <!-- Speed Options -->-- <div class="option-group" id="opt-speed" style="display:none">-- <label class="option-label">Speed: <span id="speed-val">1.0</span>x</label>-- <input type="range" id="speed-slider" min="0.25" max="4.0" value="1.0" step="0.05" class="slider">-- <div class="speed-presets">-- <button class="preset-btn" data-val="0.5">0.5x</button>-- <button class="preset-btn" data-val="0.75">0.75x</button>-- <button class="preset-btn" data-val="1.25">1.25x</button>-- <button class="preset-btn" data-val="1.5">1.5x</button>-- <button class="preset-btn" data-val="2.0">2x</button>-- <button class="preset-btn" data-val="3.0">3x</button>-- </div>-- </div>---- <!-- Reverse Options (no params) -->-- <div class="option-group" id="opt-reverse" style="display:none">-- <p class="option-info"><i class="fa-solid fa-info-circle"></i> Reverses the entire audio file. Hit Process to reverse!</p>-- </div>---- <!-- Normalize Options -->-- <div class="option-group" id="opt-normalize" style="display:none">-- <label class="option-label">Normalization Mode</label>-- <div class="format-grid" id="norm-mode-grid">-- <button class="format-btn active" data-mode="loudness">Loudness (EBU R128)</button>-- <button class="format-btn" data-mode="peak">Peak</button>-- <button class="format-btn" data-mode="rms">RMS</button>-- </div>-- </div>---- <!-- Fade Options -->-- <div class="option-group" id="opt-fade" style="display:none">-- <div class="time-inputs">-- <div class="time-field">-- <label class="option-label">Fade In (seconds)</label>-- <input type="number" id="fade-in" class="num-input" placeholder="0" min="0" max="30" step="0.5" value="2">-- </div>-- <div class="time-field">-- <label class="option-label">Fade Out (seconds)</label>-- <input type="number" id="fade-out" class="num-input" placeholder="0" min="0" max="30" step="0.5" value="3">-- </div>-- </div>-- </div>---- <!-- Bass Options -->-- <div class="option-group" id="opt-bass" style="display:none">-- <label class="option-label">Bass Gain: <span id="bass-val">+6</span> dB</label>-- <input type="range" id="bass-slider" min="-20" max="20" value="6" class="slider">-- <label class="option-label">Frequency: <span id="bass-freq-val">100</span> Hz</label>-- <input type="range" id="bass-freq-slider" min="20" max="300" value="100" class="slider">-- </div>---- <!-- Equalizer Options -->-- <div class="option-group" id="opt-equalizer" style="display:none">-- <label class="option-label">EQ Preset</label>-- <div class="format-grid" id="eq-grid">-- <button class="format-btn active" data-preset="bass_boost">Bass Boost</button>-- <button class="format-btn" data-preset="treble_boost">Treble Boost</button>-- <button class="format-btn" data-preset="vocal_boost">Vocal Boost</button>-- <button class="format-btn" data-preset="rock">Rock</button>-- <button class="format-btn" data-preset="pop">Pop</button>-- <button class="format-btn" data-preset="jazz">Jazz</button>-- <button class="format-btn" data-preset="classical">Classical</button>-- <button class="format-btn" data-preset="electronic">Electronic</button>-- <button class="format-btn" data-preset="hip_hop">Hip Hop</button>-- <button class="format-btn" data-preset="podcast">Podcast</button>-- <button class="format-btn" data-preset="telephone">Telephone</button>-- </div>-- </div>---- <!-- Voice FX Options -->-- <div class="option-group" id="opt-voice" style="display:none">-- <label class="option-label">Voice Effect</label>-- <div class="format-grid" id="voice-grid">-- <button class="format-btn active" data-preset="chipmunk">🐿️ Chipmunk</button>-- <button class="format-btn" data-preset="deep">🎸 Deep</button>-- <button class="format-btn" data-preset="robot">🤖 Robot</button>-- <button class="format-btn" data-preset="echo">🔊 Echo</button>-- <button class="format-btn" data-preset="cave">🦇 Cave</button>-- <button class="format-btn" data-preset="radio">📻 Radio</button>-- <button class="format-btn" data-preset="underwater">🌊 Underwater</button>-- <button class="format-btn" data-preset="whisper">🤫 Whisper</button>-- <button class="format-btn" data-preset="megaphone">📢 Megaphone</button>-- <button class="format-btn" data-preset="demon">👹 Demon</button>-- </div>-- </div>---- <!-- Echo Options -->-- <div class="option-group" id="opt-echo" style="display:none">-- <label class="option-label">Delay: <span id="echo-delay-val">250</span> ms</label>-- <input type="range" id="echo-delay" min="50" max="2000" value="250" step="10" class="slider">-- <label class="option-label">Decay: <span id="echo-decay-val">0.5</span></label>-- <input type="range" id="echo-decay" min="0.1" max="0.9" value="0.5" step="0.05" class="slider">-- <label class="option-label">Repeats: <span id="echo-repeats-val">3</span></label>-- <input type="range" id="echo-repeats" min="1" max="10" value="3" class="slider">-- </div>---- <!-- Reverb Options -->-- <div class="option-group" id="opt-reverb" style="display:none">-- <label class="option-label">Reverb Type</label>-- <div class="format-grid" id="reverb-grid">-- <button class="format-btn" data-preset="room">Room</button>-- <button class="format-btn active" data-preset="hall">Hall</button>-- <button class="format-btn" data-preset="cathedral">Cathedral</button>-- <button class="format-btn" data-preset="plate">Plate</button>-- </div>-- <label class="option-label">Wet/Dry Mix: <span id="reverb-mix-val">50</span>%</label>-- <input type="range" id="reverb-mix" min="0" max="100" value="50" class="slider">-- </div>---- <!-- Extract Options -->-- <div class="option-group" id="opt-extract" style="display:none">-- <label class="option-label">Output Format</label>-- <div class="format-grid" id="extract-format-grid">-- <button class="format-btn active" data-format="mp3">MP3</button>-- <button class="format-btn" data-format="wav">WAV</button>-- <button class="format-btn" data-format="flac">FLAC</button>-- <button class="format-btn" data-format="ogg">OGG</button>-- <button class="format-btn" data-format="m4a">M4A</button>-- <button class="format-btn" data-format="aac">AAC</button>-- </div>-- <p class="option-hint">Upload a video file (MP4, MKV, AVI, WebM, etc.) to extract its audio track.</p>-- </div>---- <!-- Ringtone Options -->-- <div class="option-group" id="opt-ringtone" style="display:none">-- <label class="option-label">Device</label>-- <div class="format-grid" id="ringtone-device-grid">-- <button class="format-btn active" data-device="iphone">🍎 iPhone (M4R)</button>-- <button class="format-btn" data-device="android">🤖 Android (MP3)</button>-- </div>-- <div class="time-inputs" style="margin-top:16px">-- <div class="time-field">-- <label class="option-label">Start</label>-- <input type="text" id="ringtone-start" class="text-input" placeholder="0" value="0">-- </div>-- <span class="time-arrow"><i class="fa-solid fa-arrow-right"></i></span>-- <div class="time-field">-- <label class="option-label">End</label>-- <input type="text" id="ringtone-end" class="text-input" placeholder="30">-- </div>-- </div>-- <p class="option-hint">iPhone ringtones are max 40 seconds. 30 seconds is ideal.</p>-- </div>---- <!-- Process Button -->-- <button class="btn btn-primary btn-lg" id="process-btn" disabled>-- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Audio</span>-- </button>-- </section>---- <!-- Processing State -->-- <section class="processing" id="processing" style="display:none">-- <div class="spinner"></div>-- <p>Processing your audio...</p>-- </section>---- <!-- Result -->-- <section class="result-panel" id="result-panel" style="display:none">-- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>-- <div class="result-player" id="result-player"></div>-- <div class="result-info" id="result-info"></div>-- <div class="result-actions">-- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>-- <i class="fa-solid fa-download"></i> Download-- </a>-- <button class="btn btn-secondary" id="another-btn">-- <i class="fa-solid fa-rotate-left"></i> Process Another-- </button>-- </div>-- </section>---- <!-- Format Subdomains Grid (hub only) -->-- <section class="subdomains" id="subdomains">-- <h2>All Audio Tools</h2>-- <p class="subdomains-subtitle">Each tool has its own dedicated subdomain</p>-- <div class="subdomain-grid" id="subdomain-grid"></div>-- </section>---- <!-- Cross-service links -->-- <section class="cross-links" id="cross-links">-- <h2>More OpenVibe Tools</h2>-- <div class="cross-grid">-- <a class="cross-card" href="https://img.openvibe.tools">-- <span class="cross-icon"><i class="fa-solid fa-images"></i></span>-- <span class="cross-info"><span class="cross-name">Img.OpenVibe</span><span class="cross-desc">Image converter & tools</span></span>-- </a>-- <a class="cross-card" href="https://yt.openvibe.tools">-- <span class="cross-icon"><i class="fa-brands fa-youtube"></i></span>-- <span class="cross-info"><span class="cross-name">YT.OpenVibe</span><span class="cross-desc">YouTube downloader</span></span>-- </a>-- <a class="cross-card" href="https://openvibe.live">-- <span class="cross-icon"><i class="fa-solid fa-video"></i></span>-- <span class="cross-info"><span class="cross-name">OpenVibe.Live</span><span class="cross-desc">Live streaming platform</span></span>-- </a>-- <a class="cross-card" href="https://openvibe.games">-- <span class="cross-icon"><i class="fa-solid fa-gamepad"></i></span>-- <span class="cross-info"><span class="cross-name">Scraplandia</span><span class="cross-desc">Multiplayer RPG</span></span>-- </a>-- <a class="cross-card" href="https://maps.openvibe.tools">-- <span class="cross-icon"><i class="fa-solid fa-map-location-dot"></i></span>-- <span class="cross-info"><span class="cross-name">Maps.OpenVibe</span><span class="cross-desc">Camps, resources & shelters</span></span>-- </a>-- <a class="cross-card" href="https://openvibe.food">-- <span class="cross-icon"><i class="fa-solid fa-utensils"></i></span>-- <span class="cross-info"><span class="cross-name">OpenVibe.Food</span><span class="cross-desc">Food banks, cheap groceries & meal plans</span></span>-- </a>-- </div>-- </section>++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->++ <!--OVK:FRAME--></div><!-- Footer -->@@@ -354,7 +39,11 @@<script src="/shared/footer.js"></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script>++ <script src="/shared/kit.js" defer></script>++ <script src="/shared/kit-web.js" defer></script>++ <script src="/shared/kit-data-web.js" defer></script><script src="/js/ov-jobs.js"></script>++ <script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script></body></html>@@@ -1,115 +1,55 @@--/* ================================================-- Audio.OpenVibe — Client Application (public/js/app.js)-- Vanilla JS IIFE — no frameworks, same pattern as openvibe-img-- ================================================ */++// ═══════════════════════════════════════════════════════════════++// Audio.OpenVibe — Client Application++// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,++// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop++// zone, the tool's own options and the one button) and the answer (the file it made, its size before++// and after, and Download). The upload, the processing and the download are the app's own, unchanged.++// ═══════════════════════════════════════════════════════════════++(function () {'use strict';-- /* ---------- State ---------- */-- let ctx = null; // from /api/context-- let toolList = []; // from /api/tools-- let currentTool = null; // active tool ID-- let uploadedFile = null; // File object-- let uploadedFiles = []; // merge: the files in the order they are joined-- let resultId = null; // retention ID from POST /api/v1/tools/:id/run++ // ── State ────────────────────────────────────────────────++ let ctx = null; // domain context from /api/context++ let selectedFiles = []; // the File objects the drop zone holds-- /* ---------- DOM refs ---------- */++ // ── DOM refs ─────────────────────────────────────────────const $ = (sel) => document.querySelector(sel);-- const $$ = (sel) => [...document.querySelectorAll(sel)];---- const heroIcon = $('#hero-icon');-- const heroTitle = $('#hero-title');-- const heroSub = $('#hero-subtitle');-- const toolTabs = $('#tool-tabs');-- const uploadZone = $('#upload-zone');-- const uploadArea = $('#upload-area');-- const uploadInfo = $('#upload-info');-- const fileInput = $('#file-input');-- const browseBtn = $('#browse-btn');-- const clearBtn = $('#clear-btn');++ const $$ = (sel) => document.querySelectorAll(sel);++++ const fileInput = $('#file-input');const optionsPanel = $('#options-panel');-- const processBtn = $('#process-btn');-- const processText = $('#process-text');-- const processing = $('#processing');-- const resultPanel = $('#result-panel');-- const resultPlayer = $('#result-player');-- const resultInfo = $('#result-info');-- const downloadBtn = $('#download-btn');-- const anotherBtn = $('#another-btn');-- const subdomains = $('#subdomains');-- const subdomainGrid = $('#subdomain-grid');---- /* ---------- Init ---------- */++ const processBtn = $('#process-btn');++ const clearBtn = $('#clear-btn');++ const kit = () => window.OpenVibeKit;++++ // ── Init ─────────────────────────────────────────────────async function init() {try {-- const [ctxRes, toolsRes] = await Promise.all([-- fetch('/api/context').then(r => r.json()),-- fetch('/api/tools').then(r => r.json())-- ]);-- ctx = ctxRes;-- toolList = toolsRes.tools || [];-- } catch (e) {-- console.error('Failed to load context:', e);-- ctx = { toolId: 'hub', brandName: 'Audio.OpenVibe', seoTitle: 'Audio.OpenVibe', seoDescription: 'Online Audio Tools' };-- toolList = [];++ const res = await fetch('/api/context');++ ctx = await res.json();++ } catch {++ ctx = { toolId: 'hub', brandName: 'Audio.OpenVibe', defaultOp: 'convert', tools: [] };}-- applyBranding();initNavbar();initNotifications();if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });-- initToolTabs();-- initUpload();++ initDrop();initOptions();-- initSubdomains();-- initResult();---- // Set initial tool-- const FORMAT_DOMAINS = ['mp3','wav','flac','ogg','m4a','aac','opus','wma','aiff','ac3'];-- if (ctx.toolId && ctx.toolId !== 'hub') {-- // Format-specific domains (mp3.openvibe.tools etc.) use the convert tool-- if (FORMAT_DOMAINS.includes(ctx.toolId)) {-- selectTool('convert');-- } else {-- selectTool(ctx.toolId);-- }-- } else {-- selectTool('convert');-- }reattach();}-- /* ---------- Branding ---------- */-- function applyBranding() {-- document.title = ctx.seoTitle || ctx.brandName || 'Audio.OpenVibe';-- const metaDesc = document.querySelector('meta[name="description"]');-- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;-- const metaOg = document.querySelector('meta[property="og:title"]');-- if (metaOg) metaOg.content = ctx.seoTitle || ctx.brandName;---- if (ctx.faIcon) heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon}"></i>`;-- heroTitle.textContent = ctx.brandName || 'Audio.OpenVibe';-- heroSub.textContent = ctx.seoDescription || 'Online Audio Tools';---- // On a specific tool domain, hide tool tabs and subdomains-- if (ctx.toolId && ctx.toolId !== 'hub') {-- toolTabs.style.display = 'none';-- subdomains.style.display = 'none';-- }-- }---- /* ---------- Navbar (OpenVibe unified bar) ---------- */++ // ── Navbar ───────────────────────────────────────────────function initNavbar() {const token = getCookie('ov_token') || localStorage.getItem('ov_token');let user = null;if (token) {-- try {-- const payload = JSON.parse(atob(token.split('.')[1]));-- user = payload;-- } catch { /* invalid token */ }++ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }}-- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").++ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and++ // code tool pages carry (owner ask 11).if (typeof OpenVibeNavbar !== 'undefined') {OpenVibeNavbar.init({service: 'audio',@@@ -118,46 +58,41 @@apiBase: 'https://openvibe.network',history: { type: 'tool', title: ctx?.brandName || 'Audio.OpenVibe' },silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },});}initFooter();}/* ---------- Footer (shared OpenVibeFooter) ---------- */-- // Full footer on the hub host, the compact strip on the single-tool hosts.function initFooter() {if (typeof OpenVibeFooter === 'undefined') return;try {OpenVibeFooter.init({service: 'audio',-- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',++ variant: 'compact',mount: '#ov-footer',apiBase: 'https://openvibe.network',++ brandName: 'OpenVibe.Tools',links: [{ heading: 'Audio tools', items: [-- { name: 'Convert', url: 'https://convert.audio.openvibe.tools' },-- { name: 'Trim', url: 'https://trim.openvibe.tools' },-- { name: 'Merge', url: 'https://merge.openvibe.tools' },-- { name: 'Pitch', url: 'https://pitch.openvibe.tools' },-- { name: 'Speed', url: 'https://speed.openvibe.tools' },-- { name: 'Normalize', url: 'https://normalize.openvibe.tools' },{ name: 'MP3', url: 'https://mp3.openvibe.tools' },{ name: 'WAV', url: 'https://wav.openvibe.tools' },++ { name: 'FLAC', url: 'https://flac.openvibe.tools' },++ { name: 'Trim', url: 'https://trim.openvibe.tools' },++ { name: 'Merge', url: 'https://merge.openvibe.tools' },++ { name: 'Podcasts', url: 'https://podcast.openvibe.tools' },] }],});} catch { /* optional */ }}-- /* ---------- Notifications (OpenVibe bell + toasts) ---------- */++ // ── Notifications (OpenVibe bell + toasts) ───────────function initNotifications() {const token = getCookie('ov_token') || localStorage.getItem('ov_token');if (typeof OpenVibeNotifications === 'undefined') return;-- OpenVibeNotifications.init({-- token: token || null,-- apiBase: 'https://openvibe.network',-- });++ OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });-- // Mount bell into navbar if availableif (typeof OpenVibeNavbar !== 'undefined') {const mount = OpenVibeNavbar.getBellMount();if (mount) {@@@ -167,407 +102,122 @@}}-- /* ---------- Tool Tabs ---------- */-- function initToolTabs() {-- $$('.tool-tab').forEach(tab => {-- tab.addEventListener('click', () => selectTool(tab.dataset.tool));-- });-- }---- function selectTool(toolId) {-- currentTool = toolId;---- // Update tab active state-- $$('.tool-tab').forEach(t => t.classList.toggle('active', t.dataset.tool === toolId));---- // Show matching option group, hide others-- $$('.option-group').forEach(g => g.style.display = 'none');-- const group = $(`#opt-${toolId}`);-- if (group) group.style.display = '';---- // Update process button text-- const tool = toolList.find(t => t.id === toolId);-- processText.textContent = tool ? `Process — ${tool.label}` : 'Process Audio';---- // Merge takes several files; the other tools one.-- fileInput.multiple = isMerge();-- if (isMerge() && uploadedFile && !uploadedFiles.length) uploadedFiles = [uploadedFile];-- if (!isMerge() && uploadedFiles.length) { uploadedFile = uploadedFiles[0]; uploadedFiles = []; }-- if (uploadedFile || uploadedFiles.length) showFiles();---- updateProcessBtn();-- }---- const MERGE_MAX = 5;-- function isMerge() { return currentTool === 'merge'; }---- /* ---------- Upload ---------- */-- function initUpload() {-- // Click to browse-- browseBtn.addEventListener('click', () => fileInput.click());-- uploadArea.addEventListener('click', (e) => {-- if (e.target === uploadArea || e.target.closest('.upload-icon') || e.target.closest('.upload-text') || e.target.closest('.upload-hint')) {-- fileInput.click();-- }-- });---- // File selected++ // ── The box: the drop zone ───────────────────────────────++ function initDrop() {++ if (!fileInput) return;fileInput.addEventListener('change', () => {-- if (isMerge()) addFiles([...fileInput.files]);-- else if (fileInput.files[0]) handleFile(fileInput.files[0]);-- fileInput.value = '';-- });---- // Drag & drop-- uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('drag-over'); });-- uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));-- uploadZone.addEventListener('drop', (e) => {-- e.preventDefault();-- uploadZone.classList.remove('drag-over');-- if (isMerge()) addFiles([...e.dataTransfer.files]);-- else if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);++ const files = Array.prototype.slice.call(fileInput.files || []);++ if (files.length) handleFiles(files);});++ if (clearBtn) clearBtn.addEventListener('click', resetUpload);-- // Clear-- clearBtn.addEventListener('click', clearFile);-- }---- function handleFile(file) {-- uploadedFile = file;-- showFiles();-- }---- /** Merge: add files to the list (up to MERGE_MAX), in the order chosen. */-- function addFiles(files) {-- const room = MERGE_MAX - uploadedFiles.length;-- if (files.length > room) showToast(`At most ${MERGE_MAX} files can be merged at once`, 'error');-- uploadedFiles = uploadedFiles.concat(files.slice(0, Math.max(0, room)));-- uploadedFile = uploadedFiles[0] || null;-- if (uploadedFiles.length) showFiles(); else clearFile();-- }---- function showFiles() {-- const details = $('#file-details');-- details.textContent = '';-- const list = isMerge() ? uploadedFiles : (uploadedFile ? [uploadedFile] : []);-- list.forEach((file, i) => {-- const row = document.createElement('div');-- row.className = 'file-row';-- const name = document.createElement('div');-- name.className = 'file-name';-- name.textContent = (isMerge() ? `${i + 1}. ` : '') + file.name;-- const meta = document.createElement('div');-- meta.className = 'file-meta';-- meta.textContent = `${file.name.split('.').pop().toUpperCase()} — ${formatSize(file.size)}`;-- row.append(name, meta);-- if (isMerge()) {-- const rm = document.createElement('button');-- rm.type = 'button';-- rm.className = 'btn btn-ghost btn-sm';-- rm.textContent = 'Remove';-- rm.addEventListener('click', () => { uploadedFiles.splice(i, 1); addFiles([]); });-- row.append(rm);-- }-- details.append(row);-- });-- if (isMerge() && uploadedFiles.length < MERGE_MAX) {-- const more = document.createElement('button');-- more.type = 'button';-- more.className = 'btn btn-ghost btn-sm';-- more.textContent = uploadedFiles.length < 2 ? 'Add another file' : 'Add a file';-- more.addEventListener('click', () => fileInput.click());-- details.append(more);++ const sampleLink = document.querySelector('[data-ovk-sample]');++ if (sampleLink && window.OVToolFrame) {++ sampleLink.addEventListener('click', (e) => {++ e.preventDefault();++ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), (file) => handleFiles([file]));++ });}---- uploadArea.style.display = 'none';-- uploadInfo.style.display = 'flex';-- optionsPanel.style.display = '';-- resultPanel.style.display = 'none';-- resultId = null;-- updateProcessBtn();}-- function clearFile() {-- uploadedFile = null;-- uploadedFiles = [];-- fileInput.value = '';-- uploadArea.style.display = '';-- uploadInfo.style.display = 'none';-- optionsPanel.style.display = 'none';-- updateProcessBtn();++ function handleFiles(files) {++ const max = files.length > 1 ? 5 : 1;++ if (files.length > max) {++ fail(`That is more than ${max} file${max === 1 ? '' : 's'}.`);++ return;++ }++ const tooBig = files.find((f) => f.size > 100 * 1024 * 1024);++ if (tooBig) {++ fail('That file is too large. The maximum is 100 MB.');++ return;++ }++ selectedFiles = files;++ if (optionsPanel) optionsPanel.hidden = false;++ if (processBtn) processBtn.disabled = false;++ hideAnswer();}-- function updateProcessBtn() {-- processBtn.disabled = !currentTool || (isMerge() ? uploadedFiles.length < 2 : !uploadedFile);++ function resetUpload() {++ selectedFiles = [];++ if (fileInput) fileInput.value = '';++ if (optionsPanel) optionsPanel.hidden = true;++ if (processBtn) processBtn.disabled = true;++ hideAnswer();}-- /* ---------- Options ---------- */++ // ── The box: the options and the one button ─────────────function initOptions() {-- // Bitrate slider (convert)-- const bitrateSlider = $('#bitrate-slider');-- const bitrateVal = $('#bitrate-val');-- if (bitrateSlider) {-- bitrateSlider.addEventListener('input', () => { bitrateVal.textContent = bitrateSlider.value; });-- }---- // Pitch slider-- const pitchSlider = $('#pitch-slider');-- const pitchVal = $('#pitch-val');-- if (pitchSlider) {-- pitchSlider.addEventListener('input', () => {-- const v = parseInt(pitchSlider.value);-- pitchVal.textContent = (v > 0 ? '+' : '') + v;-- });-- }---- // Pitch presets-- $$('#opt-pitch .preset-btn').forEach(btn => {-- btn.addEventListener('click', () => {-- pitchSlider.value = btn.dataset.val;-- pitchSlider.dispatchEvent(new Event('input'));-- });-- });---- // Speed slider-- const speedSlider = $('#speed-slider');-- const speedVal = $('#speed-val');-- if (speedSlider) {-- speedSlider.addEventListener('input', () => { speedVal.textContent = parseFloat(speedSlider.value).toFixed(2); });-- }---- // Speed presets-- $$('#opt-speed .preset-btn').forEach(btn => {-- btn.addEventListener('click', () => {-- speedSlider.value = btn.dataset.val;-- speedSlider.dispatchEvent(new Event('input'));-- });-- });---- // Bass sliders-- const bassSlider = $('#bass-slider');-- const bassVal = $('#bass-val');-- if (bassSlider) {-- bassSlider.addEventListener('input', () => {-- const v = parseInt(bassSlider.value);-- bassVal.textContent = (v > 0 ? '+' : '') + v;-- });-- }-- const bassFreqSlider = $('#bass-freq-slider');-- const bassFreqVal = $('#bass-freq-val');-- if (bassFreqSlider) {-- bassFreqSlider.addEventListener('input', () => { bassFreqVal.textContent = bassFreqSlider.value; });-- }---- // Echo sliders-- const echoDelay = $('#echo-delay');-- const echoDecay = $('#echo-decay');-- const echoRepeats = $('#echo-repeats');-- if (echoDelay) echoDelay.addEventListener('input', () => { $('#echo-delay-val').textContent = echoDelay.value; });-- if (echoDecay) echoDecay.addEventListener('input', () => { $('#echo-decay-val').textContent = parseFloat(echoDecay.value).toFixed(2); });-- if (echoRepeats) echoRepeats.addEventListener('input', () => { $('#echo-repeats-val').textContent = echoRepeats.value; });---- // Merge bitrate slider-- const mergeBitrate = $('#merge-bitrate-slider');-- if (mergeBitrate) mergeBitrate.addEventListener('input', () => { $('#merge-bitrate-val').textContent = mergeBitrate.value; });---- // Reverb mix slider-- const reverbMix = $('#reverb-mix');-- if (reverbMix) reverbMix.addEventListener('input', () => { $('#reverb-mix-val').textContent = reverbMix.value; });---- // Format grid click handlers (convert)-- initGridSelect('#format-grid .format-btn', 'format');-- initGridSelect('#merge-format-grid .format-btn', 'format');-- initGridSelect('#norm-mode-grid .format-btn', 'mode');-- initGridSelect('#eq-grid .format-btn', 'preset');-- initGridSelect('#voice-grid .format-btn', 'preset');-- initGridSelect('#reverb-grid .format-btn', 'preset');-- initGridSelect('#extract-format-grid .format-btn', 'format');-- initGridSelect('#ringtone-device-grid .format-btn', 'device');---- // Context defaults — if domain implies a format, pre-select it-- if (ctx.defaultFormat) {-- const fmtBtn = $(`#format-grid .format-btn[data-format="${ctx.defaultFormat}"]`);-- if (fmtBtn) {-- $$('#format-grid .format-btn').forEach(b => b.classList.remove('active'));-- fmtBtn.classList.add('active');-- }-- }---- // Process button-- processBtn.addEventListener('click', processAudio);++ if (processBtn) processBtn.addEventListener('click', processAudio);}-- function initGridSelect(selector, attr) {-- const btns = $$(selector);-- if (!btns.length) return;-- const parent = btns[0].parentElement;-- btns.forEach(btn => {-- btn.addEventListener('click', () => {-- parent.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));-- btn.classList.add('active');-- });++ /** The tool's own options, as the job's input: the fields the descriptor names, and nothing else. */++ function collectInput() {++ const form = document.querySelector('#ovk-audio-form');++ const input = { tool: (ctx && ctx.defaultOp) || 'convert' };++ if (!form) return input;++ form.querySelectorAll('input[name], select[name], textarea[name]').forEach((el) => {++ // The drop zone's own file field is not an option: the file goes in the job's files.++ if (el.type === 'file') return;++ if (el.type === 'radio') { if (el.checked) input[el.name] = el.value; return; }++ if (el.type === 'checkbox') { input[el.name] = el.checked; return; }++ if (el.value === '') return;++ input[el.name] = el.value;});++ return input;}-- /* ---------- Process ---------- */++ // ── Process ──────────────────────────────────────────────async function processAudio() {-- if (!uploadedFile || !currentTool) return;-- const files = isMerge() ? uploadedFiles.slice() : [uploadedFile];-- if (isMerge() && files.length < 2) return;---- const formData = new FormData();-- for (const f of files) formData.append(isMerge() ? 'files' : 'file', f);-- formData.append('tool', currentTool);---- // Gather tool-specific options-- const opts = gatherOptions();-- for (const [k, v] of Object.entries(opts)) {-- formData.append(k, v);++ if (!selectedFiles.length) return;++++ const input = collectInput();++ if (processBtn) {++ processBtn.disabled = true;++ const span = processBtn.querySelector('span');++ // The button keeps its verb while it works: "Compress…", not "Working…".++ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;}++ showWorking();-- // UI state-- optionsPanel.style.display = 'none';-- uploadZone.style.display = 'none';-- resultPanel.style.display = 'none';-- processing.style.display = '';---- // Jobs: the work is accepted, followed live (ffmpeg's progress) and survives a reload.-- const input = {};-- for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;try {-- const job = await OVJobs.submit({ type: 'audio.process', input, files, fileField: isMerge() ? 'files' : 'file' });++ const many = selectedFiles.length > 1;++ const job = await OVJobs.submit({++ type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',++ });OVJobs.remember(job.id);followJob(job);} catch (err) {-- showToast(err.message, 'error');-- resetUI();-- }-- }---- function gatherOptions() {-- const o = {};-- switch (currentTool) {-- case 'convert': {-- const fmt = $('#format-grid .format-btn.active');-- if (fmt) o.format = fmt.dataset.format;-- o.bitrate = $('#bitrate-slider').value;-- break;-- }-- case 'merge': {-- const fmt = $('#merge-format-grid .format-btn.active');-- if (fmt) o.format = fmt.dataset.format;-- o.bitrate = $('#merge-bitrate-slider').value;-- break;-- }-- case 'trim':-- o.start = $('#trim-start').value || '0';-- o.end = $('#trim-end').value;-- break;-- case 'pitch':-- o.semitones = $('#pitch-slider').value;-- break;-- case 'speed':-- o.speed = $('#speed-slider').value;-- break;-- case 'reverse':-- break;-- case 'normalize': {-- const mode = $('#norm-mode-grid .format-btn.active');-- if (mode) o.mode = mode.dataset.mode;-- break;-- }-- case 'fade':-- o.fadeIn = $('#fade-in').value || '0';-- o.fadeOut = $('#fade-out').value || '0';-- break;-- case 'bass':-- o.gain = $('#bass-slider').value;-- o.frequency = $('#bass-freq-slider').value;-- break;-- case 'equalizer': {-- const p = $('#eq-grid .format-btn.active');-- if (p) o.preset = p.dataset.preset;-- break;-- }-- case 'voice': {-- const p = $('#voice-grid .format-btn.active');-- if (p) o.preset = p.dataset.preset;-- break;-- }-- case 'echo':-- o.delay = $('#echo-delay').value;-- o.decay = $('#echo-decay').value;-- o.repeats = $('#echo-repeats').value;-- break;-- case 'reverb': {-- const p = $('#reverb-grid .format-btn.active');-- if (p) o.preset = p.dataset.preset;-- o.mix = $('#reverb-mix').value;-- break;-- }-- case 'extract': {-- const fmt = $('#extract-format-grid .format-btn.active');-- if (fmt) o.format = fmt.dataset.format;-- break;-- }-- case 'ringtone': {-- const d = $('#ringtone-device-grid .format-btn.active');-- if (d) o.device = d.dataset.device;-- o.start = $('#ringtone-start').value || '0';-- o.end = $('#ringtone-end').value;-- break;-- }++ failProcessing(err.message);}-- return o;}-- /* ---------- Jobs ---------- */++ // ── Jobs ─────────────────────────────────────────────────let watcher = null;-- let currentJob = null;function processingText(job) {const pct = job.progress && job.progress.percent;-- if (job.cancel_requested) return 'Cancelling...';-- if (job.state === 'queued') return 'Waiting for a free slot...';++ if (job.cancel_requested) return 'Cancelling…';++ if (job.state === 'queued') return 'Waiting for a free slot…';const label = (job.progress && job.progress.message) || 'Processing your audio';-- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;-- }---- function cancelButton() {-- let btn = processing.querySelector('.job-cancel');-- if (!btn) {-- btn = document.createElement('button');-- btn.type = 'button';-- btn.className = 'btn-secondary job-cancel';-- btn.textContent = 'Cancel';-- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });-- processing.appendChild(btn);-- }-- return btn;++ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;}function followJob(job) {-- currentJob = job.id;-- optionsPanel.style.display = 'none';-- uploadZone.style.display = 'none';-- resultPanel.style.display = 'none';-- processing.style.display = '';-- const label = processing.querySelector('p');-- const update = (j) => { if (label) label.textContent = processingText(j); };-- cancelButton().style.display = '';-- update(job);++ hideAnswer();++ showWorking();if (watcher) watcher.close();watcher = OVJobs.watch(job.id, {-- onUpdate: update,++ onUpdate: (j) => {++ const out = document.querySelector('.ovk-tool-out');++ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));++ },onDone(done) {watcher = null;-- cancelButton().style.display = 'none';-- if (done.state === 'succeeded') { resultId = done.id; return showResult(jobResult(done)); }++ if (done.state === 'succeeded') return showResult(jobResult(done));OVJobs.forget();-- if (done.state !== 'cancelled') showToast((done.error && done.error.detail) || 'Processing failed', 'error');-- resetUI();++ failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');},-- onGone() { OVJobs.forget(); resetUI(); },++ onGone() { OVJobs.forget(); failProcessing(null); },});}@@@ -580,6 +230,18 @@};}++ function resetVerb() {++ const span = processBtn && processBtn.querySelector('span');++ if (span) span.textContent = span.textContent.replace(/…$/, '');++ }++++ function failProcessing(message) {++ resetVerb();++ if (processBtn) processBtn.disabled = !selectedFiles.length;++ if (message) fail(message);++ else hideAnswer();++ }++/** After a reload: pick the job up again from ?job=<id> or this tab's session. */function reattach() {const id = window.OVJobs && OVJobs.recall();@@@ -587,122 +249,64 @@OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());}-- /* ---------- Result ---------- */-- function initResult() {-- anotherBtn.addEventListener('click', () => {-- if (window.OVJobs) OVJobs.forget();-- clearFile();-- resetUI();++ // ── The answer ───────────────────────────────────────────++ function showWorking() {++ const out = document.querySelector('.ovk-tool-out');++ if (!out || !kit()) return;++ out.innerHTML = kit().answer({++ title: 'Working…',++ body: '<p data-ovk-working>Your recording is being worked on by ffmpeg on our server. This page stays open until it is done.</p>',});}-- function showResult(data) {-- processing.style.display = 'none';-- resultPanel.style.display = '';---- const fileId = data.download.id;-- const previewUrl = data.download.previewUrl || `/api/preview/${fileId}`;-- const downloadUrl = data.download.downloadUrl || `/api/download/${fileId}`;-- const outputMime = data.output?.mime || '';---- if (outputMime.startsWith('image/')) {-- // Waveform — returns an image-- resultPlayer.innerHTML = `<img src="${previewUrl}" class="result-waveform" alt="Waveform">`;-- } else {-- // Audio player-- resultPlayer.innerHTML = `<audio controls preload="auto" src="${previewUrl}"></audio>`;-- }---- // Info: filename, output size, savings %-- const parts = [];-- if (data.download.filename) parts.push(data.download.filename);-- if (data.output?.size) parts.push(formatSize(data.output.size));-- if (data.input?.size && data.output?.size) {-- const pct = ((1 - data.output.size / data.input.size) * 100).toFixed(1);-- if (pct > 0) parts.push(`${pct}% smaller`);-- }-- resultInfo.textContent = parts.join(' — ');---- // Download-- downloadBtn.href = downloadUrl;-- if (data.download.filename) downloadBtn.download = data.download.filename;++ function hideAnswer() {++ const out = document.querySelector('.ovk-tool-out');++ if (out) out.innerHTML = '';}-- function resetUI() {-- processing.style.display = 'none';-- resultPanel.style.display = 'none';-- uploadZone.style.display = '';-- if (uploadedFile) {-- optionsPanel.style.display = '';-- }++ function fail(message) {++ const out = document.querySelector('.ovk-tool-out');++ if (!out || !kit()) return;++ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });}-- /* ---------- Subdomain Grid ---------- */-- function initSubdomains() {-- if (ctx.toolId && ctx.toolId !== 'hub') {-- subdomains.style.display = 'none';-- return;-- }---- const categories = [-- { label: 'Format Conversion', icon: 'fa-solid fa-arrows-rotate', ids: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus', 'wma', 'aiff', 'ac3'] },-- { label: 'Audio Editing', icon: 'fa-solid fa-scissors', ids: ['trim', 'merge', 'fade', 'loop', 'silence', 'stereo', 'normalize', 'speed', 'reverse'] },-- { label: 'Audio Effects', icon: 'fa-solid fa-wand-magic-sparkles', ids: ['pitch', 'bass', 'equalizer', 'echo', 'reverb', 'chorus', 'distortion', 'compressor', 'bitcrusher', 'voice', 'noise'] },-- { label: 'Extraction & Analysis', icon: 'fa-solid fa-magnifying-glass', ids: ['extract', 'vocal', 'waveform', 'metadata'] },-- { label: 'Specialized', icon: 'fa-solid fa-star', ids: ['ringtone', 'podcast'] },-- ];---- // Build a lookup from tools list-- const toolMap = {};-- toolList.forEach(t => { toolMap[t.id] = t; });---- let html = '';-- for (const cat of categories) {-- html += `<div class="cat-header"><i class="${cat.icon}"></i> ${cat.label}</div>`;-- for (const id of cat.ids) {-- const t = toolMap[id];-- if (!t) continue;-- const host = `${id}.openvibe.tools`;-- html += `-- <a class="subdomain-card" href="https://${host}">-- <span class="card-icon"><i class="fa-solid ${t.faIcon || 'fa-music'}"></i></span>-- <span class="card-info">-- <span class="card-name">${escHtml(t.label)}</span>-- <span class="card-desc">${escHtml(t.description)}</span>-- </span>-- </a>`;-- }++ function showResult(data) {++ resetVerb();++ if (processBtn) processBtn.disabled = false;++ const out = data.output || {};++ const rows = [];++ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);++ if (out.duration != null) rows.push(['Length', `${Math.round(out.duration)} s`]);++ if (data.fileCount) rows.push(['Files joined', String(data.fileCount)]);++ const expires = data.download && data.download.expiresIn;++ if (expires) {++ const mins = Math.max(1, Math.round(expires / 60000));++ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);}-- subdomainGrid.innerHTML = html;++ const html = window.OVToolFrame.fileAnswer({++ title: 'Your recording is ready',++ before: (data.input && data.input.size != null) ? data.input.size : (selectedFiles[0] ? selectedFiles[0].size : null),++ after: out.size != null ? out.size : null,++ rows,++ downloadUrl: data.download && data.download.downloadUrl,++ filename: data.download && data.download.filename,++ });++ window.OVToolFrame.show(html);}-- /* ---------- Utilities ---------- */++ // ── Helpers ──────────────────────────────────────────────function getCookie(name) {const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));return match ? decodeURIComponent(match[1]) : null;}-- function formatSize(bytes) {-- if (bytes < 1024) return bytes + ' B';-- if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';-- return (bytes / 1048576).toFixed(1) + ' MB';-- }---- function escHtml(str) {-- const d = document.createElement('div');-- d.textContent = str;-- return d.innerHTML;-- }---- function showToast(msg, type = '') {-- const el = document.createElement('div');-- el.className = 'toast' + (type ? ` ${type}` : '');-- el.textContent = msg;-- document.body.appendChild(el);-- setTimeout(() => { el.remove(); }, 4500);++ function escapeHtml(str) {++ const div = document.createElement('div');++ div.textContent = str;++ return div.innerHTML;}-- /* ---------- Boot ---------- */++ // ── Boot ─────────────────────────────────────────────────if (document.readyState === 'loading') {document.addEventListener('DOMContentLoaded', init);} else {nnew file mode 100644BBinary files /dev/null and b/apps/audio/public/sample.mp3 differnnew file mode 100644@@@ -0,0 +1,216 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the audio pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// Every audio host — mp3, trim, merge, reverb and the rest — is a tool page in the shared frame: the++// head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone and the++// tool's own options), the answer (the file it made, its size before and after, and Download), the++// family, the folds and "People also use".++//++// The box's options are the tool's own descriptor (server/descriptors.js): a field the descriptor does++// not have is not on the page, and its range and its default are the ones the tool reads. The terms++// come from the app's real configuration: the file's lifetime from config.js's retention, its size++// from config.js's upload limits, and the anonymous rate from the guard's quota table.++// ═══════════════════════════════════════════════════════════════++++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const config = require('./config');++const { DOMAIN_MAP } = require('./domain-map');++const { SPECS, FORMAT_MIME, AUDIO_ACCEPT } = require('./descriptors');++const { TOOLS } = require('./tools');++++const HUB = 'https://audio.openvibe.tools/';++const esc = kit.esc;++++// The tools, in the order the family row shows them: the formats first, then the edits and the effects.++const TOOL_LIST = [++ ['mp3', 'MP3 Converter', 'MP3', 'Convert any audio file to MP3, the format every player reads.', 'Convert to MP3'],++ ['wav', 'WAV Converter', 'WAV', 'Convert audio to WAV, the uncompressed format every program reads.', 'Convert to WAV'],++ ['flac', 'FLAC Converter', 'FLAC', 'Convert audio to FLAC, the lossless format.', 'Convert to FLAC'],++ ['ogg', 'OGG Converter', 'OGG', 'Convert audio to OGG Vorbis, the open format.', 'Convert to OGG'],++ ['m4a', 'M4A Converter', 'M4A', 'Convert audio to M4A, the format phones and podcasts use.', 'Convert to M4A'],++ ['opus', 'Opus Converter', 'Opus', 'Convert audio to Opus, the smallest format for speech.', 'Convert to Opus'],++ ['aac', 'AAC Converter', 'AAC', 'Convert audio to AAC, the format inside an MP4.', 'Convert to AAC'],++ ['wma', 'WMA Converter', 'WMA', 'Convert audio to WMA, the format Windows plays.', 'Convert to WMA'],++ ['aiff', 'AIFF Converter', 'AIFF', 'Convert audio to AIFF, the format audio programs write.', 'Convert to AIFF'],++ ['ac3', 'AC3 Converter', 'AC3', 'Convert audio to AC3, the surround-sound format.', 'Convert to AC3'],++ ['extract', 'Extract Audio', 'Extract', 'Take the sound out of a video file.', 'Extract audio'],++ ['trim', 'Trim Audio', 'Trim', 'Cut a recording down to the part you want to keep.', 'Trim'],++ ['merge', 'Merge Audio', 'Merge', 'Join up to five recordings into one file.', 'Merge'],++ ['ringtone', 'Ringtone Maker', 'Ringtone', 'Cut a ringtone for a phone.', 'Make ringtone'],++ ['speed', 'Change Speed', 'Speed', 'Make a recording play faster or slower, with the pitch kept.', 'Change speed'],++ ['pitch', 'Change Pitch', 'Pitch', 'Make a recording sound higher or lower without changing its speed.', 'Change pitch'],++ ['reverse', 'Reverse Audio', 'Reverse', 'Play a recording backwards.', 'Reverse'],++ ['normalize', 'Normalize Volume', 'Normalize', 'Make a recording as loud as it should be.', 'Normalize'],++ ['noise', 'Reduce Noise', 'Noise', 'Take the hiss and the hum out of a recording.', 'Reduce noise'],++ ['silence', 'Remove Silence', 'Silence', 'Cut the quiet parts out of a recording.', 'Remove silence'],++ ['vocal', 'Remove Vocals', 'Vocals', 'Take the singing out of a recording, or keep only the singing.', 'Remove vocals'],++ ['fade', 'Add Fade', 'Fade', 'Fade a recording in at the start and out at the end.', 'Add fade'],++ ['loop', 'Loop Audio', 'Loop', 'Play a recording more than once.', 'Loop'],++ ['bass', 'Boost Bass', 'Bass', 'Make the low end of a recording louder.', 'Boost bass'],++ ['equalizer', 'Equalizer', 'Equalizer', 'Change the balance of the frequencies in a recording.', 'Equalize'],++ ['compressor', 'Compressor', 'Compressor', 'Even out the quiet parts and the loud parts of a recording.', 'Compress'],++ ['podcast', 'Podcast', 'Podcast', 'Make a recording ready to publish as a podcast.', 'Optimize for podcast'],++ ['voice', 'Voice Changer', 'Voice', 'Change a voice into chipmunk, robot, giant and more.', 'Change voice'],++ ['echo', 'Add Echo', 'Echo', 'Add an echo to a recording.', 'Add echo'],++ ['reverb', 'Add Reverb', 'Reverb', 'Put a recording in a room, a hall or a cathedral.', 'Add reverb'],++ ['chorus', 'Add Chorus', 'Chorus', 'Double a recording\'s voice with a chorus.', 'Add chorus'],++ ['distortion', 'Add Distortion', 'Distortion', 'Make a recording sound driven and rough.', 'Add distortion'],++ ['bitcrusher', 'Bitcrusher', 'Lo-Fi', 'Make a recording sound old and lo-fi.', 'Add lo-fi'],++ ['stereo', 'Stereo', 'Stereo', 'Turn a recording into stereo or into mono.', 'Change channels'],++ ['waveform', 'Waveform Image', 'Waveform', 'Draw the shape of a recording as a picture.', 'Draw waveform'],++ ['metadata', 'Edit Metadata', 'Metadata', 'Read and write the tags inside an audio file.', 'Save metadata'],++];++++const RECS = TOOL_LIST.map(([id, name, short, task, verb]) => ({ id, name, short, task, verb, op: id }));++const BY_ID = new Map(RECS.map((r) => [r.id, r]));++const SPEC = new Map(SPECS.map((s) => [s.id, s]));++++const HUB_TOOL = {++ id: 'hub', name: 'Audio.OpenVibe', short: 'All tools', task: 'Convert audio to any format, and change it with the tools below.', verb: 'Convert',++ op: 'convert',++ about: 'Every audio tool in one place: convert a recording to another format, cut it, join it, and put an effect on it.',++};++++/** The tool a host is for (the karaoke host is the vocal tool under another name). */++function toolFor(host) {++ const ctx = DOMAIN_MAP[host];++ if (!ctx) return null;++ if (ctx.toolId === 'hub') return HUB_TOOL;++ return BY_ID.get(ctx.toolId) || null;++}++++/** The verb of a host: the karaoke host says what it makes. */++function verbFor(rec, host) {++ if (host === 'karaoke.openvibe.tools') return 'Make karaoke';++ if (rec.id === 'hub') return 'Convert';++ return rec.verb;++}++++/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */++function termsOf(rec) {++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);++ return frame.terms({++ work: 'server',++ ttlMs: config.retention.anonTTL,++ maxBytes: config.upload.maxFileSize,++ maxFiles: (spec && spec.files && spec.files.max) || 1,++ quotaClass: (spec && spec.quotaClass) || 'tools-job',++ cost: (spec && spec.cost) || 1,++ unit: 'runs',++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ });++}++++/** The labels of the descriptor's fields, in the words a person reads. */++const FIELD_LABEL = {++ format: 'Output format', bitrate: 'Bitrate (kbps)', sampleRate: 'Sample rate (Hz)', channels: 'Channels',++ start: 'Start', end: 'End', speed: 'Speed', semitones: 'Semitones', mode: 'Mode', target: 'Target (dB)',++ strength: 'Strength', threshold: 'Silence below (dB)', minDuration: 'Shortest gap (s)', device: 'Phone',++ fadeIn: 'Fade in (s)', fadeOut: 'Fade out (s)', count: 'Times', gain: 'Gain (dB)', frequency: 'Frequency (Hz)',++ preset: 'Preset', mix: 'Wet mix (%)', delay: 'Delay (ms)', decay: 'Decay', repeats: 'Repeats', bits: 'Bits',++ width: 'Width (px)', height: 'Height (px)', color: 'Colour', bgColor: 'Background',++ title: 'Title', artist: 'Artist', album: 'Album', genre: 'Genre', year: 'Year', track: 'Track', comment: 'Comment',++};++++/** The values a person reads better than the code's: the channels are mono and stereo. */++const VALUE_LABEL = { 1: 'Mono', 2: 'Stereo' };++++/** The box's options: the tool's own descriptor fields, with their ranges and their defaults. */++function optionsFor(rec) {++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);++ const props = (spec && spec.input && spec.input.properties) || {};++ const fixed = rec.op === 'convert' && (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat;++ const out = [];++ for (const [name, s] of Object.entries(props)) {++ // A format host fixes its own format; the page does not offer another one.++ if (name === 'format' && fixed) continue;++ out.push(frame.field(name, s, FIELD_LABEL, VALUE_LABEL));++ }++ return out.join('');++}++++/**++ * The box: a drop zone for the recording, the tool's options and the one button.++ * @param {object} rec the tool record++ */++function boxFor(rec, host) {++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);++ const many = (spec && spec.files && spec.files.max) > 1;++ const accept = (spec && spec.files && spec.files.accept) || AUDIO_ACCEPT;++ const drop = kit.drop({++ name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,++ label: many ? 'Drop your recordings here' : 'Drop a recording here',++ hint: many ? 'Up to five files at once' : 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4, WebM',++ });++ return `<form class="ovk-tool-form" id="ovk-audio-form" enctype="multipart/form-data" data-ovk-tool-form>`++ + drop++ + `<p class="ovk-try"><span>Try</span><a href="/sample.mp3" data-ovk-sample="/sample.mp3">A sample recording</a></p>`++ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`++ + `<div class="ovk-tool-buttons">${frame.verbButton(verbFor(rec, host), { id: 'process-btn', icon: 'wave' })}`++ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`++ + `</form>`;++}++++/** The family: every audio tool, with the one on the page marked. */++function familyItems(rec) {++ // The hub is the first of the family: the whole audio suite, and the page the family marks there.++ const hub = { href: HUB, label: 'Audio.OpenVibe', short: 'All tools', current: rec.id === 'hub' };++ return [hub, ...RECS.map((t) => ({ href: `https://${t.id}.openvibe.tools/`, label: t.name, short: t.short, current: t.id === rec.id }))];++}++++/** "People also use": the tools next to this one in the family. */++function relatedFor(rec) {++ // The hub is the converter, so the converter is not "also" there.++ const self = rec.id === 'hub' ? 'convert' : rec.id;++ const at = RECS.findIndex((t) => t.id === self);++ const out = [];++ for (let i = 1; out.length < 3 && i < RECS.length + 1; i++) {++ const other = RECS[(at + i) % RECS.length];++ if (other.id !== self && other.id !== 'hub') out.push(other);++ }++ return out.map((t) => kit.row({ href: `https://${t.id}.openvibe.tools/`, title: t.name, sub: t.task }));++}++++/**++ * The whole frame for one host.++ * @param {string} host the satellite's own hostname, e.g. 'mp3.openvibe.tools'++ * @returns {string} HTML++ */++function frameFor(host) {++ const rec = toolFor(host);++ if (!rec) return '';++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);++ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';++ const apiId = rec.id === 'hub' ? 'convert' : rec.id;++ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(++ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : '']),++ ) : {};++ const about = rec.about || `The ${rec.name.toLowerCase()} tool works on our server: the recording is sent to us, changed there and sent back as a file.`;++ const faq = [++ ['Is it free?', 'Yes. Every audio tool here is free, with a fair-use limit per address so the shared servers stay fast for everyone.'],++ ['How long is my recording kept?', 'An hour for a visitor, a day for a signed-in person. Then the file is gone.'],++ ['What does it accept?', 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF and the video formats that carry audio.'],++ ];++ return frame.page({++ id: rec.id,++ icon: 'wave', hue: 265, family: 'audio',++ crumb: { href: HUB, label: 'Audio tools' },++ title: rec.name, task: rec.task, facts: termsOf(rec),++ box: boxFor(rec, host),++ familyTitle: 'More audio tools',++ familyMore: { href: HUB, label: `All ${RECS.length + 1}` },++ familyItems: familyItems(rec),++ about, faq,++ api: {++ id: apiId, path: `/api/v1/tools/${apiId}/run`, fields, payload: { input },++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ },++ related: relatedFor(rec),++ });++}++++module.exports = { frameFor, toolFor, termsOf, boxFor, verbFor, optionsFor, RECS, HUB_TOOL };@@@ -304,6 +304,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {},}));++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});++// SPA fallback — every host gets index.html stamped with its own title, canonical, social// tags and structured data (server/seo.js); the raw file carries only the hub's.const { sendIndex } = require('./seo');@@@ -15,8 +15,10 @@const fs = require('fs');const path = require('path');++const serve = require('openvibe-shared/serve');const { canonicalHostFor, ownHost } = require('../../_shared/host-role');const { DOMAIN_MAP } = require('./domain-map');++const { frameFor } = require('./frame');const PUBLIC = path.join(__dirname, '..', 'public');const HUB_HOST = 'audio.openvibe.tools';@@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {`<meta name="twitter:title" content="${esc(title)}">`,`<meta name="twitter:description" content="${esc(desc)}">`,`<meta name="twitter:image" content="${image}">`,++ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),].join('\n');-- const out = html.replace(/<\/head>/i, `${head}\n</head>`);++ const out = html.replace(/<\/head>/i, `${head}\n</head>`)++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".++ .replace('<!--OVK:FRAME-->', frameFor(host) || '');if (_cache.size > 100) _cache.clear();_cache.set(key, out);return out;nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++// The tool frame on the audio pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • every audio host is a tool page in the same frame: the head, the box, the answer slot, the++// family, the folds and "People also use", in that order;++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its++// size from the upload limits, the rate from the guard's quota table — and they follow it when it++// changes;++// • the box's options are the tool's own descriptor: a field the descriptor does not have is not on++// the page;++// • the one button says the verb, never "Run".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { frameFor, verbFor, RECS } = require('../server/frame');++const { DOMAIN_MAP } = require('../server/domain-map');++const { SPECS } = require('../server/descriptors');++const config = require('../server/config');++const { startApp } = require('../../_shared/test/spawn');++++const HOSTS = Object.keys(DOMAIN_MAP);++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++// ── Every audio host is a framed tool page, in the same order ──++{++ for (const host of HOSTS) {++ const html = frameFor(host);++ assert.ok(html, `${host}: a frame`);++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('https://audio.openvibe.tools/">Audio tools'), `${host}: the crumb is the family`);++ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ assert.strictEqual(RECS.length, 36, 'the family has thirty-six audio tools');++}++++// ── The terms come from the app's own configuration ──++{++ const facts = factsOf(frameFor('mp3.openvibe.tools'));++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'mp3: free, on our server, and the file\'s lifetime');++ assert.ok(facts.includes('One file up to 100 MB'), 'mp3: the upload limit');++ assert.ok(facts.includes('A browser session: no account needed'), 'mp3: the tool refuses anonymous callers, so the page says what it needs');++++ const saved = config.retention.anonTTL;++ try {++ config.retention.anonTTL = 15 * 60 * 1000;++ assert.ok(factsOf(frameFor('mp3.openvibe.tools')).includes('Runs on our server; your file is deleted after 15 minutes'), 'the lifetime follows the config');++ config.upload.maxFileSize = 20 * 1024 * 1024;++ assert.ok(factsOf(frameFor('mp3.openvibe.tools')).includes('One file up to 20 MB'), 'the size follows the config');++ } finally {++ config.retention.anonTTL = saved;++ config.upload.maxFileSize = 100 * 1024 * 1024;++ }++++ // A tool that takes several files says so.++ assert.ok(factsOf(frameFor('merge.openvibe.tools')).includes('Up to 5 files, 100 MB each'), 'merge: the file count from its descriptor');++}++++// ── The box's options are the tool's own descriptor ──++{++ const spec = SPECS.find((s) => s.id === 'trim');++ const html = frameFor('trim.openvibe.tools');++ for (const name of Object.keys(spec.input.properties)) {++ assert.ok(html.includes(`name="${name}"`), `trim: the descriptor's field ${name} is on the page`);++ }++ assert.ok(!html.includes('name="bitrate"'), 'trim: a field the descriptor does not have is not on the page');++++ // The range and the default are the tool's own.++ assert.ok(html.includes('name="start"'), 'trim: the start field');++ assert.ok(/name="bitrate"[^>]*min="32"[^>]*max="320"/.test(frameFor('mp3.openvibe.tools')), 'mp3: the bitrate range is the descriptor\'s');++}++++// ── The one button says the verb ──++{++ assert.strictEqual(verbFor({ id: 'trim', verb: 'Trim' }, 'trim.openvibe.tools'), 'Trim');++ assert.strictEqual(verbFor({ id: 'vocal', verb: 'Remove vocals' }, 'karaoke.openvibe.tools'), 'Make karaoke');++ for (const host of HOSTS) {++ const html = frameFor(host);++ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);++ assert.ok(m, `${host}: a verb button`);++ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);++ }++ assert.ok(/<span>Convert to MP3<\/span>/.test(frameFor('mp3.openvibe.tools')), 'mp3: the button says the verb');++}++++// ── The page the server sends: the frame, the title, the canonical and the structured data ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-audio-frame-'));++ const app = await startApp('audio', { DATA_DIR: tmp });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ for (const host of HOSTS) {++ const r = await get(host);++ assert.strictEqual(r.status, 200, `${host}: answers 200`);++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log(`tool frame (audio): ${HOSTS.length} audio pages framed, the terms from the config, the options from the descriptors: all checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -38,14 +38,17 @@ body {flex-direction: column;}++/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */#app {flex: 1;-- max-width: 900px;width: 100%;-- margin: 0 auto;-- padding: 0 20px 40px;}++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++/* ── Hero ───────────────────────────────────────────────────── */.hero {text-align: center;@@@ -475,4 +478,7 @@ body {}.check-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 14px; color: var(--text-secondary); cursor: pointer; }++++/* The page is the shared tool frame: it starts under the navbar, not behind it. */++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }@@@ -26,197 +26,9 @@<!-- Navbar injected by OpenVibeNavbar.init() --><div id="app">-- <!-- Hero -->-- <section class="hero" id="hero">-- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-file-pdf"></i></div>-- <h1 id="hero-title">Docs.OpenVibe</h1>-- <p class="hero-subtitle" id="hero-subtitle">Online PDF & Document Tools</p>-- </section>---- <!-- Tool Selector (visible on hub domain) -->-- <nav class="tool-tabs" id="tool-tabs">-- <button class="tool-tab active" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>-- <button class="tool-tab" data-tool="split"><i class="fa-solid fa-scissors"></i> Split</button>-- <button class="tool-tab" data-tool="compress"><i class="fa-solid fa-compress"></i> Compress</button>-- <button class="tool-tab" data-tool="rotate"><i class="fa-solid fa-rotate"></i> Rotate</button>-- <button class="tool-tab" data-tool="reorder"><i class="fa-solid fa-arrow-down-wide-short"></i> Reorder</button>-- <button class="tool-tab" data-tool="watermark"><i class="fa-solid fa-signature"></i> Watermark</button>-- <button class="tool-tab" data-tool="img2pdf"><i class="fa-solid fa-file-image"></i> IMG→PDF</button>-- <button class="tool-tab" data-tool="pdf2img"><i class="fa-solid fa-image"></i> PDF→IMG</button>-- </nav>---- <!-- Upload Zone -->-- <section class="upload-zone" id="upload-zone">-- <div class="upload-area" id="upload-area">-- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>-- <p class="upload-text">Drag & drop files here</p>-- <p class="upload-hint" id="upload-hint">or click to browse — PDF, PNG, JPG, WebP, TIFF, BMP, GIF</p>-- <input type="file" id="file-input" accept=".pdf,image/*" hidden>-- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose Files</button>-- </div>---- <!-- Single file info -->-- <div class="upload-info" id="upload-info" style="display:none">-- <div class="file-preview" id="file-preview"><i class="fa-solid fa-file-pdf"></i></div>-- <div class="file-details" id="file-details"></div>-- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>-- </div>---- <!-- Multi-file list -->-- <div id="multi-file-zone" style="display:none">-- <ul class="file-list" id="file-list"></ul>-- <div style="display:flex;gap:8px;justify-content:center;margin-top:8px">-- <button class="btn btn-ghost" id="add-more-btn"><i class="fa-solid fa-plus"></i> Add More</button>-- <button class="btn btn-ghost" id="clear-all-btn"><i class="fa-solid fa-xmark"></i> Clear All</button>-- </div>-- <input type="file" id="multi-file-input" accept=".pdf,image/*" multiple hidden>-- </div>-- </section>---- <!-- Tool Options -->-- <section class="options-panel" id="options-panel" style="display:none">-- <!-- Split Options -->-- <div class="option-group" id="opt-split" style="display:none">-- <label class="option-label">Split Mode</label>-- <select id="split-mode" class="select-input">-- <option value="ranges">By Page Ranges</option>-- <option value="all">Every Page Separate</option>-- <option value="half">Split in Half</option>-- </select>-- <div id="split-ranges-input" style="margin-top:12px">-- <label class="option-label">Page Ranges</label>-- <input type="text" id="split-ranges" class="text-input" placeholder="e.g. 1-3, 5, 7-9">-- <p class="option-hint">Comma-separated page numbers or ranges</p>-- </div>-- <p class="option-hint">Several parts download as one ZIP of PDF files.</p>-- </div>---- <!-- Compress Options -->-- <div class="option-group" id="opt-compress" style="display:none">-- <label class="option-label">Compression Level</label>-- <select id="compress-level" class="select-input">-- <option value="low">Low (best quality)</option>-- <option value="medium" selected>Medium (balanced)</option>-- <option value="high">High (smallest file)</option>-- </select>-- </div>---- <!-- Rotate Options -->-- <div class="option-group" id="opt-rotate" style="display:none">-- <label class="option-label">Rotation Angle</label>-- <div class="tool-tabs" style="margin-bottom:12px">-- <button class="tool-tab active" data-angle="90">90° CW</button>-- <button class="tool-tab" data-angle="180">180°</button>-- <button class="tool-tab" data-angle="270">90° CCW</button>-- </div>-- <label class="option-label">Pages</label>-- <input type="text" id="rotate-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">-- </div>---- <!-- Reorder Options -->-- <div class="option-group" id="opt-reorder" style="display:none">-- <label class="option-label">New Page Order</label>-- <input type="text" id="reorder-order" class="text-input" placeholder="e.g. 3,1,2,5,4">-- <p class="option-hint">Comma-separated page numbers in the desired order. Omit pages to remove them.</p>-- </div>---- <!-- Watermark Options -->-- <div class="option-group" id="opt-watermark" style="display:none">-- <label class="option-label">Watermark Text</label>-- <input type="text" id="wm-text" class="text-input" placeholder="CONFIDENTIAL" value="CONFIDENTIAL">-- <div class="page-inputs" style="margin-top:12px">-- <div class="dim-field">-- <label class="option-label">Font Size</label>-- <input type="number" id="wm-fontsize" class="num-input" value="48" min="8" max="200">-- </div>-- <div class="dim-field">-- <label class="option-label">Opacity <span id="wm-opacity-val">15</span>%</label>-- <input type="range" id="wm-opacity" class="slider" min="1" max="100" value="15">-- </div>-- <div class="dim-field">-- <label class="option-label">Rotation</label>-- <input type="number" id="wm-rotation" class="num-input" value="-45" min="-180" max="180">-- </div>-- <div class="dim-field">-- <label class="option-label">Color</label>-- <input type="color" id="wm-color" value="#888888" style="width:100%;height:38px;padding:2px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);">-- </div>-- </div>-- <label class="option-label" style="margin-top:12px">Pages</label>-- <input type="text" id="wm-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">-- </div>---- <!-- Image to PDF Options -->-- <div class="option-group" id="opt-img2pdf" style="display:none">-- <label class="option-label">Page Size</label>-- <select id="img2pdf-pagesize" class="select-input">-- <option value="a4" selected>A4</option>-- <option value="letter">US Letter</option>-- <option value="legal">US Legal</option>-- <option value="fit">Fit to Image</option>-- </select>-- </div>---- <!-- PDF to Image Options -->-- <div class="option-group" id="opt-pdf2img" style="display:none">-- <label class="option-label">Output Format</label>-- <div class="tool-tabs" style="margin-bottom:12px">-- <button class="tool-tab active" data-imgfmt="png">PNG</button>-- <button class="tool-tab" data-imgfmt="jpg">JPG</button>-- </div>-- <label class="option-label">DPI</label>-- <input type="number" id="pdf2img-dpi" class="num-input num-input-sm" value="150" min="72" max="600">-- <label class="option-label" style="margin-top:12px">Pages</label>-- <input type="text" id="pdf2img-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">-- <p class="option-hint">Up to 50 pages at 150 DPI (20 at 300 DPI, 5 above). One page downloads as an image, several as a ZIP.</p>-- </div>---- <!-- Protect Options -->-- <div class="option-group" id="opt-protect" style="display:none">-- <label class="option-label">Password</label>-- <input type="password" id="protect-password" class="text-input" placeholder="Enter a password" autocomplete="new-password" maxlength="127">-- <label class="check-row"><input type="checkbox" id="protect-print" checked> Allow printing</label>-- <label class="check-row"><input type="checkbox" id="protect-copy" checked> Allow copying text</label>-- <p class="option-hint">Encrypted with AES-256. The password cannot be recovered, so keep it safe.</p>-- </div>---- <!-- Unlock Options -->-- <div class="option-group" id="opt-unlock" style="display:none">-- <label class="option-label">Current Password</label>-- <input type="password" id="unlock-password" class="text-input" placeholder="Enter the PDF password">-- </div>---- <!-- Process Button -->-- <button class="btn btn-primary btn-lg" id="process-btn" disabled style="margin-top:20px;width:100%">-- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Document</span>-- </button>-- </section>---- <!-- Processing State -->-- <section class="processing" id="processing" style="display:none">-- <div class="spinner"></div>-- <p>Processing your document...</p>-- </section>---- <!-- Result -->-- <section class="result-panel" id="result-panel" style="display:none">-- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>-- <div class="result-info" id="result-info"></div>-- <div class="result-actions">-- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>-- <i class="fa-solid fa-download"></i> Download-- </a>-- <button class="btn btn-secondary" id="another-btn">-- <i class="fa-solid fa-rotate-left"></i> Process Another-- </button>-- </div>-- </section>---- <!-- Subdomains Grid (hub only) -->-- <section class="subdomains" id="subdomains">-- <h2>All Document Tools</h2>-- <div class="subdomain-grid" id="subdomain-grid"></div>-- </section>++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->++ <!--OVK:FRAME--></div><!-- Footer -->@@@ -227,7 +39,11 @@<script src="/shared/footer.js"></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script>++ <script src="/shared/kit.js" defer></script>++ <script src="/shared/kit-web.js" defer></script>++ <script src="/shared/kit-data-web.js" defer></script><script src="/js/ov-jobs.js"></script>++ <script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script></body></html>@@@ -1,51 +1,27 @@// ═══════════════════════════════════════════════════════════════// Docs.OpenVibe — Client Application--// Domain-aware document processing SPA.--// Detects hostname → fetches context → adapts branding + tools.++// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,++// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop++// zone, the tool's own options and the one button) and the answer (the file it made, its size before++// and after, and Download). The upload, the processing and the download are the app's own, unchanged.// ═══════════════════════════════════════════════════════════════(function () {'use strict';// ── State ────────────────────────────────────────────────-- let ctx = null; // domain context from /api/context-- let selectedFiles = []; // File objects (single or multi)-- let selectedTool = 'merge';-- let selectedAngle = 90;-- let selectedImgFmt = 'png';++ let ctx = null; // domain context from /api/context++ let selectedFiles = []; // the File objects the drop zone holds// ── DOM refs ─────────────────────────────────────────────const $ = (sel) => document.querySelector(sel);const $$ = (sel) => document.querySelectorAll(sel);-- const heroIcon = $('#hero-icon');-- const heroTitle = $('#hero-title');-- const heroSubtitle = $('#hero-subtitle');-- const toolTabs = $('#tool-tabs');-- const uploadArea = $('#upload-area');-- const uploadHint = $('#upload-hint');-- const uploadInfo = $('#upload-info');-- const multiFileZone = $('#multi-file-zone');const fileInput = $('#file-input');-- const multiFileInput = $('#multi-file-input');-- const browseBtn = $('#browse-btn');-- const clearBtn = $('#clear-btn');-- const addMoreBtn = $('#add-more-btn');-- const clearAllBtn = $('#clear-all-btn');-- const fileList = $('#file-list');const optionsPanel = $('#options-panel');const processBtn = $('#process-btn');-- const processText = $('#process-text');-- const processingEl = $('#processing');-- const resultPanel = $('#result-panel');-- const resultInfo = $('#result-info');-- const downloadBtn = $('#download-btn');-- const anotherBtn = $('#another-btn');-- const subdomainsSection = $('#subdomains');-- const subdomainGrid = $('#subdomain-grid');---- // Multi-file tools-- const MULTI_FILE_TOOLS = new Set(['merge', 'img2pdf']);++ const clearBtn = $('#clear-btn');++ const kit = () => window.OpenVibeKit;// ── Init ─────────────────────────────────────────────────async function init() {@@@ -53,104 +29,43 @@const res = await fetch('/api/context');ctx = await res.json();} catch {-- ctx = { toolId: 'hub', brandName: 'Docs.OpenVibe', defaultOp: null, faIcon: 'fa-file-pdf', tools: [] };++ ctx = { toolId: 'hub', brandName: 'Docs.OpenVibe', tools: [] };}-- applyBranding();-- showAvailability();initNavbar();initNotifications();if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });-- initToolTabs();-- initUpload();++ showAvailability();++ initDrop();initOptions();-- initSubdomains();-- initResult();reattach();}-- // ── Branding ─────────────────────────────────────────────-- function applyBranding() {-- document.title = ctx.seoTitle || `${ctx.brandName} — Document Tools`;-- heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon || 'fa-file-pdf'}"></i>`;-- heroTitle.textContent = ctx.brandName || 'Docs.OpenVibe';---- const subtitles = {-- hub: 'Online PDF & Document Tools',-- merge: 'Combine PDF Files Online',-- split: 'Split PDF Files Online',-- compress: 'Compress PDF Files Online',-- rotate: 'Rotate PDF Pages Online',-- reorder: 'Rearrange PDF Pages Online',-- watermark: 'Add Watermarks to PDFs Online',-- protect: 'Password Protect PDFs Online',-- unlock: 'Remove PDF Password Online',-- img2pdf: 'Convert Images to PDF Online',-- pdf2img: 'Convert PDF to Images Online',-- };-- heroSubtitle.textContent = subtitles[ctx.toolId] || ctx.seoDescription || subtitles.hub;---- if (ctx.defaultOp) selectedTool = ctx.defaultOp;---- // Hide tool tabs on single-tool domains-- if (ctx.toolId !== 'hub') {-- toolTabs.style.display = 'none';-- }---- // Hide subdomains section on non-hub domains-- if (ctx.toolId !== 'hub') {-- subdomainsSection.style.display = 'none';-- }---- // Update upload hint based on tool-- updateUploadHint();---- // Update meta description-- const metaDesc = document.querySelector('meta[name="description"]');-- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;-- }---- // A tool whose server-side program is not installed yet (Protect/Unlock need qpdf, PDF to image-- // needs poppler): the page says so instead of letting an upload fail.++ // ── A tool whose command-line tools are not on the server yet ──++ // Protect and Unlock need qpdf, PDF to images needs poppler: while one is missing the tool answers++ // 503, so the page says so instead of letting the upload fail.function showAvailability() {-- const tool = (ctx.tools || []).find(t => t.id === selectedTool);++ const op = (ctx && ctx.defaultOp) || null;++ const tool = (ctx.tools || []).find((t) => t.id === op);const off = !!(tool && tool.available === false);++ const card = document.querySelector('.ovk-tool-card');++ if (!card) return;let note = document.getElementById('tool-notice');-- const zone = document.getElementById('upload-zone');-- if (off && !note && zone) {++ if (off && !note) {note = document.createElement('div');note.id = 'tool-notice';note.className = 'tool-notice';note.setAttribute('role', 'status');-- zone.parentNode.insertBefore(note, zone);++ card.appendChild(note);}if (note) {note.textContent = off ? `${tool.label} is being set up on the server and is not available yet. The other PDF tools work now.` : '';note.style.display = off ? '' : 'none';}-- if (zone) zone.style.display = off ? 'none' : '';-- if (off) { optionsPanel.style.display = 'none'; processBtn.disabled = true; }-- }---- function updateUploadHint() {-- const isImageTool = selectedTool === 'img2pdf';-- const isPdfTool = !isImageTool;-- if (isImageTool) {-- uploadHint.textContent = 'or click to browse — PNG, JPG, WebP, TIFF, BMP, GIF';-- fileInput.accept = 'image/*';-- multiFileInput.accept = 'image/*';-- } else {-- uploadHint.textContent = 'or click to browse — PDF files';-- fileInput.accept = '.pdf,application/pdf';-- multiFileInput.accept = '.pdf,application/pdf';-- }---- // Multi-file tools allow multiple selection-- if (MULTI_FILE_TOOLS.has(selectedTool)) {-- fileInput.multiple = true;-- } else {-- fileInput.multiple = false;-- }++ const drop = document.querySelector('.ovk-drop');++ if (drop) drop.hidden = off;++ if (optionsPanel) optionsPanel.hidden = off || optionsPanel.hidden;++ if (off && processBtn) processBtn.disabled = true;}// ── Navbar ───────────────────────────────────────────────@@@ -158,13 +73,11 @@const token = getCookie('ov_token') || localStorage.getItem('ov_token');let user = null;if (token) {-- try {-- const payload = JSON.parse(atob(token.split('.')[1]));-- user = payload;-- } catch { /* invalid token */ }++ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }}-- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").++ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and++ // code tool pages carry (owner ask 11).if (typeof OpenVibeNavbar !== 'undefined') {OpenVibeNavbar.init({service: 'docs',@@@ -173,39 +86,41 @@apiBase: 'https://openvibe.network',history: { type: 'tool', title: ctx?.brandName || 'Docs.OpenVibe' },silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },});}initFooter();}/* ---------- Footer (shared OpenVibeFooter) ---------- */-- // Full footer on the hub host, the compact strip on the single-tool hosts.function initFooter() {if (typeof OpenVibeFooter === 'undefined') return;try {OpenVibeFooter.init({service: 'docs',-- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',++ variant: 'compact',mount: '#ov-footer',apiBase: 'https://openvibe.network',-- links: [{ heading: 'Docs tools', items: [-- { name: 'Merge PDF', url: 'https://mergepdf.openvibe.tools' },-- { name: 'Split PDF', url: 'https://splitpdf.openvibe.tools' },-- { name: 'Compress PDF', url: 'https://compresspdf.openvibe.tools' },-- { name: 'Rotate PDF', url: 'https://rotatepdf.openvibe.tools' },-- { name: 'Watermark PDF', url: 'https://watermarkpdf.openvibe.tools' },-- { name: 'Protect PDF', url: 'https://protectpdf.openvibe.tools' },-- { name: 'Images to PDF', url: 'https://image2pdf.openvibe.tools' },-- { name: 'PDF to images', url: 'https://pdf2jpg.openvibe.tools' },++ brandName: 'OpenVibe.Tools',++ links: [{ heading: 'PDF tools', items: [++ { name: 'Merge', url: 'https://mergepdf.openvibe.tools' },++ { name: 'Split', url: 'https://splitpdf.openvibe.tools' },++ { name: 'Compress', url: 'https://compresspdf.openvibe.tools' },++ { name: 'Rotate', url: 'https://rotatepdf.openvibe.tools' },++ { name: 'Watermark', url: 'https://watermarkpdf.openvibe.tools' },++ { name: 'Protect', url: 'https://protectpdf.openvibe.tools' },] }],});} catch { /* optional */ }}++ // ── Notifications (OpenVibe bell + toasts) ───────────function initNotifications() {const token = getCookie('ov_token') || localStorage.getItem('ov_token');if (typeof OpenVibeNotifications === 'undefined') return;++OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });++if (typeof OpenVibeNavbar !== 'undefined') {const mount = OpenVibeNavbar.getBellMount();if (mount) {@@@ -215,255 +130,90 @@}}-- // ── Tool Tabs ────────────────────────────────────────────-- function initToolTabs() {-- $$('.tool-tab[data-tool]').forEach(tab => {-- if (tab.dataset.tool === selectedTool) tab.classList.add('active');-- else tab.classList.remove('active');---- tab.addEventListener('click', () => {-- selectedTool = tab.dataset.tool;-- $$('.tool-tab[data-tool]').forEach(t => t.classList.toggle('active', t.dataset.tool === selectedTool));-- resetUpload();-- updateUploadHint();-- updateProcessButton();-- showAvailability();-- });-- });-- }---- // ── Upload ───────────────────────────────────────────────-- function initUpload() {-- uploadArea.addEventListener('dragover', (e) => {-- e.preventDefault();-- uploadArea.classList.add('dragover');-- });-- uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));-- uploadArea.addEventListener('drop', (e) => {-- e.preventDefault();-- uploadArea.classList.remove('dragover');-- if (e.dataTransfer.files.length) handleFiles(Array.from(e.dataTransfer.files));-- });---- uploadArea.addEventListener('click', () => fileInput.click());-- browseBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });++ // ── The box: the drop zone ───────────────────────────────++ function initDrop() {++ if (!fileInput) return;fileInput.addEventListener('change', () => {-- if (fileInput.files.length) handleFiles(Array.from(fileInput.files));++ const files = Array.prototype.slice.call(fileInput.files || []);++ if (files.length) handleFiles(files);});++ if (clearBtn) clearBtn.addEventListener('click', resetUpload);-- // Multi-file controls-- addMoreBtn.addEventListener('click', () => multiFileInput.click());-- multiFileInput.addEventListener('change', () => {-- if (multiFileInput.files.length) handleFiles(Array.from(multiFileInput.files), true);-- });---- clearBtn.addEventListener('click', resetUpload);-- clearAllBtn.addEventListener('click', resetUpload);-- }---- function handleFiles(files, append = false) {-- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);---- // Validate file types-- for (const file of files) {-- if (selectedTool === 'img2pdf') {-- if (!file.type.startsWith('image/')) {-- alert(`"${file.name}" is not an image file.`);-- return;-- }-- } else {-- if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {-- alert(`"${file.name}" is not a PDF file.`);-- return;-- }-- }-- if (file.size > 100 * 1024 * 1024) {-- alert(`"${file.name}" is too large. Maximum 100MB per file.`);-- return;-- }-- }---- if (isMulti) {-- if (append) {-- selectedFiles = [...selectedFiles, ...files];-- } else {-- selectedFiles = [...files];-- }-- showMultiFileList();-- } else {-- selectedFiles = [files[0]];-- showSingleFile(files[0]);++ const sampleLink = document.querySelector('[data-ovk-sample]');++ if (sampleLink && window.OVToolFrame) {++ sampleLink.addEventListener('click', (e) => {++ e.preventDefault();++ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), (file) => handleFiles([file]));++ });}---- optionsPanel.style.display = '';-- showToolOptions();-- updateProcessButton();}-- function showSingleFile(file) {-- uploadArea.style.display = 'none';-- multiFileZone.style.display = 'none';-- uploadInfo.style.display = 'flex';---- const preview = $('#file-preview');-- const details = $('#file-details');---- if (file.type === 'application/pdf') {-- preview.innerHTML = '<i class="fa-solid fa-file-pdf"></i>';-- } else {-- const url = URL.createObjectURL(file);-- preview.innerHTML = `<img src="${url}" alt="Preview">`;++ function handleFiles(files) {++ const many = fileInput.hasAttribute('multiple');++ const max = many ? 50 : 1;++ if (files.length > max) {++ fail(`That is more than ${max} file${max === 1 ? '' : 's'}.`);++ return;}---- details.innerHTML = `-- <div class="fname">${escapeHtml(file.name)}</div>-- <div class="fmeta">${file.type || 'application/pdf'} • ${formatBytes(file.size)}</div>-- `;-- }---- function showMultiFileList() {-- uploadArea.style.display = 'none';-- uploadInfo.style.display = 'none';-- multiFileZone.style.display = '';---- fileList.innerHTML = selectedFiles.map((f, i) => `-- <li>-- <span class="fname"><i class="fa-solid ${f.type === 'application/pdf' ? 'fa-file-pdf' : 'fa-file-image'}" style="color:var(--accent);margin-right:8px"></i>${escapeHtml(f.name)}</span>-- <span class="fsize">${formatBytes(f.size)}</span>-- <button class="remove-file" data-idx="${i}" title="Remove"><i class="fa-solid fa-xmark"></i></button>-- </li>-- `).join('');---- // Bind remove buttons-- fileList.querySelectorAll('.remove-file').forEach(btn => {-- btn.addEventListener('click', () => {-- const idx = parseInt(btn.dataset.idx);-- selectedFiles.splice(idx, 1);-- if (selectedFiles.length === 0) {-- resetUpload();-- } else {-- showMultiFileList();-- updateProcessButton();-- }-- });-- });++ const tooBig = files.find((f) => f.size > 100 * 1024 * 1024);++ if (tooBig) {++ fail('That file is too large. The maximum is 100 MB.');++ return;++ }++ selectedFiles = files;++ if (optionsPanel) optionsPanel.hidden = false;++ if (processBtn) processBtn.disabled = false;++ hideAnswer();}function resetUpload() {selectedFiles = [];-- fileInput.value = '';-- multiFileInput.value = '';-- uploadArea.style.display = '';-- uploadInfo.style.display = 'none';-- multiFileZone.style.display = 'none';-- optionsPanel.style.display = 'none';-- resultPanel.style.display = 'none';-- processingEl.style.display = 'none';-- processBtn.disabled = true;++ if (fileInput) fileInput.value = '';++ if (optionsPanel) optionsPanel.hidden = true;++ if (processBtn) processBtn.disabled = true;++ hideAnswer();}-- // ── Options ──────────────────────────────────────────────++ // ── The box: the options and the one button ─────────────function initOptions() {-- // Split mode toggle-- const splitMode = $('#split-mode');-- if (splitMode) {-- splitMode.addEventListener('change', () => {-- const rangesInput = $('#split-ranges-input');-- rangesInput.style.display = splitMode.value === 'ranges' ? '' : 'none';-- });-- }---- // Watermark opacity slider-- const wmOpacity = $('#wm-opacity');-- const wmOpacityVal = $('#wm-opacity-val');-- if (wmOpacity) {-- wmOpacity.addEventListener('input', () => { wmOpacityVal.textContent = wmOpacity.value; });-- }---- // Angle buttons-- $$('[data-angle]').forEach(btn => {-- btn.addEventListener('click', () => {-- selectedAngle = parseInt(btn.dataset.angle);-- $$('[data-angle]').forEach(b => b.classList.toggle('active', b.dataset.angle === btn.dataset.angle));-- });-- });---- // Image format buttons-- $$('[data-imgfmt]').forEach(btn => {-- btn.addEventListener('click', () => {-- selectedImgFmt = btn.dataset.imgfmt;-- $$('[data-imgfmt]').forEach(b => b.classList.toggle('active', b.dataset.imgfmt === btn.dataset.imgfmt));-- });-- });---- processBtn.addEventListener('click', processDocument);++ if (processBtn) processBtn.addEventListener('click', processDocument);}-- function showToolOptions() {-- // Hide all option groups-- const groups = ['opt-split', 'opt-compress', 'opt-rotate', 'opt-reorder',-- 'opt-watermark', 'opt-img2pdf', 'opt-pdf2img', 'opt-protect', 'opt-unlock'];-- groups.forEach(id => {-- const el = $(`#${id}`);-- if (el) el.style.display = 'none';++ /** The tool's own options, as the job's input: the fields the descriptor names, and nothing else. */++ function collectInput() {++ const form = document.querySelector('#ovk-docs-form');++ const input = {};++ if (!form) return input;++ form.querySelectorAll('input[name], select[name], textarea[name]').forEach((el) => {++ if (el.type === 'hidden') { input[el.name] = el.value; return; }++ // The drop zone's own file field is not an option: the file goes in the job's files.++ if (el.type === 'file') return;++ if (el.type === 'radio') { if (el.checked) input[el.name] = el.value; return; }++ if (el.type === 'checkbox') { input[el.name] = el.checked; return; }++ if (el.value === '') return;++ input[el.name] = el.value;});---- // Show active tool options-- const optEl = $(`#opt-${selectedTool}`);-- if (optEl) optEl.style.display = '';-- }---- function updateProcessButton() {-- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);-- const hasFiles = selectedFiles.length > 0;-- const hasEnoughFiles = isMulti ? selectedFiles.length >= (selectedTool === 'merge' ? 2 : 1) : hasFiles;-- processBtn.disabled = !hasEnoughFiles;---- const labels = {-- merge: 'Merge PDFs',-- split: 'Split PDF',-- compress: 'Compress PDF',-- rotate: 'Rotate Pages',-- reorder: 'Reorder Pages',-- watermark: 'Add Watermark',-- protect: 'Protect PDF',-- unlock: 'Unlock PDF',-- img2pdf: 'Convert to PDF',-- pdf2img: 'Convert to Images',-- metadata: 'View Info',-- };-- processText.textContent = labels[selectedTool] || 'Process Document';++ return input;}// ── Process ──────────────────────────────────────────────async function processDocument() {-- if (selectedFiles.length === 0) return;---- optionsPanel.style.display = 'none';-- resultPanel.style.display = 'none';-- processingEl.style.display = '';-- processBtn.disabled = true;---- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);-- const formData = new FormData();-- formData.append('tool', selectedTool);---- if (isMulti) {-- for (const file of selectedFiles) {-- formData.append('files', file);-- }-- } else {-- formData.append('file', selectedFiles[0]);++ if (!selectedFiles.length) return;++++ const input = collectInput();++ if (processBtn) {++ processBtn.disabled = true;++ const span = processBtn.querySelector('span');++ // The button keeps its verb while it works: "Compress…", not "Working…".++ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;}++ showWorking();-- // Add tool-specific options-- addToolOptions(formData);---- // Jobs: the work is accepted, followed live and survives a reload (?job=<id> in the address).-- const input = {};-- for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;try {-- const job = await OVJobs.submit({ type: 'docs.process', input, files: isMulti ? selectedFiles : [selectedFiles[0]], fileField: isMulti ? 'files' : 'file' });++ const many = selectedFiles.length > 1;++ const job = await OVJobs.submit({++ type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',++ });OVJobs.remember(job.id);followJob(job);} catch (err) {@@@ -471,99 +221,29 @@}}-- function addToolOptions(formData) {-- switch (selectedTool) {-- case 'split': {-- const mode = $('#split-mode').value;-- if (mode === 'ranges') {-- formData.append('ranges', $('#split-ranges').value);-- } else {-- formData.append('mode', mode);-- }-- break;-- }-- case 'compress':-- formData.append('level', $('#compress-level').value);-- break;-- case 'rotate':-- formData.append('angle', selectedAngle);-- formData.append('pages', $('#rotate-pages').value || 'all');-- break;-- case 'reorder':-- formData.append('order', $('#reorder-order').value);-- break;-- case 'watermark':-- formData.append('text', $('#wm-text').value);-- formData.append('fontSize', $('#wm-fontsize').value);-- formData.append('opacity', ($('#wm-opacity').value / 100).toString());-- formData.append('rotation', $('#wm-rotation').value);-- formData.append('color', $('#wm-color').value);-- formData.append('pages', $('#wm-pages').value || 'all');-- break;-- case 'img2pdf':-- formData.append('pageSize', $('#img2pdf-pagesize').value);-- break;-- case 'pdf2img':-- formData.append('format', selectedImgFmt);-- formData.append('dpi', $('#pdf2img-dpi').value);-- formData.append('pages', $('#pdf2img-pages').value || 'all');-- break;-- case 'protect':-- formData.append('password', $('#protect-password').value);-- formData.append('allowPrint', $('#protect-print').checked ? 'true' : 'false');-- formData.append('allowCopy', $('#protect-copy').checked ? 'true' : 'false');-- break;-- case 'unlock':-- formData.append('password', $('#unlock-password').value);-- break;-- }-- }--// ── Jobs ─────────────────────────────────────────────────let watcher = null;-- let currentJob = null;function processingText(job) {const pct = job.progress && job.progress.percent;-- if (job.cancel_requested) return 'Cancelling...';-- if (job.state === 'queued') return 'Waiting for a free slot...';-- const label = (job.progress && job.progress.message) || 'Processing your document';-- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;-- }---- function cancelButton() {-- let btn = processingEl.querySelector('.job-cancel');-- if (!btn) {-- btn = document.createElement('button');-- btn.type = 'button';-- btn.className = 'btn-secondary job-cancel';-- btn.textContent = 'Cancel';-- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });-- processingEl.appendChild(btn);-- }-- return btn;++ if (job.cancel_requested) return 'Cancelling…';++ if (job.state === 'queued') return 'Waiting for a free slot…';++ const label = (job.progress && job.progress.message) || 'Working on your document';++ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;}function followJob(job) {-- currentJob = job.id;-- optionsPanel.style.display = 'none';-- resultPanel.style.display = 'none';-- processingEl.style.display = '';-- processBtn.disabled = true;-- const label = processingEl.querySelector('p');-- const update = (j) => { if (label) label.textContent = processingText(j); };-- cancelButton().style.display = '';-- update(job);++ hideAnswer();++ showWorking();if (watcher) watcher.close();watcher = OVJobs.watch(job.id, {-- onUpdate: update,++ onUpdate: (j) => {++ const out = document.querySelector('.ovk-tool-out');++ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));++ },onDone(done) {watcher = null;-- cancelButton().style.display = 'none';-- if (done.state === 'succeeded') {-- const data = jobResult(done);-- return data.viewOnly ? showMetadataResult(data) : showResult(data);-- }++ if (done.state === 'succeeded') return showResult(jobResult(done));OVJobs.forget();failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');},@@@ -571,20 +251,25 @@});}-- /** A finished job in the shape the synchronous endpoints answer with. */++ /** A finished job in the shape the synchronous endpoint answers with. */function jobResult(job) {-- const f = job.result && job.result.files && job.result.files[0];++ const f = (job.result && job.result.files && job.result.files[0]) || {};return {...(job.result && job.result.data),-- download: f ? { id: job.id, downloadUrl: f.url, filename: f.name, expiresIn: OVJobs.expiresIn(job) } : undefined,++ download: { id: job.id, downloadUrl: f.url, previewUrl: f.url ? `${f.url}?inline=1` : null, filename: f.name, expiresIn: OVJobs.expiresIn(job) },};}++ function resetVerb() {++ const span = processBtn && processBtn.querySelector('span');++ if (span) span.textContent = span.textContent.replace(/…$/, '');++ }++function failProcessing(message) {-- processingEl.style.display = 'none';-- optionsPanel.style.display = selectedFiles.length ? '' : 'none';-- processBtn.disabled = !selectedFiles.length;-- if (message) alert(`Error: ${message}`);++ resetVerb();++ if (processBtn) processBtn.disabled = !selectedFiles.length;++ if (message) fail(message);++ else hideAnswer();}/** After a reload: pick the job up again from ?job=<id> or this tab's session. */@@@ -594,104 +279,50 @@OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());}-- // ── Result ───────────────────────────────────────────────-- function initResult() {-- anotherBtn.addEventListener('click', () => {-- if (window.OVJobs) OVJobs.forget();-- resetUpload();-- (toolTabs.style.display !== 'none' ? toolTabs : uploadArea).scrollIntoView({ behavior: 'smooth' });++ // ── The answer ───────────────────────────────────────────++ function showWorking() {++ const out = document.querySelector('.ovk-tool-out');++ if (!out || !kit()) return;++ out.innerHTML = kit().answer({++ title: 'Working…',++ body: '<p data-ovk-working>Your document is being worked on by our server. This page stays open until it is done.</p>',});}-- function showResult(data) {-- processingEl.style.display = 'none';-- resultPanel.style.display = '';---- let infoHtml = '';---- if (data.output) {-- infoHtml += `<p>Format: <strong>${data.output.ext.toUpperCase()}</strong> • Size: <strong>${data.output.sizeKB} KB</strong></p>`;-- }-- if (data.pageCount !== undefined) {-- infoHtml += `<p>Pages: <strong>${data.pageCount}</strong></p>`;-- }-- if (data.fileCount) {-- infoHtml += `<p>Files processed: <strong>${data.fileCount}</strong></p>`;-- }-- if (data.savings) {-- const pct = data.savings.savedPercent;-- if (pct > 0) {-- infoHtml += `<p class="savings">Saved ${pct}% (${formatBytes(data.savings.savedBytes)} smaller)</p>`;-- } else {-- infoHtml += `<p>File size unchanged or slightly larger.</p>`;-- }-- }-- if (data.note) {-- infoHtml += `<p style="color:var(--text-muted);font-size:12px">${escapeHtml(data.note)}</p>`;-- }---- const expires = data.download?.expiresIn;-- if (expires) {-- const expiresStr = expires >= 3600000 ? `${Math.round(expires / 3600000)}h` : `${Math.round(expires / 60000)}m`;-- infoHtml += `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Download expires in ${expiresStr}</p>`;-- }---- resultInfo.innerHTML = infoHtml;-- downloadBtn.href = data.download?.downloadUrl || '#';-- downloadBtn.download = '';-- downloadBtn.style.display = data.download ? '' : 'none';-- resultPanel.scrollIntoView({ behavior: 'smooth' });++ function hideAnswer() {++ const out = document.querySelector('.ovk-tool-out');++ if (out) out.innerHTML = '';}-- function showMetadataResult(data) {-- processingEl.style.display = 'none';-- resultPanel.style.display = '';---- const m = data.metadata || {};-- let infoHtml = '<div style="text-align:left;max-width:500px;margin:0 auto">';-- infoHtml += `<p><strong>Title:</strong> ${escapeHtml(m.title || '(none)')}</p>`;-- infoHtml += `<p><strong>Author:</strong> ${escapeHtml(m.author || '(none)')}</p>`;-- infoHtml += `<p><strong>Subject:</strong> ${escapeHtml(m.subject || '(none)')}</p>`;-- infoHtml += `<p><strong>Creator:</strong> ${escapeHtml(m.creator || '(none)')}</p>`;-- infoHtml += `<p><strong>Producer:</strong> ${escapeHtml(m.producer || '(none)')}</p>`;-- infoHtml += `<p><strong>Pages:</strong> ${m.pageCount || '?'}</p>`;-- if (m.creationDate) infoHtml += `<p><strong>Created:</strong> ${m.creationDate}</p>`;-- if (m.modificationDate) infoHtml += `<p><strong>Modified:</strong> ${m.modificationDate}</p>`;-- infoHtml += '</div>';---- resultInfo.innerHTML = infoHtml;-- downloadBtn.style.display = 'none';-- resultPanel.scrollIntoView({ behavior: 'smooth' });++ function fail(message) {++ const out = document.querySelector('.ovk-tool-out');++ if (!out || !kit()) return;++ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });}-- // ── Subdomains Grid ──────────────────────────────────────-- function initSubdomains() {-- const tools = [-- { host: 'mergepdf.openvibe.tools', name: 'MergePDF', icon: 'fa-object-group', desc: 'Combine PDF files' },-- { host: 'splitpdf.openvibe.tools', name: 'SplitPDF', icon: 'fa-scissors', desc: 'Split by page range' },-- { host: 'compresspdf.openvibe.tools', name: 'CompressPDF', icon: 'fa-compress', desc: 'Reduce file size' },-- { host: 'rotatepdf.openvibe.tools', name: 'RotatePDF', icon: 'fa-rotate', desc: 'Rotate PDF pages' },-- { host: 'reorderpdf.openvibe.tools', name: 'ReorderPDF', icon: 'fa-arrow-down-wide-short', desc: 'Rearrange pages' },-- { host: 'watermarkpdf.openvibe.tools', name: 'WatermarkPDF', icon: 'fa-signature', desc: 'Add watermarks' },-- { host: 'protectpdf.openvibe.tools', name: 'ProtectPDF', icon: 'fa-lock', desc: 'Password protect' },-- { host: 'unlockpdf.openvibe.tools', name: 'UnlockPDF', icon: 'fa-unlock', desc: 'Remove password' },-- { host: 'image2pdf.openvibe.tools', name: 'Image2PDF', icon: 'fa-file-image', desc: 'Images to PDF' },-- { host: 'jpg2pdf.openvibe.tools', name: 'JPG2PDF', icon: 'fa-file-image', desc: 'JPG to PDF' },-- { host: 'png2pdf.openvibe.tools', name: 'PNG2PDF', icon: 'fa-file-image', desc: 'PNG to PDF' },-- { host: 'pdf2jpg.openvibe.tools', name: 'PDF2JPG', icon: 'fa-image', desc: 'PDF to JPG' },-- { host: 'pdf2png.openvibe.tools', name: 'PDF2PNG', icon: 'fa-image', desc: 'PDF to PNG' },-- { host: 'pdf.openvibe.tools', name: 'OpenVibePDF', icon: 'fa-file-pdf', desc: 'All PDF tools' },-- ];---- subdomainGrid.innerHTML = tools.map(t => `-- <a class="subdomain-card" href="https://${t.host}">-- <span class="sd-icon"><i class="fa-solid ${t.icon}"></i></span>-- <span class="sd-info">-- <span class="sd-name">${t.name}</span>-- <span class="sd-desc">${t.desc}</span>-- </span>-- </a>-- `).join('');++ function showResult(data) {++ resetVerb();++ if (processBtn) processBtn.disabled = false;++ const out = data.output || {};++ const rows = [];++ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);++ if (data.fileCount) rows.push(['Files', String(data.fileCount)]);++ if (data.pageCount) rows.push(['Pages', String(data.pageCount)]);++ if (data.savings && data.savings.savedPercent) rows.push(['Smaller by', `${data.savings.savedPercent}%`, { text: 'good', tone: 'ok' }]);++ const expires = data.download && data.download.expiresIn;++ if (expires) {++ const mins = Math.max(1, Math.round(expires / 60000));++ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);++ }++ const html = window.OVToolFrame.fileAnswer({++ title: 'Your document is ready',++ before: selectedFiles.reduce((sum, f) => sum + f.size, 0) || null,++ after: out.size != null ? out.size : null,++ rows,++ downloadUrl: data.download && data.download.downloadUrl,++ filename: data.download && data.download.filename,++ });++ window.OVToolFrame.show(html);}// ── Helpers ──────────────────────────────────────────────@@@ -700,12 +331,6 @@return match ? decodeURIComponent(match[1]) : null;}-- function formatBytes(bytes) {-- if (bytes < 1024) return bytes + ' B';-- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';-- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';-- }--function escapeHtml(str) {const div = document.createElement('div');div.textContent = str;nnew file mode 100644BBinary files /dev/null and b/apps/docs/public/sample.pdf differnnew file mode 100644@@@ -0,0 +1,194 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the PDF pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// Every PDF host — merge, split, compress, rotate, protect and the rest — is a tool page in the shared++// frame: the head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone++// and the tool's own options), the answer (the file it made, its size before and after, and Download),++// the family, the folds and "People also use".++//++// The box's options are the tool's own descriptor (server/descriptors.js): a field the descriptor does++// not have is not on the page, and its range and its default are the ones the tool reads. The terms come++// from the app's real configuration: the file's lifetime from config.js's retention, its size from++// config.js's upload limits, and the rate from the guard's quota table.++// ═══════════════════════════════════════════════════════════════++++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const config = require('./config');++const { DOMAIN_MAP } = require('./domain-map');++const { SPECS } = require('./descriptors');++++const HUB = 'https://docs.openvibe.tools/';++const esc = kit.esc;++++// The tools, in the order the family row shows them.++const TOOL_LIST = [++ ['merge', 'mergepdf', 'Merge PDFs', 'Merge', 'Combine several PDF files into one.', 'Merge'],++ ['split', 'splitpdf', 'Split PDF', 'Split', 'Cut a PDF into its pages, or into the ranges you name.', 'Split'],++ ['compress', 'compresspdf', 'Compress PDF', 'Compress', 'Make a PDF smaller without losing what is in it.', 'Compress'],++ ['rotate', 'rotatepdf', 'Rotate PDF', 'Rotate', 'Turn the pages of a PDF around.', 'Rotate'],++ ['reorder', 'reorderpdf', 'Reorder Pages', 'Reorder', 'Put the pages of a PDF in the order you want.', 'Reorder'],++ ['watermark', 'watermarkpdf', 'Watermark PDF', 'Watermark', 'Write a word or a name over every page of a PDF.', 'Add watermark'],++ ['protect', 'protectpdf', 'Protect PDF', 'Protect', 'Put a password on a PDF.', 'Protect'],++ ['unlock', 'unlockpdf', 'Unlock PDF', 'Unlock', 'Take the password off a PDF you know.', 'Unlock'],++ ['img2pdf', 'image2pdf', 'Images to PDF', 'Images to PDF', 'Turn pictures into a PDF.', 'Make PDF'],++ ['pdf2img', 'pdf2jpg', 'PDF to Images', 'PDF to images', 'Turn the pages of a PDF into pictures.', 'Make pictures'],++];++++const RECS = TOOL_LIST.map(([id, spec, name, short, task, verb]) => ({ id, spec, name, short, task, verb }));++const BY_ID = new Map(RECS.map((r) => [r.id, r]));++const SPEC = new Map(SPECS.map((s) => [s.id, s]));++++const HUB_TOOL = {++ id: 'hub', name: 'Docs.OpenVibe', short: 'All tools', task: 'Every PDF tool in one place — the merge page is below, the rest are one click away.', verb: 'Merge',++ op: 'merge', spec: 'mergepdf',++ about: 'Every PDF tool in one place: combine files, cut one up, make it smaller, turn the pages, write on them, protect them and turn pictures into a PDF.',++};++++/** The tool a host is for (the JPG and PNG hosts are the same tool as the images one). */++function toolFor(host) {++ const ctx = DOMAIN_MAP[host];++ if (!ctx) return null;++ if (ctx.toolId === 'hub') return HUB_TOOL;++ return BY_ID.get(ctx.toolId) || null;++}++++/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */++function termsOf(rec) {++ const spec = SPEC.get(rec.spec);++ return frame.terms({++ work: 'server',++ ttlMs: config.retention.anonTTL,++ maxBytes: config.upload.maxFileSize,++ maxFiles: (spec && spec.files && spec.files.max) || 1,++ quotaClass: (spec && spec.quotaClass) || 'tools-job',++ cost: (spec && spec.cost) || 1,++ unit: 'runs',++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ });++}++++/** The labels of the descriptor's fields, in the words a person reads. */++const FIELD_LABEL = {++ ranges: 'Pages', angle: 'Turn by', pages: 'Pages', order: 'New order', text: 'Text', fontSize: 'Text size',++ opacity: 'Opacity', rotation: 'Tilt', color: 'Colour', pageSize: 'Page size', format: 'Picture format',++ dpi: 'Resolution (dpi)', password: 'Password', ownerPassword: 'Owner password', allowPrint: 'Allow printing',++ allowCopy: 'Allow copying', level: 'Level',++};++++/** The values a person reads better than the code's. */++const VALUE_LABEL = {++ a4: 'A4', letter: 'Letter', legal: 'Legal', fit: 'Fit the picture',++ low: 'Light', medium: 'Medium', high: 'Heavy',++ '90': '90°', '180': '180°', '270': '270°',++ jpg: 'JPG', png: 'PNG',++};++++/** The box's options: the tool's own descriptor fields, with their ranges and their defaults. */++function optionsFor(rec) {++ const spec = SPEC.get(rec.spec);++ const props = (spec && spec.input && spec.input.properties) || {};++ const preset = (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat;++ const out = [];++ for (const [name, s] of Object.entries(props)) {++ // A list of page numbers is not a field a person types; the tool reads it from the pages it has.++ if (s.type === 'array') continue;++ const field = { ...s };++ if (name === 'format' && preset) field.default = preset;++ out.push(frame.field(name, field, FIELD_LABEL, VALUE_LABEL));++ }++ return out.join('');++}++++/**++ * The operation the box runs: the tool's own id (the registry's key: merge, compress, pdf2img…),++ * or the hub's own default.++ * @param {object} rec the tool record++ * @returns {string}++ */++function opOf(rec) {++ return rec.op || rec.id;++}++++/**++ * The box: a drop zone for the document, the tool's options and the one button.++ * @param {object} rec the tool record++ */++function boxFor(rec) {++ const spec = SPEC.get(rec.spec);++ const many = (spec && spec.files && spec.files.max) > 1;++ const accept = (spec && spec.files && spec.files.accept) || ['application/pdf'];++ const drop = kit.drop({++ name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,++ label: many ? 'Drop your documents here' : 'Drop a document here',++ hint: many ? 'Up to 50 files at once' : 'A PDF, a picture, or both',++ });++ return `<form class="ovk-tool-form" id="ovk-docs-form" enctype="multipart/form-data" data-ovk-tool-form>`++ + `<input type="hidden" name="tool" value="${esc(opOf(rec))}">`++ + drop++ + `<p class="ovk-try"><span>Try</span><a href="/sample.pdf" data-ovk-sample="/sample.pdf">A sample document</a></p>`++ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`++ + `<div class="ovk-tool-buttons">${frame.verbButton(rec.verb, { id: 'process-btn', icon: 'file' })}`++ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`++ + `</form>`;++}++++/** The family: every PDF tool, with the one on the page marked. */++function familyItems(rec) {++ const hub = { href: HUB, label: 'Docs.OpenVibe', short: 'All tools', current: rec.id === 'hub' };++ const current = rec.id === 'hub' ? 'merge' : rec.id;++ return [hub, ...RECS.map((t) => ({ href: `https://${t.spec}.openvibe.tools/`, label: t.name, short: t.short, current: t.id === current }))];++}++++/** "People also use": the tools next to this one in the family. */++function relatedFor(rec) {++ const at = RECS.findIndex((t) => t.id === rec.id);++ const out = [];++ for (let i = 1; out.length < 3 && i < RECS.length + 1; i++) {++ const other = RECS[(at + i) % RECS.length];++ if (other.id !== rec.id) out.push(other);++ }++ return out.map((t) => kit.row({ href: `https://${t.spec}.openvibe.tools/`, title: t.name, sub: t.task }));++}++++/**++ * The whole frame for one host.++ * @param {string} host the satellite's own hostname, e.g. 'mergepdf.openvibe.tools'++ * @returns {string} HTML++ */++function frameFor(host) {++ const rec = toolFor(host);++ if (!rec) return '';++ const spec = SPEC.get(rec.spec);++ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';++ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(++ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : '']),++ ) : {};++ const faq = [++ ['Is it free?', 'Yes. Every PDF tool here is free, with a fair-use limit per address so the shared servers stay fast for everyone.'],++ ['How long is my document kept?', 'An hour for a visitor, a day for a signed-in person. Then the file is gone.'],++ ['Are my files safe?', 'They are worked on by our own server and deleted afterwards. Nothing is kept and nothing is shared.'],++ ];++ return frame.page({++ id: rec.id,++ icon: 'file', hue: 25, family: 'pdf',++ crumb: { href: HUB, label: 'PDF tools' },++ title: rec.name, task: rec.task, facts: termsOf(rec),++ box: boxFor(rec),++ familyTitle: 'More PDF tools',++ familyMore: { href: HUB, label: `All ${RECS.length + 1}` },++ familyItems: familyItems(rec),++ about: rec.about || `The ${rec.name.toLowerCase()} tool works on our server: the document is sent to us, changed there and sent back as a file.`,++ faq,++ api: {++ id: rec.spec, path: `/api/v1/tools/${rec.spec}/run`, fields, payload: { input },++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ },++ related: relatedFor(rec),++ });++}++++module.exports = { frameFor, toolFor, termsOf, boxFor, optionsFor, RECS, HUB_TOOL };@@@ -300,6 +300,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {},}));++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});++// SPA fallback — every host gets index.html stamped with its own title, canonical, social// tags and structured data (server/seo.js); the raw file carries only the hub's.const { sendIndex } = require('./seo');@@@ -15,8 +15,10 @@const fs = require('fs');const path = require('path');++const serve = require('openvibe-shared/serve');const { canonicalHostFor, ownHost } = require('../../_shared/host-role');const { DOMAIN_MAP } = require('./domain-map');++const { frameFor } = require('./frame');const PUBLIC = path.join(__dirname, '..', 'public');const HUB_HOST = 'docs.openvibe.tools';@@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {`<meta name="twitter:title" content="${esc(title)}">`,`<meta name="twitter:description" content="${esc(desc)}">`,`<meta name="twitter:image" content="${image}">`,++ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),].join('\n');-- const out = html.replace(/<\/head>/i, `${head}\n</head>`);++ const out = html.replace(/<\/head>/i, `${head}\n</head>`)++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".++ .replace('<!--OVK:FRAME-->', frameFor(host) || '');if (_cache.size > 100) _cache.clear();_cache.set(key, out);return out;nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++// The tool frame on the PDF pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • every PDF host is a tool page in the same frame: the head, the box, the answer slot, the family,++// the folds and "People also use", in that order;++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its++// size from the upload limits, the rate from the guard's quota table — and they follow it when it++// changes;++// • the box's options are the tool's own descriptor;++// • the one button says the verb, never "Run".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { frameFor, RECS } = require('../server/frame');++const { DOMAIN_MAP } = require('../server/domain-map');++const { SPECS } = require('../server/descriptors');++const config = require('../server/config');++const { startApp } = require('../../_shared/test/spawn');++++const HOSTS = Object.keys(DOMAIN_MAP);++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++// ── Every PDF host is a framed tool page, in the same order ──++{++ for (const host of HOSTS) {++ const html = frameFor(host);++ assert.ok(html, `${host}: a frame`);++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('https://docs.openvibe.tools/">PDF tools'), `${host}: the crumb is the family`);++ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ assert.strictEqual(RECS.length, 10, 'the family has ten PDF tools');++}++++// ── The terms come from the app's own configuration ──++{++ const facts = factsOf(frameFor('mergepdf.openvibe.tools'));++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'merge: free, on our server, and the file\'s lifetime');++ assert.ok(facts.includes('Up to 50 files, 100 MB each'), 'merge: the file count and size from its descriptor and the upload limits');++++ const saved = config.retention.anonTTL;++ try {++ config.retention.anonTTL = 45 * 60 * 1000;++ assert.ok(factsOf(frameFor('mergepdf.openvibe.tools')).includes('Runs on our server; your file is deleted after 45 minutes'), 'the lifetime follows the config');++ config.upload.maxFileSize = 25 * 1024 * 1024;++ assert.ok(factsOf(frameFor('splitpdf.openvibe.tools')).includes('One file up to 25 MB'), 'the size follows the config');++ } finally {++ config.retention.anonTTL = saved;++ config.upload.maxFileSize = 100 * 1024 * 1024;++ }++}++++// ── The box's options are the tool's own descriptor ──++{++ const spec = SPECS.find((s) => s.id === 'rotatepdf');++ const html = frameFor('rotatepdf.openvibe.tools');++ for (const name of Object.keys(spec.input.properties)) {++ assert.ok(html.includes(`name="${name}"`), `rotate: the descriptor's field ${name} is on the page`);++ }++ assert.ok(!html.includes('name="level"'), 'rotate: a field the descriptor does not have is not on the page');++ assert.ok(frameFor('compresspdf.openvibe.tools').includes('name="level"'), 'compress: the level field');++}++++// ── The box runs the tool the page is for ──++{++ for (const host of HOSTS) {++ const html = frameFor(host);++ const m = /<input type="hidden" name="tool" value="([^"]+)">/.exec(html);++ assert.ok(m, `${host}: the box says which tool it runs`);++ const op = (DOMAIN_MAP[host] || {}).defaultOp;++ assert.strictEqual(m[1], op || 'merge', `${host}: the box runs ${op || 'merge'}`);++ }++}++++// ── The one button says the verb ──++{++ for (const host of HOSTS) {++ const html = frameFor(host);++ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);++ assert.ok(m, `${host}: a verb button`);++ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);++ }++ assert.ok(/<span>Split<\/span>/.test(frameFor('splitpdf.openvibe.tools')), 'split: the button says the verb');++ assert.ok(/<span>Add watermark<\/span>/.test(frameFor('watermarkpdf.openvibe.tools')), 'watermark: the button says the verb');++}++++// ── The page the server sends: the frame, the title, the canonical and the structured data ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-frame-'));++ const app = await startApp('docs', { DATA_DIR: tmp });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ for (const host of HOSTS) {++ const r = await get(host);++ assert.strictEqual(r.status, 200, `${host}: answers 200`);++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log(`tool frame (docs): ${HOSTS.length} PDF pages framed, the terms from the config, the options from the descriptors: all checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -236,4 +236,15 @@ body { min-height:100%; font-family:var(--font); background:var(--bg); color:var.food-table th, .food-table td { padding:6px; }}++++/* ── The tool frame ─────────────────────────────────────────────── */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++.ovk-food-box .loc-search { max-width: 720px; margin: 0 auto; }++.ovk-food-box .loc-search input { min-width: 0; flex: 1; }++++++/* The page is the shared tool frame: it starts under the navbar, not behind it. */++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }@@@ -39,6 +39,10 @@<link rel="stylesheet" href="/css/style.css"></head><body>++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->++ <!--OVK:FRAME-->++ <!--OVK:BOX--><!-- Location Bar --><div id="loc-bar"><div class="loc-search">@@@ -49,6 +53,8 @@</div></div>++ <!--/OVK:BOX-->++ <!--OVK:ANSWER--><!-- Main Content --><main id="main"><!-- Tabs -->@@@ -161,6 +167,7 @@</section></div></main>++ <!--/OVK:ANSWER--><p class="data-credit"><i class="fa-solid fa-database"></i> Data from USDA, OSM and store pricing research</p><div id="ov-footer"></div>@@@ -170,7 +177,11 @@<script src="/shared/footer.js"></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script>-- <script src="/js/app.js"></script>++ <script src="/shared/kit.js" defer></script>++<script src="/shared/kit-web.js" defer></script>++<script src="/shared/kit-data-web.js" defer></script>++<script src="/js/tool-frame.js" defer></script>++<script src="/js/app.js"></script><script>(function () {function getCookie(name) {@@@ -181,12 +192,15 @@let user = null;if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }if (typeof OpenVibeNavbar !== 'undefined') {-- OpenVibeNavbar.init({ service: 'food', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Food.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });++ // The brand is the site ("Tools"), not the subdomain's name: the same brand every framed tool++ // page carries (owner ask 11).++ OpenVibeNavbar.init({ service: 'food', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Food.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}', brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } });}if (typeof OpenVibeFooter !== 'undefined') {try {OpenVibeFooter.init({service: 'food', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',++ brandName: 'OpenVibe.Tools',links: [{ heading: 'Food.OpenVibe', items: [{ name: 'Find food', url: 'https://food.openvibe.tools' },{ name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools' },nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the food finder (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// The finder's page is one tool on one host: the head (the crumb, the name, one sentence, the terms),++// the box (the place search), the answer (the food banks, stores, meal plan, food list and map), the++// family, the folds and "People also use".++//++// The page's own parts are marked in public/index.html and moved into the frame's box and answer slot++// here, so the page keeps its own script, its own ids and its own behaviour.++//++// The terms come from the app's real configuration and from the guard's quota table: the lookups++// happen on our server (the food app proxies them to the maps satellite), and the rate is the one++// the guard holds for the tools-map class.++// ═══════════════════════════════════════════════════════════════++++const fs = require('fs');++const path = require('path');++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const { canonicalHostFor, restampHead } = require('../../_shared/host-role');++const { SPECS } = require('../../maps/server/descriptors');++++const esc = kit.esc;++const HOST = 'food.openvibe.tools';++const SITE = 'https://openvibe.tools';++const FILE = path.join(__dirname, '..', 'public', 'index.html');++const SPEC = SPECS.find((s) => s.id === 'food') || {};++++// The hubs this tool sits next to.++const HUBS = [++ { href: `${SITE}/maps-and-food`, label: 'Maps & Food', short: 'Maps' },++ { href: 'https://maps.openvibe.tools', label: 'Survival map', short: 'Map' },++ { href: 'https://openvibe.food', label: 'OpenVibe.Food', short: 'Food' },++ { href: `${SITE}/`, label: 'All tools', short: 'All' },++];++++/**++ * The terms of the food finder, from the app's real configuration and the guard's quota table.++ */++function termsOf() {++ return frame.terms({++ work: 'server',++ serverNote: 'Runs on our server: your search is looked up there',++ quotaClass: SPEC.quotaClass || 'tools-map',++ cost: SPEC.cost || 2,++ unit: 'lookups',++ });++}++++/** The family: the hubs this tool sits next to. The page's own hub is the one marked. */++function familyItems() {++ return HUBS.map((h) => ({ ...h, current: h.href === 'https://openvibe.food' }));++}++++/** "People also use": the other hubs, as rows. */++function relatedItems() {++ return HUBS.filter((h) => h.href !== 'https://openvibe.food')++ .map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));++}++++/**++ * The whole frame for the finder's page.++ * @param {string} box the page's own place search++ * @param {string} answer the page's own tabs and panels++ * @returns {string} HTML++ */++function frameFor(box, answer) {++ const title = 'Food finder';++ const task = 'Find free food near you: food banks, pantries, budget grocery stores and a meal plan.';++ const faq = [++ ['Is it free?', 'Yes. Thirty lookups a minute without an account.'],++ ['Where does the data come from?', 'Public sources: food banks, the national food bank directory, and grocery stores that accept food assistance.'],++ ['Is my search sent anywhere?', 'Only to our server, which does the lookups. Nothing is kept about you afterwards.'],++ ['Can I use it offline?', 'No. The finder needs a connection: it looks places up as you search.'],++ ];++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="food">${kit.tool({++ head: frame.head({++ icon: 'cart', hue: 25, family: 'places',++ crumb: { href: SITE, label: 'Tools' },++ title, task, facts: termsOf(),++ }),++ input: `<div class="ovk-food-box">${box}</div>`,++ answer: `<div class="ovk-food-answer">${answer}</div>`,++ family: kit.family({++ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },++ items: familyItems(), label: 'More tools',++ }),++ folds: frame.folds({++ name: title,++ about: 'The food finder puts food banks, community pantries, budget grocery stores, a meal planner and the food list on one page, so you can find what is free near you and plan with what you have.',++ faq,++ api: null,++ }),++ related: kit.list(relatedItems(), { title: 'People also use' }),++ })}</div>`;++}++++/** The page's own part between two markers. */++function pick(html, re) {++ const m = re.exec(html);++ return m ? m[1] : '';++}++++let _parts = null;++++/** The page's own parts, read once: the box and the answer. */++function parts() {++ if (_parts) return _parts;++ const html = fs.readFileSync(FILE, 'utf8');++ _parts = {++ box: pick(html, /<!--OVK:BOX-->([\s\S]*?)<!--\/OVK:BOX-->/),++ answer: pick(html, /<!--OVK:ANSWER-->([\s\S]*?)<!--\/OVK:ANSWER-->/),++ page: html++ .replace(/<!--OVK:BOX-->[\s\S]*?<!--\/OVK:BOX-->/, '')++ .replace(/<!--OVK:ANSWER-->[\s\S]*?<!--\/OVK:ANSWER-->/, ''),++ };++ return _parts;++}++++let _cache = null;++++/**++ * The finder's page with the frame around it, stamped for this request's canonical host.++ * @param {string} canonicalHost the host the page is about++ * @returns {string} HTML++ */++function renderIndex(canonicalHost) {++ if (_cache === null) {++ const { box, answer, page: stripped } = parts();++ _cache = stripped++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))++ .replace(/<\/head>/i, `${[++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,++ ].join('\n')}\n</head>`)++ .replace(/<\/body>/i, `${[++ '<script src="/shared/kit.js" defer></script>',++ '<script src="/shared/kit-web.js" defer></script>',++ '<script src="/shared/kit-data-web.js" defer></script>',++ '<script src="/js/tool-frame.js"></script>',++ ].join('\n')}\n</body>`);++ }++ return _cache;++}++++/**++ * The page with the frame, built fresh (no cache): the tests change the configuration and read the++ * page again.++ * @param {string} canonicalHost the host the page is about++ * @returns {string} HTML++ */++function renderFrame(canonicalHost) {++ const { box, answer, page } = parts();++ const out = page++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))++ .replace(/<\/head>/i, `${[++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,++ ].join('\n')}\n</head>`)++ .replace(/<\/body>/i, `${[++ '<script src="/shared/kit.js" defer></script>',++ '<script src="/shared/kit-web.js" defer></script>',++ '<script src="/shared/kit-data-web.js" defer></script>',++ '<script src="/js/tool-frame.js"></script>',++ ].join('\n')}\n</body>`);++ return canonicalHost ? restampHead(out, HOST, canonicalHost) : out;++}++++/** Express handler: the page with the frame, stamped for this request's host. */++function sendIndex(req, res) {++ const canonical = canonicalHostFor(req, HOST);++ res.setHeader('Content-Type', 'text/html; charset=utf-8');++ res.setHeader('Cache-Control', 'no-cache');++ res.setHeader('Vary', 'X-OV-Canonical-Host');++ return res.send(renderIndex(canonical));++}++++module.exports = { frameFor, termsOf, familyItems, relatedItems, renderIndex, renderFrame, sendIndex, HUBS, HOST };@@@ -120,8 +120,11 @@ app.use(analytics.middleware());const HOST = 'food.openvibe.tools';app.use(hostGuard({ knows: (h) => h === HOST }));// The page is OpenVibe.Food's now; the JSON routes below stay for whoever calls them.--const FOOD_URL = (process.env.OV_FOOD_PUBLIC_URL || 'https://openvibe.food').replace(/\/+$/, '');--const toFood = (req, res) => res.redirect(301, `${FOOD_URL}/`);++// Where the page went: OpenVibe.Food (openvibe.food). Set OV_FOOD_PUBLIC_URL to an empty value to keep++// the food finder's own page here (the frame around it, the same frame every other tool page has).++const FOOD_URL = (process.env.OV_FOOD_PUBLIC_URL === '' ? '' : (process.env.OV_FOOD_PUBLIC_URL || 'https://openvibe.food')).replace(/\/+$/, '');++const { sendIndex: sendFoodPage } = require('./frame');++const toFood = (req, res) => (FOOD_URL ? res.redirect(301, `${FOOD_URL}/`) : sendFoodPage(req, res));app.get(['/', '/index.html'], toFood);// Static files@@@ -168,6 +171,12 @@ app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {catch (err) { res.status(500).json({ ok: false, error: err.message }); }});++// The tool frame's browser glue (apps/_shared/public/tool-frame.js).++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});++// What used to be the page's own paths go to OpenVibe.Food as well (the API answers JSON 404s).app.get('*', (req, res) => (req.path.startsWith('/api/') ? res.status(404).json({ error: 'Not found' }) : toFood(req, res)));nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// The tool frame on the food finder's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the++// folds and "People also use", in that order;++// • the terms come from the app's real configuration — the lookups happen on our server and the rate++// is the guard's quota table — and they follow it when it changes;++// • the one button says the verb, never "Run".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { renderFrame } = require('../server/frame');++const { startApp } = require('../../_shared/test/spawn');++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++// ── The page is a framed tool page, in the same order ──++{++ const html = renderFrame('food.openvibe.tools');++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');++ assert.ok(html.includes('id="loc-input"'), 'the box has the place search');++ assert.ok(html.includes('id="food-banks"'), 'the answer holds the food banks');++ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');++ assert.ok(/id="btn-loc-search"[^>]*>[\s\S]*?Find Food/.test(html), 'the one button says the verb');++ assert.ok(!/>\s*Run\s*</.test(html), 'never Run');++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');++}++++// ── The terms come from the app's own configuration ──++{++ const facts = factsOf(renderFrame('food.openvibe.tools'));++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server: your search is looked up there'], 'free, and the lookups happen on our server');++ assert.ok(facts.includes('30 lookups a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');++ assert.ok(facts.includes('No sign-up'), 'no account needed');++++ const savedEnv = process.env.TOOLS_GUARD_LIMITS;++ try {++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-map': { anonymous: { perMinute: 20, burst: 10, perDay: 0 } } });++ assert.ok(factsOf(renderFrame('food.openvibe.tools')).includes('10 lookups a minute without an account'), 'the rate follows the guard\'s table');++ } finally {++ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;++ else process.env.TOOLS_GUARD_LIMITS = savedEnv;++ }++}++++// ── The page the server sends: the frame, the title and the canonical ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-food-frame-'));++ // The food app has no /api/health (its readiness needs the maps satellite): the page answers.++ const app = await startApp('food', { DATA_DIR: tmp, OV_FOOD_PUBLIC_URL: '' }, undefined, { readyPath: '/' });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ const r = await get('food.openvibe.tools');++ assert.strictEqual(r.status, 200, 'answers 200');++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), 'the frame\'s title');++ assert.ok(r.body.includes('<link rel="canonical"'), 'a canonical');++ assert.ok(r.body.includes('/shared/kit.css?'), 'the kit\'s look');++ assert.ok(r.body.includes('/js/tool-frame.js'), 'the frame\'s behaviour');++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, 'one h1');++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('tool frame (food): the food finder framed, the terms from the config, the button says the verb: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -103,6 +103,7 @@ function rawGet(port, p, headers = {}) {'_shared/tools/run.js': 'the satellites on loopback', '_shared/usage.js': 'Network user modules (configured)','_shared/egress.js': 'the egress guard itself','_shared/billing.js': 'OpenVibe.Billing and Network token endpoint (configured)',++ '_shared/public/tool-frame.js': 'the app\'s own sample file, on the app\'s own origin (same origin; no visitor-chosen host)',};const found = [];const walk = (dir) => {@@@ -38,14 +38,17 @@ body {flex-direction: column;}++/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */#app {flex: 1;-- max-width: 900px;width: 100%;-- margin: 0 auto;-- padding: 0 20px 40px;}++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++/* ── Hero ───────────────────────────────────────────────────── */.hero {text-align: center;@@@ -478,4 +481,7 @@ body {line-height: 1.5;}++++/* The page is the shared tool frame: it starts under the navbar, not behind it. */++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }@@@ -26,141 +26,9 @@<!-- Navbar injected by OpenVibeNavbar.init() --><div id="app">-- <!-- Hero -->-- <section class="hero" id="hero">-- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-images"></i></div>-- <h1 id="hero-title">Img.OpenVibe</h1>-- <p class="hero-subtitle" id="hero-subtitle">Online Image Converter & Tools</p>-- </section>---- <!-- Tool Selector (visible on hub domain) -->-- <nav class="tool-tabs" id="tool-tabs">-- <button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>-- <button class="tool-tab" data-tool="compress"><i class="fa-solid fa-compress"></i> Compress</button>-- <button class="tool-tab" data-tool="resize"><i class="fa-solid fa-up-right-and-down-left-from-center"></i> Resize</button>-- <button class="tool-tab" data-tool="crop"><i class="fa-solid fa-scissors"></i> Crop</button>-- </nav>---- <!-- Upload Zone -->-- <section class="upload-zone" id="upload-zone">-- <div class="upload-area" id="upload-area">-- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>-- <p class="upload-text">Drag & drop an image here</p>-- <p class="upload-hint">or click to browse — PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP</p>-- <input type="file" id="file-input" accept="image/*" hidden>-- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose File</button>-- </div>-- <div class="upload-info" id="upload-info" style="display:none">-- <div class="file-preview" id="file-preview"></div>-- <div class="file-details" id="file-details"></div>-- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>-- </div>-- </section>---- <!-- Tool Options -->-- <section class="options-panel" id="options-panel" style="display:none">-- <!-- Convert Options -->-- <div class="option-group" id="opt-convert">-- <label class="option-label">Output Format</label>-- <div class="format-grid" id="format-grid">-- <button class="format-btn" data-format="png">PNG</button>-- <button class="format-btn" data-format="jpg">JPG</button>-- <button class="format-btn" data-format="webp">WebP</button>-- <button class="format-btn" data-format="avif">AVIF</button>-- <button class="format-btn" data-format="gif">GIF</button>-- <button class="format-btn" data-format="tiff">TIFF</button>-- <button class="format-btn" data-format="bmp">BMP</button>-- <button class="format-btn" data-format="ico">ICO</button>-- </div>-- <label class="option-label">Quality <span id="quality-val">80</span>%</label>-- <input type="range" id="quality-slider" min="1" max="100" value="80" class="slider">-- </div>---- <!-- Compress Options -->-- <div class="option-group" id="opt-compress" style="display:none">-- <label class="option-label">Compression Level <span id="compress-val">75</span>%</label>-- <input type="range" id="compress-slider" min="1" max="100" value="75" class="slider">-- <p class="option-hint">Lower = smaller file size, higher = better quality</p>-- </div>---- <!-- Resize Options -->-- <div class="option-group" id="opt-resize" style="display:none">-- <div class="dimension-inputs">-- <div class="dim-field">-- <label class="option-label">Width (px)</label>-- <input type="number" id="resize-w" class="num-input" placeholder="auto" min="1" max="16384">-- </div>-- <span class="dim-x"><i class="fa-solid fa-xmark"></i></span>-- <div class="dim-field">-- <label class="option-label">Height (px)</label>-- <input type="number" id="resize-h" class="num-input" placeholder="auto" min="1" max="16384">-- </div>-- </div>-- <label class="option-label">Or scale by percentage</label>-- <input type="number" id="resize-pct" class="num-input num-input-sm" placeholder="e.g. 50" min="1" max="1000"> %-- <label class="option-label">Fit Mode</label>-- <select id="resize-fit" class="select-input">-- <option value="inside" selected>Inside (fit within bounds)</option>-- <option value="cover">Cover (fill & crop)</option>-- <option value="contain">Contain (fit with padding)</option>-- <option value="fill">Fill (stretch)</option>-- <option value="outside">Outside (cover minimum)</option>-- </select>-- </div>---- <!-- Crop Options -->-- <div class="option-group" id="opt-crop" style="display:none">-- <label class="option-label">Aspect Ratio Preset</label>-- <div class="aspect-grid" id="aspect-grid">-- <button class="aspect-btn" data-aspect="1:1">1:1</button>-- <button class="aspect-btn" data-aspect="16:9">16:9</button>-- <button class="aspect-btn" data-aspect="4:3">4:3</button>-- <button class="aspect-btn" data-aspect="3:2">3:2</button>-- <button class="aspect-btn" data-aspect="21:9">21:9</button>-- <button class="aspect-btn" data-aspect="9:16">9:16</button>-- <button class="aspect-btn" data-aspect="3:4">3:4</button>-- <button class="aspect-btn active" data-aspect="">Custom</button>-- </div>-- <div class="crop-coords" id="crop-coords">-- <div class="dim-field"><label class="option-label">Left</label><input type="number" id="crop-left" class="num-input" placeholder="0" min="0"></div>-- <div class="dim-field"><label class="option-label">Top</label><input type="number" id="crop-top" class="num-input" placeholder="0" min="0"></div>-- <div class="dim-field"><label class="option-label">Width</label><input type="number" id="crop-w" class="num-input" placeholder="auto" min="1"></div>-- <div class="dim-field"><label class="option-label">Height</label><input type="number" id="crop-h" class="num-input" placeholder="auto" min="1"></div>-- </div>-- </div>---- <!-- Process Button -->-- <button class="btn btn-primary btn-lg" id="process-btn" disabled>-- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Image</span>-- </button>-- </section>---- <!-- Processing State -->-- <section class="processing" id="processing" style="display:none">-- <div class="spinner"></div>-- <p>Processing your image...</p>-- </section>---- <!-- Result -->-- <section class="result-panel" id="result-panel" style="display:none">-- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>-- <div class="result-preview" id="result-preview"></div>-- <div class="result-info" id="result-info"></div>-- <div class="result-actions">-- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>-- <i class="fa-solid fa-download"></i> Download-- </a>-- <button class="btn btn-secondary" id="another-btn">-- <i class="fa-solid fa-rotate-left"></i> Process Another-- </button>-- </div>-- </section>---- <!-- Format Subdomains Grid (hub only) -->-- <section class="subdomains" id="subdomains">-- <h2>All Image Tools</h2>-- <div class="subdomain-grid" id="subdomain-grid"></div>-- </section>++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->++ <!--OVK:FRAME--></div><!-- Footer -->@@@ -171,7 +39,11 @@<script src="/shared/footer.js"></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script>++ <script src="/shared/kit.js" defer></script>++ <script src="/shared/kit-web.js" defer></script>++ <script src="/shared/kit-data-web.js" defer></script><script src="/js/ov-jobs.js"></script>++ <script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script></body></html>@@@ -1,7 +1,9 @@// ═══════════════════════════════════════════════════════════════// Img.OpenVibe — Client Application--// Domain-aware image processing SPA.--// Detects hostname → fetches context → adapts branding + tools.++// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,++// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop++// zone, the options and the one button) and the answer (the file it made, its size before and after,++// and Download). The upload, the processing and the download are the app's own, unchanged.// ═══════════════════════════════════════════════════════════════(function () {@@@ -11,43 +13,19 @@let ctx = null; // domain context from /api/contextlet selectedFile = null; // File objectlet selectedTool = 'convert';-- let selectedFormat = 'png';-- let selectedAspect = '';// ── DOM refs ─────────────────────────────────────────────const $ = (sel) => document.querySelector(sel);const $$ = (sel) => document.querySelectorAll(sel);-- const heroIcon = $('#hero-icon');-- const heroTitle = $('#hero-title');-- const heroSubtitle = $('#hero-subtitle');-- const toolTabs = $('#tool-tabs');-- const uploadArea = $('#upload-area');-- const uploadInfo = $('#upload-info');const fileInput = $('#file-input');-- const browseBtn = $('#browse-btn');-- const clearBtn = $('#clear-btn');const optionsPanel = $('#options-panel');const processBtn = $('#process-btn');-- const processText = $('#process-text');-- const processingEl = $('#processing');-- const resultPanel = $('#result-panel');-- const resultPreview = $('#result-preview');-- const resultInfo = $('#result-info');-- const downloadBtn = $('#download-btn');-- const anotherBtn = $('#another-btn');-- const subdomainsSection = $('#subdomains');-- const subdomainGrid = $('#subdomain-grid');---- // Option elements-- const qualitySlider = $('#quality-slider');-- const qualityVal = $('#quality-val');-- const compressSlider = $('#compress-slider');-- const compressVal = $('#compress-val');++ const clearBtn = $('#clear-btn');++ const kit = () => window.OpenVibeKit;// ── Init ─────────────────────────────────────────────────async function init() {-- // Fetch domain contexttry {const res = await fetch('/api/context');ctx = await res.json();@@@ -55,88 +33,26 @@ctx = { toolId: 'hub', brandName: 'Img.OpenVibe', defaultOp: 'convert', faIcon: 'fa-images', tools: [] };}-- applyBranding();-- showUnavailableInputs();initNavbar();initNotifications();if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });-- initToolTabs();-- initUpload();++ showUnavailableInputs();++ initDrop();initOptions();-- initSubdomains();initResult();reattach();}-- // ── Branding ─────────────────────────────────────────────-- function applyBranding() {-- document.title = ctx.seoTitle || `${ctx.brandName} — Image Tools`;-- heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon || 'fa-images'}"></i>`;-- heroTitle.textContent = ctx.brandName || 'Img.OpenVibe';---- // Set subtitle based on tool-- const subtitles = {-- hub: 'Online Image Converter & Tools',-- convert: 'Online Image Format Converter',-- compress: 'Online Image Compressor',-- resize: 'Online Image Resizer',-- crop: 'Online Image Cropper',-- favicon: 'Online Favicon Generator',-- };-- heroSubtitle.textContent = subtitles[ctx.toolId] || ctx.seoDescription || subtitles.hub;---- // If format-specific domain, set default format-- if (ctx.defaultFormat) {-- selectedFormat = ctx.defaultFormat;-- }---- // Set default tool from context-- selectedTool = ctx.defaultOp || 'convert';---- // Hide tool tabs on single-tool domains (format-specific ones show convert)-- const isFormatDomain = ctx.toolId && !['hub', 'convert', 'compress', 'resize', 'crop', 'favicon'].includes(ctx.toolId);-- if (isFormatDomain) {-- toolTabs.style.display = 'none';-- selectedTool = 'convert';-- }---- // Hide subdomains section on non-hub domains-- if (ctx.toolId !== 'hub') {-- subdomainsSection.style.display = 'none';-- }---- // Update meta description-- const metaDesc = document.querySelector('meta[name="description"]');-- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;-- }---- // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up-- // front on the page that is about it, instead of letting the upload fail.-- function showUnavailableInputs() {-- const heicPage = ctx.toolId === 'heic';-- if (!ctx.inputs || ctx.inputs.heic !== false || !heicPage) return;-- const zone = document.getElementById('upload-zone');-- if (!zone || document.getElementById('input-notice')) return;-- const note = document.createElement('div');-- note.id = 'input-notice';-- note.className = 'tool-notice';-- note.setAttribute('role', 'status');-- note.textContent = 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.';-- zone.parentNode.insertBefore(note, zone);-- }--// ── Navbar ───────────────────────────────────────────────function initNavbar() {const token = getCookie('ov_token') || localStorage.getItem('ov_token');let user = null;if (token) {-- try {-- const payload = JSON.parse(atob(token.split('.')[1]));-- user = payload;-- } catch { /* invalid token */ }++ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }}-- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").++ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and++ // code tool pages carry (owner ask 11).if (typeof OpenVibeNavbar !== 'undefined') {OpenVibeNavbar.init({service: 'img',@@@ -145,21 +61,22 @@apiBase: 'https://openvibe.network',history: { type: 'tool', title: ctx?.brandName || 'Img.OpenVibe' },silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },});}initFooter();}/* ---------- Footer (shared OpenVibeFooter) ---------- */-- // Full footer on the hub host, the compact strip on the single-tool hosts.function initFooter() {if (typeof OpenVibeFooter === 'undefined') return;try {OpenVibeFooter.init({service: 'img',-- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',++ variant: 'compact',mount: '#ov-footer',apiBase: 'https://openvibe.network',++ brandName: 'OpenVibe.Tools',links: [{ heading: 'Img tools', items: [{ name: 'Convert', url: 'https://convert.openvibe.tools' },{ name: 'Compress', url: 'https://compress.openvibe.tools' },@@@ -184,7 +101,6 @@apiBase: 'https://openvibe.network',});-- // Mount bell into navbar if availableif (typeof OpenVibeNavbar !== 'undefined') {const mount = OpenVibeNavbar.getBellMount();if (mount) {@@@ -194,188 +110,143 @@}}-- // ── Tool Tabs ────────────────────────────────────────────-- function initToolTabs() {-- // Highlight active tab-- $$('.tool-tab').forEach(tab => {-- if (tab.dataset.tool === selectedTool) tab.classList.add('active');-- else tab.classList.remove('active');---- tab.addEventListener('click', () => {-- selectedTool = tab.dataset.tool;-- $$('.tool-tab').forEach(t => t.classList.toggle('active', t.dataset.tool === selectedTool));-- showToolOptions();-- updateProcessButton();-- });++ // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up++ // front on the page that is about it, instead of letting the upload fail.++ function showUnavailableInputs() {++ if (!ctx || ctx.toolId !== 'heic') return;++ if (!ctx.inputs || ctx.inputs.heic !== false) return;++ const card = document.querySelector('.ovk-tool-card');++ if (!card || document.getElementById('input-notice') || !kit()) return;++ const note = document.createElement('div');++ note.id = 'input-notice';++ note.innerHTML = kit().notice({++ kind: 'warn',++ text: 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.',});++ card.insertBefore(note, card.firstChild);}-- // ── Upload ───────────────────────────────────────────────-- function initUpload() {-- // Drag & drop-- uploadArea.addEventListener('dragover', (e) => {-- e.preventDefault();-- uploadArea.classList.add('dragover');-- });-- uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));-- uploadArea.addEventListener('drop', (e) => {-- e.preventDefault();-- uploadArea.classList.remove('dragover');-- if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);-- });---- // Click to browse-- uploadArea.addEventListener('click', () => fileInput.click());-- browseBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });++ // ── The box: the drop zone ───────────────────────────────++ function initDrop() {++ if (!fileInput) return;fileInput.addEventListener('change', () => {-- if (fileInput.files.length) handleFile(fileInput.files[0]);++ if (fileInput.files && fileInput.files.length) handleFile(fileInput.files[0]);});---- // Clear-- clearBtn.addEventListener('click', resetUpload);++ if (clearBtn) clearBtn.addEventListener('click', resetUpload);++++ // "Try": the sample picture goes into the drop zone as if it had been chosen.++ const sampleLink = document.querySelector('[data-ovk-sample]');++ if (sampleLink && window.OVToolFrame) {++ sampleLink.addEventListener('click', (e) => {++ e.preventDefault();++ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), handleFile);++ });++ }}function handleFile(file) {++ if (!file) return;if (!file.type.startsWith('image/') && file.type !== 'image/svg+xml') {-- alert('Please select an image file.');++ fail('Please choose an image file.');return;}if (file.size > 50 * 1024 * 1024) {-- alert('File too large. Maximum 50MB.');++ fail('That file is too large. The maximum is 50 MB.');return;}selectedFile = file;-- uploadArea.style.display = 'none';-- uploadInfo.style.display = 'flex';---- // Preview-- const preview = $('#file-preview');-- const details = $('#file-details');-- const objectUrl = URL.createObjectURL(file);-- preview.innerHTML = `<img src="${objectUrl}" alt="Preview">`;-- details.innerHTML = `-- <div class="fname">${escapeHtml(file.name)}</div>-- <div class="fmeta">${file.type} • ${formatBytes(file.size)}</div>-- `;---- optionsPanel.style.display = '';-- showToolOptions();-- updateProcessButton();++ selectedTool = (ctx && ctx.defaultOp) || 'convert';++ if (optionsPanel) optionsPanel.hidden = false;++ if (processBtn) processBtn.disabled = false;++ hideAnswer();}function resetUpload() {selectedFile = null;-- fileInput.value = '';-- uploadArea.style.display = '';-- uploadInfo.style.display = 'none';-- optionsPanel.style.display = 'none';-- resultPanel.style.display = 'none';-- processingEl.style.display = 'none';-- processBtn.disabled = true;++ if (fileInput) fileInput.value = '';++ if (optionsPanel) optionsPanel.hidden = true;++ if (processBtn) processBtn.disabled = true;++ hideAnswer();}-- // ── Options ──────────────────────────────────────────────++ // ── The box: the options and the one button ─────────────function initOptions() {-- // Quality slider-- qualitySlider.addEventListener('input', () => { qualityVal.textContent = qualitySlider.value; });-- compressSlider.addEventListener('input', () => { compressVal.textContent = compressSlider.value; });---- // Format buttons-- $$('.format-btn').forEach(btn => {-- btn.addEventListener('click', () => {-- selectedFormat = btn.dataset.format;-- $$('.format-btn').forEach(b => b.classList.toggle('active', b.dataset.format === selectedFormat));-- });-- });---- // Aspect ratio buttons-- $$('.aspect-btn').forEach(btn => {-- btn.addEventListener('click', () => {-- selectedAspect = btn.dataset.aspect;-- $$('.aspect-btn').forEach(b => b.classList.toggle('active', b.dataset.aspect === selectedAspect));-- // Show/hide custom coords-- $('#crop-coords').style.display = selectedAspect ? 'none' : 'grid';-- });-- });++ // The format chips: the button follows the format that is picked ("Convert to PNG").++ $$('input[name=format]').forEach((radio) => radio.addEventListener('change', updateVerb));-- // Process button-- processBtn.addEventListener('click', processImage);++ // The quality slider: the kit shows the value; the app only reads it.++ if (processBtn) processBtn.addEventListener('click', processImage);}-- function showToolOptions() {-- // Hide all option groups-- ['opt-convert', 'opt-compress', 'opt-resize', 'opt-crop'].forEach(id => {-- const el = $(`#${id}`);-- if (el) el.style.display = 'none';-- });++ function selectedFormat() {++ const checked = document.querySelector('input[name=format]:checked');++ return checked ? checked.value : ((ctx && ctx.defaultFormat) || 'png');++ }-- // Show active tool options-- const optEl = $(`#opt-${selectedTool}`);-- if (optEl) optEl.style.display = '';++ function updateVerb() {++ if (!processBtn) return;++ const span = processBtn.querySelector('span');++ if (!span) return;++ if (selectedTool === 'compress') span.textContent = 'Compress';++ else if (selectedTool === 'resize') span.textContent = 'Resize';++ else if (selectedTool === 'crop') span.textContent = 'Crop';++ else if (ctx && ctx.toolId === 'favicon') span.textContent = 'Make favicon';++ else span.textContent = `Convert to ${selectedFormat().toUpperCase()}`;++ }-- // Pre-select format if domain-specific-- if (ctx.defaultFormat) {-- selectedFormat = ctx.defaultFormat;-- $$('.format-btn').forEach(b => b.classList.toggle('active', b.dataset.format === selectedFormat));-- }++ function numberValue(name) {++ const el = document.querySelector(`[name="${name}"]`);++ return el && el.value !== '' ? Number(el.value) : null;}-- function updateProcessButton() {-- processBtn.disabled = !selectedFile;-- const labels = { convert: 'Convert Image', compress: 'Compress Image', resize: 'Resize Image', crop: 'Crop Image' };-- processText.textContent = labels[selectedTool] || 'Process Image';++ function radioValue(name) {++ const el = document.querySelector(`input[name="${name}"]:checked`);++ return el ? el.value : '';}// ── Process ──────────────────────────────────────────────async function processImage() {if (!selectedFile) return;-- // Show processing state-- optionsPanel.style.display = 'none';-- resultPanel.style.display = 'none';-- processingEl.style.display = '';-- processBtn.disabled = true;---- const formData = new FormData();-- formData.append('file', selectedFile);-- formData.append('tool', selectedTool);---- // Add tool-specific options++ const input = { tool: selectedTool };if (selectedTool === 'convert') {-- formData.append('format', selectedFormat);-- formData.append('quality', qualitySlider.value);++ input.format = selectedFormat();++ const q = numberValue('quality');++ if (q != null) input.quality = q;} else if (selectedTool === 'compress') {-- formData.append('quality', compressSlider.value);++ const q = numberValue('quality');++ if (q != null) input.quality = q;} else if (selectedTool === 'resize') {-- const w = $('#resize-w').value;-- const h = $('#resize-h').value;-- const pct = $('#resize-pct').value;-- const fit = $('#resize-fit').value;-- if (pct) formData.append('percentage', pct);++ const pct = numberValue('percentage');++ if (pct != null) input.percentage = pct;else {-- if (w) formData.append('width', w);-- if (h) formData.append('height', h);++ const w = numberValue('width');++ const h = numberValue('height');++ if (w != null) input.width = w;++ if (h != null) input.height = h;}-- formData.append('fit', fit);++ const fit = document.querySelector('[name="fit"]');++ if (fit && fit.value) input.fit = fit.value;} else if (selectedTool === 'crop') {-- if (selectedAspect) {-- formData.append('aspect', selectedAspect);-- } else {-- const l = $('#crop-left').value;-- const t = $('#crop-top').value;-- const w = $('#crop-w').value;-- const h = $('#crop-h').value;-- if (l) formData.append('left', l);-- if (t) formData.append('top', t);-- if (w) formData.append('width', w);-- if (h) formData.append('height', h);++ const aspect = radioValue('aspect');++ if (aspect) input.aspect = aspect;++ else {++ for (const k of ['left', 'top', 'width', 'height']) {++ const v = numberValue(k);++ if (v != null) input[k] = v;++ }}}-- // Jobs: the work is accepted, followed live and survives a reload (?job=<id> in the address).-- const input = {};-- for (const [k, v] of formData.entries()) if (k !== 'file') input[k] = v;++ if (processBtn) {++ processBtn.disabled = true;++ const span = processBtn.querySelector('span');++ // The button keeps its verb while it works: "Compress…", not "Working…".++ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;++ }++ showWorking();++try {const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile] });OVJobs.remember(job.id);@@@ -391,41 +262,24 @@function processingText(job) {const pct = job.progress && job.progress.percent;-- if (job.cancel_requested) return 'Cancelling...';-- if (job.state === 'queued') return 'Waiting for a free slot...';++ if (job.cancel_requested) return 'Cancelling…';++ if (job.state === 'queued') return 'Waiting for a free slot…';const label = (job.progress && job.progress.message) || 'Processing your image';-- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;-- }---- function cancelButton() {-- let btn = processingEl.querySelector('.job-cancel');-- if (!btn) {-- btn = document.createElement('button');-- btn.type = 'button';-- btn.className = 'btn-secondary job-cancel';-- btn.textContent = 'Cancel';-- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });-- processingEl.appendChild(btn);-- }-- return btn;++ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;}function followJob(job) {currentJob = job.id;-- optionsPanel.style.display = 'none';-- resultPanel.style.display = 'none';-- processingEl.style.display = '';-- processBtn.disabled = true;-- const label = processingEl.querySelector('p');-- const update = (j) => { if (label) label.textContent = processingText(j); };-- cancelButton().style.display = '';-- update(job);++ hideAnswer();++ showWorking();if (watcher) watcher.close();watcher = OVJobs.watch(job.id, {-- onUpdate: update,++ onUpdate: (j) => {++ const out = document.querySelector('.ovk-tool-out');++ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));++ },onDone(done) {watcher = null;-- cancelButton().style.display = 'none';if (done.state === 'succeeded') return showResult(jobResult(done));OVJobs.forget();failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');@@@ -443,11 +297,19 @@};}++ function resetVerb() {++ const span = processBtn && processBtn.querySelector('span');++ if (span) span.textContent = span.textContent.replace(/…$/, '');++ }++function failProcessing(message) {-- processingEl.style.display = 'none';-- optionsPanel.style.display = selectedFile ? '' : 'none';-- processBtn.disabled = !selectedFile;-- if (message) alert(`Error: ${message}`);++ resetVerb();++ if (processBtn) {++ processBtn.disabled = !selectedFile;++ updateVerb();++ }++ if (message) fail(message);++ else hideAnswer();}/** After a reload: pick the job up again from ?job=<id> or this tab's session. */@@@ -457,89 +319,58 @@OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());}-- // ── Result ───────────────────────────────────────────────-- function initResult() {-- anotherBtn.addEventListener('click', () => {-- if (window.OVJobs) OVJobs.forget();-- resetUpload();-- toolTabs.scrollIntoView({ behavior: 'smooth' });++ // ── The answer ───────────────────────────────────────────++ function showWorking() {++ const out = document.querySelector('.ovk-tool-out');++ if (!out || !kit()) return;++ out.innerHTML = kit().answer({++ title: 'Working…',++ body: '<p data-ovk-working>Your picture is being processed on our server. This page stays open until it is done.</p>',});}-- function showResult(data) {-- processingEl.style.display = 'none';-- resultPanel.style.display = '';---- // Preview image (if it's a previewable type)-- const previewable = ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/avif'];-- if (previewable.includes(data.output.mime)) {-- const img = document.createElement('img');-- img.src = data.download.previewUrl || data.download.downloadUrl;-- img.alt = 'Result';-- resultPreview.replaceChildren(img);-- } else {-- resultPreview.innerHTML = `<div style="font-size:48px;color:var(--accent);padding:24px"><i class="fa-solid fa-file-image"></i></div>`;-- }++ function hideAnswer() {++ const out = document.querySelector('.ovk-tool-out');++ if (out) out.innerHTML = '';++ }-- // Info-- let infoHtml = `<p>Format: <strong>${data.output.ext.toUpperCase()}</strong> • Size: <strong>${data.output.sizeKB} KB</strong></p>`;-- if (data.savings) {-- const pct = data.savings.savedPercent;-- if (pct > 0) {-- infoHtml += `<p class="savings">Saved ${pct}% (${formatBytes(data.savings.savedBytes)} smaller)</p>`;-- } else {-- infoHtml += `<p>File size unchanged or slightly larger — try a lower quality.</p>`;-- }-- }-- if (data.dimensions) {-- infoHtml += `<p>${data.dimensions.original.width}x${data.dimensions.original.height} → ${data.dimensions.resized.width}x${data.dimensions.resized.height}</p>`;++ function fail(message) {++ const out = document.querySelector('.ovk-tool-out');++ if (!out || !kit()) return;++ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });++ }++++ function showResult(data) {++ resetVerb();++ if (processBtn) {++ processBtn.disabled = false;++ updateVerb();}-- if (data.crop) {-- infoHtml += `<p>Cropped to ${data.crop.width}x${data.crop.height}</p>`;++ const out = data.output || {};++ const rows = [];++ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);++ if (data.dimensions) rows.push(['Size', `${data.dimensions.original.width}×${data.dimensions.original.height} → ${data.dimensions.resized.width}×${data.dimensions.resized.height}`]);++ if (data.crop) rows.push(['Kept', `${data.crop.width}×${data.crop.height}`]);++ const expires = data.download && data.download.expiresIn;++ if (expires) {++ const mins = Math.max(1, Math.round(expires / 60000));++ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);}---- const expires = data.download.expiresIn;-- const expiresStr = expires >= 3600000 ? `${Math.round(expires / 3600000)}h` : `${Math.round(expires / 60000)}m`;-- infoHtml += `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Download expires in ${expiresStr}</p>`;---- resultInfo.innerHTML = infoHtml;---- // Download link-- downloadBtn.href = data.download.downloadUrl;-- downloadBtn.download = '';---- resultPanel.scrollIntoView({ behavior: 'smooth' });++ const html = window.OVToolFrame.fileAnswer({++ title: 'Your picture is ready',++ before: selectedFile ? selectedFile.size : null,++ after: out.size != null ? out.size : null,++ savedPercent: data.savings && data.savings.savedPercent,++ rows,++ downloadUrl: data.download && data.download.downloadUrl,++ filename: data.download && data.download.filename,++ });++ window.OVToolFrame.show(html);}-- // ── Subdomains Grid ──────────────────────────────────────-- function initSubdomains() {-- const tools = [-- { host: 'png.openvibe.tools', name: 'OpenVibePNG', icon: 'fa-file-image', desc: 'Convert to PNG' },-- { host: 'jpg.openvibe.tools', name: 'OpenVibeJPG', icon: 'fa-file-image', desc: 'Convert to JPG' },-- { host: 'webp.openvibe.tools', name: 'OpenVibeWebP', icon: 'fa-file-image', desc: 'Convert to WebP' },-- { host: 'avif.openvibe.tools', name: 'OpenVibeAVIF', icon: 'fa-file-image', desc: 'Convert to AVIF' },-- { host: 'heic.openvibe.tools', name: 'OpenVibeHEIC', icon: 'fa-file-image', desc: 'HEIC Converter' },-- { host: 'svg.openvibe.tools', name: 'OpenVibeSVG', icon: 'fa-shapes', desc: 'SVG Converter' },-- { host: 'gif.openvibe.tools', name: 'OpenVibeGIF', icon: 'fa-film', desc: 'GIF Converter' },-- { host: 'ico.openvibe.tools', name: 'OpenVibeICO', icon: 'fa-icons', desc: 'ICO Favicon Maker' },-- { host: 'tiff.openvibe.tools', name: 'OpenVibeTIFF', icon: 'fa-file-image', desc: 'Convert to TIFF' },-- { host: 'bmp.openvibe.tools', name: 'OpenVibeBMP', icon: 'fa-file-image', desc: 'Convert to BMP' },-- { host: 'compress.openvibe.tools', name: 'OpenVibeCompress', icon: 'fa-compress', desc: 'Compress Images' },-- { host: 'resize.openvibe.tools', name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center', desc: 'Resize Images' },-- { host: 'crop.openvibe.tools', name: 'OpenVibeCrop', icon: 'fa-scissors', desc: 'Crop Images' },-- { host: 'convert.openvibe.tools', name: 'OpenVibeConvert', icon: 'fa-arrows-rotate', desc: 'Format Converter' },-- { host: 'favicon.openvibe.tools', name: 'OpenVibeFavicon', icon: 'fa-icons', desc: 'Favicon Generator' },-- ];---- subdomainGrid.innerHTML = tools.map(t => `-- <a class="subdomain-card" href="https://${t.host}">-- <span class="sd-icon"><i class="fa-solid ${t.icon}"></i></span>-- <span class="sd-info">-- <span class="sd-name">${t.name}</span>-- <span class="sd-desc">${t.desc}</span>-- </span>-- </a>-- `).join('');++ // ── Result ───────────────────────────────────────────────++ function initResult() {++ // Nothing to wire: the answer is drawn in the frame's answer slot.}// ── Helpers ──────────────────────────────────────────────@@@ -548,12 +379,6 @@return match ? decodeURIComponent(match[1]) : null;}-- function formatBytes(bytes) {-- if (bytes < 1024) return bytes + ' B';-- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';-- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';-- }--function escapeHtml(str) {const div = document.createElement('div');div.textContent = str;nnew file mode 100644BBinary files /dev/null and b/apps/img/public/sample.png differnnew file mode 100644@@@ -0,0 +1,314 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the image pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// Every image host — png, jpg, compress, resize, crop and the rest — is a tool page in the shared++// frame: the head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone++// and the tool's own options), the answer (the file it made, its size before and after, and Download),++// the family, the folds and "People also use".++//++// The terms come from the app's real configuration: the file's lifetime from config.js's retention,++// its size from config.js's upload limits, and the anonymous rate from the guard's quota table and the++// tool's own cost in it (server/descriptors.js).++// ═══════════════════════════════════════════════════════════════++++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const config = require('./config');++const { DOMAIN_MAP } = require('./domain-map');++const { SPECS } = require('./descriptors');++++const HUB = 'https://img.openvibe.tools/';++const esc = kit.esc;++++// The tools, in the order the family row shows them. `op` is the operation the host runs++// (domain-map.js); `format` the one it writes by default.++const TOOLS = [++ {++ id: 'convert', host: 'convert.openvibe.tools', name: 'Image Converter', short: 'Convert', icon: 'swap', hue: 190, op: 'convert',++ task: 'Convert an image to PNG, JPG, WebP, AVIF, GIF, TIFF, BMP or ICO.',++ about: 'The image converter writes the format you pick and nothing else: the picture is decoded, re-encoded and sent back as a file you can download.',++ bullets: ['PNG keeps every pixel, JPG is small and universal, WebP and AVIF are smaller still.', 'A quality slider is there for the formats that lose a little to save bytes.'],++ faq: [['Which format should I pick?', 'PNG for a picture with text or flat colours, JPG for a photograph you want small, WebP or AVIF when the file travels far.'],++ ['Is my picture uploaded?', 'Yes, to our server, and it is deleted an hour later. Nothing is kept.'],++ ['Can I convert a HEIC photo?', 'Yes, on a server with the HEIC decoder installed. The page says so when it is not.']],++ },++ {++ id: 'compress', host: 'compress.openvibe.tools', name: 'Image Compressor', short: 'Compress', icon: 'zap', hue: 190, op: 'compress',++ task: 'Make an image smaller without changing how it looks.',++ about: 'The compressor re-encodes your picture at the quality you choose and shows the size before and after, so you can see what it saved.',++ bullets: ['A lower level means a smaller file and a softer picture.', 'The original is never touched; you get a new file.'],++ faq: [['How much smaller can it get?', 'A photograph often loses half its size; a flat picture with sharp edges loses less.'],++ ['Does it change the resolution?', 'No. The picture keeps its dimensions; only the way it is encoded changes.']],++ },++ {++ id: 'resize', host: 'resize.openvibe.tools', name: 'Image Resizer', short: 'Resize', icon: 'grid', hue: 190, op: 'resize',++ task: 'Make an image a new size, in pixels or by a percentage.',++ about: 'The resizer scales a picture to the width and height you give it, or to a percentage of what it is now.',++ bullets: ['Five ways to fit: inside, cover, contain, fill or outside.', 'A picture is never enlarged past its own size unless you ask for it.'],++ faq: [['What does "inside" mean?', 'The picture fits inside the box you typed; the other side keeps its ratio.'],++ ['Can I make it bigger?', 'Yes, but a picture scaled up is softer than the original.']],++ },++ {++ id: 'crop', host: 'crop.openvibe.tools', name: 'Image Cropper', short: 'Crop', icon: 'grid', hue: 190, op: 'crop',++ task: 'Cut an image down to the part you want to keep.',++ about: 'The cropper keeps a rectangle of your picture, or the largest centred piece with the ratio you choose.',++ bullets: ['Nine ratios, from 1:1 to 21:9.', 'Type the rectangle yourself when the ratio is not the one you want.'],++ faq: [['Which part is kept when I pick a ratio?', 'The largest centred piece with that ratio.'],++ ['Can I crop to an exact rectangle?', 'Yes. Leave the ratio on "Custom" and type the four numbers.']],++ },++ {++ id: 'favicon', host: 'favicon.openvibe.tools', name: 'Favicon Generator', short: 'Favicon', icon: 'star', hue: 190, op: 'convert', format: 'ico',++ task: 'Turn a picture into the .ico file a website asks for.',++ about: 'The favicon generator writes a multi-size ICO file from your picture, the file a browser tab and a bookmark ask a site for.',++ bullets: ['One .ico file with the sizes browsers look for.', 'A PNG of the same picture comes with it for social cards.'],++ faq: [['Where do I put the file?', 'In your site\'s root folder, next to your index.html, and you link it in the head.'],++ ['What size should the picture be?', 'A square of at least 512 pixels; the generator resizes it for you.']],++ },++ {++ id: 'png', host: 'png.openvibe.tools', name: 'PNG Converter', short: 'PNG', icon: 'image', hue: 190, op: 'convert', format: 'png',++ task: 'Convert an image to PNG, the format that keeps every pixel.',++ about: 'The PNG converter writes a lossless PNG from any picture you drop: JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF or SVG.',++ bullets: ['Lossless: the pixels that come out are the pixels that went in.', 'The background is filled with white when the picture had none.'],++ faq: [['Is a PNG bigger than a JPG?', 'Usually. PNG keeps every pixel, so it is bigger for a photograph and smaller for a logo.'],++ ['Does it keep the transparency?', 'Yes, when the format you drop it in has transparency.']],++ },++ {++ id: 'jpg', host: 'jpg.openvibe.tools', name: 'JPG Converter', short: 'JPG', icon: 'image', hue: 190, op: 'convert', format: 'jpg',++ task: 'Convert an image to JPG, the format every program reads.',++ about: 'The JPG converter writes a JPEG from any picture you drop, at the quality you pick.',++ bullets: ['The format for photographs and for anything that has to be small.', 'Transparency is filled with white.'],++ faq: [['What quality should I use?', '80 to 85 is invisible to the eye and small enough for the web.'],++ ['Can it write a progressive JPG?', 'Yes, the file loads line by line while it downloads.']],++ },++ {++ id: 'webp', host: 'webp.openvibe.tools', name: 'WebP Converter', short: 'WebP', icon: 'image', hue: 190, op: 'convert', format: 'webp',++ task: 'Convert an image to WebP, the web format that is smaller than JPG.',++ about: 'The WebP converter writes a modern web image format: smaller than JPG at the same look, and it keeps transparency.',++ bullets: ['Every current browser reads WebP.', 'Usually a third smaller than the same picture as a JPG.'],++ faq: [['Does every browser read WebP?', 'Every browser released since 2020 does.'],++ ['Is WebP lossy?', 'You choose: a quality slider, like the JPG converter.']],++ },++ {++ id: 'avif', host: 'avif.openvibe.tools', name: 'AVIF Converter', short: 'AVIF', icon: 'image', hue: 190, op: 'convert', format: 'avif',++ task: 'Convert an image to AVIF, the smallest format the web has.',++ about: 'The AVIF converter writes the modern image format browsers support: smaller than WebP at the same quality.',++ bullets: ['The smallest files of the lot, and the slowest to write.', 'Transparency, like PNG.'],++ faq: [['Is AVIF slow?', 'Yes. A big picture takes a while to encode, and the page says so while it works.'],++ ['Which browsers read AVIF?', 'Chrome, Firefox, Safari 16 and every browser since.']],++ },++ {++ id: 'heic', host: 'heic.openvibe.tools', name: 'HEIC Converter', short: 'HEIC', icon: 'image', hue: 190, op: 'convert', format: 'png',++ task: 'Convert an iPhone HEIC photo to a picture every program can open.',++ about: 'The HEIC converter reads the photos an iPhone saves and writes them as PNG or JPG.',++ bullets: ['HEIC and HEIF, the formats iPhones and some cameras use.', 'Needs a HEIC decoder on the server; the page says so when there is none.'],++ faq: [['Why can I not convert my photo?', 'The server needs the HEIC decoder. The page says so when it is missing.'],++ ['What does it write?', 'PNG by default; the format grid is there if you want JPG instead.']],++ },++ {++ id: 'svg', host: 'svg.openvibe.tools', name: 'SVG Converter', short: 'SVG', icon: 'image', hue: 190, op: 'convert', format: 'png',++ task: 'Convert an SVG drawing to a picture you can use anywhere.',++ about: 'The SVG converter rasterises a vector drawing, so you can use it as a PNG or a JPG in a program that cannot read vectors.',++ bullets: ['Vector in, picture out.', 'The width and height of the drawing decide the size of the picture.'],++ faq: [['Will the picture be sharp?', 'It is drawn at the size you ask for, so ask for the size you need.'],++ ['Can I go the other way?', 'Not here: a picture turned into a vector is a different tool.']],++ },++ {++ id: 'gif', host: 'gif.openvibe.tools', name: 'GIF Converter', short: 'GIF', icon: 'image', hue: 190, op: 'convert', format: 'gif',++ task: 'Convert an image to GIF, the format every program still reads.',++ about: 'The GIF converter writes an animated or still GIF from any picture you drop.',++ bullets: ['Good for pictures with few colours and for short animations.', 'A picture of many colours becomes bigger, not smaller.'],++ faq: [['Does it keep the animation?', 'Yes, when the file you drop is an animated picture.'],++ ['Why is my GIF so big?', 'GIF has no compression for photographs. Use JPG or WebP for those.']],++ },++ {++ id: 'ico', host: 'ico.openvibe.tools', name: 'ICO Converter', short: 'ICO', icon: 'image', hue: 190, op: 'convert', format: 'ico',++ task: 'Convert an image to ICO, the icon file a website and a program use.',++ about: 'The ICO converter writes a multi-size icon file from your picture.',++ bullets: ['The sizes Windows and browsers look for, in one file.', 'A PNG of the same picture comes with it.'],++ faq: [['What size should the picture be?', 'A square of 512 pixels or more; the sizes below it are made for you.'],++ ['Can I make a favicon from a photo?', 'Yes, but a picture with one shape in it reads better as an icon.']],++ },++ {++ id: 'tiff', host: 'tiff.openvibe.tools', name: 'TIFF Converter', short: 'TIFF', icon: 'image', hue: 190, op: 'convert', format: 'tiff',++ task: 'Convert an image to TIFF, the format printers and scanners use.',++ about: 'The TIFF converter writes an uncompressed, lossless picture that a printer or a scanner program reads.',++ bullets: ['Lossless and unshrunk.', 'Big files: a picture is not compressed here.'],++ faq: [['Why is the file so big?', 'TIFF stores the picture as it is, without compression.'],++ ['Who needs TIFF?', 'Printers, scanners and publishers.']],++ },++ {++ id: 'bmp', host: 'bmp.openvibe.tools', name: 'BMP Converter', short: 'BMP', icon: 'image', hue: 190, op: 'convert', format: 'bmp',++ task: 'Convert an image to BMP, the oldest picture format there is.',++ about: 'The BMP converter writes the uncompressed bitmap format that old programs and some tools still ask for.',++ bullets: ['No compression: the file is as big as the picture.', 'Nothing is lost on the way.'],++ faq: [['Why would I want a BMP?', 'Some old programs and device tools only read BMP.'],++ ['Is the file big?', 'Yes. It is the picture in its own bytes, with no compression.']],++ },++];++++const BY_ID = new Map(TOOLS.map((t) => [t.id, t]));++const BY_HOST = new Map(TOOLS.map((t) => [t.host, t]));++const SPEC = new Map(SPECS.map((s) => [s.id, s]));++++// The hub is a tool page too: the converter, with the whole family under it.++const HUB_TOOL = {++ id: 'hub', name: 'Img.OpenVibe', short: 'All tools', icon: 'image', hue: 190, op: 'convert', format: 'png',++ task: 'Convert an image to PNG, JPG, WebP, AVIF, GIF, TIFF, BMP or ICO — the other image tools are below.',++ about: 'Every image tool in one place: convert a picture to another format, make it smaller, change its size or cut it down.',++ faq: [['Which formats do you read?', 'PNG, JPG, WebP, AVIF, HEIC, HEIF, SVG, GIF, ICO, TIFF and BMP.'],++ ['How long are my files kept?', 'An hour for a visitor, a day for a signed-in person. Then they are gone.'],++ ['Is it free?', 'Yes. Every image tool here is free, with a fair-use limit per address so the server stays fast.']],++};++++/** The tool a host is for (the aliases jpeg and heif point at jpg and heic). */++function toolFor(host) {++ const t = BY_HOST.get(host);++ if (t) return t;++ const ctx = DOMAIN_MAP[host];++ if (!ctx) return null;++ if (ctx.toolId === 'hub') return HUB_TOOL;++ return BY_ID.get(ctx.toolId) || null;++}++++/** The format the host writes by default. */++const formatOf = (rec) => rec.format || (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat || 'png';++++/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */++function termsOf(rec) {++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);++ // The size is the app's own upload limit (config.js); the count is the tool's own descriptor.++ const maxBytes = config.upload.maxFileSize;++ const maxFiles = (spec && spec.files && spec.files.max) || 1;++ return frame.terms({++ work: 'server',++ ttlMs: config.retention.anonTTL,++ maxBytes, maxFiles,++ quotaClass: (spec && spec.quotaClass) || 'tools-job',++ cost: (spec && spec.cost) || 1,++ unit: rec.op === 'convert' ? 'conversions' : 'runs',++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ });++}++++/** The format buttons of the convert box. */++function formatChips(current) {++ const formats = [['png', 'PNG'], ['jpg', 'JPG'], ['webp', 'WebP'], ['avif', 'AVIF'], ['gif', 'GIF'], ['tiff', 'TIFF'], ['bmp', 'BMP'], ['ico', 'ICO']];++ return kit.chips({++ name: 'format', single: true, label: 'Output format',++ options: formats.map(([v, l]) => [v, l, v === current]),++ });++}++++/** A number field of the box, in the app's own field style. */++function numberField(name, label, { min = 0, max = null, placeholder = '' } = {}) {++ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`++ + `<input type="number" class="num-input num-input-sm" name="${esc(name)}" value="" placeholder="${esc(placeholder)}"${min != null ? ` min="${min}"` : ''}${max != null ? ` max="${max}"` : ''}></label>`;++}++++/** The options of the box, per operation. */++function optionsFor(rec) {++ if (rec.op === 'compress') return kit.rangeRow({ name: 'quality', label: 'Compression level', value: 75, min: 1, max: 100, step: 1 });++ if (rec.op === 'resize') {++ return numberField('width', 'Width (px)', { min: 1, max: 16384, placeholder: 'auto' })++ + numberField('height', 'Height (px)', { min: 1, max: 16384, placeholder: 'auto' })++ + numberField('percentage', 'Or scale by percentage', { min: 1, max: 1000, placeholder: 'e.g. 50' })++ + kit.selectRow({ name: 'fit', label: 'Fit mode', value: 'inside', options: [['inside', 'Inside (fit within bounds)'], ['cover', 'Cover (fill and crop)'], ['contain', 'Contain (fit with padding)'], ['fill', 'Fill (stretch)'], ['outside', 'Outside (cover minimum)']] });++ }++ if (rec.op === 'crop') {++ const ratios = [['1:1', '1:1'], ['16:9', '16:9'], ['4:3', '4:3'], ['3:2', '3:2'], ['21:9', '21:9'], ['9:16', '9:16'], ['3:4', '3:4'], ['', 'Custom']];++ return kit.chips({ name: 'aspect', single: true, label: 'Aspect ratio', options: ratios.map(([v, l]) => [v, l, v === '']) })++ + numberField('left', 'Left (px)', { min: 0, placeholder: '0' })++ + numberField('top', 'Top (px)', { min: 0, placeholder: '0' })++ + numberField('width', 'Width (px)', { min: 1, placeholder: 'auto' })++ + numberField('height', 'Height (px)', { min: 1, placeholder: 'auto' });++ }++ return formatChips(formatOf(rec)) + kit.rangeRow({ name: 'quality', label: 'Quality', value: 80, min: 1, max: 100, step: 1 });++}++++/** The one button of the page: it says the verb. */++function verbFor(rec) {++ if (rec.op === 'compress') return 'Compress';++ if (rec.op === 'resize') return 'Resize';++ if (rec.op === 'crop') return 'Crop';++ if (rec.id === 'favicon') return 'Make favicon';++ return `Convert to ${formatOf(rec).toUpperCase()}`;++}++++/**++ * The box: a drop zone for the picture, the tool's options and the one button.++ * @param {object} rec the tool record++ */++function boxFor(rec) {++ const accept = 'image/png,image/jpeg,image/webp,image/avif,image/tiff,image/bmp,image/gif,image/heic,image/heif,image/svg+xml,image/x-icon,image/vnd.microsoft.icon';++ const drop = kit.drop({++ name: 'file', accept, id: 'file-input',++ label: 'Drop an image here',++ hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP',++ });++ return `<form class="ovk-tool-form" id="ovk-img-form" enctype="multipart/form-data" data-ovk-tool-form>`++ + drop++ + `<p class="ovk-try"><span>Try</span><a href="/sample.png" data-ovk-sample="/sample.png">A sample image</a></p>`++ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`++ + `<div class="ovk-tool-buttons">${frame.verbButton(verbFor(rec), { id: 'process-btn', icon: 'zap' })}`++ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`++ + `</form>`;++}++++/** The family: every image tool, with the one on the page marked. */++function familyItems(rec) {++ // The hub is the converter itself, so the converter is the tool the family marks there.++ const current = rec.id === 'hub' ? 'convert' : rec.id;++ return TOOLS.map((t) => ({++ href: `https://${t.host}/`, label: t.name, short: t.short, current: t.id === current,++ }));++}++++/** "People also use": the tools next to this one in the family. */++function relatedFor(rec) {++ // The hub is the converter, so the converter is not "also" there.++ const self = rec.id === 'hub' ? 'convert' : rec.id;++ const at = TOOLS.findIndex((t) => t.id === self);++ const out = [];++ for (let i = 1; out.length < 3 && i < TOOLS.length + 1; i++) {++ const other = TOOLS[(at + i) % TOOLS.length];++ if (other.id !== self && other.id !== 'hub') out.push(other);++ }++ return out.map((t) => kit.row({ href: `https://${t.host}/`, title: t.name, sub: t.task }));++}++++/**++ * The whole frame for one host.++ * @param {string} host the satellite's own hostname, e.g. 'png.openvibe.tools'++ * @returns {string} HTML++ */++function frameFor(host) {++ const rec = toolFor(host);++ if (!rec) return '';++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);++ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';++ const apiId = rec.id === 'hub' ? 'convert' : rec.id;++ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(++ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : (k === 'format' ? formatOf(rec) : '')]),++ ) : {};++ return frame.page({++ id: rec.id,++ icon: rec.icon, hue: rec.hue, family: 'image',++ crumb: { href: HUB, label: 'Image tools' },++ title: rec.name, task: rec.task, facts: termsOf(rec),++ box: boxFor(rec),++ familyTitle: 'More image tools',++ familyMore: { href: HUB, label: `All ${TOOLS.length}` },++ familyItems: familyItems(rec),++ about: rec.about, bullets: rec.bullets, faq: rec.faq,++ api: {++ id: apiId, path: `/api/v1/tools/${apiId}/run`, fields, payload: { input },++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ },++ related: relatedFor(rec),++ });++}++++module.exports = { frameFor, toolFor, termsOf, boxFor, verbFor, TOOLS, HUB_TOOL, optionsFor, formatChips };@@@ -267,6 +267,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {},}));++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});++// SPA fallback — every host gets index.html stamped with its own title, canonical, social// tags and structured data (server/seo.js); the raw file carries only the hub's.const { sendIndex } = require('./seo');@@@ -15,8 +15,10 @@const fs = require('fs');const path = require('path');++const serve = require('openvibe-shared/serve');const { canonicalHostFor, ownHost } = require('../../_shared/host-role');const { DOMAIN_MAP } = require('./domain-map');++const { frameFor } = require('./frame');const PUBLIC = path.join(__dirname, '..', 'public');const HUB_HOST = 'img.openvibe.tools';@@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {`<meta name="twitter:title" content="${esc(title)}">`,`<meta name="twitter:description" content="${esc(desc)}">`,`<meta name="twitter:image" content="${image}">`,++ // The shared tool frame's look (openvibe-shared ≥ 4.57.1), from this app's own pin.++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),].join('\n');-- const out = html.replace(/<\/head>/i, `${head}\n</head>`);++ const out = html.replace(/<\/head>/i, `${head}\n</head>`)++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".++ .replace('<!--OVK:FRAME-->', frameFor(host) || '');if (_cache.size > 100) _cache.clear();_cache.set(key, out);return out;nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++// The tool frame on the image pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • every image host is a tool page in the same frame: the head, the box, the answer slot, the family,++// the folds and "People also use", in that order;++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its++// size from the upload limits, the anonymous rate from the guard's quota table — and they follow it++// when it changes;++// • the one button says the verb, never "Run";++// • the title, the canonical and the structured data stay.++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { frameFor, verbFor, TOOLS } = require('../server/frame');++const { DOMAIN_MAP } = require('../server/domain-map');++const config = require('../server/config');++const { startApp } = require('../../_shared/test/spawn');++++const HOSTS = Object.keys(DOMAIN_MAP);++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++// ── Every image host is a framed tool page, in the same order ──++{++ for (const host of HOSTS) {++ const html = frameFor(host);++ assert.ok(html, `${host}: a frame`);++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('https://img.openvibe.tools/">Image tools'), `${host}: the crumb is the family`);++ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);++ assert.ok(html.includes('data-ovk-sample'), `${host}: a sample to try`);++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);++ assert.ok(html.includes('About '), `${host}: the about fold`);++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ assert.strictEqual(TOOLS.length, 15, 'the family has fifteen image tools');++}++++// ── The terms come from the app's own configuration ──++{++ const html = frameFor('png.openvibe.tools');++ const facts = factsOf(html);++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'png: free, on our server, and the file\'s lifetime');++ assert.ok(facts.includes('One file up to 50 MB'), 'png: the upload limit');++ assert.ok(facts.includes('12 conversions a minute without an account'), 'png: the guard\'s anonymous rate');++ assert.ok(facts.includes('No sign-up'), 'png: anonymous');++++ // The lifetime follows the cleanup config.++ const saved = config.retention.anonTTL;++ try {++ config.retention.anonTTL = 30 * 60 * 1000;++ assert.ok(factsOf(frameFor('png.openvibe.tools')).includes('Runs on our server; your file is deleted after 30 minutes'), 'the lifetime follows the config');++ config.upload.maxFileSize = 10 * 1024 * 1024;++ assert.ok(factsOf(frameFor('png.openvibe.tools')).includes('One file up to 10 MB'), 'the size follows the config');++ } finally {++ config.retention.anonTTL = saved;++ config.upload.maxFileSize = 50 * 1024 * 1024;++ }++++ // A tool that costs more in the quota table gets a lower rate.++ const avif = factsOf(frameFor('avif.openvibe.tools'));++ assert.ok(avif.includes('7 conversions a minute without an account'), 'avif: its own cost in the quota table');++++ // A tool that does not convert says runs, not conversions.++ const compress = factsOf(frameFor('compress.openvibe.tools'));++ assert.ok(compress.includes('12 runs a minute without an account'), 'compress: the rate is counted in runs');++ assert.ok(!compress.some((f) => /conversion/.test(f)), 'compress: never a conversion');++}++++// ── The one button says the verb ──++{++ assert.strictEqual(verbFor({ op: 'convert', id: 'png', format: 'png' }), 'Convert to PNG');++ assert.strictEqual(verbFor({ op: 'compress', id: 'compress' }), 'Compress');++ assert.strictEqual(verbFor({ op: 'resize', id: 'resize' }), 'Resize');++ assert.strictEqual(verbFor({ op: 'crop', id: 'crop' }), 'Crop');++ for (const host of HOSTS) {++ const html = frameFor(host);++ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);++ assert.ok(m, `${host}: a verb button`);++ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);++ assert.ok(/^[A-Z]/.test(m[1]), `${host}: the button says the verb (${m[1]})`);++ }++}++++// ── The page the server sends: the frame, the title, the canonical and the structured data ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-frame-'));++ const app = await startApp('img', { DATA_DIR: tmp });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ for (const host of HOSTS) {++ const r = await get(host);++ assert.strictEqual(r.status, 200, `${host}: answers 200`);++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log(`tool frame (img): ${HOSTS.length} image pages framed, the terms from the config, the verb buttons: all checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });++@@@ -545,4 +545,33 @@ body {.header-btn { width: 30px; height: 30px; font-size: 12px; }}++++/* ── The tool frame ──────────────────────────────────────────────++ The map is in the frame's answer slot now, so the page is a normal scrolling page and the map++ takes a fixed height of the screen instead of the whole of it. */++html, body { height: auto; min-height: 100%; overflow: auto; }++.ovk-page #main-content { height: 68vh; }++.ovk-page #header { position: static; }++.ovk-maps-box .search-container { max-width: 720px; margin: 0 auto; }++.ovk-maps-box .search-box { max-width: none; }++++++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++++++/* The search box sits in the frame's box now: on a phone it wraps instead of running off the screen. */++.ovk-maps-box .search-box input { min-width: 0; flex: 1 1 120px; }++.ovk-maps-box .search-box select { max-width: 78px; }++@media (max-width: 560px) {++ .ovk-maps-box .search-box { flex-wrap: wrap; }++ .ovk-maps-box .search-box input { flex: 1 1 100%; order: -1; }++}++++++/* The page is the shared tool frame: it starts under the navbar, not behind it. */++#app { padding-top: calc(var(--ovk-nav-h, 52px) + 8px); }++.ovk-page { padding-top: 12px; }@media (prefers-reduced-motion: reduce) { .splash-content, .splash-icon, #splash-screen.fade-out, .welcome-icon, .user-marker, #detail-panel, .modal-content, .sp-item:not(.done):not(.error) { animation: none; } .search-progress .spinner { animation: none; opacity: .7; } }@@@ -35,6 +35,7 @@</script><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">++ <!-- Font Awesome 6 --><!-- Leaflet --><link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">@@@ -63,7 +64,7 @@<div id="splash-screen"><div class="splash-content"><div class="splash-icon"><i class="fa-solid fa-person-shelter"></i></div>-- <h1 class="splash-title">OPENVIBE<span class="green">MAPS</span></h1>++ <p class="splash-title">OPENVIBE<span class="green">MAPS</span></p><p class="splash-subtitle"><i class="fa-solid fa-map-location-dot"></i> Survival Map for North America</p><div class="splash-loader"><div class="loader-bar"><div class="loader-fill" id="loader-fill"></div></div>@@@ -71,7 +72,7 @@</div><div class="splash-sources"><span><i class="fa-solid fa-database"></i> RIDB</span>-- <span><i class="fa-solid fa-map"></i> OSM</span>++ <span><i class="fa-brands fa-openstreetmap"></i> OSM</span><span><i class="fa-solid fa-campground"></i> FreeCampsites</span><span><i class="fa-solid fa-globe"></i> iOverlander</span><span><i class="fa-solid fa-bridge"></i> Bridges</span>@@@ -90,7 +91,7 @@<div id="disclaimer-overlay" class="hidden"><div class="disclaimer-box"><div class="disclaimer-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>-- <h1 class="disclaimer-title">IMPORTANT DISCLAIMER</h1>++ <h2 class="disclaimer-title">IMPORTANT DISCLAIMER</h2><div class="disclaimer-body"><p><strong>READ CAREFULLY BEFORE USING MAPS.OPENVIBE</strong></p><p>Maps.OpenVibe is provided <strong>strictly for informational and educational purposes only</strong>.</p>@@@ -129,11 +130,27 @@<span class="header-tagline"><i class="fa-solid fa-map-location-dot"></i> Survival Map</span></div>-- <div class="search-container">++++ <div class="header-right">++ <button id="btn-weather" class="header-btn" title="Weather"><i class="fa-solid fa-cloud-sun"></i></button>++ <button id="btn-food" class="header-btn" title="Food Banks & Grocery"><i class="fa-solid fa-utensils"></i></button>++ <button id="btn-favorites" class="header-btn" title="Favorites"><i class="fa-solid fa-heart"></i></button>++ <button id="btn-add-spot" class="header-btn" title="Add Custom Spot"><i class="fa-solid fa-plus"></i></button>++ <button id="btn-guide" class="header-btn" title="Survival Guide"><i class="fa-solid fa-book-skull"></i></button>++ <button id="btn-stats" class="header-btn" title="Statistics"><i class="fa-solid fa-chart-pie"></i></button>++ <button id="btn-theme" class="header-btn" title="Toggle theme"><i class="fa-solid fa-moon"></i></button>++ </div>++ </header>++++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->++ <!--OVK:FRAME-->++ <!--OVK:BOX-->++ <div class="search-container"><div class="search-box"><i class="fa-solid fa-magnifying-glass search-icon"></i><input type="text" id="search-input" placeholder="Address, city, or coordinates..." autocomplete="off">-- <button id="btn-my-location" title="Use GPS"><i class="fa-solid fa-location-dot"></i></button>++ <button id="btn-my-location" title="Use GPS"><i class="fa-solid fa-crosshairs"></i></button><select id="search-radius" title="Search radius"><option value="5">5 mi</option><option value="10">10 mi</option>@@@ -144,18 +161,10 @@<button id="btn-search" class="btn-primary"><i class="fa-solid fa-person-shelter"></i><span>Search</span></button></div></div>++ <!--/OVK:BOX-->-- <div class="header-right">-- <button id="btn-weather" class="header-btn" title="Weather"><i class="fa-solid fa-cloud-sun"></i></button>-- <button id="btn-food" class="header-btn" title="Food Banks & Grocery"><i class="fa-solid fa-utensils"></i></button>-- <button id="btn-favorites" class="header-btn" title="Favorites"><i class="fa-solid fa-heart"></i></button>-- <button id="btn-add-spot" class="header-btn" title="Add Custom Spot"><i class="fa-solid fa-plus"></i></button>-- <button id="btn-guide" class="header-btn" title="Survival Guide"><i class="fa-solid fa-book"></i></button>-- <button id="btn-stats" class="header-btn" title="Statistics"><i class="fa-solid fa-chart-column"></i></button>-- <button id="btn-theme" class="header-btn" title="Toggle theme"><i class="fa-solid fa-moon"></i></button>-- </div>-- </header>++ <!--OVK:ANSWER--><!-- Filter Bar --><div id="filter-bar"><div class="filter-section">@@@ -185,7 +194,7 @@</select><button id="btn-heatmap" class="filter-tool-btn" title="Stealth heatmap"><i class="fa-solid fa-fire"></i></button><button id="btn-crime-heatmap" class="filter-tool-btn" title="Crime/sketch heatmap"><i class="fa-solid fa-skull-crossbones"></i></button>-- <button id="btn-satellite" class="filter-tool-btn" title="Satellite view"><i class="fa-solid fa-satellite-dish"></i></button>++ <button id="btn-satellite" class="filter-tool-btn" title="Satellite view"><i class="fa-solid fa-satellite"></i></button></div></div>@@@ -212,7 +221,7 @@<div class="mini-stat"><i class="fa-solid fa-database"></i> 17+ Sources</div><div class="mini-stat"><i class="fa-solid fa-map-pin"></i> 100+ Spots</div><div class="mini-stat"><i class="fa-solid fa-cloud-sun"></i> Live Weather</div>-- <div class="mini-stat"><i class="fa-solid fa-earth-americas"></i> North America</div>++ <div class="mini-stat"><i class="fa-solid fa-globe-americas"></i> North America</div></div></div><div id="results-list"></div>@@@ -222,14 +231,15 @@<div id="map-container"><div id="map"></div><div class="map-ctrl-group">-- <button id="btn-map-locate" class="map-ctrl-btn" title="My location"><i class="fa-solid fa-map-location-dot"></i></button>++ <button id="btn-map-locate" class="map-ctrl-btn" title="My location"><i class="fa-solid fa-street-view"></i></button><button id="btn-map-fullscreen" class="map-ctrl-btn" title="Fullscreen"><i class="fa-solid fa-expand"></i></button></div><button id="btn-search-area" class="search-area-btn hidden">-- <i class="fa-solid fa-magnifying-glass"></i> Search This Area++ <i class="fa-solid fa-magnifying-glass-location"></i> Search This Area</button></div></main>++ <!--/OVK:ANSWER--><!-- Footer --><footer id="footer">@@@ -276,7 +286,7 @@<button class="food-tab active" data-tab="food-banks"><i class="fa-solid fa-hand-holding-heart"></i> Food Banks</button><button class="food-tab" data-tab="stores"><i class="fa-solid fa-cart-shopping"></i> Stores</button><button class="food-tab" data-tab="meal-plan"><i class="fa-solid fa-calendar-days"></i> Meal Plan</button>-- <button class="food-tab" data-tab="food-db"><i class="fa-solid fa-utensils"></i> Food Database</button>++ <button class="food-tab" data-tab="food-db"><i class="fa-solid fa-apple-whole"></i> Food Database</button></div><div id="food-banks" class="food-panel active"></div><div id="stores" class="food-panel hidden"></div>@@@ -351,15 +361,15 @@<div id="guide-modal" class="modal hidden"><div class="modal-content modal-lg"><div class="modal-header">-- <h2><i class="fa-solid fa-book"></i> Survival Guide</h2>++ <h2><i class="fa-solid fa-book-skull"></i> Survival Guide</h2><button class="modal-close"><i class="fa-solid fa-xmark"></i></button></div><div class="modal-body"><div class="guide-tabs"><button class="guide-tab active" data-guide="stealth"><i class="fa-solid fa-eye-slash"></i> Stealth</button><button class="guide-tab" data-guide="rules"><i class="fa-solid fa-gavel"></i> Rules</button>-- <button class="guide-tab" data-guide="gear"><i class="fa-solid fa-briefcase"></i> Gear</button>-- <button class="guide-tab" data-guide="safety"><i class="fa-solid fa-shield"></i> Safety</button>++ <button class="guide-tab" data-guide="gear"><i class="fa-solid fa-backpack"></i> Gear</button>++ <button class="guide-tab" data-guide="safety"><i class="fa-solid fa-shield-heart"></i> Safety</button><button class="guide-tab" data-guide="fire"><i class="fa-solid fa-fire"></i> Fire</button><button class="guide-tab" data-guide="firstaid"><i class="fa-solid fa-kit-medical"></i> First Aid</button></div>@@@ -372,7 +382,7 @@<div id="stats-modal" class="modal hidden"><div class="modal-content modal-md"><div class="modal-header">-- <h2><i class="fa-solid fa-chart-column"></i> Statistics</h2>++ <h2><i class="fa-solid fa-chart-pie"></i> Statistics</h2><button class="modal-close"><i class="fa-solid fa-xmark"></i></button></div><div class="modal-body">@@@ -395,6 +405,10 @@<script src="/shared/footer.js"></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script>++<script src="/shared/kit.js" defer></script>++<script src="/shared/kit-web.js" defer></script>++<script src="/shared/kit-data-web.js" defer></script>++<script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script><script>(function () {@@@ -406,12 +420,15 @@let user = null;if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }if (typeof OpenVibeNavbar !== 'undefined') {-- OpenVibeNavbar.init({ service: 'maps', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Maps.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });++ // The brand is the site ("Tools"), not the subdomain's name: the same brand every framed tool++ // page carries (owner ask 11).++ OpenVibeNavbar.init({ service: 'maps', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Maps.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}', brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } });}if (typeof OpenVibeFooter !== 'undefined') {try {OpenVibeFooter.init({service: 'maps', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',++ brandName: 'OpenVibe.Tools',links: [{ heading: 'Maps.OpenVibe', items: [{ name: 'Survival map', url: 'https://maps.openvibe.tools' },{ name: 'OpenVibe.Food', url: 'https://openvibe.food' },nnew file mode 100644@@@ -0,0 +1,184 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the survival map (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// The map is one tool on one host, and it is a map first: a sticky bar, a filter bar, a sidebar of++// results and the map itself. The frame goes around it without changing what it does — the head++// (the crumb, the name, one sentence, the terms), the box (the place search), the answer (the map++// and the results), the family, the folds and "People also use".++//++// The map's own parts are marked in public/index.html and moved into the frame's box and answer++// slot here, so the page keeps its own script, its own ids and its own behaviour.++//++// The terms come from the app's real configuration and from the guard's quota table: the lookups++// happen on our server, and the rate is the one the guard holds for the tools-map class.++// ═══════════════════════════════════════════════════════════════++++const fs = require('fs');++const path = require('path');++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const { restampHead, canonicalHostFor } = require('../../_shared/host-role');++const { SPECS } = require('./descriptors');++++const esc = kit.esc;++const HOST = 'maps.openvibe.tools';++const SITE = 'https://openvibe.tools';++const FILE = path.join(__dirname, '..', 'public', 'index.html');++const SPEC = SPECS.find((s) => s.id === 'maps') || {};++++// The hubs this tool sits next to (the gateway's own families).++const HUBS = [++ { href: `${SITE}/maps-and-food`, label: 'Maps & Food', short: 'Maps' },++ { href: 'https://food.openvibe.tools', label: 'Food finder', short: 'Food' },++ { href: `${SITE}/image-tools`, label: 'Image Tools', short: 'Image' },++ { href: `${SITE}/pdf-tools`, label: 'PDF & Document Tools', short: 'PDF' },++ { href: `${SITE}/`, label: 'All tools', short: 'All' },++];++++/**++ * The terms of the survival map, from the app's real configuration and the guard's quota table:++ * the lookups happen on our server, and the rate is the one the guard holds for this class.++ */++function termsOf() {++ return frame.terms({++ work: 'server',++ serverNote: 'Runs on our server: your search is looked up there',++ quotaClass: SPEC.quotaClass || 'tools-map',++ cost: SPEC.cost || 2,++ unit: 'lookups',++ });++}++++/** The family: the hubs this tool sits next to. The page's own hub is the one marked. */++function familyItems() {++ // The family is the hubs this tool sits in: the maps and food hub holds it.++ return HUBS.map((h, i) => ({ ...h, current: i === 0 }));++}++++/** "People also use": the other hubs, as rows. */++function relatedItems() {++ return HUBS.filter((h) => h.href !== 'https://maps.openvibe.tools').slice(0, 3)++ .map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));++}++++/**++ * The whole frame for the map's page.++ * @param {string} box the page's own search box++ * @param {string} answer the page's own filter bar and map++ * @returns {string} HTML++ */++function frameFor(box, answer) {++ const title = 'Maps.OpenVibe';++ const task = 'Find free camping, dispersed campsites, shelters and other resources across North America.';++ const faq = [++ ['Is it free?', 'Yes. Thirty lookups a minute without an account.'],++ ['What does the map look up?', 'Camping, shelters, water, food banks and the weather, from public sources: OpenStreetMap, RIDB, the US Forest Service, the National Park Service and more.'],++ ['Is my search sent anywhere?', 'Only to our server, which does the lookups. Nothing is kept about you afterwards.'],++ ['Is the map safe to rely on?', 'It is informational. Places may be closed, changed or wrong, and being in the wild carries real risk.'],++ ];++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="maps">${kit.tool({++ head: frame.head({++ icon: 'map', hue: 150, family: 'places',++ crumb: { href: SITE, label: 'Tools' },++ title, task, facts: termsOf(),++ }),++ input: `<div class="ovk-maps-box">${box}</div>`,++ answer: `<div class="ovk-maps-answer">${answer}</div>`,++ family: kit.family({++ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },++ items: familyItems(), label: 'More tools',++ }),++ folds: frame.folds({++ name: title,++ about: 'The survival map puts free camping, dispersed campsites, shelters, bridges, rain cover, water, restrooms and food banks on one map of North America, with the weather where you are.',++ faq,++ api: null,++ }),++ related: kit.list(relatedItems(), { title: 'People also use' }),++ })}</div>`;++}++++/** The page's own part between two markers. */++function pick(html, re) {++ const m = re.exec(html);++ return m ? m[1] : '';++}++++let _parts = null;++++/** The page's own parts, read once: the box and the answer. */++function parts() {++ if (_parts) return _parts;++ const html = fs.readFileSync(FILE, 'utf8');++ _parts = {++ box: pick(html, /<!--OVK:BOX-->([\s\S]*?)<!--\/OVK:BOX-->/),++ answer: pick(html, /<!--OVK:ANSWER-->([\s\S]*?)<!--\/OVK:ANSWER-->/),++ page: html++ .replace(/<!--OVK:BOX-->[\s\S]*?<!--\/OVK:BOX-->/, '')++ .replace(/<!--OVK:ANSWER-->[\s\S]*?<!--\/OVK:ANSWER-->/, ''),++ };++ return _parts;++}++++let _cache = null;++++/**++ * The map's page with the frame around it, stamped for this request's canonical host.++ * @param {string} canonicalHost the host the page is about++ * @returns {string} HTML++ */++function renderIndex(canonicalHost) {++ if (_cache === null) {++ const { box, answer, page: stripped } = parts();++ _cache = stripped++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))++ .replace(/<\/head>/i, `${[++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,++ ].join('\n')}\n</head>`)++ .replace(/<\/body>/i, `${[++ '<script src="/shared/kit.js" defer></script>',++ '<script src="/shared/kit-web.js" defer></script>',++ '<script src="/shared/kit-data-web.js" defer></script>',++ '<script src="/js/tool-frame.js"></script>',++ ].join('\n')}\n</body>`);++ }++ return restampHead(_cache, HOST, canonicalHost);++}++++/**++ * The page with the frame, built fresh (no cache): the tests change the configuration and read the++ * page again.++ * @param {string} canonicalHost the host the page is about++ * @returns {string} HTML++ */++function renderFrame(canonicalHost) {++ const { box, answer, page } = parts();++ const out = page++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))++ .replace(/<\/head>/i, `${[++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,++ ].join('\n')}\n</head>`)++ .replace(/<\/body>/i, `${[++ '<script src="/shared/kit.js" defer></script>',++ '<script src="/shared/kit-web.js" defer></script>',++ '<script src="/shared/kit-data-web.js" defer></script>',++ '<script src="/js/tool-frame.js"></script>',++ ].join('\n')}\n</body>`);++ return canonicalHost ? restampHead(out, HOST, canonicalHost) : out;++}++++/** Express handler: the page with the frame, stamped for this request's host. */++function sendIndex(req, res) {++ const canonical = canonicalHostFor(req, HOST);++ res.setHeader('Content-Type', 'text/html; charset=utf-8');++ res.setHeader('Cache-Control', 'no-cache');++ res.setHeader('Vary', 'X-OV-Canonical-Host');++ return res.send(renderIndex(canonical));++}++++module.exports = { frameFor, termsOf, familyItems, relatedItems, renderIndex, renderFrame, sendIndex, HUBS, HOST };@@@ -20,7 +20,7 @@ const http = require('./egress');// ── Analytics ────────────────────────────────────────────────const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recordedconst { requireInternalAccess } = require('../../_shared/internal-token');--const { hostGuard, stampedPage } = require('../../_shared/host-role');++const { hostGuard } = require('../../_shared/host-role');const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');const { createGuard, TRUST_PROXY } = require('../../_shared/guard');@@@ -119,9 +119,17 @@ app.use(analytics.middleware());// page's canonical / og:url / JSON-LD follow it; aliases are redirected; other hosts go to the tools index.const HOST = 'maps.openvibe.tools';app.use(hostGuard({ knows: (h) => h === HOST }));--const sendIndex = stampedPage(path.join(__dirname, '..', 'public', 'index.html'), HOST);++// The page with the shared tool frame around it (openvibe-shared >= 4.57.1): the head, the box, the++// answer slot, the family, the folds and "People also use".++const { sendIndex } = require('./frame');app.get(['/', '/index.html'], sendIndex);++// The tool frame's browser glue (apps/_shared/public/tool-frame.js).++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});++// ── Static files ───────────────────────────────────────────app.use(express.static(path.join(__dirname, '..', 'public'), {maxAge: '1h',nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++// The tool frame on the survival map's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the++// folds and "People also use", in that order;++// • the terms come from the app's real configuration — the lookups happen on our server and the rate++// is the guard's quota table — and they follow it when it changes;++// • the one button says the verb, never "Run".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { renderFrame, termsOf } = require('../server/frame');++const { SPECS } = require('../server/descriptors');++const { startApp } = require('../../_shared/test/spawn');++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++// ── The page is a framed tool page, in the same order ──++{++ const html = renderFrame('maps.openvibe.tools');++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');++ assert.ok(html.includes('id="search-input"'), 'the box has the place search');++ assert.ok(html.includes('id="map-container"'), 'the answer holds the map');++ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');++ assert.ok(html.includes('id="btn-search"') && /<span>Search<\/span>/.test(html), 'the one button says the verb');++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');++}++++// ── The terms come from the app's own configuration ──++{++ const facts = factsOf(renderFrame('maps.openvibe.tools'));++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server: your search is looked up there'], 'free, and the lookups happen on our server');++ assert.ok(facts.includes('30 lookups a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');++ assert.ok(facts.includes('No sign-up'), 'no account needed');++++ // Change the configuration: the line follows it.++ const savedEnv = process.env.TOOLS_GUARD_LIMITS;++ try {++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-map': { anonymous: { perMinute: 20, burst: 10, perDay: 0 } } });++ assert.ok(factsOf(renderFrame('maps.openvibe.tools')).includes('10 lookups a minute without an account'), 'the rate follows the guard\'s table');++ } finally {++ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;++ else process.env.TOOLS_GUARD_LIMITS = savedEnv;++ }++ assert.ok(SPECS.find((s) => s.id === 'maps'), 'the map has a descriptor');++}++++// ── The page the server sends: the frame, the title, the canonical and the structured data ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-maps-frame-'));++ const app = await startApp('maps', { DATA_DIR: tmp });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ const r = await get('maps.openvibe.tools');++ assert.strictEqual(r.status, 200, 'answers 200');++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), 'the frame\'s title');++ assert.ok(r.body.includes('<link rel="canonical"'), 'a canonical');++ assert.ok(r.body.includes('/shared/kit.css?'), 'the kit\'s look');++ assert.ok(r.body.includes('/js/tool-frame.js'), 'the frame\'s behaviour');++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, 'one h1');++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('tool frame (maps): the survival map framed, the terms from the config, the button says the verb: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -896,4 +896,17 @@ textarea.large {.interconnect-banner .ic-links { margin-left: 0; }}++/* ── The shared tool frame (openvibe-shared >= 4.57.1) ── */++/* The page's own section is the box now: it does not draw a card inside the frame's card. */++.ovk-tool-card .tool-section { background: none; border: 0; padding: 0; margin: 0 0 12px; backdrop-filter: none; }++.ovk-tool-card .tool-section:last-child { margin-bottom: 0; }++.ovk-tool-out--live > #output, .ovk-tool-out--live > #result, .ovk-tool-out--live > #results { margin: 0; }++/* The brand is the site ("Tools"), not the subdomain's name. */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++++++/* The page is the shared tool frame: its head starts under the navbar, not behind it. */++.ovk-tool-head { margin-top: calc(var(--ovk-nav-h, 52px) + 12px); }@media (prefers-reduced-motion: reduce) { .live-dot, .output-flash, .toast { animation: none; } }@@@ -185,6 +185,9 @@ function initNavbar() {token,history: { type: 'tool', title: pageTitle() },silentLogin: SILENT_LOGIN,++ // The brand is the site ("Tools"), not the tool's own name: the same brand every framed++ // tool page carries (owner ask 11).++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },});} catch (e) { /* the page works without the navbar */ }if (typeof OpenVibeAccountSwitcher !== 'undefined') {@@@ -219,7 +222,7 @@ function initFooter(opts = {}) {links.push({ heading: 'Logo tools', items: LOGO_TOOLS });}try {-- OpenVibeFooter.init({ service, variant: opts.variant || 'compact', links, apiBase: NETWORK, mount: '#ov-footer' });++ OpenVibeFooter.init({ service, variant: opts.variant || 'compact', links, apiBase: NETWORK, mount: '#ov-footer', brandName: 'OpenVibe.Tools' });} catch (e) { /* optional */ }}nnew file mode 100644@@@ -0,0 +1,242 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the text and logo pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// The text app's pages are their own HTML files, one per tool (several hosts share one file). The page++// keeps its own editor and its own live output; the frame goes around them: the head (the family crumb,++// the tool's name, one sentence, the terms), the box (the page's own editor), the answer (the page's++// live output, moved into the frame's answer slot by the shared browser glue), the family, the folds++// and "People also use".++//++// The head's copy is the page's own: its title is the tool's name and its description is the one++// sentence. The terms come from the app's real configuration: these tools run in the browser, so++// nothing is sent, and the rate is the guard's own for the class they are in.++// ═══════════════════════════════════════════════════════════════++++const fs = require('fs');++const path = require('path');++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const { SPECS } = require('./descriptors');++++const HUB = 'https://text.openvibe.tools/';++const esc = kit.esc;++++// The page each tool lives in, and the family row's short names.++const PAGE_OF = {++ fancy: 'fancy.html', zalgo: 'zalgo.html', bubble: 'bubble.html', ascii: 'ascii.html', symbols: 'symbols.html',++ kaomoji: 'kaomoji.html', unicode: 'unicode.html', braille: 'braille.html', morse: 'morse.html', binary: 'binary.html',++ case: 'case.html', count: 'count.html', reversetext: 'reverse.html', clean: 'clean.html', sort: 'sort.html',++ compare: 'compare.html', json: 'json.html', markdown: 'markdown.html', escape: 'escape.html', slug: 'slug.html',++ bio: 'bio.html', nickname: 'nickname.html', logo: 'logo-hub.html', wordmark: 'wordmark.html', title: 'title.html',++ thumbnail: 'thumbnail.html', badge: 'badge.html', watermark: 'watermark.html', transparent: 'transparent.html',++ peso: 'peso.html', spanish: 'spanish.html', slang: 'slang.html', ice: 'ice.html',++};++++const SHORT = {++ fancy: 'Fancy', zalgo: 'Zalgo', bubble: 'Bubble', ascii: 'ASCII', symbols: 'Symbols', kaomoji: 'Kaomoji',++ unicode: 'Unicode', braille: 'Braille', morse: 'Morse', binary: 'Binary', case: 'Case', count: 'Count',++ reversetext: 'Reverse', clean: 'Clean', sort: 'Sort', compare: 'Compare', json: 'JSON', markdown: 'Markdown',++ escape: 'Escape', slug: 'Slug', bio: 'Bio', nickname: 'Nickname', logo: 'Logo', wordmark: 'Wordmark',++ title: 'Title', thumbnail: 'Thumbnail', badge: 'Badge', watermark: 'Watermark', transparent: 'Transparent',++ peso: 'Peso', spanish: 'Spanish', slang: 'Slang', ice: 'ICE',++};++++const ICON = {++ fancy: 'spark', zalgo: 'wave', bubble: 'spark', ascii: 'grid', symbols: 'list', kaomoji: 'star',++ unicode: 'code', braille: 'grid', morse: 'zap', binary: 'code', case: 'code', count: 'chart',++ reversetext: 'swap', clean: 'spark', sort: 'list', compare: 'swap', json: 'code', markdown: 'book',++ escape: 'lock', slug: 'link', bio: 'user', nickname: 'tag', logo: 'palette', wordmark: 'palette',++ title: 'pencil', thumbnail: 'image', badge: 'star', watermark: 'image', transparent: 'image',++ peso: 'coin', spanish: 'book', slang: 'chat', ice: 'shield',++};++++const SPEC = new Map(SPECS.map((s) => [s.id, s]));++++// The verb of each tool: the one button says what it does, never "Run".++const VERB = {'fancy': 'Make fancy text', 'zalgo': 'Add Zalgo', 'bubble': 'Make bubble text', 'ascii': 'Make ASCII art', 'symbols': 'Make symbols', 'kaomoji': 'Make kaomoji', 'unicode': 'Make Unicode', 'braille': 'Make braille', 'morse': 'Make Morse code', 'binary': 'Make binary', 'case': 'Change the case', 'count': 'Count the words', 'reversetext': 'Reverse the text', 'clean': 'Clean the text', 'sort': 'Sort the lines', 'compare': 'Compare the text', 'json': 'Format the JSON', 'markdown': 'Make Markdown', 'escape': 'Escape the text', 'slug': 'Make a slug', 'bio': 'Make a bio', 'nickname': 'Make a nickname', 'logo': 'Make a logo', 'wordmark': 'Make a wordmark', 'title': 'Make a title', 'thumbnail': 'Make a thumbnail', 'badge': 'Make a badge', 'watermark': 'Add a watermark', 'transparent': 'Make it transparent', 'peso': 'Make peso art', 'spanish': 'Add Spanish accents', 'slang': 'Make slang', 'ice': 'Make an ICE card'};++++// The plain text of a page's element, without its tags.++const textOf = (html) => String(html || '')++ .replace(/<[^>]+>/g, ' ')++ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")++ .replace(/\s+/g, ' ').trim();++++/** The first group of a match, as plain text. */++const pick = (html, re) => {++ const m = re.exec(html);++ return m ? textOf(m[1]) : '';++};++++/**++ * The terms of a text tool, from the app's real configuration: these tools run in the browser, so++ * nothing is sent; the size of the text is the tool's own limit; and the rate is the guard's own for++ * the class the tool is in (apps/_shared/guard/limits.js).++ */++function termsOf(toolId) {++ const spec = SPEC.get(toolId);++ const facts = frame.terms({++ work: 'browser',++ quotaClass: (spec && spec.quotaClass) || 'tools-run',++ cost: (spec && spec.cost) || 1,++ unit: 'runs',++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),++ });++ // A tool with no server engine is not rate limited at all: it runs on the device. A tool with an++ // engine is limited by the guard's own table, and says so that the rate is not read as the page's.++ const at = facts.findIndex((f) => f.text.includes('a minute'));++ if (at >= 0) {++ facts[at] = spec && spec.api++ ? { ...facts[at], text: `API: ${facts[at].text}` }++ : { text: 'No limit: it runs on your device', icon: 'zap' };++ }++ // The size of the text the tool takes, from the tool's own limit.++ const maxBytes = spec && spec.limits && spec.limits.maxInputBytes;++ if (maxBytes) facts.push({ text: `Up to ${Math.round(maxBytes / 1024)} KB of text`, icon: 'file' });++ return facts;++}++++/** The family: every text tool, with the one on the page marked. */++function familyItems(current, hostnameMap, nameOf) {++ const items = [];++ const seen = new Set();++ for (const [host, file] of Object.entries(hostnameMap)) {++ const toolId = TOOL_OF_FILE[file];++ if (!toolId || seen.has(toolId)) continue;++ seen.add(toolId);++ items.push({ href: `https://${host}/`, label: nameOf(toolId), short: SHORT[toolId] || nameOf(toolId), current: toolId === current });++ }++ return items;++}++++/** The tool a page file holds. */++const TOOL_OF_FILE = Object.fromEntries(Object.entries(PAGE_OF).map(([id, file]) => [file, id]));++++const PUBLIC = path.join(__dirname, '..', 'public');++let _names = null;++++/** Every tool's name and its one sentence, read from the page's own head. */++function namesOf() {++ if (_names) return _names;++ _names = new Map();++ for (const [id, file] of Object.entries(PAGE_OF)) {++ let html = '';++ try { html = fs.readFileSync(path.join(PUBLIC, file), 'utf8'); } catch { continue; }++ _names.set(id, {++ name: pick(html, /<h1[^>]*>([\s\S]*?)<\/h1>/) || id,++ desc: pick(html, /<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i),++ });++ }++ return _names;++}++++/** The "People also use" of a page: its own related links (the footer's data-related). */++function relatedOf(html) {++ const m = /data-related="([^"]*)"/.exec(html);++ if (!m) return [];++ return m[1].split(';').map((pair) => {++ const [title, href] = pair.split('|');++ if (!title || !href) return null;++ return kit.row({ href: href.trim(), title: title.trim(), sub: '' });++ }).filter(Boolean).slice(0, 4);++}++++/**++ * The frame around a text page: the head, the box, the answer slot, the family, the folds and++ * "People also use". The page's own editor and buttons stay inside the box; its live output moves++ * into the answer slot (apps/_shared/public/tool-frame.js).++ *++ * @param {string} html the page's own HTML++ * @param {string} host the host that serves it++ * @param {object} [ctx] { hostnameMap, nameOf }++ * @returns {string} the page with the frame around it++ */++function frameAround(html, host, ctx = {}) {++ const file = ctx.file;++ const toolId = TOOL_OF_FILE[file];++ if (!toolId) return html;++++ const own = namesOf().get(toolId) || { name: toolId, desc: '' };++ const name = (ctx.nameOf && ctx.nameOf(toolId)) || own.name;++ const task = (ctx.descOf && ctx.descOf(toolId)) || pick(html, /<p class="subtitle"[^>]*>([\s\S]*?)<\/p>/) || own.desc;++ const about = own.desc;++++ const head = frame.head({++ icon: ICON[toolId] || 'code', hue: 140, family: 'text',++ crumb: { href: HUB, label: 'Text tools' },++ title: name, task, facts: termsOf(toolId),++ });++++ const items = familyItems(toolId, ctx.hostnameMap || {}, ctx.nameOf || ((id) => (namesOf().get(id) || {}).name || id));++ const family = kit.family({++ title: 'More text tools', more: { href: HUB, label: 'All text tools' },++ items,++ });++ const folds = frame.folds({++ name, about,++ faq: [++ ['Is it free?', 'Yes. Every text tool here is free, and it runs in your browser.'],++ ['Does my text go anywhere?', 'No. The work happens in your browser; nothing is sent to a server.'],++ ['Can I call it from code?', 'Some of these tools have an API: POST /api/v1/tools/<tool>/run. The <a href="https://openvibe.tools/developers">API docs</a> has the list.'],++ ],++ api: {++ id: toolId, path: `/api/v1/tools/${toolId}/run`, fields: 'text',++ payload: { input: { text: 'Hello, World!' } }, needsAccount: false,++ },++ });++ // "People also use": the page's own related links, or the tools next to this one in the family.++ let related = relatedOf(html);++ if (!related.length) {++ related = items.filter((it) => !it.current).slice(0, 3)++ .map((it) => kit.row({ href: it.href, title: it.label, sub: '' }));++ }++++ // The page's own main button says the verb, the way the other tools' buttons do.++ const verb = VERB[toolId] || 'Make it';++ const withVerb = html.replace(/(<button[^>]*class="[^"]*generate-btn[^"]*"[^>]*>)([\s\S]*?)(<\/button>)/i,++ // The button's own text is the last text in it: the icon, then the word.++ (m, open, mid, close) => `${open}${mid.replace(/>([^<>]*)$/, (_, text) => `>${esc(verb)}`)}${close}`);++++ // The head replaces the page's own header (the page keeps one h1: the frame's), the box holds the++ // page's own editor and buttons, and the answer slot waits for the page's live output.++ let out = withVerb;++ // The page's own header and crumb go: the frame's head takes their place, and the page keeps one h1.++ out = out.replace(/<nav class="crumbs"[^>]*>[\s\S]*?<\/nav>\s*/i, '');++ out = out.replace(/<header class="tool-head"[^>]*>[\s\S]*?<\/header>\s*/i, '');++ out = out.replace(/<div class="page-header"[^>]*>[\s\S]*?<\/div>\s*/i, '');++ out = out.replace(/<div class="hub-hero"[^>]*>[\s\S]*?<\/div>\s*/i, '');++ // A text tool's example: a word to try it on. The page's own field takes it (the kit's try++ // fills the field and the page answers).++ const tryRow = /id="input"/.test(html)++ ? kit.tries({ name: 'text', target: '#input', items: [{ label: 'OpenVibe', value: 'OpenVibe' }] })++ : '';++ const open = `${head}\n<div class="ovk-tool-card">\n${tryRow}`;++ if (/<div class="tool-wrap"[^>]*>/i.test(out)) {++ out = out.replace(/<div class="tool-wrap"[^>]*>/i, (m) => `${m}\n${open}`);++ } else {++ out = out.replace(/<body[^>]*>/i, (m) => `${m}\n${open}`);++ }++++ const tail = `</div>\n<div class="ovk-tool-out"></div>\n${family}\n${kit.tool ? `<div class="ovk-tool-folds">${folds.join('')}</div>` : ''}\n`++ + (related.length ? `<div class="ovk-tool-related">${kit.list(related, { title: 'People also use' })}</div>` : '')++ + `\n`;++ out = out.replace(/<div id="ov-footer"/i, `${tail}<div id="ov-footer"`);++++ // The kit's look and behaviour, from this app's own pin.++ out = out.replace(/<\/head>/i, `${[++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,++ ].join('\n')}\n</head>`);++ out = out.replace(/<\/body>/i, `${[++ '<script src="/shared/kit.js" defer></script>',++ '<script src="/shared/kit-web.js" defer></script>',++ '<script src="/shared/kit-data-web.js" defer></script>',++ '<script src="/js/tool-frame.js" defer></script>',++ ].join('\n')}\n</body>`);++++ return out;++}++++module.exports = { frameAround, termsOf, familyItems, relatedOf, namesOf, PAGE_OF, TOOL_OF_FILE, SHORT, VERB, HUB };@@@ -144,6 +144,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {},}));++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every app.++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});++// ── Hostname-based routing ───────────────────────────────────// Pages are stamped with a canonical + structured data on the way out (server/seo.js). Several// hosts deliberately share one file (smallcaps/fancy, glitch/zalgo); without a canonical those@@@ -19,8 +19,10 @@const fs = require('fs');const path = require('path');const { canonicalHostFor, ownHost } = require('../../_shared/host-role');++const { frameAround } = require('./frame');const PUBLIC = path.join(__dirname, '..', 'public');++let HOSTNAME_MAP = {};const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));/** file → the first hostname that serves it; that host is the canonical one. */@@@ -38,7 +40,7 @@ const _cache = new Map();* Read a page and stamp in canonical, social tags and JSON-LD.* Falls back to the untouched file if anything goes wrong — SEO extras must never break serving.*/--function renderPage(file, primaryHost) {++function renderPage(file, primaryHost, hostnameMap) {const key = `${file}::${primaryHost}`;if (_cache.has(key)) return _cache.get(key);@@@ -89,7 +91,13 @@ function renderPage(file, primaryHost) {return !new RegExp(`(?:rel|property|name)=["']${m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(html);}).join('\n');-- const out = html.replace(/<\/head>/i, `${wanted}\n</head>`);++ let out = html.replace(/<\/head>/i, `${wanted}\n</head>`);++++ // The shared tool frame around the page (openvibe-shared >= 4.57.1): the head, the box, the++ // answer slot, the family, the folds and "People also use". The page's own editor and its live++ // output stay where they are.++ out = frameAround(out, primaryHost, { file, hostnameMap: hostnameMap || HOSTNAME_MAP });++if (_cache.size > 100) _cache.clear();_cache.set(key, out);return out;@@@ -97,6 +105,7 @@ function renderPage(file, primaryHost) {/** Express handler factory: serves the host's page with its SEO tags applied. */function pageSender(hostnameMap) {++ HOSTNAME_MAP = hostnameMap;const primary = buildPrimaryHosts(hostnameMap);const has = (h) => Object.prototype.hasOwnProperty.call(hostnameMap, h);return function sendPage(req, res) {@@@ -104,7 +113,7 @@ function pageSender(hostnameMap) {const file = (has(hostname) && hostnameMap[hostname]) || 'index.html';res.setHeader('Cache-Control', 'no-cache');res.setHeader('Vary', 'X-OV-Canonical-Host');-- const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname));++ const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname), hostnameMap);if (!rendered) return res.sendFile(path.join(PUBLIC, file));res.setHeader('Content-Type', 'text/html; charset=utf-8');res.send(rendered);nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++// The tool frame on the text pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • every text tool's page is a tool page in the same frame: the head, the box, the answer slot, the++// family, the folds and "People also use", in that order;++// • the terms come from the app's real configuration — the work happens in the browser, the size of++// the text is the tool's own limit, the rate is the guard's quota table — and they follow it when++// it changes;++// • the one button says the verb, never "Run".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { frameAround, termsOf, PAGE_OF, TOOL_OF_FILE, VERB } = require('../server/frame');++const { HOSTNAME_MAP } = require('../server/hosts');++const { SPECS } = require('../server/descriptors');++const { startApp } = require('../../_shared/test/spawn');++++/** The hosts that are a tool's own page (the hubs are their own thing). */++const TOOL_HOSTS = Object.entries(HOSTNAME_MAP).filter(([host, file]) => TOOL_OF_FILE[file]);++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++/** The page a host serves, with the frame around it. */++function pageOf(host, file) {++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', file), 'utf8');++ return frameAround(html, host, { file, hostnameMap: HOSTNAME_MAP });++}++++// ── Every text tool's page is a framed tool page, in the same order ──++{++ for (const [host, file] of TOOL_HOSTS) {++ const html = pageOf(host, file);++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('https://text.openvibe.tools/">Text tools'), `${host}: the crumb is the family`);++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ assert.ok(TOOL_HOSTS.length >= 60, `the text app has ${TOOL_HOSTS.length} tool pages`);++}++++// ── The terms come from the app's own configuration ──++{++ const spec = SPECS.find((s) => s.id === 'fancy');++ const facts = factsOf(pageOf('fancy.openvibe.tools', 'fancy.html'));++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs in your browser: nothing is sent'], 'fancy: free, and it runs in the browser');++ assert.ok(facts.includes('API: 120 runs a minute without an account'), 'fancy: the rate from the guard\'s table');++ assert.ok(facts.includes(`Up to ${Math.round(spec.limits.maxInputBytes / 1024)} KB of text`), 'fancy: the size from the tool\'s own limit');++++ // Change the configuration: the line follows it.++ const saved = spec.limits.maxInputBytes;++ try {++ spec.limits.maxInputBytes = 5 * 1024;++ assert.ok(factsOf(pageOf('fancy.openvibe.tools', 'fancy.html')).includes('Up to 5 KB of text'), 'the size follows the config');++ } finally {++ spec.limits.maxInputBytes = saved;++ }++ const savedEnv = process.env.TOOLS_GUARD_LIMITS;++ try {++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-run': { anonymous: { perMinute: 40, burst: 20, perDay: 0 } } });++ assert.ok(factsOf(pageOf('fancy.openvibe.tools', 'fancy.html')).includes('API: 40 runs a minute without an account'), 'the rate follows the guard\'s table');++ } finally {++ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;++ else process.env.TOOLS_GUARD_LIMITS = savedEnv;++ }++++ // A tool that runs on the device only is not rate limited at all.++ const logo = factsOf(pageOf('logo.openvibe.tools', 'logo-hub.html'));++ assert.ok(logo.includes('No limit: it runs on your device'), 'logo: no server, so no limit to claim');++}++++// ── The one button says the verb ──++{++ let withButton = 0;++ for (const [host, file] of TOOL_HOSTS) {++ const html = pageOf(host, file);++ const toolId = TOOL_OF_FILE[file];++ assert.ok(!/>\s*Run\s*</.test(html), `${host}: never Run`);++ const m = /<button[^>]*class="[^"]*generate-btn[^"]*"[^>]*>[\s\S]*?<i[^>]*><\/i>\s*([^<]+?)\s*<\/button>/.exec(html);++ if (!m) continue; // a live or gallery page: it has no action button to name++ withButton += 1;++ assert.strictEqual(m[1], VERB[toolId], `${host}: the button says the verb`);++ assert.notStrictEqual(m[1], 'Generate', `${host}: not a generic "Generate"`);++ }++ assert.ok(withButton >= 10, `only ${withButton} pages have an action button`);++ assert.strictEqual(VERB.escape, 'Escape the text', 'escape: the verb');++ assert.ok(/<i class="fas fa-bolt"><\/i>\s*Escape the text\s*<\/button>/.test(pageOf('escape.openvibe.tools', 'escape.html')), 'escape: the button says the verb');++ assert.ok(/<i class="fas fa-bolt"><\/i>\s*Make a slug\s*<\/button>/.test(pageOf('slug.openvibe.tools', 'slug.html')), 'slug: the button says the verb');++}++++// ── The page the server sends: the frame, the title, the canonical and the structured data ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-text-frame-'));++ const app = await startApp('text', { DATA_DIR: tmp });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ for (const host of ['escape.openvibe.tools', 'fancy.openvibe.tools', 'slug.openvibe.tools', 'logo.openvibe.tools']) {++ const r = await get(host);++ assert.strictEqual(r.status, 200, `${host}: answers 200`);++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);++ assert.ok(r.body.includes('/js/tool-frame.js'), `${host}: the frame's behaviour`);++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log(`tool frame (text): ${TOOL_HOSTS.length} text pages framed, the terms from the config, the button says the verb: all checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -413,4 +413,13 @@ a:hover { text-decoration: underline; }.footer-inner { flex-direction: column; text-align: center; }}++++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */++.openvibe-navbar .openvibe-navbar-brand .b-core,++.openvibe-navbar .openvibe-navbar-brand .b-dot,++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }++++++/* The page is the shared tool frame: it starts under the navbar, not behind it. */++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }@media (prefers-reduced-motion: reduce) { .spinner, .progress-bar.indeterminate { animation: none; opacity: .7; } }@@@ -23,83 +23,9 @@<!-- Navbar injected by OpenVibeNavbar.init() --><div id="app">-- <!-- Hero -->-- <section class="hero">-- <div class="hero-icon"><i class="fa-solid fa-circle-play"></i></div>-- <h1>YT.OpenVibe</h1>-- <p class="hero-subtitle">YouTube Downloader</p>-- </section>---- <!-- URL Input -->-- <section class="url-section">-- <div class="url-input-wrap">-- <i class="fa-solid fa-link url-icon"></i>-- <input type="url" id="url-input" class="url-input" placeholder="Paste YouTube URL here..." autocomplete="off">-- <button class="btn btn-primary" id="fetch-btn"><i class="fa-solid fa-magnifying-glass"></i> <span class="btn-label">Fetch</span></button>-- </div>-- <p class="url-hint">Supports youtube.com and youtu.be links</p>-- </section>---- <!-- Loading -->-- <section class="loading" id="loading" style="display:none">-- <div class="spinner"></div>-- <p>Fetching video info...</p>-- </section>---- <!-- Video Info -->-- <section class="video-card" id="video-card" style="display:none">-- <div class="video-thumb" id="video-thumb"></div>-- <div class="video-info" id="video-info"></div>-- </section>---- <!-- Format Selection -->-- <section class="format-section" id="format-section" style="display:none">-- <h3>Choose Format</h3>-- <div class="format-groups">-- <div class="format-group">-- <h4><i class="fa-solid fa-video"></i> Video</h4>-- <div class="format-list" id="video-formats"></div>-- </div>-- <div class="format-group">-- <h4><i class="fa-solid fa-music"></i> Audio Only</h4>-- <div class="format-list" id="audio-formats"></div>-- </div>-- </div>-- <button class="btn btn-primary btn-lg" id="download-btn">-- <i class="fa-solid fa-download"></i> Download-- </button>-- </section>---- <!-- Download Progress -->-- <section class="progress-section" id="progress-section" style="display:none">-- <h3 id="progress-title">Downloading...</h3>-- <div class="progress-bar-wrap">-- <div class="progress-bar" id="progress-bar"></div>-- </div>-- <div class="progress-stats" id="progress-stats"></div>-- </section>---- <!-- Download Ready -->-- <section class="done-section" id="done-section" style="display:none">-- <div class="done-icon"><i class="fa-solid fa-circle-check"></i></div>-- <h3>Ready!</h3>-- <p id="done-info"></p>-- <div class="done-actions">-- <a class="btn btn-primary btn-lg" id="save-btn" href="#" download>-- <i class="fa-solid fa-download"></i> Save File-- </a>-- <button class="btn btn-secondary" id="new-btn">-- <i class="fa-solid fa-rotate-left"></i> Download Another-- </button>-- </div>-- </section>---- <!-- Error -->-- <section class="error-section" id="error-section" style="display:none">-- <div class="error-icon"><i class="fa-solid fa-circle-exclamation"></i></div>-- <p id="error-msg"></p>-- <button class="btn btn-secondary" id="retry-btn"><i class="fa-solid fa-rotate-left"></i> Try Again</button>-- </section>++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->++ <!--OVK:FRAME--></div><!-- Footer -->@@@ -114,7 +40,11 @@<script src="/shared/ov-icons.js" defer></script><script src="/shared/island.js" defer></script><script src="/shared/ui.js" defer></script>++ <script src="/shared/kit.js" defer></script>++ <script src="/shared/kit-web.js" defer></script>++ <script src="/shared/kit-data-web.js" defer></script><script src="/js/download-state.js"></script>++ <script src="/js/tool-frame.js" defer></script><script src="/js/app.js"></script></body></html>@@@ -393,12 +393,16 @@apiBase: 'https://openvibe.network',history: { type: 'tool', title: 'YT.OpenVibe' },silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',++ // The brand is the site ("Tools"), not the subdomain's name: the same brand every++ // framed tool page carries (owner ask 11).++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },});}if (typeof OpenVibeFooter !== 'undefined') {try {OpenVibeFooter.init({service: 'yt', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',++ brandName: 'OpenVibe.Tools',links: [{ heading: 'YT.OpenVibe', items: [{ name: 'Video downloader', url: 'https://yt.openvibe.tools' },{ name: 'Audio converter', url: 'https://audio.openvibe.tools' },nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// The tool frame for the YouTube downloader (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).++//++// The downloader is one tool on two hosts (yt.openvibe.tools and youtube-downloader.openvibe.tools),++// so its page is a tool page in the shared frame: the head (the crumb, the name, one sentence, the++// terms), the box (the link field and the app's own steps), the answer (the file it made and Download),++// the family, the folds and "People also use".++//++// The page around the tool changes; the download itself does not. The app's own sections keep their++// ids, so its script still finds them.++//++// The terms come from the app's real configuration: the file's lifetime from the cleanup config++// (config.js retention), its size and length from the download limits, and the rate from the guard's++// quota table and the tool's own cost in it.++// ═══════════════════════════════════════════════════════════════++++const kit = require('openvibe-shared/kit');++const serve = require('openvibe-shared/serve');++const frame = require('../../_shared/frame')({ kit, serve });++const config = require('./config');++const { SPECS } = require('./descriptors');++++const esc = kit.esc;++const SPEC = SPECS.find((s) => s.id === 'yt') || {};++const SITE = 'https://openvibe.tools';++++// The hubs the other tools live in (the gateway's own families).++const HUBS = [++ { href: `${SITE}/video-tools`, label: 'Video Tools', short: 'Video' },++ { href: `${SITE}/audio-tools`, label: 'Audio Tools', short: 'Audio' },++ { href: `${SITE}/image-tools`, label: 'Image Tools', short: 'Image' },++ { href: `${SITE}/pdf-tools`, label: 'PDF & Document Tools', short: 'PDF' },++ { href: `${SITE}/text-tools`, label: 'Text & Logo Tools', short: 'Text' },++];++++/** The length of a video in the words a person reads: "3 hours", "12 minutes". */++function lengthOf(seconds) {++ const s = Number(seconds) || 0;++ if (s >= 3600) return `${Math.round(s / 3600)} hour${Math.round(s / 3600) === 1 ? '' : 's'}`;++ if (s >= 60) return `${Math.round(s / 60)} minute${Math.round(s / 60) === 1 ? '' : 's'}`;++ return `${s} second${s === 1 ? '' : 's'}`;++}++++/**++ * The terms of the downloader, from the app's real configuration and the guard's quota table.++ * @returns {Array<{text: string, icon: string}>}++ */++function termsOf() {++ const facts = frame.terms({++ work: 'server',++ ttlMs: config.retention.fileTTL,++ maxBytes: config.download.maxFilesize * 1024 * 1024,++ maxFiles: 1,++ quotaClass: SPEC.quotaClass || 'tools-download',++ cost: SPEC.cost || 50,++ unit: 'downloads',++ });++ // The length of the video is the download limit the app enforces.++ const at = facts.findIndex((f) => f.text.includes('up to'));++ const limit = { text: `Up to ${lengthOf(config.download.maxDuration)} long`, icon: 'clock' };++ if (at >= 0) facts.splice(at + 1, 0, limit);++ else facts.push(limit);++ return facts;++}++++/** The family: the tool hubs this tool sits next to. */++function familyItems() {++ return HUBS.map((h, i) => ({ ...h, current: i === 0 }));++}++++/** "People also use": the hubs, as rows. */++function relatedItems() {++ return HUBS.slice(1, 4).map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));++}++++/**++ * The box: the link field, the one button, and the app's own steps (the video card, the formats, the++ * progress and the error). The ids are the app's, so its script keeps working.++ */++function boxFor() {++ return `<form class="ovk-tool-form" id="ovk-yt-form" onsubmit="return false">++ <label class="ovk-tool-label" for="url-input">Video link</label>++ <div class="ovk-tool-row">++ <span class="ovk-tool-field"><input id="url-input" name="url" type="url" placeholder="Paste a YouTube link here…" autocomplete="off" spellcheck="false"></span>++ ${frame.verbButton('Fetch the video', { id: 'fetch-btn', icon: 'search' })}++ </div>++ <p class="ovk-try"><span>Try</span><a href="?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Daqz-KE-bpKQ" data-ovk-try="https://www.youtube.com/watch?v=aqz-KE-bpKQ" data-ovk-name="url" data-ovk-for="#url-input">Big Buck Bunny</a></p>++ <section class="loading" id="loading" style="display:none"><div class="spinner"></div><p>Fetching video info…</p></section>++ <section class="video-card" id="video-card" style="display:none">++ <div class="video-thumb" id="video-thumb"></div>++ <div class="video-info" id="video-info"></div>++ </section>++ <section class="format-section" id="format-section" style="display:none">++ <h3>Choose a format</h3>++ <div class="format-groups">++ <div class="format-group">++ <h4><i class="fa-solid fa-video"></i> Video</h4>++ <div class="format-list" id="video-formats"></div>++ </div>++ <div class="format-group">++ <h4><i class="fa-solid fa-music"></i> Audio only</h4>++ <div class="format-list" id="audio-formats"></div>++ </div>++ </div>++ <div class="ovk-tool-buttons">${frame.verbButton('Start the download', { id: 'download-btn', icon: 'download' })}</div>++ </section>++ <section class="progress-section" id="progress-section" style="display:none">++ <h3 id="progress-title">Downloading…</h3>++ <div class="progress-bar-wrap"><div class="progress-bar" id="progress-bar"></div></div>++ <div class="progress-stats" id="progress-stats"></div>++ </section>++ <section class="error-section" id="error-section" style="display:none">++ <div class="error-icon"><i class="fa-solid fa-circle-exclamation"></i></div>++ <p id="error-msg"></p>++ <button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="retry-btn"><i class="fa-solid fa-arrow-rotate-left"></i> <span>Try again</span></button>++ </section>++ </form>`;++}++++/** The answer: the file it made, its size, and Download. */++function answerFor() {++ return `<section class="ovk-answer" id="done-section" style="display:none" aria-live="polite">++ <div class="ovk-answer-head">++ <h2>Your file is ready</h2>++ <span class="ovk-answer-acts"><a class="ovk-btn ovk-btn--primary" id="save-btn" href="#" download>${kit.icon('download')}<span>Download</span></a></span>++ </div>++ <dl class="ovk-answer-rows"><div><dt>File</dt><dd id="done-info">Your file is ready</dd></div></dl>++ <div class="ovk-answer-body"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="new-btn"><i class="fa-solid fa-arrow-rotate-left"></i> <span>Download another</span></button></div>++ </section>`;++}++++/**++ * The whole frame for the downloader's page.++ * @param {string} [host] the host that serves it++ * @returns {string} HTML++ */++function frameFor(host) {++ const title = 'YouTube Downloader';++ const task = 'Save a YouTube video as MP4 or WebM, or keep just the audio as MP3.';++ const faq = [++ ['Is it free?', 'Yes. A download a minute without an account, and more when you sign in.'],++ ['How long is my file kept?', 'About an hour, so you can save it. Then it is deleted.'],++ ['How big can the video be?', `Up to ${frame.size(config.download.maxFilesize * 1024 * 1024)} and ${lengthOf(config.download.maxDuration)} long.`],++ ['Can I download any video?', 'Your own uploads, public-domain works and videos whose licence allows copies. You are responsible for having the right to save what you download.'],++ ];++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="yt">${kit.tool({++ head: frame.head({++ icon: 'play', hue: 355, family: 'media',++ crumb: { href: SITE, label: 'Tools' },++ title, task, facts: termsOf(),++ }),++ input: boxFor(),++ answer: answerFor(),++ family: kit.family({++ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },++ items: familyItems(), label: 'More tools',++ }),++ folds: frame.folds({++ name: title,++ about: 'The downloader fetches one video with yt-dlp on our server and hands you the file as MP4, WebM, MP3, M4A, Opus or FLAC. The file is kept for about an hour and then deleted.',++ faq,++ api: null,++ }),++ related: kit.list(relatedItems(), { title: 'People also use' }),++ })}</div>`;++}++++module.exports = { frameFor, termsOf, boxFor, answerFor, familyItems, relatedItems, HUBS, lengthOf };@@@ -310,6 +310,12 @@ app.get(['/', '/index.html'], (req, res) => {if (req.path !== '/') return res.redirect(301, '/');return seo.sendIndex(req, res);});++++// The tool frame's browser glue (apps/_shared/public/tool-frame.js).++app.get('/js/tool-frame.js', (_req, res) => {++ res.set('Cache-Control', 'no-cache');++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));++});app.use((req, res) => {if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });res.status(404).set('Cache-Control', 'no-cache').type('html')@@@ -13,6 +13,8 @@const fs = require('fs');const path = require('path');const { canonicalHostFor, requestHost, isLocalHost } = require('../../_shared/host-role');++const serve = require('openvibe-shared/serve');++const { frameFor } = require('./frame');const PUBLIC = path.join(__dirname, '..', 'public');@@@ -107,6 +109,9 @@ function headTags(canonicalHost) {`<meta name="twitter:title" content="${esc(TITLE)}">`,`<meta name="twitter:description" content="${esc(DESCRIPTION)}">`,`<meta name="twitter:image" content="${image}">`,++ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,...ld.map(ldScript),].join('\n ');}@@@ -129,7 +134,11 @@ function baseHtml() {function renderIndex(canonicalHost) {if (process.env.NODE_ENV !== 'development' && _cache.has(canonicalHost)) return _cache.get(canonicalHost);-- const out = baseHtml().replace('<!--OV:HEAD-->', headTags(canonicalHost)).replace('<!--OV:CONTENT-->', contentBlock());++ const out = baseHtml()++ .replace('<!--OV:HEAD-->', headTags(canonicalHost))++ .replace('<!--OV:CONTENT-->', contentBlock())++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".++ .replace('<!--OVK:FRAME-->', frameFor(canonicalHost));if (_cache.size > 50) _cache.clear();_cache.set(canonicalHost, out);return out;nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++// The tool frame on the YouTube downloader's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):++// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the++// folds and "People also use", in that order;++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config,++// its size and the video's length from the download limits, the rate from the guard's quota table —++// and they follow it when it changes;++// • the one button says the verb, never "Run".++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { frameFor, termsOf } = require('../server/frame');++const config = require('../server/config');++const { startApp } = require('../../_shared/test/spawn');++++/** The frame's order: each marker appears once, and in this order. */++function inOrder(html, parts) {++ let at = -1;++ for (const p of parts) {++ const i = html.indexOf(p, at + 1);++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);++ at = i;++ }++}++++/** The head's terms, as text. */++function factsOf(html) {++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);++ if (!m) return [];++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);++}++++// ── The page is a framed tool page, in the same order ──++{++ const html = frameFor('yt.openvibe.tools');++ inOrder(html, [++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',++ ]);++ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');++ assert.ok(html.includes('id="url-input"'), 'the box has the link field');++ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');++ assert.ok(html.includes('id="save-btn"') && html.includes('<span>Download</span>'), 'the answer downloads the file');++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');++}++++// ── The terms come from the app's own configuration ──++{++ const facts = factsOf(frameFor('yt.openvibe.tools'));++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'free, on our server, and the file\'s lifetime');++ assert.ok(facts.includes('One file up to 2 GB'), 'the size from the download limits');++ assert.ok(facts.includes('Up to 3 hours long'), 'the length from the download limits');++ assert.ok(facts.includes('1 download a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');++++ const savedTtl = config.retention.fileTTL;++ const savedSize = config.download.maxFilesize;++ const savedDuration = config.download.maxDuration;++ try {++ config.retention.fileTTL = 30 * 60 * 1000;++ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('Runs on our server; your file is deleted after 30 minutes'), 'the lifetime follows the config');++ config.download.maxFilesize = 512;++ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('One file up to 512 MB'), 'the size follows the config');++ config.download.maxDuration = 600;++ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('Up to 10 minutes long'), 'the length follows the config');++ } finally {++ config.retention.fileTTL = savedTtl;++ config.download.maxFilesize = savedSize;++ config.download.maxDuration = savedDuration;++ }++}++++// ── The one button says the verb ──++{++ const html = frameFor('yt.openvibe.tools');++ const buttons = [...html.matchAll(/<button[^>]*>([\s\S]*?)<\/button>/g)]++ .map((m) => m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<[^>]+>/g, '').trim())++ .filter(Boolean);++ assert.ok(buttons.includes('Fetch the video'), 'the box button says the verb');++ assert.ok(buttons.includes('Start the download'), 'the download button says the verb');++ assert.ok(!buttons.includes('Run'), 'never Run');++ assert.ok(/<span>Download<\/span>/.test(html), 'the answer downloads the file');++}++++// ── The page the server sends: the frame, the title, the canonical and the structured data ──++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-yt-frame-'));++ const app = await startApp('yt', { DATA_DIR: tmp });++ const get = (host) => new Promise((resolve) => {++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {++ let body = '';++ res.setEncoding('utf8');++ res.on('data', (d) => { body += d; });++ res.on('end', () => resolve({ status: res.statusCode, body }));++ });++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });++ req.on('error', () => resolve({ status: 0, body: '' }));++ });++ try {++ for (const host of ['yt.openvibe.tools', 'youtube-downloader.openvibe.tools']) {++ const r = await get(host);++ assert.strictEqual(r.status, 200, `${host}: answers 200`);++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);++ assert.ok(r.body.includes('/js/tool-frame.js'), `${host}: the frame's behaviour`);++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);++ }++ } finally {++ await app.kill();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('tool frame (yt): the downloader page framed, the terms from the config, the buttons say the verb: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,229 @@
@@ -0,0 +1,229 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the satellites (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// The network and code tools on openvibe.tools got the shared frame in #155. The satellites that draw
+// their own pages — img, text, audio, docs, maps, yt, food — get the same frame here: the head (crumb,
+// name, one sentence, terms), the box, the answer, the family, the folds and "People also use".
+//
+// The markup is openvibe-shared/kit's; this module only chooses the copy and the links. It takes the
+// kit and the server from the app that requires it (apps/_shared has no dependencies of its own).
+//
+// The terms come from the app's real configuration and from nowhere else:
+// • where the work happens, and how long the file is kept, from the app's own cleanup config;
+// • the file's size and count limits, from the app's upload limits;
+// • the anonymous rate, from the guard's quota table (apps/_shared/guard/limits.js) and the tool's
+// own cost in it.
+// Nothing here promises a limit the configuration does not have.
+// ═══════════════════════════════════════════════════════════════
+
+const { quotas } = require('./guard/limits');
+
+const SITE = 'https://openvibe.tools';
+
+/**
+ * The frame's builders, for one app.
+ * @param {object} deps { kit, serve } — openvibe-shared/kit and openvibe-shared/serve
+ */
+module.exports = function createFrame({ kit, serve }) {
+ const esc = kit.esc;
+
+ /** A file size in the words a person reads: "50 MB", "1.5 GB". */
+ function size(bytes) {
+ const b = Number(bytes) || 0;
+ if (b >= 1024 * 1024 * 1024) return `${Math.round(b / (1024 * 1024 * 1024))} GB`;
+ if (b >= 1024 * 1024) return `${Math.round(b / (1024 * 1024))} MB`;
+ return `${Math.round(b / 1024)} KB`;
+ }
+
+ /**
+ * The terms of a tool, from its real configuration.
+ * @param {object} s
+ * work 'server' (the file goes to our server) or 'browser' (nothing leaves the page)
+ * ttlMs how long the server keeps the file (the app's cleanup config)
+ * maxBytes the largest file the app takes (the app's upload limits)
+ * maxFiles how many files at once (the tool's own descriptor)
+ * quotaClass the guard's quota class for the tool
+ * cost what one run costs in quota units (the tool's own descriptor)
+ * unit what one run is called: 'conversions', 'downloads', 'lookups'
+ * needsAccount the tool refuses anonymous callers
+ * @returns {Array<{text: string, icon: string}>} the head's terms
+ */
+ function terms(s) {
+ const facts = [{ text: 'Free', icon: 'gift' }];
+ if (s.work === 'server') {
+ if (s.ttlMs) {
+ const minutes = Math.max(1, Math.round((Number(s.ttlMs) || 0) / 60000));
+ facts.push({ text: `Runs on our server; your file is deleted after ${minutes} minutes`, icon: 'server' });
+ } else {
+ // A tool that keeps no file: the app says where its work happens, in its own words.
+ facts.push({ text: s.serverNote || 'Runs on our server', icon: 'server' });
+ }
+ } else {
+ facts.push({ text: 'Runs in your browser: nothing is sent', icon: 'computer' });
+ }
+ if (s.maxBytes) {
+ facts.push({
+ text: s.maxFiles && s.maxFiles > 1
+ ? `Up to ${s.maxFiles} files, ${size(s.maxBytes)} each`
+ : `One file up to ${size(s.maxBytes)}`,
+ icon: 'file',
+ });
+ }
+ const q = quotas()[s.quotaClass];
+ // One run is "1 download", two are "2 downloads": the unit follows the number.
+ const unit = (n) => {
+ const u = s.unit || 'runs';
+ return n === 1 ? u.replace(/s$/, '') : u;
+ };
+ if (s.needsAccount) {
+ // The tool refuses anonymous callers: the page still works, because the browser gets a
+ // session, and that session's rate is the one the guard's table holds.
+ const tier = q && q.session ? q.session : null;
+ if (tier && tier.perMinute) {
+ const n = Math.max(1, Math.floor(tier.perMinute / (s.cost || 1)));
+ facts.push({ text: `${n} ${unit(n)} a minute in a browser session`, icon: 'clock' });
+ }
+ facts.push({ text: 'A browser session: no account needed', icon: 'user' });
+ } else {
+ if (q && q.anonymous && q.anonymous.perMinute) {
+ const n = Math.max(1, Math.floor(q.anonymous.perMinute / (s.cost || 1)));
+ facts.push({ text: `${n} ${unit(n)} a minute without an account`, icon: 'clock' });
+ }
+ facts.push({ text: 'No sign-up', icon: 'user' });
+ }
+ return facts;
+ }
+
+ /** A field's label, from its name: "sampleRate" is "Sample rate". */
+ function labelOf(name, labels = {}) {
+ if (labels[name]) return labels[name];
+ const words = String(name).replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
+ return words.charAt(0).toUpperCase() + words.slice(1);
+ }
+
+ /** An enum's value, in the words a person reads: "bass_boost" is "Bass boost". */
+ function humanValue(value) {
+ const s = String(value).replace(/[_-]+/g, ' ').trim();
+ return s.charAt(0).toUpperCase() + s.slice(1);
+ }
+
+ /**
+ * One field of a box, from a tool's own descriptor: its type, its range and its default. A field
+ * the descriptor does not have is not on the page, and the field reads what the tool reads.
+ * @param {string} name the field's name
+ * @param {object} s the descriptor's field
+ * @param {object} [labels] the field's name in the words a person reads
+ * @param {object} [values] the field's values in the words a person reads
+ * @returns {string} HTML
+ */
+ function field(name, s, labels = {}, values = {}) {
+ const label = labelOf(name, labels);
+ if (s.enum) {
+ const value = s.default !== undefined ? s.default : s.enum[0];
+ return kit.selectRow({ name, label, value: String(value), options: s.enum.map((v) => [String(v), values[String(v)] || humanValue(v)]) });
+ }
+ if (s.type === 'boolean') {
+ return kit.selectRow({ name, label, value: String(s.default === true), options: [['true', 'Yes'], ['false', 'No']] });
+ }
+ const min = s.minimum != null ? ` min="${esc(s.minimum)}"` : '';
+ const max = s.maximum != null ? ` max="${esc(s.maximum)}"` : '';
+ const step = s.type === 'integer' ? ' step="1"' : '';
+ if (s.type === 'integer' || s.type === 'number') {
+ const value = s.default !== undefined ? ` value="${esc(s.default)}"` : ' value=""';
+ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`
+ + `<input type="number" class="num-input num-input-sm" name="${esc(name)}"${value}${min}${max}${step}></label>`;
+ }
+ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`
+ + `<input type="text" class="num-input num-input-sm" name="${esc(name)}" value=""${s.maxLength ? ` maxlength="${esc(s.maxLength)}"` : ''}></label>`;
+ }
+
+ /**
+ * The tool's head: the crumb, the name, the one sentence and the terms.
+ * @param {object} rec { icon, hue, family, crumb: { href, label }, title, task, facts }
+ */
+ function head(rec) {
+ return kit.toolHead({
+ icon: rec.icon || 'grid', hue: rec.hue, family: rec.family || '',
+ crumb: rec.crumb || null, title: rec.title || '', task: rec.task || '', facts: rec.facts || [],
+ });
+ }
+
+ /** The one button of a tool page: it says the verb, never "Run". */
+ function verbButton(label, { id = '', icon = null, disabled = false, extra = '' } = {}) {
+ return `<button type="button"${id ? ` id="${esc(id)}"` : ''} class="ovk-btn ovk-btn--primary ovk-tool-go"${disabled ? ' disabled' : ''}${extra}>`
+ + `${icon ? kit.icon(icon) : ''}<span>${esc(label)}</span></button>`;
+ }
+
+ /**
+ * The folds: About the tool, Questions, and the real call from code.
+ * @param {object} rec { name, about, bullets, faq: [[q, a]], api: { path, fields, needsAccount, capability, input } }
+ */
+ function folds(rec) {
+ const out = [];
+ const name = rec.name || rec.title || 'this tool';
+ if (rec.about) {
+ const bullets = (rec.bullets || []).length ? `<ul>${rec.bullets.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>` : '';
+ out.push(kit.fold({ id: 'about-this-tool', title: `About ${name}`, body: `<p>${esc(rec.about)}</p>${bullets}` }));
+ }
+ if ((rec.faq || []).length) {
+ const body = rec.faq.map(([q, a]) => `<p><strong>${esc(q)}</strong></p><p>${esc(a)}</p>`).join('');
+ out.push(kit.fold({ title: 'Questions', meta: String(rec.faq.length), body }));
+ }
+ if (rec.api) {
+ const a = rec.api;
+ const lines = [`curl -X POST ${SITE}${a.path}`];
+ if (a.needsAccount) lines.push(`-H 'Authorization: Bearer $OPENVIBE_TOKEN'`);
+ lines.push(`-H 'Content-Type: application/json'`, `-d ${shq(JSON.stringify(a.payload || {}))}`);
+ const who = a.needsAccount
+ ? 'Needs a signed-in browser session or a token with <code>tools.tool.run</code>.'
+ : 'No account needed within the free limit; a token with <code>tools.tool.run</code> raises it.';
+ out.push(kit.fold({
+ title: 'Use it from code', meta: 'API',
+ body: `<p>Input: <code>${esc(a.fields || 'none')}</code>. ${who}</p>${kit.snippet({ code: lines.join(' \\\n ') })}`
+ + `<p><a href="${SITE}/api/v1/tools/${esc(a.id)}/schema">JSON Schema</a> · <a href="${SITE}/api/v1/openapi.json">OpenAPI</a> · <a href="${SITE}/developers">API docs</a></p>`,
+ }));
+ }
+ return out.filter(Boolean);
+ }
+
+ // curl quoting for a value with single quotes (the same helper the gateway's frame uses).
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
+
+ /**
+ * The whole frame for one tool page.
+ * @param {object} rec {
+ * id, icon, hue, family, crumb, title, task, facts, → the head
+ * box, → the card's markup
+ * familyItems, familyMore, familyTitle, → the family row
+ * about, bullets, faq, api, → the folds
+ * related: [{ href, title, sub }] → "People also use"
+ * }
+ * @returns {string} HTML
+ */
+ function page(rec) {
+ const related = (rec.related || []).filter(Boolean);
+ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="${esc(rec.id || '')}">${kit.tool({
+ head: head(rec),
+ input: rec.box || '',
+ answer: '',
+ family: kit.family({
+ title: rec.familyTitle || '', more: rec.familyMore || null,
+ items: rec.familyItems || [], label: rec.familyLabel || 'More tools',
+ }),
+ folds: folds(rec),
+ related: related.length ? kit.list(related, { title: 'People also use' }) : '',
+ })}</div>`;
+ }
+
+ /** The browser files a framed page loads (the kit and its behaviour, from this app's own pin). */
+ function assets() {
+ return {
+ css: [serve.url('kit.css'), serve.url('kit-data.css')],
+ js: [serve.url('kit.js'), serve.url('kit-web.js'), serve.url('kit-data-web.js')],
+ };
+ }
+
+ return { terms, field, labelOf, head, verbButton, folds, page, assets, size, SITE };
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+/* The tool frame's browser glue (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+ *
+ * The satellite apps keep their own upload, processing and download code; this only fills the frame:
+ * the answer a file tool draws in the frame's answer slot, and the sample a "Try" link drops into the
+ * drop zone. The markup comes from openvibe-shared/kit, the same builders the server used.
+ *
+ * Served by each app at /js/tool-frame.js (the file lives in apps/_shared/public, so the three file
+ * apps share one copy instead of three).
+ */
+(function () {
+ 'use strict';
+
+ var kit = window.OpenVibeKit;
+ if (!kit) return;
+
+ /** A file size in the words a person reads: "1.4 MB", "812 KB". */
+ function size(bytes) {
+ var b = Number(bytes) || 0;
+ if (b >= 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB';
+ if (b >= 1024) return Math.round(b / 1024) + ' KB';
+ return b + ' B';
+ }
+
+ /**
+ * The answer of a file tool: the file it made, its size before and after, and Download.
+ * @param {object} o
+ * title the answer's heading
+ * before the size of the file that went in (bytes)
+ * after the size of the file that came out (bytes)
+ * savedPercent how much smaller it is (a number, or nothing)
+ * rows extra rows: [[label, value]]
+ * downloadUrl where the file is
+ * filename the name it downloads under
+ * body markup under the rows
+ * tone 'bad' for a failure
+ */
+ function fileAnswer(o) {
+ var rows = [];
+ if (o.before != null) rows.push(['Before', size(o.before)]);
+ if (o.after != null) rows.push(['After', size(o.after), o.savedPercent ? { text: o.savedPercent + '% smaller', tone: 'ok' } : null]);
+ (o.rows || []).forEach(function (r) { rows.push(r); });
+ var actions = o.downloadUrl
+ ? [kit.button({ href: o.downloadUrl, label: 'Download', icon: 'download', kind: 'primary', attrs: { download: o.filename || '' } })]
+ : [];
+ return kit.answer({
+ title: o.title || '', badge: o.badge || null, rows: rows, actions: actions,
+ body: o.body || '', tone: o.tone || '', live: true,
+ });
+ }
+
+ /** Put an answer in the frame's answer slot. */
+ function show(html) {
+ var out = document.querySelector('.ovk-tool-out');
+ if (out) out.innerHTML = html;
+ return out;
+ }
+
+ /**
+ * Load a sample file into the drop zone, as if the person had chosen it.
+ * @param {string} url the sample's address
+ * @param {function} onFile called with the File
+ */
+ function sample(url, onFile) {
+ if (!url || typeof fetch !== 'function') return;
+ fetch(url, { credentials: 'same-origin' }).then(function (r) {
+ if (!r.ok) throw new Error('sample ' + r.status);
+ return r.blob();
+ }).then(function (blob) {
+ var name = String(url).split('/').pop() || 'sample';
+ var file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
+ var input = document.querySelector('.ovk-drop-input');
+ if (input && window.DataTransfer) {
+ var dt = new DataTransfer();
+ dt.items.add(file);
+ input.files = dt.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ if (onFile) onFile(file);
+ }).catch(function () { /* the sample is a convenience, not a promise */ });
+ }
+
+ /**
+ * A text tool's live output belongs in the frame's answer slot. Only an output that sits directly
+ * in the box moves: one inside a two-pane editor (the Markdown preview) keeps its place.
+ */
+ function moveAnswer() {
+ var card = document.querySelector('.ovk-tool-card');
+ var out = document.querySelector('.ovk-tool-out');
+ if (!card || !out) return;
+ var live = card.querySelector('#output, #result, #results, #grid');
+ // A two-pane editor (the Markdown preview) keeps its place: moving it would break the grid.
+ if (live && !live.closest('.md-pane, .md-editor, .pane')) {
+ out.appendChild(live);
+ out.classList.add('ovk-tool-out--live');
+ }
+ }
+
+ /**
+ * The navbar sits over the page, so the frame starts under it: its height is measured and put in
+ * --ovk-nav-h, which the app's own stylesheet uses for the frame's top padding.
+ */
+ function underNavbar() {
+ var nav = document.querySelector('.openvibe-navbar');
+ if (!nav) return;
+ var h = Math.round(nav.getBoundingClientRect().height);
+ if (h > 0) document.documentElement.style.setProperty('--ovk-nav-h', h + 'px');
+ }
+
+ function boot() {
+ moveAnswer();
+ underNavbar();
+ if (typeof ResizeObserver === 'function') {
+ var nav = document.querySelector('.openvibe-navbar');
+ if (nav) new ResizeObserver(underNavbar).observe(nav);
+ }
+ // The navbar renders after the page: measure again once it is there.
+ setTimeout(underNavbar, 400);
+ setTimeout(underNavbar, 1500);
+ window.addEventListener('resize', underNavbar);
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+ else boot();
+
+ window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, moveAnswer: moveAnswer };
+})();
@@ -46,14 +46,17 @@ a { color: var(--accent); text-decoration: none; }
@@ -46,14 +46,17 @@ a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
a:hover { color: var(--accent-hover); }
/* ---------- App Container ---------- */
/* ---------- App Container ---------- */
+/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */
#app {
#app {
width: 100%;
width: 100%;
- max-width: var(--max-width);
- margin: 0 auto;
- padding: 0 20px 60px;
flex: 1;
flex: 1;
}
}
+/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+
/* ---------- Hero ---------- */
/* ---------- Hero ---------- */
.hero {
.hero {
text-align: center;
text-align: center;
@@ -311,6 +314,8 @@ a:hover { color: var(--accent-hover); }
@@ -311,6 +314,8 @@ a:hover { color: var(--accent-hover); }
transition: var(--transition);
transition: var(--transition);
}
}
.text-input:focus, .num-input:focus { border-color: var(--accent); }
.text-input:focus, .num-input:focus { border-color: var(--accent); }
+/* The frame's option rows: the field sits beside its label and keeps its own width. */
+.num-input-sm { width: 120px; flex: none; }
/* Slider */
/* Slider */
.slider {
.slider {
@@ -583,4 +588,7 @@ a:hover { color: var(--accent-hover); }
@@ -583,4 +588,7 @@ a:hover { color: var(--accent-hover); }
.file-details .file-row + .file-row { border-top: 1px solid var(--border); }
.file-details .file-row + .file-row { border-top: 1px solid var(--border); }
.file-details .btn-sm { padding: 4px 10px; font-size: 12px; margin-top: 4px; }
.file-details .btn-sm { padding: 4px 10px; font-size: 12px; margin-top: 4px; }
+
+/* The page is the shared tool frame: it starts under the navbar, not behind it. */
+.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .option-group, .result-panel, .toast { animation: none; } .spinner { animation: none; opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .option-group, .result-panel, .toast { animation: none; } .spinner { animation: none; opacity: .7; } }
@@ -26,324 +26,9 @@
@@ -26,324 +26,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
<div id="app">
- <!-- Hero -->
- <section class="hero" id="hero">
- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-headphones"></i></div>
- <h1 id="hero-title">Audio.OpenVibe</h1>
- <p class="hero-subtitle" id="hero-subtitle">Online Audio Converter & Tools</p>
- </section>
-
- <!-- Tool Selector (visible on hub domain) -->
- <nav class="tool-tabs" id="tool-tabs">
- <button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>
- <button class="tool-tab" data-tool="trim"><i class="fa-solid fa-scissors"></i> Trim</button>
- <button class="tool-tab" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>
- <button class="tool-tab" data-tool="pitch"><i class="fa-solid fa-wave-square"></i> Pitch</button>
- <button class="tool-tab" data-tool="speed"><i class="fa-solid fa-gauge-high"></i> Speed</button>
- <button class="tool-tab" data-tool="reverse"><i class="fa-solid fa-backward"></i> Reverse</button>
- <button class="tool-tab" data-tool="normalize"><i class="fa-solid fa-sliders"></i> Normalize</button>
- <button class="tool-tab" data-tool="fade"><i class="fa-solid fa-volume-low"></i> Fade</button>
- <button class="tool-tab" data-tool="bass"><i class="fa-solid fa-volume-high"></i> Bass</button>
- <button class="tool-tab" data-tool="equalizer"><i class="fa-solid fa-sliders"></i> EQ</button>
- <button class="tool-tab" data-tool="voice"><i class="fa-solid fa-user-astronaut"></i> Voice FX</button>
- <button class="tool-tab" data-tool="echo"><i class="fa-solid fa-tower-broadcast"></i> Echo</button>
- <button class="tool-tab" data-tool="reverb"><i class="fa-solid fa-house-chimney"></i> Reverb</button>
- <button class="tool-tab" data-tool="extract"><i class="fa-solid fa-music"></i> Extract</button>
- <button class="tool-tab" data-tool="ringtone"><i class="fa-solid fa-bell"></i> Ringtone</button>
- </nav>
-
- <!-- Upload Zone -->
- <section class="upload-zone" id="upload-zone">
- <div class="upload-area" id="upload-area">
- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
- <p class="upload-text">Drag & drop an audio file here</p>
- <p class="upload-hint">or click to browse — MP3, WAV, FLAC, OGG, M4A, AAC, OPUS, WMA, AIFF, video files</p>
- <input type="file" id="file-input" accept="audio/*,video/*" hidden>
- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose File</button>
- </div>
- <div class="upload-info" id="upload-info" style="display:none">
- <div class="file-icon"><i class="fa-solid fa-file-audio"></i></div>
- <div class="file-details" id="file-details"></div>
- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>
- </div>
- </section>
-
- <!-- Tool Options -->
- <section class="options-panel" id="options-panel" style="display:none">
- <!-- Convert Options -->
- <div class="option-group" id="opt-convert">
- <label class="option-label">Output Format</label>
- <div class="format-grid" id="format-grid">
- <button class="format-btn active" data-format="mp3">MP3</button>
- <button class="format-btn" data-format="wav">WAV</button>
- <button class="format-btn" data-format="flac">FLAC</button>
- <button class="format-btn" data-format="ogg">OGG</button>
- <button class="format-btn" data-format="m4a">M4A</button>
- <button class="format-btn" data-format="aac">AAC</button>
- <button class="format-btn" data-format="opus">OPUS</button>
- <button class="format-btn" data-format="wma">WMA</button>
- <button class="format-btn" data-format="aiff">AIFF</button>
- <button class="format-btn" data-format="ac3">AC3</button>
- </div>
- <label class="option-label">Bitrate <span id="bitrate-val">192</span> kbps</label>
- <input type="range" id="bitrate-slider" min="64" max="320" value="192" step="32" class="slider">
- <p class="option-hint">Higher = better quality, larger file. Lossless formats (WAV, FLAC, AIFF) ignore bitrate.</p>
- </div>
-
- <!-- Merge Options -->
- <div class="option-group" id="opt-merge" style="display:none">
- <p class="option-hint">The files are joined in the order listed above (2 to 5 files, up to 3 hours in total). Different formats can be mixed; the result is re-encoded.</p>
- <label class="option-label">Output Format</label>
- <div class="format-grid" id="merge-format-grid">
- <button class="format-btn active" data-format="mp3">MP3</button>
- <button class="format-btn" data-format="wav">WAV</button>
- <button class="format-btn" data-format="flac">FLAC</button>
- <button class="format-btn" data-format="ogg">OGG</button>
- <button class="format-btn" data-format="m4a">M4A</button>
- <button class="format-btn" data-format="opus">OPUS</button>
- </div>
- <label class="option-label">Bitrate <span id="merge-bitrate-val">192</span> kbps</label>
- <input type="range" id="merge-bitrate-slider" min="64" max="320" value="192" step="32" class="slider">
- </div>
-
- <!-- Trim Options -->
- <div class="option-group" id="opt-trim" style="display:none">
- <div class="time-inputs">
- <div class="time-field">
- <label class="option-label">Start Time</label>
- <input type="text" id="trim-start" class="text-input" placeholder="0:00" value="0">
- </div>
- <span class="time-arrow"><i class="fa-solid fa-arrow-right"></i></span>
- <div class="time-field">
- <label class="option-label">End Time</label>
- <input type="text" id="trim-end" class="text-input" placeholder="0:30">
- </div>
- </div>
- <p class="option-hint">Accepts seconds (30) or time format (1:30 or 0:01:30)</p>
- </div>
-
- <!-- Pitch Options -->
- <div class="option-group" id="opt-pitch" style="display:none">
- <label class="option-label">Pitch Shift: <span id="pitch-val">0</span> semitones</label>
- <input type="range" id="pitch-slider" min="-12" max="12" value="0" class="slider">
- <div class="pitch-presets">
- <button class="preset-btn" data-val="-12">-1 Octave</button>
- <button class="preset-btn" data-val="-5">-5 Semi</button>
- <button class="preset-btn" data-val="-2">-2 Semi</button>
- <button class="preset-btn" data-val="2">+2 Semi</button>
- <button class="preset-btn" data-val="5">+5 Semi</button>
- <button class="preset-btn" data-val="12">+1 Octave</button>
- </div>
- </div>
-
- <!-- Speed Options -->
- <div class="option-group" id="opt-speed" style="display:none">
- <label class="option-label">Speed: <span id="speed-val">1.0</span>x</label>
- <input type="range" id="speed-slider" min="0.25" max="4.0" value="1.0" step="0.05" class="slider">
- <div class="speed-presets">
- <button class="preset-btn" data-val="0.5">0.5x</button>
- <button class="preset-btn" data-val="0.75">0.75x</button>
- <button class="preset-btn" data-val="1.25">1.25x</button>
- <button class="preset-btn" data-val="1.5">1.5x</button>
- <button class="preset-btn" data-val="2.0">2x</button>
- <button class="preset-btn" data-val="3.0">3x</button>
- </div>
- </div>
-
- <!-- Reverse Options (no params) -->
- <div class="option-group" id="opt-reverse" style="display:none">
- <p class="option-info"><i class="fa-solid fa-info-circle"></i> Reverses the entire audio file. Hit Process to reverse!</p>
- </div>
-
- <!-- Normalize Options -->
- <div class="option-group" id="opt-normalize" style="display:none">
- <label class="option-label">Normalization Mode</label>
- <div class="format-grid" id="norm-mode-grid">
- <button class="format-btn active" data-mode="loudness">Loudness (EBU R128)</button>
- <button class="format-btn" data-mode="peak">Peak</button>
- <button class="format-btn" data-mode="rms">RMS</button>
- </div>
- </div>
-
- <!-- Fade Options -->
- <div class="option-group" id="opt-fade" style="display:none">
- <div class="time-inputs">
- <div class="time-field">
- <label class="option-label">Fade In (seconds)</label>
- <input type="number" id="fade-in" class="num-input" placeholder="0" min="0" max="30" step="0.5" value="2">
- </div>
- <div class="time-field">
- <label class="option-label">Fade Out (seconds)</label>
- <input type="number" id="fade-out" class="num-input" placeholder="0" min="0" max="30" step="0.5" value="3">
- </div>
- </div>
- </div>
-
- <!-- Bass Options -->
- <div class="option-group" id="opt-bass" style="display:none">
- <label class="option-label">Bass Gain: <span id="bass-val">+6</span> dB</label>
- <input type="range" id="bass-slider" min="-20" max="20" value="6" class="slider">
- <label class="option-label">Frequency: <span id="bass-freq-val">100</span> Hz</label>
- <input type="range" id="bass-freq-slider" min="20" max="300" value="100" class="slider">
- </div>
-
- <!-- Equalizer Options -->
- <div class="option-group" id="opt-equalizer" style="display:none">
- <label class="option-label">EQ Preset</label>
- <div class="format-grid" id="eq-grid">
- <button class="format-btn active" data-preset="bass_boost">Bass Boost</button>
- <button class="format-btn" data-preset="treble_boost">Treble Boost</button>
- <button class="format-btn" data-preset="vocal_boost">Vocal Boost</button>
- <button class="format-btn" data-preset="rock">Rock</button>
- <button class="format-btn" data-preset="pop">Pop</button>
- <button class="format-btn" data-preset="jazz">Jazz</button>
- <button class="format-btn" data-preset="classical">Classical</button>
- <button class="format-btn" data-preset="electronic">Electronic</button>
- <button class="format-btn" data-preset="hip_hop">Hip Hop</button>
- <button class="format-btn" data-preset="podcast">Podcast</button>
- <button class="format-btn" data-preset="telephone">Telephone</button>
- </div>
- </div>
-
- <!-- Voice FX Options -->
- <div class="option-group" id="opt-voice" style="display:none">
- <label class="option-label">Voice Effect</label>
- <div class="format-grid" id="voice-grid">
- <button class="format-btn active" data-preset="chipmunk">🐿️ Chipmunk</button>
- <button class="format-btn" data-preset="deep">🎸 Deep</button>
- <button class="format-btn" data-preset="robot">🤖 Robot</button>
- <button class="format-btn" data-preset="echo">🔊 Echo</button>
- <button class="format-btn" data-preset="cave">🦇 Cave</button>
- <button class="format-btn" data-preset="radio">📻 Radio</button>
- <button class="format-btn" data-preset="underwater">🌊 Underwater</button>
- <button class="format-btn" data-preset="whisper">🤫 Whisper</button>
- <button class="format-btn" data-preset="megaphone">📢 Megaphone</button>
- <button class="format-btn" data-preset="demon">👹 Demon</button>
- </div>
- </div>
-
- <!-- Echo Options -->
- <div class="option-group" id="opt-echo" style="display:none">
- <label class="option-label">Delay: <span id="echo-delay-val">250</span> ms</label>
- <input type="range" id="echo-delay" min="50" max="2000" value="250" step="10" class="slider">
- <label class="option-label">Decay: <span id="echo-decay-val">0.5</span></label>
- <input type="range" id="echo-decay" min="0.1" max="0.9" value="0.5" step="0.05" class="slider">
- <label class="option-label">Repeats: <span id="echo-repeats-val">3</span></label>
- <input type="range" id="echo-repeats" min="1" max="10" value="3" class="slider">
- </div>
-
- <!-- Reverb Options -->
- <div class="option-group" id="opt-reverb" style="display:none">
- <label class="option-label">Reverb Type</label>
- <div class="format-grid" id="reverb-grid">
- <button class="format-btn" data-preset="room">Room</button>
- <button class="format-btn active" data-preset="hall">Hall</button>
- <button class="format-btn" data-preset="cathedral">Cathedral</button>
- <button class="format-btn" data-preset="plate">Plate</button>
- </div>
- <label class="option-label">Wet/Dry Mix: <span id="reverb-mix-val">50</span>%</label>
- <input type="range" id="reverb-mix" min="0" max="100" value="50" class="slider">
- </div>
-
- <!-- Extract Options -->
- <div class="option-group" id="opt-extract" style="display:none">
- <label class="option-label">Output Format</label>
- <div class="format-grid" id="extract-format-grid">
- <button class="format-btn active" data-format="mp3">MP3</button>
- <button class="format-btn" data-format="wav">WAV</button>
- <button class="format-btn" data-format="flac">FLAC</button>
- <button class="format-btn" data-format="ogg">OGG</button>
- <button class="format-btn" data-format="m4a">M4A</button>
- <button class="format-btn" data-format="aac">AAC</button>
- </div>
- <p class="option-hint">Upload a video file (MP4, MKV, AVI, WebM, etc.) to extract its audio track.</p>
- </div>
-
- <!-- Ringtone Options -->
- <div class="option-group" id="opt-ringtone" style="display:none">
- <label class="option-label">Device</label>
- <div class="format-grid" id="ringtone-device-grid">
- <button class="format-btn active" data-device="iphone">🍎 iPhone (M4R)</button>
- <button class="format-btn" data-device="android">🤖 Android (MP3)</button>
- </div>
- <div class="time-inputs" style="margin-top:16px">
- <div class="time-field">
- <label class="option-label">Start</label>
- <input type="text" id="ringtone-start" class="text-input" placeholder="0" value="0">
- </div>
- <span class="time-arrow"><i class="fa-solid fa-arrow-right"></i></span>
- <div class="time-field">
- <label class="option-label">End</label>
- <input type="text" id="ringtone-end" class="text-input" placeholder="30">
- </div>
- </div>
- <p class="option-hint">iPhone ringtones are max 40 seconds. 30 seconds is ideal.</p>
- </div>
-
- <!-- Process Button -->
- <button class="btn btn-primary btn-lg" id="process-btn" disabled>
- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Audio</span>
- </button>
- </section>
-
- <!-- Processing State -->
- <section class="processing" id="processing" style="display:none">
- <div class="spinner"></div>
- <p>Processing your audio...</p>
- </section>
-
- <!-- Result -->
- <section class="result-panel" id="result-panel" style="display:none">
- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>
- <div class="result-player" id="result-player"></div>
- <div class="result-info" id="result-info"></div>
- <div class="result-actions">
- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>
- <i class="fa-solid fa-download"></i> Download
- </a>
- <button class="btn btn-secondary" id="another-btn">
- <i class="fa-solid fa-rotate-left"></i> Process Another
- </button>
- </div>
- </section>
-
- <!-- Format Subdomains Grid (hub only) -->
- <section class="subdomains" id="subdomains">
- <h2>All Audio Tools</h2>
- <p class="subdomains-subtitle">Each tool has its own dedicated subdomain</p>
- <div class="subdomain-grid" id="subdomain-grid"></div>
- </section>
-
- <!-- Cross-service links -->
- <section class="cross-links" id="cross-links">
- <h2>More OpenVibe Tools</h2>
- <div class="cross-grid">
- <a class="cross-card" href="https://img.openvibe.tools">
- <span class="cross-icon"><i class="fa-solid fa-images"></i></span>
- <span class="cross-info"><span class="cross-name">Img.OpenVibe</span><span class="cross-desc">Image converter & tools</span></span>
- </a>
- <a class="cross-card" href="https://yt.openvibe.tools">
- <span class="cross-icon"><i class="fa-brands fa-youtube"></i></span>
- <span class="cross-info"><span class="cross-name">YT.OpenVibe</span><span class="cross-desc">YouTube downloader</span></span>
- </a>
- <a class="cross-card" href="https://openvibe.live">
- <span class="cross-icon"><i class="fa-solid fa-video"></i></span>
- <span class="cross-info"><span class="cross-name">OpenVibe.Live</span><span class="cross-desc">Live streaming platform</span></span>
- </a>
- <a class="cross-card" href="https://openvibe.games">
- <span class="cross-icon"><i class="fa-solid fa-gamepad"></i></span>
- <span class="cross-info"><span class="cross-name">Scraplandia</span><span class="cross-desc">Multiplayer RPG</span></span>
- </a>
- <a class="cross-card" href="https://maps.openvibe.tools">
- <span class="cross-icon"><i class="fa-solid fa-map-location-dot"></i></span>
- <span class="cross-info"><span class="cross-name">Maps.OpenVibe</span><span class="cross-desc">Camps, resources & shelters</span></span>
- </a>
- <a class="cross-card" href="https://openvibe.food">
- <span class="cross-icon"><i class="fa-solid fa-utensils"></i></span>
- <span class="cross-info"><span class="cross-name">OpenVibe.Food</span><span class="cross-desc">Food banks, cheap groceries & meal plans</span></span>
- </a>
- </div>
- </section>
+ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
+ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
+ <!--OVK:FRAME-->
</div>
</div>
<!-- Footer -->
<!-- Footer -->
@@ -354,7 +39,11 @@
@@ -354,7 +39,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
+ <script src="/shared/kit.js" defer></script>
+ <script src="/shared/kit-web.js" defer></script>
+ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/ov-jobs.js"></script>
+ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
</body>
</body>
</html>
</html>
@@ -1,115 +1,55 @@
@@ -1,115 +1,55 @@
-/* ================================================
- Audio.OpenVibe — Client Application (public/js/app.js)
- Vanilla JS IIFE — no frameworks, same pattern as openvibe-img
- ================================================ */
+// ═══════════════════════════════════════════════════════════════
+// Audio.OpenVibe — Client Application
+// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,
+// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop
+// zone, the tool's own options and the one button) and the answer (the file it made, its size before
+// and after, and Download). The upload, the processing and the download are the app's own, unchanged.
+// ═══════════════════════════════════════════════════════════════
+
(function () {
(function () {
'use strict';
'use strict';
- /* ---------- State ---------- */
- let ctx = null; // from /api/context
- let toolList = []; // from /api/tools
- let currentTool = null; // active tool ID
- let uploadedFile = null; // File object
- let uploadedFiles = []; // merge: the files in the order they are joined
- let resultId = null; // retention ID from POST /api/v1/tools/:id/run
+ // ── State ────────────────────────────────────────────────
+ let ctx = null; // domain context from /api/context
+ let selectedFiles = []; // the File objects the drop zone holds
- /* ---------- DOM refs ---------- */
+ // ── DOM refs ─────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $ = (sel) => document.querySelector(sel);
- const $$ = (sel) => [...document.querySelectorAll(sel)];
-
- const heroIcon = $('#hero-icon');
- const heroTitle = $('#hero-title');
- const heroSub = $('#hero-subtitle');
- const toolTabs = $('#tool-tabs');
- const uploadZone = $('#upload-zone');
- const uploadArea = $('#upload-area');
- const uploadInfo = $('#upload-info');
- const fileInput = $('#file-input');
- const browseBtn = $('#browse-btn');
- const clearBtn = $('#clear-btn');
+ const $$ = (sel) => document.querySelectorAll(sel);
+
+ const fileInput = $('#file-input');
const optionsPanel = $('#options-panel');
const optionsPanel = $('#options-panel');
- const processBtn = $('#process-btn');
- const processText = $('#process-text');
- const processing = $('#processing');
- const resultPanel = $('#result-panel');
- const resultPlayer = $('#result-player');
- const resultInfo = $('#result-info');
- const downloadBtn = $('#download-btn');
- const anotherBtn = $('#another-btn');
- const subdomains = $('#subdomains');
- const subdomainGrid = $('#subdomain-grid');
-
- /* ---------- Init ---------- */
+ const processBtn = $('#process-btn');
+ const clearBtn = $('#clear-btn');
+ const kit = () => window.OpenVibeKit;
+
+ // ── Init ─────────────────────────────────────────────────
async function init() {
async function init() {
try {
try {
- const [ctxRes, toolsRes] = await Promise.all([
- fetch('/api/context').then(r => r.json()),
- fetch('/api/tools').then(r => r.json())
- ]);
- ctx = ctxRes;
- toolList = toolsRes.tools || [];
- } catch (e) {
- console.error('Failed to load context:', e);
- ctx = { toolId: 'hub', brandName: 'Audio.OpenVibe', seoTitle: 'Audio.OpenVibe', seoDescription: 'Online Audio Tools' };
- toolList = [];
+ const res = await fetch('/api/context');
+ ctx = await res.json();
+ } catch {
+ ctx = { toolId: 'hub', brandName: 'Audio.OpenVibe', defaultOp: 'convert', tools: [] };
}
}
- applyBranding();
initNavbar();
initNavbar();
initNotifications();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
- initToolTabs();
- initUpload();
+ initDrop();
initOptions();
initOptions();
- initSubdomains();
- initResult();
-
- // Set initial tool
- const FORMAT_DOMAINS = ['mp3','wav','flac','ogg','m4a','aac','opus','wma','aiff','ac3'];
- if (ctx.toolId && ctx.toolId !== 'hub') {
- // Format-specific domains (mp3.openvibe.tools etc.) use the convert tool
- if (FORMAT_DOMAINS.includes(ctx.toolId)) {
- selectTool('convert');
- } else {
- selectTool(ctx.toolId);
- }
- } else {
- selectTool('convert');
- }
reattach();
reattach();
}
}
- /* ---------- Branding ---------- */
- function applyBranding() {
- document.title = ctx.seoTitle || ctx.brandName || 'Audio.OpenVibe';
- const metaDesc = document.querySelector('meta[name="description"]');
- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
- const metaOg = document.querySelector('meta[property="og:title"]');
- if (metaOg) metaOg.content = ctx.seoTitle || ctx.brandName;
-
- if (ctx.faIcon) heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon}"></i>`;
- heroTitle.textContent = ctx.brandName || 'Audio.OpenVibe';
- heroSub.textContent = ctx.seoDescription || 'Online Audio Tools';
-
- // On a specific tool domain, hide tool tabs and subdomains
- if (ctx.toolId && ctx.toolId !== 'hub') {
- toolTabs.style.display = 'none';
- subdomains.style.display = 'none';
- }
- }
-
- /* ---------- Navbar (OpenVibe unified bar) ---------- */
+ // ── Navbar ───────────────────────────────────────────────
function initNavbar() {
function initNavbar() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
let user = null;
let user = null;
if (token) {
if (token) {
- try {
- const payload = JSON.parse(atob(token.split('.')[1]));
- user = payload;
- } catch { /* invalid token */ }
+ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }
}
}
- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").
+ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and
+ // code tool pages carry (owner ask 11).
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
OpenVibeNavbar.init({
OpenVibeNavbar.init({
service: 'audio',
service: 'audio',
@@ -118,46 +58,41 @@
@@ -118,46 +58,41 @@
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: ctx?.brandName || 'Audio.OpenVibe' },
history: { type: 'tool', title: ctx?.brandName || 'Audio.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
+ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
});
}
}
initFooter();
initFooter();
}
}
/* ---------- Footer (shared OpenVibeFooter) ---------- */
/* ---------- Footer (shared OpenVibeFooter) ---------- */
- // Full footer on the hub host, the compact strip on the single-tool hosts.
function initFooter() {
function initFooter() {
if (typeof OpenVibeFooter === 'undefined') return;
if (typeof OpenVibeFooter === 'undefined') return;
try {
try {
OpenVibeFooter.init({
OpenVibeFooter.init({
service: 'audio',
service: 'audio',
- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',
+ variant: 'compact',
mount: '#ov-footer',
mount: '#ov-footer',
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
+ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Audio tools', items: [
links: [{ heading: 'Audio tools', items: [
- { name: 'Convert', url: 'https://convert.audio.openvibe.tools' },
- { name: 'Trim', url: 'https://trim.openvibe.tools' },
- { name: 'Merge', url: 'https://merge.openvibe.tools' },
- { name: 'Pitch', url: 'https://pitch.openvibe.tools' },
- { name: 'Speed', url: 'https://speed.openvibe.tools' },
- { name: 'Normalize', url: 'https://normalize.openvibe.tools' },
{ name: 'MP3', url: 'https://mp3.openvibe.tools' },
{ name: 'MP3', url: 'https://mp3.openvibe.tools' },
{ name: 'WAV', url: 'https://wav.openvibe.tools' },
{ name: 'WAV', url: 'https://wav.openvibe.tools' },
+ { name: 'FLAC', url: 'https://flac.openvibe.tools' },
+ { name: 'Trim', url: 'https://trim.openvibe.tools' },
+ { name: 'Merge', url: 'https://merge.openvibe.tools' },
+ { name: 'Podcasts', url: 'https://podcast.openvibe.tools' },
] }],
] }],
});
});
} catch { /* optional */ }
} catch { /* optional */ }
}
}
- /* ---------- Notifications (OpenVibe bell + toasts) ---------- */
+ // ── Notifications (OpenVibe bell + toasts) ───────────
function initNotifications() {
function initNotifications() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
if (typeof OpenVibeNotifications === 'undefined') return;
if (typeof OpenVibeNotifications === 'undefined') return;
- OpenVibeNotifications.init({
- token: token || null,
- apiBase: 'https://openvibe.network',
- });
+ OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });
- // Mount bell into navbar if available
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
const mount = OpenVibeNavbar.getBellMount();
const mount = OpenVibeNavbar.getBellMount();
if (mount) {
if (mount) {
@@ -167,407 +102,122 @@
@@ -167,407 +102,122 @@
}
}
}
}
- /* ---------- Tool Tabs ---------- */
- function initToolTabs() {
- $$('.tool-tab').forEach(tab => {
- tab.addEventListener('click', () => selectTool(tab.dataset.tool));
- });
- }
-
- function selectTool(toolId) {
- currentTool = toolId;
-
- // Update tab active state
- $$('.tool-tab').forEach(t => t.classList.toggle('active', t.dataset.tool === toolId));
-
- // Show matching option group, hide others
- $$('.option-group').forEach(g => g.style.display = 'none');
- const group = $(`#opt-${toolId}`);
- if (group) group.style.display = '';
-
- // Update process button text
- const tool = toolList.find(t => t.id === toolId);
- processText.textContent = tool ? `Process — ${tool.label}` : 'Process Audio';
-
- // Merge takes several files; the other tools one.
- fileInput.multiple = isMerge();
- if (isMerge() && uploadedFile && !uploadedFiles.length) uploadedFiles = [uploadedFile];
- if (!isMerge() && uploadedFiles.length) { uploadedFile = uploadedFiles[0]; uploadedFiles = []; }
- if (uploadedFile || uploadedFiles.length) showFiles();
-
- updateProcessBtn();
- }
-
- const MERGE_MAX = 5;
- function isMerge() { return currentTool === 'merge'; }
-
- /* ---------- Upload ---------- */
- function initUpload() {
- // Click to browse
- browseBtn.addEventListener('click', () => fileInput.click());
- uploadArea.addEventListener('click', (e) => {
- if (e.target === uploadArea || e.target.closest('.upload-icon') || e.target.closest('.upload-text') || e.target.closest('.upload-hint')) {
- fileInput.click();
- }
- });
-
- // File selected
+ // ── The box: the drop zone ───────────────────────────────
+ function initDrop() {
+ if (!fileInput) return;
fileInput.addEventListener('change', () => {
fileInput.addEventListener('change', () => {
- if (isMerge()) addFiles([...fileInput.files]);
- else if (fileInput.files[0]) handleFile(fileInput.files[0]);
- fileInput.value = '';
- });
-
- // Drag & drop
- uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('drag-over'); });
- uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
- uploadZone.addEventListener('drop', (e) => {
- e.preventDefault();
- uploadZone.classList.remove('drag-over');
- if (isMerge()) addFiles([...e.dataTransfer.files]);
- else if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
+ const files = Array.prototype.slice.call(fileInput.files || []);
+ if (files.length) handleFiles(files);
});
});
+ if (clearBtn) clearBtn.addEventListener('click', resetUpload);
- // Clear
- clearBtn.addEventListener('click', clearFile);
- }
-
- function handleFile(file) {
- uploadedFile = file;
- showFiles();
- }
-
- /** Merge: add files to the list (up to MERGE_MAX), in the order chosen. */
- function addFiles(files) {
- const room = MERGE_MAX - uploadedFiles.length;
- if (files.length > room) showToast(`At most ${MERGE_MAX} files can be merged at once`, 'error');
- uploadedFiles = uploadedFiles.concat(files.slice(0, Math.max(0, room)));
- uploadedFile = uploadedFiles[0] || null;
- if (uploadedFiles.length) showFiles(); else clearFile();
- }
-
- function showFiles() {
- const details = $('#file-details');
- details.textContent = '';
- const list = isMerge() ? uploadedFiles : (uploadedFile ? [uploadedFile] : []);
- list.forEach((file, i) => {
- const row = document.createElement('div');
- row.className = 'file-row';
- const name = document.createElement('div');
- name.className = 'file-name';
- name.textContent = (isMerge() ? `${i + 1}. ` : '') + file.name;
- const meta = document.createElement('div');
- meta.className = 'file-meta';
- meta.textContent = `${file.name.split('.').pop().toUpperCase()} — ${formatSize(file.size)}`;
- row.append(name, meta);
- if (isMerge()) {
- const rm = document.createElement('button');
- rm.type = 'button';
- rm.className = 'btn btn-ghost btn-sm';
- rm.textContent = 'Remove';
- rm.addEventListener('click', () => { uploadedFiles.splice(i, 1); addFiles([]); });
- row.append(rm);
- }
- details.append(row);
- });
- if (isMerge() && uploadedFiles.length < MERGE_MAX) {
- const more = document.createElement('button');
- more.type = 'button';
- more.className = 'btn btn-ghost btn-sm';
- more.textContent = uploadedFiles.length < 2 ? 'Add another file' : 'Add a file';
- more.addEventListener('click', () => fileInput.click());
- details.append(more);
+ const sampleLink = document.querySelector('[data-ovk-sample]');
+ if (sampleLink && window.OVToolFrame) {
+ sampleLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), (file) => handleFiles([file]));
+ });
}
}
-
- uploadArea.style.display = 'none';
- uploadInfo.style.display = 'flex';
- optionsPanel.style.display = '';
- resultPanel.style.display = 'none';
- resultId = null;
- updateProcessBtn();
}
}
- function clearFile() {
- uploadedFile = null;
- uploadedFiles = [];
- fileInput.value = '';
- uploadArea.style.display = '';
- uploadInfo.style.display = 'none';
- optionsPanel.style.display = 'none';
- updateProcessBtn();
+ function handleFiles(files) {
+ const max = files.length > 1 ? 5 : 1;
+ if (files.length > max) {
+ fail(`That is more than ${max} file${max === 1 ? '' : 's'}.`);
+ return;
+ }
+ const tooBig = files.find((f) => f.size > 100 * 1024 * 1024);
+ if (tooBig) {
+ fail('That file is too large. The maximum is 100 MB.');
+ return;
+ }
+ selectedFiles = files;
+ if (optionsPanel) optionsPanel.hidden = false;
+ if (processBtn) processBtn.disabled = false;
+ hideAnswer();
}
}
- function updateProcessBtn() {
- processBtn.disabled = !currentTool || (isMerge() ? uploadedFiles.length < 2 : !uploadedFile);
+ function resetUpload() {
+ selectedFiles = [];
+ if (fileInput) fileInput.value = '';
+ if (optionsPanel) optionsPanel.hidden = true;
+ if (processBtn) processBtn.disabled = true;
+ hideAnswer();
}
}
- /* ---------- Options ---------- */
+ // ── The box: the options and the one button ─────────────
function initOptions() {
function initOptions() {
- // Bitrate slider (convert)
- const bitrateSlider = $('#bitrate-slider');
- const bitrateVal = $('#bitrate-val');
- if (bitrateSlider) {
- bitrateSlider.addEventListener('input', () => { bitrateVal.textContent = bitrateSlider.value; });
- }
-
- // Pitch slider
- const pitchSlider = $('#pitch-slider');
- const pitchVal = $('#pitch-val');
- if (pitchSlider) {
- pitchSlider.addEventListener('input', () => {
- const v = parseInt(pitchSlider.value);
- pitchVal.textContent = (v > 0 ? '+' : '') + v;
- });
- }
-
- // Pitch presets
- $$('#opt-pitch .preset-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- pitchSlider.value = btn.dataset.val;
- pitchSlider.dispatchEvent(new Event('input'));
- });
- });
-
- // Speed slider
- const speedSlider = $('#speed-slider');
- const speedVal = $('#speed-val');
- if (speedSlider) {
- speedSlider.addEventListener('input', () => { speedVal.textContent = parseFloat(speedSlider.value).toFixed(2); });
- }
-
- // Speed presets
- $$('#opt-speed .preset-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- speedSlider.value = btn.dataset.val;
- speedSlider.dispatchEvent(new Event('input'));
- });
- });
-
- // Bass sliders
- const bassSlider = $('#bass-slider');
- const bassVal = $('#bass-val');
- if (bassSlider) {
- bassSlider.addEventListener('input', () => {
- const v = parseInt(bassSlider.value);
- bassVal.textContent = (v > 0 ? '+' : '') + v;
- });
- }
- const bassFreqSlider = $('#bass-freq-slider');
- const bassFreqVal = $('#bass-freq-val');
- if (bassFreqSlider) {
- bassFreqSlider.addEventListener('input', () => { bassFreqVal.textContent = bassFreqSlider.value; });
- }
-
- // Echo sliders
- const echoDelay = $('#echo-delay');
- const echoDecay = $('#echo-decay');
- const echoRepeats = $('#echo-repeats');
- if (echoDelay) echoDelay.addEventListener('input', () => { $('#echo-delay-val').textContent = echoDelay.value; });
- if (echoDecay) echoDecay.addEventListener('input', () => { $('#echo-decay-val').textContent = parseFloat(echoDecay.value).toFixed(2); });
- if (echoRepeats) echoRepeats.addEventListener('input', () => { $('#echo-repeats-val').textContent = echoRepeats.value; });
-
- // Merge bitrate slider
- const mergeBitrate = $('#merge-bitrate-slider');
- if (mergeBitrate) mergeBitrate.addEventListener('input', () => { $('#merge-bitrate-val').textContent = mergeBitrate.value; });
-
- // Reverb mix slider
- const reverbMix = $('#reverb-mix');
- if (reverbMix) reverbMix.addEventListener('input', () => { $('#reverb-mix-val').textContent = reverbMix.value; });
-
- // Format grid click handlers (convert)
- initGridSelect('#format-grid .format-btn', 'format');
- initGridSelect('#merge-format-grid .format-btn', 'format');
- initGridSelect('#norm-mode-grid .format-btn', 'mode');
- initGridSelect('#eq-grid .format-btn', 'preset');
- initGridSelect('#voice-grid .format-btn', 'preset');
- initGridSelect('#reverb-grid .format-btn', 'preset');
- initGridSelect('#extract-format-grid .format-btn', 'format');
- initGridSelect('#ringtone-device-grid .format-btn', 'device');
-
- // Context defaults — if domain implies a format, pre-select it
- if (ctx.defaultFormat) {
- const fmtBtn = $(`#format-grid .format-btn[data-format="${ctx.defaultFormat}"]`);
- if (fmtBtn) {
- $$('#format-grid .format-btn').forEach(b => b.classList.remove('active'));
- fmtBtn.classList.add('active');
- }
- }
-
- // Process button
- processBtn.addEventListener('click', processAudio);
+ if (processBtn) processBtn.addEventListener('click', processAudio);
}
}
- function initGridSelect(selector, attr) {
- const btns = $$(selector);
- if (!btns.length) return;
- const parent = btns[0].parentElement;
- btns.forEach(btn => {
- btn.addEventListener('click', () => {
- parent.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- });
+ /** The tool's own options, as the job's input: the fields the descriptor names, and nothing else. */
+ function collectInput() {
+ const form = document.querySelector('#ovk-audio-form');
+ const input = { tool: (ctx && ctx.defaultOp) || 'convert' };
+ if (!form) return input;
+ form.querySelectorAll('input[name], select[name], textarea[name]').forEach((el) => {
+ // The drop zone's own file field is not an option: the file goes in the job's files.
+ if (el.type === 'file') return;
+ if (el.type === 'radio') { if (el.checked) input[el.name] = el.value; return; }
+ if (el.type === 'checkbox') { input[el.name] = el.checked; return; }
+ if (el.value === '') return;
+ input[el.name] = el.value;
});
});
+ return input;
}
}
- /* ---------- Process ---------- */
+ // ── Process ──────────────────────────────────────────────
async function processAudio() {
async function processAudio() {
- if (!uploadedFile || !currentTool) return;
- const files = isMerge() ? uploadedFiles.slice() : [uploadedFile];
- if (isMerge() && files.length < 2) return;
-
- const formData = new FormData();
- for (const f of files) formData.append(isMerge() ? 'files' : 'file', f);
- formData.append('tool', currentTool);
-
- // Gather tool-specific options
- const opts = gatherOptions();
- for (const [k, v] of Object.entries(opts)) {
- formData.append(k, v);
+ if (!selectedFiles.length) return;
+
+ const input = collectInput();
+ if (processBtn) {
+ processBtn.disabled = true;
+ const span = processBtn.querySelector('span');
+ // The button keeps its verb while it works: "Compress…", not "Working…".
+ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;
}
}
+ showWorking();
- // UI state
- optionsPanel.style.display = 'none';
- uploadZone.style.display = 'none';
- resultPanel.style.display = 'none';
- processing.style.display = '';
-
- // Jobs: the work is accepted, followed live (ffmpeg's progress) and survives a reload.
- const input = {};
- for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;
try {
try {
- const job = await OVJobs.submit({ type: 'audio.process', input, files, fileField: isMerge() ? 'files' : 'file' });
+ const many = selectedFiles.length > 1;
+ const job = await OVJobs.submit({
+ type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',
+ });
OVJobs.remember(job.id);
OVJobs.remember(job.id);
followJob(job);
followJob(job);
} catch (err) {
} catch (err) {
- showToast(err.message, 'error');
- resetUI();
- }
- }
-
- function gatherOptions() {
- const o = {};
- switch (currentTool) {
- case 'convert': {
- const fmt = $('#format-grid .format-btn.active');
- if (fmt) o.format = fmt.dataset.format;
- o.bitrate = $('#bitrate-slider').value;
- break;
- }
- case 'merge': {
- const fmt = $('#merge-format-grid .format-btn.active');
- if (fmt) o.format = fmt.dataset.format;
- o.bitrate = $('#merge-bitrate-slider').value;
- break;
- }
- case 'trim':
- o.start = $('#trim-start').value || '0';
- o.end = $('#trim-end').value;
- break;
- case 'pitch':
- o.semitones = $('#pitch-slider').value;
- break;
- case 'speed':
- o.speed = $('#speed-slider').value;
- break;
- case 'reverse':
- break;
- case 'normalize': {
- const mode = $('#norm-mode-grid .format-btn.active');
- if (mode) o.mode = mode.dataset.mode;
- break;
- }
- case 'fade':
- o.fadeIn = $('#fade-in').value || '0';
- o.fadeOut = $('#fade-out').value || '0';
- break;
- case 'bass':
- o.gain = $('#bass-slider').value;
- o.frequency = $('#bass-freq-slider').value;
- break;
- case 'equalizer': {
- const p = $('#eq-grid .format-btn.active');
- if (p) o.preset = p.dataset.preset;
- break;
- }
- case 'voice': {
- const p = $('#voice-grid .format-btn.active');
- if (p) o.preset = p.dataset.preset;
- break;
- }
- case 'echo':
- o.delay = $('#echo-delay').value;
- o.decay = $('#echo-decay').value;
- o.repeats = $('#echo-repeats').value;
- break;
- case 'reverb': {
- const p = $('#reverb-grid .format-btn.active');
- if (p) o.preset = p.dataset.preset;
- o.mix = $('#reverb-mix').value;
- break;
- }
- case 'extract': {
- const fmt = $('#extract-format-grid .format-btn.active');
- if (fmt) o.format = fmt.dataset.format;
- break;
- }
- case 'ringtone': {
- const d = $('#ringtone-device-grid .format-btn.active');
- if (d) o.device = d.dataset.device;
- o.start = $('#ringtone-start').value || '0';
- o.end = $('#ringtone-end').value;
- break;
- }
+ failProcessing(err.message);
}
}
- return o;
}
}
- /* ---------- Jobs ---------- */
+ // ── Jobs ─────────────────────────────────────────────────
let watcher = null;
let watcher = null;
- let currentJob = null;
function processingText(job) {
function processingText(job) {
const pct = job.progress && job.progress.percent;
const pct = job.progress && job.progress.percent;
- if (job.cancel_requested) return 'Cancelling...';
- if (job.state === 'queued') return 'Waiting for a free slot...';
+ if (job.cancel_requested) return 'Cancelling…';
+ if (job.state === 'queued') return 'Waiting for a free slot…';
const label = (job.progress && job.progress.message) || 'Processing your audio';
const label = (job.progress && job.progress.message) || 'Processing your audio';
- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;
- }
-
- function cancelButton() {
- let btn = processing.querySelector('.job-cancel');
- if (!btn) {
- btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'btn-secondary job-cancel';
- btn.textContent = 'Cancel';
- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });
- processing.appendChild(btn);
- }
- return btn;
+ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;
}
}
function followJob(job) {
function followJob(job) {
- currentJob = job.id;
- optionsPanel.style.display = 'none';
- uploadZone.style.display = 'none';
- resultPanel.style.display = 'none';
- processing.style.display = '';
- const label = processing.querySelector('p');
- const update = (j) => { if (label) label.textContent = processingText(j); };
- cancelButton().style.display = '';
- update(job);
+ hideAnswer();
+ showWorking();
if (watcher) watcher.close();
if (watcher) watcher.close();
watcher = OVJobs.watch(job.id, {
watcher = OVJobs.watch(job.id, {
- onUpdate: update,
+ onUpdate: (j) => {
+ const out = document.querySelector('.ovk-tool-out');
+ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));
+ },
onDone(done) {
onDone(done) {
watcher = null;
watcher = null;
- cancelButton().style.display = 'none';
- if (done.state === 'succeeded') { resultId = done.id; return showResult(jobResult(done)); }
+ if (done.state === 'succeeded') return showResult(jobResult(done));
OVJobs.forget();
OVJobs.forget();
- if (done.state !== 'cancelled') showToast((done.error && done.error.detail) || 'Processing failed', 'error');
- resetUI();
+ failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
},
},
- onGone() { OVJobs.forget(); resetUI(); },
+ onGone() { OVJobs.forget(); failProcessing(null); },
});
});
}
}
@@ -580,6 +230,18 @@
@@ -580,6 +230,18 @@
};
};
}
}
+ function resetVerb() {
+ const span = processBtn && processBtn.querySelector('span');
+ if (span) span.textContent = span.textContent.replace(/…$/, '');
+ }
+
+ function failProcessing(message) {
+ resetVerb();
+ if (processBtn) processBtn.disabled = !selectedFiles.length;
+ if (message) fail(message);
+ else hideAnswer();
+ }
+
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
function reattach() {
function reattach() {
const id = window.OVJobs && OVJobs.recall();
const id = window.OVJobs && OVJobs.recall();
@@ -587,122 +249,64 @@
@@ -587,122 +249,64 @@
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
}
}
- /* ---------- Result ---------- */
- function initResult() {
- anotherBtn.addEventListener('click', () => {
- if (window.OVJobs) OVJobs.forget();
- clearFile();
- resetUI();
+ // ── The answer ───────────────────────────────────────────
+ function showWorking() {
+ const out = document.querySelector('.ovk-tool-out');
+ if (!out || !kit()) return;
+ out.innerHTML = kit().answer({
+ title: 'Working…',
+ body: '<p data-ovk-working>Your recording is being worked on by ffmpeg on our server. This page stays open until it is done.</p>',
});
});
}
}
- function showResult(data) {
- processing.style.display = 'none';
- resultPanel.style.display = '';
-
- const fileId = data.download.id;
- const previewUrl = data.download.previewUrl || `/api/preview/${fileId}`;
- const downloadUrl = data.download.downloadUrl || `/api/download/${fileId}`;
- const outputMime = data.output?.mime || '';
-
- if (outputMime.startsWith('image/')) {
- // Waveform — returns an image
- resultPlayer.innerHTML = `<img src="${previewUrl}" class="result-waveform" alt="Waveform">`;
- } else {
- // Audio player
- resultPlayer.innerHTML = `<audio controls preload="auto" src="${previewUrl}"></audio>`;
- }
-
- // Info: filename, output size, savings %
- const parts = [];
- if (data.download.filename) parts.push(data.download.filename);
- if (data.output?.size) parts.push(formatSize(data.output.size));
- if (data.input?.size && data.output?.size) {
- const pct = ((1 - data.output.size / data.input.size) * 100).toFixed(1);
- if (pct > 0) parts.push(`${pct}% smaller`);
- }
- resultInfo.textContent = parts.join(' — ');
-
- // Download
- downloadBtn.href = downloadUrl;
- if (data.download.filename) downloadBtn.download = data.download.filename;
+ function hideAnswer() {
+ const out = document.querySelector('.ovk-tool-out');
+ if (out) out.innerHTML = '';
}
}
- function resetUI() {
- processing.style.display = 'none';
- resultPanel.style.display = 'none';
- uploadZone.style.display = '';
- if (uploadedFile) {
- optionsPanel.style.display = '';
- }
+ function fail(message) {
+ const out = document.querySelector('.ovk-tool-out');
+ if (!out || !kit()) return;
+ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });
}
}
- /* ---------- Subdomain Grid ---------- */
- function initSubdomains() {
- if (ctx.toolId && ctx.toolId !== 'hub') {
- subdomains.style.display = 'none';
- return;
- }
-
- const categories = [
- { label: 'Format Conversion', icon: 'fa-solid fa-arrows-rotate', ids: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus', 'wma', 'aiff', 'ac3'] },
- { label: 'Audio Editing', icon: 'fa-solid fa-scissors', ids: ['trim', 'merge', 'fade', 'loop', 'silence', 'stereo', 'normalize', 'speed', 'reverse'] },
- { label: 'Audio Effects', icon: 'fa-solid fa-wand-magic-sparkles', ids: ['pitch', 'bass', 'equalizer', 'echo', 'reverb', 'chorus', 'distortion', 'compressor', 'bitcrusher', 'voice', 'noise'] },
- { label: 'Extraction & Analysis', icon: 'fa-solid fa-magnifying-glass', ids: ['extract', 'vocal', 'waveform', 'metadata'] },
- { label: 'Specialized', icon: 'fa-solid fa-star', ids: ['ringtone', 'podcast'] },
- ];
-
- // Build a lookup from tools list
- const toolMap = {};
- toolList.forEach(t => { toolMap[t.id] = t; });
-
- let html = '';
- for (const cat of categories) {
- html += `<div class="cat-header"><i class="${cat.icon}"></i> ${cat.label}</div>`;
- for (const id of cat.ids) {
- const t = toolMap[id];
- if (!t) continue;
- const host = `${id}.openvibe.tools`;
- html += `
- <a class="subdomain-card" href="https://${host}">
- <span class="card-icon"><i class="fa-solid ${t.faIcon || 'fa-music'}"></i></span>
- <span class="card-info">
- <span class="card-name">${escHtml(t.label)}</span>
- <span class="card-desc">${escHtml(t.description)}</span>
- </span>
- </a>`;
- }
+ function showResult(data) {
+ resetVerb();
+ if (processBtn) processBtn.disabled = false;
+ const out = data.output || {};
+ const rows = [];
+ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);
+ if (out.duration != null) rows.push(['Length', `${Math.round(out.duration)} s`]);
+ if (data.fileCount) rows.push(['Files joined', String(data.fileCount)]);
+ const expires = data.download && data.download.expiresIn;
+ if (expires) {
+ const mins = Math.max(1, Math.round(expires / 60000));
+ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);
}
}
- subdomainGrid.innerHTML = html;
+ const html = window.OVToolFrame.fileAnswer({
+ title: 'Your recording is ready',
+ before: (data.input && data.input.size != null) ? data.input.size : (selectedFiles[0] ? selectedFiles[0].size : null),
+ after: out.size != null ? out.size : null,
+ rows,
+ downloadUrl: data.download && data.download.downloadUrl,
+ filename: data.download && data.download.filename,
+ });
+ window.OVToolFrame.show(html);
}
}
- /* ---------- Utilities ---------- */
+ // ── Helpers ──────────────────────────────────────────────
function getCookie(name) {
function getCookie(name) {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
return match ? decodeURIComponent(match[1]) : null;
}
}
- function formatSize(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / 1048576).toFixed(1) + ' MB';
- }
-
- function escHtml(str) {
- const d = document.createElement('div');
- d.textContent = str;
- return d.innerHTML;
- }
-
- function showToast(msg, type = '') {
- const el = document.createElement('div');
- el.className = 'toast' + (type ? ` ${type}` : '');
- el.textContent = msg;
- document.body.appendChild(el);
- setTimeout(() => { el.remove(); }, 4500);
+ function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
}
}
- /* ---------- Boot ---------- */
+ // ── Boot ─────────────────────────────────────────────────
if (document.readyState === 'loading') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', init);
} else {
} else {
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/audio/public/sample.mp3 differ
Binary files /dev/null and b/apps/audio/public/sample.mp3 differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the audio pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// Every audio host — mp3, trim, merge, reverb and the rest — is a tool page in the shared frame: the
+// head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone and the
+// tool's own options), the answer (the file it made, its size before and after, and Download), the
+// family, the folds and "People also use".
+//
+// The box's options are the tool's own descriptor (server/descriptors.js): a field the descriptor does
+// not have is not on the page, and its range and its default are the ones the tool reads. The terms
+// come from the app's real configuration: the file's lifetime from config.js's retention, its size
+// from config.js's upload limits, and the anonymous rate from the guard's quota table.
+// ═══════════════════════════════════════════════════════════════
+
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const config = require('./config');
+const { DOMAIN_MAP } = require('./domain-map');
+const { SPECS, FORMAT_MIME, AUDIO_ACCEPT } = require('./descriptors');
+const { TOOLS } = require('./tools');
+
+const HUB = 'https://audio.openvibe.tools/';
+const esc = kit.esc;
+
+// The tools, in the order the family row shows them: the formats first, then the edits and the effects.
+const TOOL_LIST = [
+ ['mp3', 'MP3 Converter', 'MP3', 'Convert any audio file to MP3, the format every player reads.', 'Convert to MP3'],
+ ['wav', 'WAV Converter', 'WAV', 'Convert audio to WAV, the uncompressed format every program reads.', 'Convert to WAV'],
+ ['flac', 'FLAC Converter', 'FLAC', 'Convert audio to FLAC, the lossless format.', 'Convert to FLAC'],
+ ['ogg', 'OGG Converter', 'OGG', 'Convert audio to OGG Vorbis, the open format.', 'Convert to OGG'],
+ ['m4a', 'M4A Converter', 'M4A', 'Convert audio to M4A, the format phones and podcasts use.', 'Convert to M4A'],
+ ['opus', 'Opus Converter', 'Opus', 'Convert audio to Opus, the smallest format for speech.', 'Convert to Opus'],
+ ['aac', 'AAC Converter', 'AAC', 'Convert audio to AAC, the format inside an MP4.', 'Convert to AAC'],
+ ['wma', 'WMA Converter', 'WMA', 'Convert audio to WMA, the format Windows plays.', 'Convert to WMA'],
+ ['aiff', 'AIFF Converter', 'AIFF', 'Convert audio to AIFF, the format audio programs write.', 'Convert to AIFF'],
+ ['ac3', 'AC3 Converter', 'AC3', 'Convert audio to AC3, the surround-sound format.', 'Convert to AC3'],
+ ['extract', 'Extract Audio', 'Extract', 'Take the sound out of a video file.', 'Extract audio'],
+ ['trim', 'Trim Audio', 'Trim', 'Cut a recording down to the part you want to keep.', 'Trim'],
+ ['merge', 'Merge Audio', 'Merge', 'Join up to five recordings into one file.', 'Merge'],
+ ['ringtone', 'Ringtone Maker', 'Ringtone', 'Cut a ringtone for a phone.', 'Make ringtone'],
+ ['speed', 'Change Speed', 'Speed', 'Make a recording play faster or slower, with the pitch kept.', 'Change speed'],
+ ['pitch', 'Change Pitch', 'Pitch', 'Make a recording sound higher or lower without changing its speed.', 'Change pitch'],
+ ['reverse', 'Reverse Audio', 'Reverse', 'Play a recording backwards.', 'Reverse'],
+ ['normalize', 'Normalize Volume', 'Normalize', 'Make a recording as loud as it should be.', 'Normalize'],
+ ['noise', 'Reduce Noise', 'Noise', 'Take the hiss and the hum out of a recording.', 'Reduce noise'],
+ ['silence', 'Remove Silence', 'Silence', 'Cut the quiet parts out of a recording.', 'Remove silence'],
+ ['vocal', 'Remove Vocals', 'Vocals', 'Take the singing out of a recording, or keep only the singing.', 'Remove vocals'],
+ ['fade', 'Add Fade', 'Fade', 'Fade a recording in at the start and out at the end.', 'Add fade'],
+ ['loop', 'Loop Audio', 'Loop', 'Play a recording more than once.', 'Loop'],
+ ['bass', 'Boost Bass', 'Bass', 'Make the low end of a recording louder.', 'Boost bass'],
+ ['equalizer', 'Equalizer', 'Equalizer', 'Change the balance of the frequencies in a recording.', 'Equalize'],
+ ['compressor', 'Compressor', 'Compressor', 'Even out the quiet parts and the loud parts of a recording.', 'Compress'],
+ ['podcast', 'Podcast', 'Podcast', 'Make a recording ready to publish as a podcast.', 'Optimize for podcast'],
+ ['voice', 'Voice Changer', 'Voice', 'Change a voice into chipmunk, robot, giant and more.', 'Change voice'],
+ ['echo', 'Add Echo', 'Echo', 'Add an echo to a recording.', 'Add echo'],
+ ['reverb', 'Add Reverb', 'Reverb', 'Put a recording in a room, a hall or a cathedral.', 'Add reverb'],
+ ['chorus', 'Add Chorus', 'Chorus', 'Double a recording\'s voice with a chorus.', 'Add chorus'],
+ ['distortion', 'Add Distortion', 'Distortion', 'Make a recording sound driven and rough.', 'Add distortion'],
+ ['bitcrusher', 'Bitcrusher', 'Lo-Fi', 'Make a recording sound old and lo-fi.', 'Add lo-fi'],
+ ['stereo', 'Stereo', 'Stereo', 'Turn a recording into stereo or into mono.', 'Change channels'],
+ ['waveform', 'Waveform Image', 'Waveform', 'Draw the shape of a recording as a picture.', 'Draw waveform'],
+ ['metadata', 'Edit Metadata', 'Metadata', 'Read and write the tags inside an audio file.', 'Save metadata'],
+];
+
+const RECS = TOOL_LIST.map(([id, name, short, task, verb]) => ({ id, name, short, task, verb, op: id }));
+const BY_ID = new Map(RECS.map((r) => [r.id, r]));
+const SPEC = new Map(SPECS.map((s) => [s.id, s]));
+
+const HUB_TOOL = {
+ id: 'hub', name: 'Audio.OpenVibe', short: 'All tools', task: 'Convert audio to any format, and change it with the tools below.', verb: 'Convert',
+ op: 'convert',
+ about: 'Every audio tool in one place: convert a recording to another format, cut it, join it, and put an effect on it.',
+};
+
+/** The tool a host is for (the karaoke host is the vocal tool under another name). */
+function toolFor(host) {
+ const ctx = DOMAIN_MAP[host];
+ if (!ctx) return null;
+ if (ctx.toolId === 'hub') return HUB_TOOL;
+ return BY_ID.get(ctx.toolId) || null;
+}
+
+/** The verb of a host: the karaoke host says what it makes. */
+function verbFor(rec, host) {
+ if (host === 'karaoke.openvibe.tools') return 'Make karaoke';
+ if (rec.id === 'hub') return 'Convert';
+ return rec.verb;
+}
+
+/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */
+function termsOf(rec) {
+ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
+ return frame.terms({
+ work: 'server',
+ ttlMs: config.retention.anonTTL,
+ maxBytes: config.upload.maxFileSize,
+ maxFiles: (spec && spec.files && spec.files.max) || 1,
+ quotaClass: (spec && spec.quotaClass) || 'tools-job',
+ cost: (spec && spec.cost) || 1,
+ unit: 'runs',
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ });
+}
+
+/** The labels of the descriptor's fields, in the words a person reads. */
+const FIELD_LABEL = {
+ format: 'Output format', bitrate: 'Bitrate (kbps)', sampleRate: 'Sample rate (Hz)', channels: 'Channels',
+ start: 'Start', end: 'End', speed: 'Speed', semitones: 'Semitones', mode: 'Mode', target: 'Target (dB)',
+ strength: 'Strength', threshold: 'Silence below (dB)', minDuration: 'Shortest gap (s)', device: 'Phone',
+ fadeIn: 'Fade in (s)', fadeOut: 'Fade out (s)', count: 'Times', gain: 'Gain (dB)', frequency: 'Frequency (Hz)',
+ preset: 'Preset', mix: 'Wet mix (%)', delay: 'Delay (ms)', decay: 'Decay', repeats: 'Repeats', bits: 'Bits',
+ width: 'Width (px)', height: 'Height (px)', color: 'Colour', bgColor: 'Background',
+ title: 'Title', artist: 'Artist', album: 'Album', genre: 'Genre', year: 'Year', track: 'Track', comment: 'Comment',
+};
+
+/** The values a person reads better than the code's: the channels are mono and stereo. */
+const VALUE_LABEL = { 1: 'Mono', 2: 'Stereo' };
+
+/** The box's options: the tool's own descriptor fields, with their ranges and their defaults. */
+function optionsFor(rec) {
+ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
+ const props = (spec && spec.input && spec.input.properties) || {};
+ const fixed = rec.op === 'convert' && (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat;
+ const out = [];
+ for (const [name, s] of Object.entries(props)) {
+ // A format host fixes its own format; the page does not offer another one.
+ if (name === 'format' && fixed) continue;
+ out.push(frame.field(name, s, FIELD_LABEL, VALUE_LABEL));
+ }
+ return out.join('');
+}
+
+/**
+ * The box: a drop zone for the recording, the tool's options and the one button.
+ * @param {object} rec the tool record
+ */
+function boxFor(rec, host) {
+ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
+ const many = (spec && spec.files && spec.files.max) > 1;
+ const accept = (spec && spec.files && spec.files.accept) || AUDIO_ACCEPT;
+ const drop = kit.drop({
+ name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,
+ label: many ? 'Drop your recordings here' : 'Drop a recording here',
+ hint: many ? 'Up to five files at once' : 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4, WebM',
+ });
+ return `<form class="ovk-tool-form" id="ovk-audio-form" enctype="multipart/form-data" data-ovk-tool-form>`
+ + drop
+ + `<p class="ovk-try"><span>Try</span><a href="/sample.mp3" data-ovk-sample="/sample.mp3">A sample recording</a></p>`
+ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`
+ + `<div class="ovk-tool-buttons">${frame.verbButton(verbFor(rec, host), { id: 'process-btn', icon: 'wave' })}`
+ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`
+ + `</form>`;
+}
+
+/** The family: every audio tool, with the one on the page marked. */
+function familyItems(rec) {
+ // The hub is the first of the family: the whole audio suite, and the page the family marks there.
+ const hub = { href: HUB, label: 'Audio.OpenVibe', short: 'All tools', current: rec.id === 'hub' };
+ return [hub, ...RECS.map((t) => ({ href: `https://${t.id}.openvibe.tools/`, label: t.name, short: t.short, current: t.id === rec.id }))];
+}
+
+/** "People also use": the tools next to this one in the family. */
+function relatedFor(rec) {
+ // The hub is the converter, so the converter is not "also" there.
+ const self = rec.id === 'hub' ? 'convert' : rec.id;
+ const at = RECS.findIndex((t) => t.id === self);
+ const out = [];
+ for (let i = 1; out.length < 3 && i < RECS.length + 1; i++) {
+ const other = RECS[(at + i) % RECS.length];
+ if (other.id !== self && other.id !== 'hub') out.push(other);
+ }
+ return out.map((t) => kit.row({ href: `https://${t.id}.openvibe.tools/`, title: t.name, sub: t.task }));
+}
+
+/**
+ * The whole frame for one host.
+ * @param {string} host the satellite's own hostname, e.g. 'mp3.openvibe.tools'
+ * @returns {string} HTML
+ */
+function frameFor(host) {
+ const rec = toolFor(host);
+ if (!rec) return '';
+ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
+ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';
+ const apiId = rec.id === 'hub' ? 'convert' : rec.id;
+ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(
+ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : '']),
+ ) : {};
+ const about = rec.about || `The ${rec.name.toLowerCase()} tool works on our server: the recording is sent to us, changed there and sent back as a file.`;
+ const faq = [
+ ['Is it free?', 'Yes. Every audio tool here is free, with a fair-use limit per address so the shared servers stay fast for everyone.'],
+ ['How long is my recording kept?', 'An hour for a visitor, a day for a signed-in person. Then the file is gone.'],
+ ['What does it accept?', 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF and the video formats that carry audio.'],
+ ];
+ return frame.page({
+ id: rec.id,
+ icon: 'wave', hue: 265, family: 'audio',
+ crumb: { href: HUB, label: 'Audio tools' },
+ title: rec.name, task: rec.task, facts: termsOf(rec),
+ box: boxFor(rec, host),
+ familyTitle: 'More audio tools',
+ familyMore: { href: HUB, label: `All ${RECS.length + 1}` },
+ familyItems: familyItems(rec),
+ about, faq,
+ api: {
+ id: apiId, path: `/api/v1/tools/${apiId}/run`, fields, payload: { input },
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ },
+ related: relatedFor(rec),
+ });
+}
+
+module.exports = { frameFor, toolFor, termsOf, boxFor, verbFor, optionsFor, RECS, HUB_TOOL };
@@ -304,6 +304,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -304,6 +304,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
},
}));
}));
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
+
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// tags and structured data (server/seo.js); the raw file carries only the hub's.
// tags and structured data (server/seo.js); the raw file carries only the hub's.
const { sendIndex } = require('./seo');
const { sendIndex } = require('./seo');
@@ -15,8 +15,10 @@
@@ -15,8 +15,10 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
+const serve = require('openvibe-shared/serve');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { DOMAIN_MAP } = require('./domain-map');
const { DOMAIN_MAP } = require('./domain-map');
+const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const PUBLIC = path.join(__dirname, '..', 'public');
const HUB_HOST = 'audio.openvibe.tools';
const HUB_HOST = 'audio.openvibe.tools';
@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:image" content="${image}">`,
`<meta name="twitter:image" content="${image}">`,
+ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
].join('\n');
].join('\n');
- const out = html.replace(/<\/head>/i, `${head}\n</head>`);
+ const out = html.replace(/<\/head>/i, `${head}\n</head>`)
+ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
+ .replace('<!--OVK:FRAME-->', frameFor(host) || '');
if (_cache.size > 100) _cache.clear();
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
_cache.set(key, out);
return out;
return out;
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+// The tool frame on the audio pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • every audio host is a tool page in the same frame: the head, the box, the answer slot, the
+// family, the folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its
+// size from the upload limits, the rate from the guard's quota table — and they follow it when it
+// changes;
+// • the box's options are the tool's own descriptor: a field the descriptor does not have is not on
+// the page;
+// • the one button says the verb, never "Run".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { frameFor, verbFor, RECS } = require('../server/frame');
+const { DOMAIN_MAP } = require('../server/domain-map');
+const { SPECS } = require('../server/descriptors');
+const config = require('../server/config');
+const { startApp } = require('../../_shared/test/spawn');
+
+const HOSTS = Object.keys(DOMAIN_MAP);
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+// ── Every audio host is a framed tool page, in the same order ──
+{
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ assert.ok(html, `${host}: a frame`);
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('https://audio.openvibe.tools/">Audio tools'), `${host}: the crumb is the family`);
+ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
+ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
+ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ assert.strictEqual(RECS.length, 36, 'the family has thirty-six audio tools');
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const facts = factsOf(frameFor('mp3.openvibe.tools'));
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'mp3: free, on our server, and the file\'s lifetime');
+ assert.ok(facts.includes('One file up to 100 MB'), 'mp3: the upload limit');
+ assert.ok(facts.includes('A browser session: no account needed'), 'mp3: the tool refuses anonymous callers, so the page says what it needs');
+
+ const saved = config.retention.anonTTL;
+ try {
+ config.retention.anonTTL = 15 * 60 * 1000;
+ assert.ok(factsOf(frameFor('mp3.openvibe.tools')).includes('Runs on our server; your file is deleted after 15 minutes'), 'the lifetime follows the config');
+ config.upload.maxFileSize = 20 * 1024 * 1024;
+ assert.ok(factsOf(frameFor('mp3.openvibe.tools')).includes('One file up to 20 MB'), 'the size follows the config');
+ } finally {
+ config.retention.anonTTL = saved;
+ config.upload.maxFileSize = 100 * 1024 * 1024;
+ }
+
+ // A tool that takes several files says so.
+ assert.ok(factsOf(frameFor('merge.openvibe.tools')).includes('Up to 5 files, 100 MB each'), 'merge: the file count from its descriptor');
+}
+
+// ── The box's options are the tool's own descriptor ──
+{
+ const spec = SPECS.find((s) => s.id === 'trim');
+ const html = frameFor('trim.openvibe.tools');
+ for (const name of Object.keys(spec.input.properties)) {
+ assert.ok(html.includes(`name="${name}"`), `trim: the descriptor's field ${name} is on the page`);
+ }
+ assert.ok(!html.includes('name="bitrate"'), 'trim: a field the descriptor does not have is not on the page');
+
+ // The range and the default are the tool's own.
+ assert.ok(html.includes('name="start"'), 'trim: the start field');
+ assert.ok(/name="bitrate"[^>]*min="32"[^>]*max="320"/.test(frameFor('mp3.openvibe.tools')), 'mp3: the bitrate range is the descriptor\'s');
+}
+
+// ── The one button says the verb ──
+{
+ assert.strictEqual(verbFor({ id: 'trim', verb: 'Trim' }, 'trim.openvibe.tools'), 'Trim');
+ assert.strictEqual(verbFor({ id: 'vocal', verb: 'Remove vocals' }, 'karaoke.openvibe.tools'), 'Make karaoke');
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);
+ assert.ok(m, `${host}: a verb button`);
+ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);
+ }
+ assert.ok(/<span>Convert to MP3<\/span>/.test(frameFor('mp3.openvibe.tools')), 'mp3: the button says the verb');
+}
+
+// ── The page the server sends: the frame, the title, the canonical and the structured data ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-audio-frame-'));
+ const app = await startApp('audio', { DATA_DIR: tmp });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ for (const host of HOSTS) {
+ const r = await get(host);
+ assert.strictEqual(r.status, 200, `${host}: answers 200`);
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
+ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
+ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
+ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log(`tool frame (audio): ${HOSTS.length} audio pages framed, the terms from the config, the options from the descriptors: all checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -38,14 +38,17 @@ body {
@@ -38,14 +38,17 @@ body {
flex-direction: column;
flex-direction: column;
}
}
+/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */
#app {
#app {
flex: 1;
flex: 1;
- max-width: 900px;
width: 100%;
width: 100%;
- margin: 0 auto;
- padding: 0 20px 40px;
}
}
+/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+
/* ── Hero ───────────────────────────────────────────────────── */
/* ── Hero ───────────────────────────────────────────────────── */
.hero {
.hero {
text-align: center;
text-align: center;
@@ -475,4 +478,7 @@ body {
@@ -475,4 +478,7 @@ body {
}
}
.check-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 14px; color: var(--text-secondary); cursor: pointer; }
.check-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 14px; color: var(--text-secondary); cursor: pointer; }
+
+/* The page is the shared tool frame: it starts under the navbar, not behind it. */
+.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
@@ -26,197 +26,9 @@
@@ -26,197 +26,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
<div id="app">
- <!-- Hero -->
- <section class="hero" id="hero">
- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-file-pdf"></i></div>
- <h1 id="hero-title">Docs.OpenVibe</h1>
- <p class="hero-subtitle" id="hero-subtitle">Online PDF & Document Tools</p>
- </section>
-
- <!-- Tool Selector (visible on hub domain) -->
- <nav class="tool-tabs" id="tool-tabs">
- <button class="tool-tab active" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>
- <button class="tool-tab" data-tool="split"><i class="fa-solid fa-scissors"></i> Split</button>
- <button class="tool-tab" data-tool="compress"><i class="fa-solid fa-compress"></i> Compress</button>
- <button class="tool-tab" data-tool="rotate"><i class="fa-solid fa-rotate"></i> Rotate</button>
- <button class="tool-tab" data-tool="reorder"><i class="fa-solid fa-arrow-down-wide-short"></i> Reorder</button>
- <button class="tool-tab" data-tool="watermark"><i class="fa-solid fa-signature"></i> Watermark</button>
- <button class="tool-tab" data-tool="img2pdf"><i class="fa-solid fa-file-image"></i> IMG→PDF</button>
- <button class="tool-tab" data-tool="pdf2img"><i class="fa-solid fa-image"></i> PDF→IMG</button>
- </nav>
-
- <!-- Upload Zone -->
- <section class="upload-zone" id="upload-zone">
- <div class="upload-area" id="upload-area">
- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
- <p class="upload-text">Drag & drop files here</p>
- <p class="upload-hint" id="upload-hint">or click to browse — PDF, PNG, JPG, WebP, TIFF, BMP, GIF</p>
- <input type="file" id="file-input" accept=".pdf,image/*" hidden>
- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose Files</button>
- </div>
-
- <!-- Single file info -->
- <div class="upload-info" id="upload-info" style="display:none">
- <div class="file-preview" id="file-preview"><i class="fa-solid fa-file-pdf"></i></div>
- <div class="file-details" id="file-details"></div>
- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>
- </div>
-
- <!-- Multi-file list -->
- <div id="multi-file-zone" style="display:none">
- <ul class="file-list" id="file-list"></ul>
- <div style="display:flex;gap:8px;justify-content:center;margin-top:8px">
- <button class="btn btn-ghost" id="add-more-btn"><i class="fa-solid fa-plus"></i> Add More</button>
- <button class="btn btn-ghost" id="clear-all-btn"><i class="fa-solid fa-xmark"></i> Clear All</button>
- </div>
- <input type="file" id="multi-file-input" accept=".pdf,image/*" multiple hidden>
- </div>
- </section>
-
- <!-- Tool Options -->
- <section class="options-panel" id="options-panel" style="display:none">
- <!-- Split Options -->
- <div class="option-group" id="opt-split" style="display:none">
- <label class="option-label">Split Mode</label>
- <select id="split-mode" class="select-input">
- <option value="ranges">By Page Ranges</option>
- <option value="all">Every Page Separate</option>
- <option value="half">Split in Half</option>
- </select>
- <div id="split-ranges-input" style="margin-top:12px">
- <label class="option-label">Page Ranges</label>
- <input type="text" id="split-ranges" class="text-input" placeholder="e.g. 1-3, 5, 7-9">
- <p class="option-hint">Comma-separated page numbers or ranges</p>
- </div>
- <p class="option-hint">Several parts download as one ZIP of PDF files.</p>
- </div>
-
- <!-- Compress Options -->
- <div class="option-group" id="opt-compress" style="display:none">
- <label class="option-label">Compression Level</label>
- <select id="compress-level" class="select-input">
- <option value="low">Low (best quality)</option>
- <option value="medium" selected>Medium (balanced)</option>
- <option value="high">High (smallest file)</option>
- </select>
- </div>
-
- <!-- Rotate Options -->
- <div class="option-group" id="opt-rotate" style="display:none">
- <label class="option-label">Rotation Angle</label>
- <div class="tool-tabs" style="margin-bottom:12px">
- <button class="tool-tab active" data-angle="90">90° CW</button>
- <button class="tool-tab" data-angle="180">180°</button>
- <button class="tool-tab" data-angle="270">90° CCW</button>
- </div>
- <label class="option-label">Pages</label>
- <input type="text" id="rotate-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
- </div>
-
- <!-- Reorder Options -->
- <div class="option-group" id="opt-reorder" style="display:none">
- <label class="option-label">New Page Order</label>
- <input type="text" id="reorder-order" class="text-input" placeholder="e.g. 3,1,2,5,4">
- <p class="option-hint">Comma-separated page numbers in the desired order. Omit pages to remove them.</p>
- </div>
-
- <!-- Watermark Options -->
- <div class="option-group" id="opt-watermark" style="display:none">
- <label class="option-label">Watermark Text</label>
- <input type="text" id="wm-text" class="text-input" placeholder="CONFIDENTIAL" value="CONFIDENTIAL">
- <div class="page-inputs" style="margin-top:12px">
- <div class="dim-field">
- <label class="option-label">Font Size</label>
- <input type="number" id="wm-fontsize" class="num-input" value="48" min="8" max="200">
- </div>
- <div class="dim-field">
- <label class="option-label">Opacity <span id="wm-opacity-val">15</span>%</label>
- <input type="range" id="wm-opacity" class="slider" min="1" max="100" value="15">
- </div>
- <div class="dim-field">
- <label class="option-label">Rotation</label>
- <input type="number" id="wm-rotation" class="num-input" value="-45" min="-180" max="180">
- </div>
- <div class="dim-field">
- <label class="option-label">Color</label>
- <input type="color" id="wm-color" value="#888888" style="width:100%;height:38px;padding:2px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);">
- </div>
- </div>
- <label class="option-label" style="margin-top:12px">Pages</label>
- <input type="text" id="wm-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
- </div>
-
- <!-- Image to PDF Options -->
- <div class="option-group" id="opt-img2pdf" style="display:none">
- <label class="option-label">Page Size</label>
- <select id="img2pdf-pagesize" class="select-input">
- <option value="a4" selected>A4</option>
- <option value="letter">US Letter</option>
- <option value="legal">US Legal</option>
- <option value="fit">Fit to Image</option>
- </select>
- </div>
-
- <!-- PDF to Image Options -->
- <div class="option-group" id="opt-pdf2img" style="display:none">
- <label class="option-label">Output Format</label>
- <div class="tool-tabs" style="margin-bottom:12px">
- <button class="tool-tab active" data-imgfmt="png">PNG</button>
- <button class="tool-tab" data-imgfmt="jpg">JPG</button>
- </div>
- <label class="option-label">DPI</label>
- <input type="number" id="pdf2img-dpi" class="num-input num-input-sm" value="150" min="72" max="600">
- <label class="option-label" style="margin-top:12px">Pages</label>
- <input type="text" id="pdf2img-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
- <p class="option-hint">Up to 50 pages at 150 DPI (20 at 300 DPI, 5 above). One page downloads as an image, several as a ZIP.</p>
- </div>
-
- <!-- Protect Options -->
- <div class="option-group" id="opt-protect" style="display:none">
- <label class="option-label">Password</label>
- <input type="password" id="protect-password" class="text-input" placeholder="Enter a password" autocomplete="new-password" maxlength="127">
- <label class="check-row"><input type="checkbox" id="protect-print" checked> Allow printing</label>
- <label class="check-row"><input type="checkbox" id="protect-copy" checked> Allow copying text</label>
- <p class="option-hint">Encrypted with AES-256. The password cannot be recovered, so keep it safe.</p>
- </div>
-
- <!-- Unlock Options -->
- <div class="option-group" id="opt-unlock" style="display:none">
- <label class="option-label">Current Password</label>
- <input type="password" id="unlock-password" class="text-input" placeholder="Enter the PDF password">
- </div>
-
- <!-- Process Button -->
- <button class="btn btn-primary btn-lg" id="process-btn" disabled style="margin-top:20px;width:100%">
- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Document</span>
- </button>
- </section>
-
- <!-- Processing State -->
- <section class="processing" id="processing" style="display:none">
- <div class="spinner"></div>
- <p>Processing your document...</p>
- </section>
-
- <!-- Result -->
- <section class="result-panel" id="result-panel" style="display:none">
- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>
- <div class="result-info" id="result-info"></div>
- <div class="result-actions">
- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>
- <i class="fa-solid fa-download"></i> Download
- </a>
- <button class="btn btn-secondary" id="another-btn">
- <i class="fa-solid fa-rotate-left"></i> Process Another
- </button>
- </div>
- </section>
-
- <!-- Subdomains Grid (hub only) -->
- <section class="subdomains" id="subdomains">
- <h2>All Document Tools</h2>
- <div class="subdomain-grid" id="subdomain-grid"></div>
- </section>
+ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
+ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
+ <!--OVK:FRAME-->
</div>
</div>
<!-- Footer -->
<!-- Footer -->
@@ -227,7 +39,11 @@
@@ -227,7 +39,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
+ <script src="/shared/kit.js" defer></script>
+ <script src="/shared/kit-web.js" defer></script>
+ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/ov-jobs.js"></script>
+ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
</body>
</body>
</html>
</html>
@@ -1,51 +1,27 @@
@@ -1,51 +1,27 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Docs.OpenVibe — Client Application
// Docs.OpenVibe — Client Application
-// Domain-aware document processing SPA.
-// Detects hostname → fetches context → adapts branding + tools.
+// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,
+// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop
+// zone, the tool's own options and the one button) and the answer (the file it made, its size before
+// and after, and Download). The upload, the processing and the download are the app's own, unchanged.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function () {
(function () {
'use strict';
'use strict';
// ── State ────────────────────────────────────────────────
// ── State ────────────────────────────────────────────────
- let ctx = null; // domain context from /api/context
- let selectedFiles = []; // File objects (single or multi)
- let selectedTool = 'merge';
- let selectedAngle = 90;
- let selectedImgFmt = 'png';
+ let ctx = null; // domain context from /api/context
+ let selectedFiles = []; // the File objects the drop zone holds
// ── DOM refs ─────────────────────────────────────────────
// ── DOM refs ─────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const $$ = (sel) => document.querySelectorAll(sel);
- const heroIcon = $('#hero-icon');
- const heroTitle = $('#hero-title');
- const heroSubtitle = $('#hero-subtitle');
- const toolTabs = $('#tool-tabs');
- const uploadArea = $('#upload-area');
- const uploadHint = $('#upload-hint');
- const uploadInfo = $('#upload-info');
- const multiFileZone = $('#multi-file-zone');
const fileInput = $('#file-input');
const fileInput = $('#file-input');
- const multiFileInput = $('#multi-file-input');
- const browseBtn = $('#browse-btn');
- const clearBtn = $('#clear-btn');
- const addMoreBtn = $('#add-more-btn');
- const clearAllBtn = $('#clear-all-btn');
- const fileList = $('#file-list');
const optionsPanel = $('#options-panel');
const optionsPanel = $('#options-panel');
const processBtn = $('#process-btn');
const processBtn = $('#process-btn');
- const processText = $('#process-text');
- const processingEl = $('#processing');
- const resultPanel = $('#result-panel');
- const resultInfo = $('#result-info');
- const downloadBtn = $('#download-btn');
- const anotherBtn = $('#another-btn');
- const subdomainsSection = $('#subdomains');
- const subdomainGrid = $('#subdomain-grid');
-
- // Multi-file tools
- const MULTI_FILE_TOOLS = new Set(['merge', 'img2pdf']);
+ const clearBtn = $('#clear-btn');
+ const kit = () => window.OpenVibeKit;
// ── Init ─────────────────────────────────────────────────
// ── Init ─────────────────────────────────────────────────
async function init() {
async function init() {
@@ -53,104 +29,43 @@
@@ -53,104 +29,43 @@
const res = await fetch('/api/context');
const res = await fetch('/api/context');
ctx = await res.json();
ctx = await res.json();
} catch {
} catch {
- ctx = { toolId: 'hub', brandName: 'Docs.OpenVibe', defaultOp: null, faIcon: 'fa-file-pdf', tools: [] };
+ ctx = { toolId: 'hub', brandName: 'Docs.OpenVibe', tools: [] };
}
}
- applyBranding();
- showAvailability();
initNavbar();
initNavbar();
initNotifications();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
- initToolTabs();
- initUpload();
+ showAvailability();
+ initDrop();
initOptions();
initOptions();
- initSubdomains();
- initResult();
reattach();
reattach();
}
}
- // ── Branding ─────────────────────────────────────────────
- function applyBranding() {
- document.title = ctx.seoTitle || `${ctx.brandName} — Document Tools`;
- heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon || 'fa-file-pdf'}"></i>`;
- heroTitle.textContent = ctx.brandName || 'Docs.OpenVibe';
-
- const subtitles = {
- hub: 'Online PDF & Document Tools',
- merge: 'Combine PDF Files Online',
- split: 'Split PDF Files Online',
- compress: 'Compress PDF Files Online',
- rotate: 'Rotate PDF Pages Online',
- reorder: 'Rearrange PDF Pages Online',
- watermark: 'Add Watermarks to PDFs Online',
- protect: 'Password Protect PDFs Online',
- unlock: 'Remove PDF Password Online',
- img2pdf: 'Convert Images to PDF Online',
- pdf2img: 'Convert PDF to Images Online',
- };
- heroSubtitle.textContent = subtitles[ctx.toolId] || ctx.seoDescription || subtitles.hub;
-
- if (ctx.defaultOp) selectedTool = ctx.defaultOp;
-
- // Hide tool tabs on single-tool domains
- if (ctx.toolId !== 'hub') {
- toolTabs.style.display = 'none';
- }
-
- // Hide subdomains section on non-hub domains
- if (ctx.toolId !== 'hub') {
- subdomainsSection.style.display = 'none';
- }
-
- // Update upload hint based on tool
- updateUploadHint();
-
- // Update meta description
- const metaDesc = document.querySelector('meta[name="description"]');
- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
- }
-
- // A tool whose server-side program is not installed yet (Protect/Unlock need qpdf, PDF to image
- // needs poppler): the page says so instead of letting an upload fail.
+ // ── A tool whose command-line tools are not on the server yet ──
+ // Protect and Unlock need qpdf, PDF to images needs poppler: while one is missing the tool answers
+ // 503, so the page says so instead of letting the upload fail.
function showAvailability() {
function showAvailability() {
- const tool = (ctx.tools || []).find(t => t.id === selectedTool);
+ const op = (ctx && ctx.defaultOp) || null;
+ const tool = (ctx.tools || []).find((t) => t.id === op);
const off = !!(tool && tool.available === false);
const off = !!(tool && tool.available === false);
+ const card = document.querySelector('.ovk-tool-card');
+ if (!card) return;
let note = document.getElementById('tool-notice');
let note = document.getElementById('tool-notice');
- const zone = document.getElementById('upload-zone');
- if (off && !note && zone) {
+ if (off && !note) {
note = document.createElement('div');
note = document.createElement('div');
note.id = 'tool-notice';
note.id = 'tool-notice';
note.className = 'tool-notice';
note.className = 'tool-notice';
note.setAttribute('role', 'status');
note.setAttribute('role', 'status');
- zone.parentNode.insertBefore(note, zone);
+ card.appendChild(note);
}
}
if (note) {
if (note) {
note.textContent = off ? `${tool.label} is being set up on the server and is not available yet. The other PDF tools work now.` : '';
note.textContent = off ? `${tool.label} is being set up on the server and is not available yet. The other PDF tools work now.` : '';
note.style.display = off ? '' : 'none';
note.style.display = off ? '' : 'none';
}
}
- if (zone) zone.style.display = off ? 'none' : '';
- if (off) { optionsPanel.style.display = 'none'; processBtn.disabled = true; }
- }
-
- function updateUploadHint() {
- const isImageTool = selectedTool === 'img2pdf';
- const isPdfTool = !isImageTool;
- if (isImageTool) {
- uploadHint.textContent = 'or click to browse — PNG, JPG, WebP, TIFF, BMP, GIF';
- fileInput.accept = 'image/*';
- multiFileInput.accept = 'image/*';
- } else {
- uploadHint.textContent = 'or click to browse — PDF files';
- fileInput.accept = '.pdf,application/pdf';
- multiFileInput.accept = '.pdf,application/pdf';
- }
-
- // Multi-file tools allow multiple selection
- if (MULTI_FILE_TOOLS.has(selectedTool)) {
- fileInput.multiple = true;
- } else {
- fileInput.multiple = false;
- }
+ const drop = document.querySelector('.ovk-drop');
+ if (drop) drop.hidden = off;
+ if (optionsPanel) optionsPanel.hidden = off || optionsPanel.hidden;
+ if (off && processBtn) processBtn.disabled = true;
}
}
// ── Navbar ───────────────────────────────────────────────
// ── Navbar ───────────────────────────────────────────────
@@ -158,13 +73,11 @@
@@ -158,13 +73,11 @@
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
let user = null;
let user = null;
if (token) {
if (token) {
- try {
- const payload = JSON.parse(atob(token.split('.')[1]));
- user = payload;
- } catch { /* invalid token */ }
+ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }
}
}
- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").
+ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and
+ // code tool pages carry (owner ask 11).
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
OpenVibeNavbar.init({
OpenVibeNavbar.init({
service: 'docs',
service: 'docs',
@@ -173,39 +86,41 @@
@@ -173,39 +86,41 @@
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: ctx?.brandName || 'Docs.OpenVibe' },
history: { type: 'tool', title: ctx?.brandName || 'Docs.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
+ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
});
}
}
initFooter();
initFooter();
}
}
/* ---------- Footer (shared OpenVibeFooter) ---------- */
/* ---------- Footer (shared OpenVibeFooter) ---------- */
- // Full footer on the hub host, the compact strip on the single-tool hosts.
function initFooter() {
function initFooter() {
if (typeof OpenVibeFooter === 'undefined') return;
if (typeof OpenVibeFooter === 'undefined') return;
try {
try {
OpenVibeFooter.init({
OpenVibeFooter.init({
service: 'docs',
service: 'docs',
- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',
+ variant: 'compact',
mount: '#ov-footer',
mount: '#ov-footer',
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
- links: [{ heading: 'Docs tools', items: [
- { name: 'Merge PDF', url: 'https://mergepdf.openvibe.tools' },
- { name: 'Split PDF', url: 'https://splitpdf.openvibe.tools' },
- { name: 'Compress PDF', url: 'https://compresspdf.openvibe.tools' },
- { name: 'Rotate PDF', url: 'https://rotatepdf.openvibe.tools' },
- { name: 'Watermark PDF', url: 'https://watermarkpdf.openvibe.tools' },
- { name: 'Protect PDF', url: 'https://protectpdf.openvibe.tools' },
- { name: 'Images to PDF', url: 'https://image2pdf.openvibe.tools' },
- { name: 'PDF to images', url: 'https://pdf2jpg.openvibe.tools' },
+ brandName: 'OpenVibe.Tools',
+ links: [{ heading: 'PDF tools', items: [
+ { name: 'Merge', url: 'https://mergepdf.openvibe.tools' },
+ { name: 'Split', url: 'https://splitpdf.openvibe.tools' },
+ { name: 'Compress', url: 'https://compresspdf.openvibe.tools' },
+ { name: 'Rotate', url: 'https://rotatepdf.openvibe.tools' },
+ { name: 'Watermark', url: 'https://watermarkpdf.openvibe.tools' },
+ { name: 'Protect', url: 'https://protectpdf.openvibe.tools' },
] }],
] }],
});
});
} catch { /* optional */ }
} catch { /* optional */ }
}
}
+ // ── Notifications (OpenVibe bell + toasts) ───────────
function initNotifications() {
function initNotifications() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
if (typeof OpenVibeNotifications === 'undefined') return;
if (typeof OpenVibeNotifications === 'undefined') return;
+
OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });
OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });
+
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
const mount = OpenVibeNavbar.getBellMount();
const mount = OpenVibeNavbar.getBellMount();
if (mount) {
if (mount) {
@@ -215,255 +130,90 @@
@@ -215,255 +130,90 @@
}
}
}
}
- // ── Tool Tabs ────────────────────────────────────────────
- function initToolTabs() {
- $$('.tool-tab[data-tool]').forEach(tab => {
- if (tab.dataset.tool === selectedTool) tab.classList.add('active');
- else tab.classList.remove('active');
-
- tab.addEventListener('click', () => {
- selectedTool = tab.dataset.tool;
- $$('.tool-tab[data-tool]').forEach(t => t.classList.toggle('active', t.dataset.tool === selectedTool));
- resetUpload();
- updateUploadHint();
- updateProcessButton();
- showAvailability();
- });
- });
- }
-
- // ── Upload ───────────────────────────────────────────────
- function initUpload() {
- uploadArea.addEventListener('dragover', (e) => {
- e.preventDefault();
- uploadArea.classList.add('dragover');
- });
- uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
- uploadArea.addEventListener('drop', (e) => {
- e.preventDefault();
- uploadArea.classList.remove('dragover');
- if (e.dataTransfer.files.length) handleFiles(Array.from(e.dataTransfer.files));
- });
-
- uploadArea.addEventListener('click', () => fileInput.click());
- browseBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });
+ // ── The box: the drop zone ───────────────────────────────
+ function initDrop() {
+ if (!fileInput) return;
fileInput.addEventListener('change', () => {
fileInput.addEventListener('change', () => {
- if (fileInput.files.length) handleFiles(Array.from(fileInput.files));
+ const files = Array.prototype.slice.call(fileInput.files || []);
+ if (files.length) handleFiles(files);
});
});
+ if (clearBtn) clearBtn.addEventListener('click', resetUpload);
- // Multi-file controls
- addMoreBtn.addEventListener('click', () => multiFileInput.click());
- multiFileInput.addEventListener('change', () => {
- if (multiFileInput.files.length) handleFiles(Array.from(multiFileInput.files), true);
- });
-
- clearBtn.addEventListener('click', resetUpload);
- clearAllBtn.addEventListener('click', resetUpload);
- }
-
- function handleFiles(files, append = false) {
- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);
-
- // Validate file types
- for (const file of files) {
- if (selectedTool === 'img2pdf') {
- if (!file.type.startsWith('image/')) {
- alert(`"${file.name}" is not an image file.`);
- return;
- }
- } else {
- if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
- alert(`"${file.name}" is not a PDF file.`);
- return;
- }
- }
- if (file.size > 100 * 1024 * 1024) {
- alert(`"${file.name}" is too large. Maximum 100MB per file.`);
- return;
- }
- }
-
- if (isMulti) {
- if (append) {
- selectedFiles = [...selectedFiles, ...files];
- } else {
- selectedFiles = [...files];
- }
- showMultiFileList();
- } else {
- selectedFiles = [files[0]];
- showSingleFile(files[0]);
+ const sampleLink = document.querySelector('[data-ovk-sample]');
+ if (sampleLink && window.OVToolFrame) {
+ sampleLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), (file) => handleFiles([file]));
+ });
}
}
-
- optionsPanel.style.display = '';
- showToolOptions();
- updateProcessButton();
}
}
- function showSingleFile(file) {
- uploadArea.style.display = 'none';
- multiFileZone.style.display = 'none';
- uploadInfo.style.display = 'flex';
-
- const preview = $('#file-preview');
- const details = $('#file-details');
-
- if (file.type === 'application/pdf') {
- preview.innerHTML = '<i class="fa-solid fa-file-pdf"></i>';
- } else {
- const url = URL.createObjectURL(file);
- preview.innerHTML = `<img src="${url}" alt="Preview">`;
+ function handleFiles(files) {
+ const many = fileInput.hasAttribute('multiple');
+ const max = many ? 50 : 1;
+ if (files.length > max) {
+ fail(`That is more than ${max} file${max === 1 ? '' : 's'}.`);
+ return;
}
}
-
- details.innerHTML = `
- <div class="fname">${escapeHtml(file.name)}</div>
- <div class="fmeta">${file.type || 'application/pdf'} • ${formatBytes(file.size)}</div>
- `;
- }
-
- function showMultiFileList() {
- uploadArea.style.display = 'none';
- uploadInfo.style.display = 'none';
- multiFileZone.style.display = '';
-
- fileList.innerHTML = selectedFiles.map((f, i) => `
- <li>
- <span class="fname"><i class="fa-solid ${f.type === 'application/pdf' ? 'fa-file-pdf' : 'fa-file-image'}" style="color:var(--accent);margin-right:8px"></i>${escapeHtml(f.name)}</span>
- <span class="fsize">${formatBytes(f.size)}</span>
- <button class="remove-file" data-idx="${i}" title="Remove"><i class="fa-solid fa-xmark"></i></button>
- </li>
- `).join('');
-
- // Bind remove buttons
- fileList.querySelectorAll('.remove-file').forEach(btn => {
- btn.addEventListener('click', () => {
- const idx = parseInt(btn.dataset.idx);
- selectedFiles.splice(idx, 1);
- if (selectedFiles.length === 0) {
- resetUpload();
- } else {
- showMultiFileList();
- updateProcessButton();
- }
- });
- });
+ const tooBig = files.find((f) => f.size > 100 * 1024 * 1024);
+ if (tooBig) {
+ fail('That file is too large. The maximum is 100 MB.');
+ return;
+ }
+ selectedFiles = files;
+ if (optionsPanel) optionsPanel.hidden = false;
+ if (processBtn) processBtn.disabled = false;
+ hideAnswer();
}
}
function resetUpload() {
function resetUpload() {
selectedFiles = [];
selectedFiles = [];
- fileInput.value = '';
- multiFileInput.value = '';
- uploadArea.style.display = '';
- uploadInfo.style.display = 'none';
- multiFileZone.style.display = 'none';
- optionsPanel.style.display = 'none';
- resultPanel.style.display = 'none';
- processingEl.style.display = 'none';
- processBtn.disabled = true;
+ if (fileInput) fileInput.value = '';
+ if (optionsPanel) optionsPanel.hidden = true;
+ if (processBtn) processBtn.disabled = true;
+ hideAnswer();
}
}
- // ── Options ──────────────────────────────────────────────
+ // ── The box: the options and the one button ─────────────
function initOptions() {
function initOptions() {
- // Split mode toggle
- const splitMode = $('#split-mode');
- if (splitMode) {
- splitMode.addEventListener('change', () => {
- const rangesInput = $('#split-ranges-input');
- rangesInput.style.display = splitMode.value === 'ranges' ? '' : 'none';
- });
- }
-
- // Watermark opacity slider
- const wmOpacity = $('#wm-opacity');
- const wmOpacityVal = $('#wm-opacity-val');
- if (wmOpacity) {
- wmOpacity.addEventListener('input', () => { wmOpacityVal.textContent = wmOpacity.value; });
- }
-
- // Angle buttons
- $$('[data-angle]').forEach(btn => {
- btn.addEventListener('click', () => {
- selectedAngle = parseInt(btn.dataset.angle);
- $$('[data-angle]').forEach(b => b.classList.toggle('active', b.dataset.angle === btn.dataset.angle));
- });
- });
-
- // Image format buttons
- $$('[data-imgfmt]').forEach(btn => {
- btn.addEventListener('click', () => {
- selectedImgFmt = btn.dataset.imgfmt;
- $$('[data-imgfmt]').forEach(b => b.classList.toggle('active', b.dataset.imgfmt === btn.dataset.imgfmt));
- });
- });
-
- processBtn.addEventListener('click', processDocument);
+ if (processBtn) processBtn.addEventListener('click', processDocument);
}
}
- function showToolOptions() {
- // Hide all option groups
- const groups = ['opt-split', 'opt-compress', 'opt-rotate', 'opt-reorder',
- 'opt-watermark', 'opt-img2pdf', 'opt-pdf2img', 'opt-protect', 'opt-unlock'];
- groups.forEach(id => {
- const el = $(`#${id}`);
- if (el) el.style.display = 'none';
+ /** The tool's own options, as the job's input: the fields the descriptor names, and nothing else. */
+ function collectInput() {
+ const form = document.querySelector('#ovk-docs-form');
+ const input = {};
+ if (!form) return input;
+ form.querySelectorAll('input[name], select[name], textarea[name]').forEach((el) => {
+ if (el.type === 'hidden') { input[el.name] = el.value; return; }
+ // The drop zone's own file field is not an option: the file goes in the job's files.
+ if (el.type === 'file') return;
+ if (el.type === 'radio') { if (el.checked) input[el.name] = el.value; return; }
+ if (el.type === 'checkbox') { input[el.name] = el.checked; return; }
+ if (el.value === '') return;
+ input[el.name] = el.value;
});
});
-
- // Show active tool options
- const optEl = $(`#opt-${selectedTool}`);
- if (optEl) optEl.style.display = '';
- }
-
- function updateProcessButton() {
- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);
- const hasFiles = selectedFiles.length > 0;
- const hasEnoughFiles = isMulti ? selectedFiles.length >= (selectedTool === 'merge' ? 2 : 1) : hasFiles;
- processBtn.disabled = !hasEnoughFiles;
-
- const labels = {
- merge: 'Merge PDFs',
- split: 'Split PDF',
- compress: 'Compress PDF',
- rotate: 'Rotate Pages',
- reorder: 'Reorder Pages',
- watermark: 'Add Watermark',
- protect: 'Protect PDF',
- unlock: 'Unlock PDF',
- img2pdf: 'Convert to PDF',
- pdf2img: 'Convert to Images',
- metadata: 'View Info',
- };
- processText.textContent = labels[selectedTool] || 'Process Document';
+ return input;
}
}
// ── Process ──────────────────────────────────────────────
// ── Process ──────────────────────────────────────────────
async function processDocument() {
async function processDocument() {
- if (selectedFiles.length === 0) return;
-
- optionsPanel.style.display = 'none';
- resultPanel.style.display = 'none';
- processingEl.style.display = '';
- processBtn.disabled = true;
-
- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);
- const formData = new FormData();
- formData.append('tool', selectedTool);
-
- if (isMulti) {
- for (const file of selectedFiles) {
- formData.append('files', file);
- }
- } else {
- formData.append('file', selectedFiles[0]);
+ if (!selectedFiles.length) return;
+
+ const input = collectInput();
+ if (processBtn) {
+ processBtn.disabled = true;
+ const span = processBtn.querySelector('span');
+ // The button keeps its verb while it works: "Compress…", not "Working…".
+ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;
}
}
+ showWorking();
- // Add tool-specific options
- addToolOptions(formData);
-
- // Jobs: the work is accepted, followed live and survives a reload (?job=<id> in the address).
- const input = {};
- for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;
try {
try {
- const job = await OVJobs.submit({ type: 'docs.process', input, files: isMulti ? selectedFiles : [selectedFiles[0]], fileField: isMulti ? 'files' : 'file' });
+ const many = selectedFiles.length > 1;
+ const job = await OVJobs.submit({
+ type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',
+ });
OVJobs.remember(job.id);
OVJobs.remember(job.id);
followJob(job);
followJob(job);
} catch (err) {
} catch (err) {
@@ -471,99 +221,29 @@
@@ -471,99 +221,29 @@
}
}
}
}
- function addToolOptions(formData) {
- switch (selectedTool) {
- case 'split': {
- const mode = $('#split-mode').value;
- if (mode === 'ranges') {
- formData.append('ranges', $('#split-ranges').value);
- } else {
- formData.append('mode', mode);
- }
- break;
- }
- case 'compress':
- formData.append('level', $('#compress-level').value);
- break;
- case 'rotate':
- formData.append('angle', selectedAngle);
- formData.append('pages', $('#rotate-pages').value || 'all');
- break;
- case 'reorder':
- formData.append('order', $('#reorder-order').value);
- break;
- case 'watermark':
- formData.append('text', $('#wm-text').value);
- formData.append('fontSize', $('#wm-fontsize').value);
- formData.append('opacity', ($('#wm-opacity').value / 100).toString());
- formData.append('rotation', $('#wm-rotation').value);
- formData.append('color', $('#wm-color').value);
- formData.append('pages', $('#wm-pages').value || 'all');
- break;
- case 'img2pdf':
- formData.append('pageSize', $('#img2pdf-pagesize').value);
- break;
- case 'pdf2img':
- formData.append('format', selectedImgFmt);
- formData.append('dpi', $('#pdf2img-dpi').value);
- formData.append('pages', $('#pdf2img-pages').value || 'all');
- break;
- case 'protect':
- formData.append('password', $('#protect-password').value);
- formData.append('allowPrint', $('#protect-print').checked ? 'true' : 'false');
- formData.append('allowCopy', $('#protect-copy').checked ? 'true' : 'false');
- break;
- case 'unlock':
- formData.append('password', $('#unlock-password').value);
- break;
- }
- }
-
// ── Jobs ─────────────────────────────────────────────────
// ── Jobs ─────────────────────────────────────────────────
let watcher = null;
let watcher = null;
- let currentJob = null;
function processingText(job) {
function processingText(job) {
const pct = job.progress && job.progress.percent;
const pct = job.progress && job.progress.percent;
- if (job.cancel_requested) return 'Cancelling...';
- if (job.state === 'queued') return 'Waiting for a free slot...';
- const label = (job.progress && job.progress.message) || 'Processing your document';
- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;
- }
-
- function cancelButton() {
- let btn = processingEl.querySelector('.job-cancel');
- if (!btn) {
- btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'btn-secondary job-cancel';
- btn.textContent = 'Cancel';
- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });
- processingEl.appendChild(btn);
- }
- return btn;
+ if (job.cancel_requested) return 'Cancelling…';
+ if (job.state === 'queued') return 'Waiting for a free slot…';
+ const label = (job.progress && job.progress.message) || 'Working on your document';
+ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;
}
}
function followJob(job) {
function followJob(job) {
- currentJob = job.id;
- optionsPanel.style.display = 'none';
- resultPanel.style.display = 'none';
- processingEl.style.display = '';
- processBtn.disabled = true;
- const label = processingEl.querySelector('p');
- const update = (j) => { if (label) label.textContent = processingText(j); };
- cancelButton().style.display = '';
- update(job);
+ hideAnswer();
+ showWorking();
if (watcher) watcher.close();
if (watcher) watcher.close();
watcher = OVJobs.watch(job.id, {
watcher = OVJobs.watch(job.id, {
- onUpdate: update,
+ onUpdate: (j) => {
+ const out = document.querySelector('.ovk-tool-out');
+ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));
+ },
onDone(done) {
onDone(done) {
watcher = null;
watcher = null;
- cancelButton().style.display = 'none';
- if (done.state === 'succeeded') {
- const data = jobResult(done);
- return data.viewOnly ? showMetadataResult(data) : showResult(data);
- }
+ if (done.state === 'succeeded') return showResult(jobResult(done));
OVJobs.forget();
OVJobs.forget();
failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
},
},
@@ -571,20 +251,25 @@
@@ -571,20 +251,25 @@
});
});
}
}
- /** A finished job in the shape the synchronous endpoints answer with. */
+ /** A finished job in the shape the synchronous endpoint answers with. */
function jobResult(job) {
function jobResult(job) {
- const f = job.result && job.result.files && job.result.files[0];
+ const f = (job.result && job.result.files && job.result.files[0]) || {};
return {
return {
...(job.result && job.result.data),
...(job.result && job.result.data),
- download: f ? { id: job.id, downloadUrl: f.url, filename: f.name, expiresIn: OVJobs.expiresIn(job) } : undefined,
+ download: { id: job.id, downloadUrl: f.url, previewUrl: f.url ? `${f.url}?inline=1` : null, filename: f.name, expiresIn: OVJobs.expiresIn(job) },
};
};
}
}
+ function resetVerb() {
+ const span = processBtn && processBtn.querySelector('span');
+ if (span) span.textContent = span.textContent.replace(/…$/, '');
+ }
+
function failProcessing(message) {
function failProcessing(message) {
- processingEl.style.display = 'none';
- optionsPanel.style.display = selectedFiles.length ? '' : 'none';
- processBtn.disabled = !selectedFiles.length;
- if (message) alert(`Error: ${message}`);
+ resetVerb();
+ if (processBtn) processBtn.disabled = !selectedFiles.length;
+ if (message) fail(message);
+ else hideAnswer();
}
}
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
@@ -594,104 +279,50 @@
@@ -594,104 +279,50 @@
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
}
}
- // ── Result ───────────────────────────────────────────────
- function initResult() {
- anotherBtn.addEventListener('click', () => {
- if (window.OVJobs) OVJobs.forget();
- resetUpload();
- (toolTabs.style.display !== 'none' ? toolTabs : uploadArea).scrollIntoView({ behavior: 'smooth' });
+ // ── The answer ───────────────────────────────────────────
+ function showWorking() {
+ const out = document.querySelector('.ovk-tool-out');
+ if (!out || !kit()) return;
+ out.innerHTML = kit().answer({
+ title: 'Working…',
+ body: '<p data-ovk-working>Your document is being worked on by our server. This page stays open until it is done.</p>',
});
});
}
}
- function showResult(data) {
- processingEl.style.display = 'none';
- resultPanel.style.display = '';
-
- let infoHtml = '';
-
- if (data.output) {
- infoHtml += `<p>Format: <strong>${data.output.ext.toUpperCase()}</strong> • Size: <strong>${data.output.sizeKB} KB</strong></p>`;
- }
- if (data.pageCount !== undefined) {
- infoHtml += `<p>Pages: <strong>${data.pageCount}</strong></p>`;
- }
- if (data.fileCount) {
- infoHtml += `<p>Files processed: <strong>${data.fileCount}</strong></p>`;
- }
- if (data.savings) {
- const pct = data.savings.savedPercent;
- if (pct > 0) {
- infoHtml += `<p class="savings">Saved ${pct}% (${formatBytes(data.savings.savedBytes)} smaller)</p>`;
- } else {
- infoHtml += `<p>File size unchanged or slightly larger.</p>`;
- }
- }
- if (data.note) {
- infoHtml += `<p style="color:var(--text-muted);font-size:12px">${escapeHtml(data.note)}</p>`;
- }
-
- const expires = data.download?.expiresIn;
- if (expires) {
- const expiresStr = expires >= 3600000 ? `${Math.round(expires / 3600000)}h` : `${Math.round(expires / 60000)}m`;
- infoHtml += `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Download expires in ${expiresStr}</p>`;
- }
-
- resultInfo.innerHTML = infoHtml;
- downloadBtn.href = data.download?.downloadUrl || '#';
- downloadBtn.download = '';
- downloadBtn.style.display = data.download ? '' : 'none';
- resultPanel.scrollIntoView({ behavior: 'smooth' });
+ function hideAnswer() {
+ const out = document.querySelector('.ovk-tool-out');
+ if (out) out.innerHTML = '';
}
}
- function showMetadataResult(data) {
- processingEl.style.display = 'none';
- resultPanel.style.display = '';
-
- const m = data.metadata || {};
- let infoHtml = '<div style="text-align:left;max-width:500px;margin:0 auto">';
- infoHtml += `<p><strong>Title:</strong> ${escapeHtml(m.title || '(none)')}</p>`;
- infoHtml += `<p><strong>Author:</strong> ${escapeHtml(m.author || '(none)')}</p>`;
- infoHtml += `<p><strong>Subject:</strong> ${escapeHtml(m.subject || '(none)')}</p>`;
- infoHtml += `<p><strong>Creator:</strong> ${escapeHtml(m.creator || '(none)')}</p>`;
- infoHtml += `<p><strong>Producer:</strong> ${escapeHtml(m.producer || '(none)')}</p>`;
- infoHtml += `<p><strong>Pages:</strong> ${m.pageCount || '?'}</p>`;
- if (m.creationDate) infoHtml += `<p><strong>Created:</strong> ${m.creationDate}</p>`;
- if (m.modificationDate) infoHtml += `<p><strong>Modified:</strong> ${m.modificationDate}</p>`;
- infoHtml += '</div>';
-
- resultInfo.innerHTML = infoHtml;
- downloadBtn.style.display = 'none';
- resultPanel.scrollIntoView({ behavior: 'smooth' });
+ function fail(message) {
+ const out = document.querySelector('.ovk-tool-out');
+ if (!out || !kit()) return;
+ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });
}
}
- // ── Subdomains Grid ──────────────────────────────────────
- function initSubdomains() {
- const tools = [
- { host: 'mergepdf.openvibe.tools', name: 'MergePDF', icon: 'fa-object-group', desc: 'Combine PDF files' },
- { host: 'splitpdf.openvibe.tools', name: 'SplitPDF', icon: 'fa-scissors', desc: 'Split by page range' },
- { host: 'compresspdf.openvibe.tools', name: 'CompressPDF', icon: 'fa-compress', desc: 'Reduce file size' },
- { host: 'rotatepdf.openvibe.tools', name: 'RotatePDF', icon: 'fa-rotate', desc: 'Rotate PDF pages' },
- { host: 'reorderpdf.openvibe.tools', name: 'ReorderPDF', icon: 'fa-arrow-down-wide-short', desc: 'Rearrange pages' },
- { host: 'watermarkpdf.openvibe.tools', name: 'WatermarkPDF', icon: 'fa-signature', desc: 'Add watermarks' },
- { host: 'protectpdf.openvibe.tools', name: 'ProtectPDF', icon: 'fa-lock', desc: 'Password protect' },
- { host: 'unlockpdf.openvibe.tools', name: 'UnlockPDF', icon: 'fa-unlock', desc: 'Remove password' },
- { host: 'image2pdf.openvibe.tools', name: 'Image2PDF', icon: 'fa-file-image', desc: 'Images to PDF' },
- { host: 'jpg2pdf.openvibe.tools', name: 'JPG2PDF', icon: 'fa-file-image', desc: 'JPG to PDF' },
- { host: 'png2pdf.openvibe.tools', name: 'PNG2PDF', icon: 'fa-file-image', desc: 'PNG to PDF' },
- { host: 'pdf2jpg.openvibe.tools', name: 'PDF2JPG', icon: 'fa-image', desc: 'PDF to JPG' },
- { host: 'pdf2png.openvibe.tools', name: 'PDF2PNG', icon: 'fa-image', desc: 'PDF to PNG' },
- { host: 'pdf.openvibe.tools', name: 'OpenVibePDF', icon: 'fa-file-pdf', desc: 'All PDF tools' },
- ];
-
- subdomainGrid.innerHTML = tools.map(t => `
- <a class="subdomain-card" href="https://${t.host}">
- <span class="sd-icon"><i class="fa-solid ${t.icon}"></i></span>
- <span class="sd-info">
- <span class="sd-name">${t.name}</span>
- <span class="sd-desc">${t.desc}</span>
- </span>
- </a>
- `).join('');
+ function showResult(data) {
+ resetVerb();
+ if (processBtn) processBtn.disabled = false;
+ const out = data.output || {};
+ const rows = [];
+ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);
+ if (data.fileCount) rows.push(['Files', String(data.fileCount)]);
+ if (data.pageCount) rows.push(['Pages', String(data.pageCount)]);
+ if (data.savings && data.savings.savedPercent) rows.push(['Smaller by', `${data.savings.savedPercent}%`, { text: 'good', tone: 'ok' }]);
+ const expires = data.download && data.download.expiresIn;
+ if (expires) {
+ const mins = Math.max(1, Math.round(expires / 60000));
+ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);
+ }
+ const html = window.OVToolFrame.fileAnswer({
+ title: 'Your document is ready',
+ before: selectedFiles.reduce((sum, f) => sum + f.size, 0) || null,
+ after: out.size != null ? out.size : null,
+ rows,
+ downloadUrl: data.download && data.download.downloadUrl,
+ filename: data.download && data.download.filename,
+ });
+ window.OVToolFrame.show(html);
}
}
// ── Helpers ──────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────
@@ -700,12 +331,6 @@
@@ -700,12 +331,6 @@
return match ? decodeURIComponent(match[1]) : null;
return match ? decodeURIComponent(match[1]) : null;
}
}
- function formatBytes(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
- }
-
function escapeHtml(str) {
function escapeHtml(str) {
const div = document.createElement('div');
const div = document.createElement('div');
div.textContent = str;
div.textContent = str;
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/docs/public/sample.pdf differ
Binary files /dev/null and b/apps/docs/public/sample.pdf differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the PDF pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// Every PDF host — merge, split, compress, rotate, protect and the rest — is a tool page in the shared
+// frame: the head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone
+// and the tool's own options), the answer (the file it made, its size before and after, and Download),
+// the family, the folds and "People also use".
+//
+// The box's options are the tool's own descriptor (server/descriptors.js): a field the descriptor does
+// not have is not on the page, and its range and its default are the ones the tool reads. The terms come
+// from the app's real configuration: the file's lifetime from config.js's retention, its size from
+// config.js's upload limits, and the rate from the guard's quota table.
+// ═══════════════════════════════════════════════════════════════
+
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const config = require('./config');
+const { DOMAIN_MAP } = require('./domain-map');
+const { SPECS } = require('./descriptors');
+
+const HUB = 'https://docs.openvibe.tools/';
+const esc = kit.esc;
+
+// The tools, in the order the family row shows them.
+const TOOL_LIST = [
+ ['merge', 'mergepdf', 'Merge PDFs', 'Merge', 'Combine several PDF files into one.', 'Merge'],
+ ['split', 'splitpdf', 'Split PDF', 'Split', 'Cut a PDF into its pages, or into the ranges you name.', 'Split'],
+ ['compress', 'compresspdf', 'Compress PDF', 'Compress', 'Make a PDF smaller without losing what is in it.', 'Compress'],
+ ['rotate', 'rotatepdf', 'Rotate PDF', 'Rotate', 'Turn the pages of a PDF around.', 'Rotate'],
+ ['reorder', 'reorderpdf', 'Reorder Pages', 'Reorder', 'Put the pages of a PDF in the order you want.', 'Reorder'],
+ ['watermark', 'watermarkpdf', 'Watermark PDF', 'Watermark', 'Write a word or a name over every page of a PDF.', 'Add watermark'],
+ ['protect', 'protectpdf', 'Protect PDF', 'Protect', 'Put a password on a PDF.', 'Protect'],
+ ['unlock', 'unlockpdf', 'Unlock PDF', 'Unlock', 'Take the password off a PDF you know.', 'Unlock'],
+ ['img2pdf', 'image2pdf', 'Images to PDF', 'Images to PDF', 'Turn pictures into a PDF.', 'Make PDF'],
+ ['pdf2img', 'pdf2jpg', 'PDF to Images', 'PDF to images', 'Turn the pages of a PDF into pictures.', 'Make pictures'],
+];
+
+const RECS = TOOL_LIST.map(([id, spec, name, short, task, verb]) => ({ id, spec, name, short, task, verb }));
+const BY_ID = new Map(RECS.map((r) => [r.id, r]));
+const SPEC = new Map(SPECS.map((s) => [s.id, s]));
+
+const HUB_TOOL = {
+ id: 'hub', name: 'Docs.OpenVibe', short: 'All tools', task: 'Every PDF tool in one place — the merge page is below, the rest are one click away.', verb: 'Merge',
+ op: 'merge', spec: 'mergepdf',
+ about: 'Every PDF tool in one place: combine files, cut one up, make it smaller, turn the pages, write on them, protect them and turn pictures into a PDF.',
+};
+
+/** The tool a host is for (the JPG and PNG hosts are the same tool as the images one). */
+function toolFor(host) {
+ const ctx = DOMAIN_MAP[host];
+ if (!ctx) return null;
+ if (ctx.toolId === 'hub') return HUB_TOOL;
+ return BY_ID.get(ctx.toolId) || null;
+}
+
+/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */
+function termsOf(rec) {
+ const spec = SPEC.get(rec.spec);
+ return frame.terms({
+ work: 'server',
+ ttlMs: config.retention.anonTTL,
+ maxBytes: config.upload.maxFileSize,
+ maxFiles: (spec && spec.files && spec.files.max) || 1,
+ quotaClass: (spec && spec.quotaClass) || 'tools-job',
+ cost: (spec && spec.cost) || 1,
+ unit: 'runs',
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ });
+}
+
+/** The labels of the descriptor's fields, in the words a person reads. */
+const FIELD_LABEL = {
+ ranges: 'Pages', angle: 'Turn by', pages: 'Pages', order: 'New order', text: 'Text', fontSize: 'Text size',
+ opacity: 'Opacity', rotation: 'Tilt', color: 'Colour', pageSize: 'Page size', format: 'Picture format',
+ dpi: 'Resolution (dpi)', password: 'Password', ownerPassword: 'Owner password', allowPrint: 'Allow printing',
+ allowCopy: 'Allow copying', level: 'Level',
+};
+
+/** The values a person reads better than the code's. */
+const VALUE_LABEL = {
+ a4: 'A4', letter: 'Letter', legal: 'Legal', fit: 'Fit the picture',
+ low: 'Light', medium: 'Medium', high: 'Heavy',
+ '90': '90°', '180': '180°', '270': '270°',
+ jpg: 'JPG', png: 'PNG',
+};
+
+/** The box's options: the tool's own descriptor fields, with their ranges and their defaults. */
+function optionsFor(rec) {
+ const spec = SPEC.get(rec.spec);
+ const props = (spec && spec.input && spec.input.properties) || {};
+ const preset = (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat;
+ const out = [];
+ for (const [name, s] of Object.entries(props)) {
+ // A list of page numbers is not a field a person types; the tool reads it from the pages it has.
+ if (s.type === 'array') continue;
+ const field = { ...s };
+ if (name === 'format' && preset) field.default = preset;
+ out.push(frame.field(name, field, FIELD_LABEL, VALUE_LABEL));
+ }
+ return out.join('');
+}
+
+/**
+ * The operation the box runs: the tool's own id (the registry's key: merge, compress, pdf2img…),
+ * or the hub's own default.
+ * @param {object} rec the tool record
+ * @returns {string}
+ */
+function opOf(rec) {
+ return rec.op || rec.id;
+}
+
+/**
+ * The box: a drop zone for the document, the tool's options and the one button.
+ * @param {object} rec the tool record
+ */
+function boxFor(rec) {
+ const spec = SPEC.get(rec.spec);
+ const many = (spec && spec.files && spec.files.max) > 1;
+ const accept = (spec && spec.files && spec.files.accept) || ['application/pdf'];
+ const drop = kit.drop({
+ name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,
+ label: many ? 'Drop your documents here' : 'Drop a document here',
+ hint: many ? 'Up to 50 files at once' : 'A PDF, a picture, or both',
+ });
+ return `<form class="ovk-tool-form" id="ovk-docs-form" enctype="multipart/form-data" data-ovk-tool-form>`
+ + `<input type="hidden" name="tool" value="${esc(opOf(rec))}">`
+ + drop
+ + `<p class="ovk-try"><span>Try</span><a href="/sample.pdf" data-ovk-sample="/sample.pdf">A sample document</a></p>`
+ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`
+ + `<div class="ovk-tool-buttons">${frame.verbButton(rec.verb, { id: 'process-btn', icon: 'file' })}`
+ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`
+ + `</form>`;
+}
+
+/** The family: every PDF tool, with the one on the page marked. */
+function familyItems(rec) {
+ const hub = { href: HUB, label: 'Docs.OpenVibe', short: 'All tools', current: rec.id === 'hub' };
+ const current = rec.id === 'hub' ? 'merge' : rec.id;
+ return [hub, ...RECS.map((t) => ({ href: `https://${t.spec}.openvibe.tools/`, label: t.name, short: t.short, current: t.id === current }))];
+}
+
+/** "People also use": the tools next to this one in the family. */
+function relatedFor(rec) {
+ const at = RECS.findIndex((t) => t.id === rec.id);
+ const out = [];
+ for (let i = 1; out.length < 3 && i < RECS.length + 1; i++) {
+ const other = RECS[(at + i) % RECS.length];
+ if (other.id !== rec.id) out.push(other);
+ }
+ return out.map((t) => kit.row({ href: `https://${t.spec}.openvibe.tools/`, title: t.name, sub: t.task }));
+}
+
+/**
+ * The whole frame for one host.
+ * @param {string} host the satellite's own hostname, e.g. 'mergepdf.openvibe.tools'
+ * @returns {string} HTML
+ */
+function frameFor(host) {
+ const rec = toolFor(host);
+ if (!rec) return '';
+ const spec = SPEC.get(rec.spec);
+ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';
+ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(
+ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : '']),
+ ) : {};
+ const faq = [
+ ['Is it free?', 'Yes. Every PDF tool here is free, with a fair-use limit per address so the shared servers stay fast for everyone.'],
+ ['How long is my document kept?', 'An hour for a visitor, a day for a signed-in person. Then the file is gone.'],
+ ['Are my files safe?', 'They are worked on by our own server and deleted afterwards. Nothing is kept and nothing is shared.'],
+ ];
+ return frame.page({
+ id: rec.id,
+ icon: 'file', hue: 25, family: 'pdf',
+ crumb: { href: HUB, label: 'PDF tools' },
+ title: rec.name, task: rec.task, facts: termsOf(rec),
+ box: boxFor(rec),
+ familyTitle: 'More PDF tools',
+ familyMore: { href: HUB, label: `All ${RECS.length + 1}` },
+ familyItems: familyItems(rec),
+ about: rec.about || `The ${rec.name.toLowerCase()} tool works on our server: the document is sent to us, changed there and sent back as a file.`,
+ faq,
+ api: {
+ id: rec.spec, path: `/api/v1/tools/${rec.spec}/run`, fields, payload: { input },
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ },
+ related: relatedFor(rec),
+ });
+}
+
+module.exports = { frameFor, toolFor, termsOf, boxFor, optionsFor, RECS, HUB_TOOL };
@@ -300,6 +300,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -300,6 +300,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
},
}));
}));
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
+
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// tags and structured data (server/seo.js); the raw file carries only the hub's.
// tags and structured data (server/seo.js); the raw file carries only the hub's.
const { sendIndex } = require('./seo');
const { sendIndex } = require('./seo');
@@ -15,8 +15,10 @@
@@ -15,8 +15,10 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
+const serve = require('openvibe-shared/serve');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { DOMAIN_MAP } = require('./domain-map');
const { DOMAIN_MAP } = require('./domain-map');
+const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const PUBLIC = path.join(__dirname, '..', 'public');
const HUB_HOST = 'docs.openvibe.tools';
const HUB_HOST = 'docs.openvibe.tools';
@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:image" content="${image}">`,
`<meta name="twitter:image" content="${image}">`,
+ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
].join('\n');
].join('\n');
- const out = html.replace(/<\/head>/i, `${head}\n</head>`);
+ const out = html.replace(/<\/head>/i, `${head}\n</head>`)
+ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
+ .replace('<!--OVK:FRAME-->', frameFor(host) || '');
if (_cache.size > 100) _cache.clear();
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
_cache.set(key, out);
return out;
return out;
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+// The tool frame on the PDF pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • every PDF host is a tool page in the same frame: the head, the box, the answer slot, the family,
+// the folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its
+// size from the upload limits, the rate from the guard's quota table — and they follow it when it
+// changes;
+// • the box's options are the tool's own descriptor;
+// • the one button says the verb, never "Run".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { frameFor, RECS } = require('../server/frame');
+const { DOMAIN_MAP } = require('../server/domain-map');
+const { SPECS } = require('../server/descriptors');
+const config = require('../server/config');
+const { startApp } = require('../../_shared/test/spawn');
+
+const HOSTS = Object.keys(DOMAIN_MAP);
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+// ── Every PDF host is a framed tool page, in the same order ──
+{
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ assert.ok(html, `${host}: a frame`);
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('https://docs.openvibe.tools/">PDF tools'), `${host}: the crumb is the family`);
+ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
+ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
+ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ assert.strictEqual(RECS.length, 10, 'the family has ten PDF tools');
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const facts = factsOf(frameFor('mergepdf.openvibe.tools'));
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'merge: free, on our server, and the file\'s lifetime');
+ assert.ok(facts.includes('Up to 50 files, 100 MB each'), 'merge: the file count and size from its descriptor and the upload limits');
+
+ const saved = config.retention.anonTTL;
+ try {
+ config.retention.anonTTL = 45 * 60 * 1000;
+ assert.ok(factsOf(frameFor('mergepdf.openvibe.tools')).includes('Runs on our server; your file is deleted after 45 minutes'), 'the lifetime follows the config');
+ config.upload.maxFileSize = 25 * 1024 * 1024;
+ assert.ok(factsOf(frameFor('splitpdf.openvibe.tools')).includes('One file up to 25 MB'), 'the size follows the config');
+ } finally {
+ config.retention.anonTTL = saved;
+ config.upload.maxFileSize = 100 * 1024 * 1024;
+ }
+}
+
+// ── The box's options are the tool's own descriptor ──
+{
+ const spec = SPECS.find((s) => s.id === 'rotatepdf');
+ const html = frameFor('rotatepdf.openvibe.tools');
+ for (const name of Object.keys(spec.input.properties)) {
+ assert.ok(html.includes(`name="${name}"`), `rotate: the descriptor's field ${name} is on the page`);
+ }
+ assert.ok(!html.includes('name="level"'), 'rotate: a field the descriptor does not have is not on the page');
+ assert.ok(frameFor('compresspdf.openvibe.tools').includes('name="level"'), 'compress: the level field');
+}
+
+// ── The box runs the tool the page is for ──
+{
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ const m = /<input type="hidden" name="tool" value="([^"]+)">/.exec(html);
+ assert.ok(m, `${host}: the box says which tool it runs`);
+ const op = (DOMAIN_MAP[host] || {}).defaultOp;
+ assert.strictEqual(m[1], op || 'merge', `${host}: the box runs ${op || 'merge'}`);
+ }
+}
+
+// ── The one button says the verb ──
+{
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);
+ assert.ok(m, `${host}: a verb button`);
+ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);
+ }
+ assert.ok(/<span>Split<\/span>/.test(frameFor('splitpdf.openvibe.tools')), 'split: the button says the verb');
+ assert.ok(/<span>Add watermark<\/span>/.test(frameFor('watermarkpdf.openvibe.tools')), 'watermark: the button says the verb');
+}
+
+// ── The page the server sends: the frame, the title, the canonical and the structured data ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-frame-'));
+ const app = await startApp('docs', { DATA_DIR: tmp });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ for (const host of HOSTS) {
+ const r = await get(host);
+ assert.strictEqual(r.status, 200, `${host}: answers 200`);
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
+ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
+ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
+ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log(`tool frame (docs): ${HOSTS.length} PDF pages framed, the terms from the config, the options from the descriptors: all checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -236,4 +236,15 @@ body { min-height:100%; font-family:var(--font); background:var(--bg); color:var
@@ -236,4 +236,15 @@ body { min-height:100%; font-family:var(--font); background:var(--bg); color:var
.food-table th, .food-table td { padding:6px; }
.food-table th, .food-table td { padding:6px; }
}
}
+
+/* ── The tool frame ─────────────────────────────────────────────── */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+.ovk-food-box .loc-search { max-width: 720px; margin: 0 auto; }
+.ovk-food-box .loc-search input { min-width: 0; flex: 1; }
+
+
+/* The page is the shared tool frame: it starts under the navbar, not behind it. */
+.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
@@ -39,6 +39,10 @@
@@ -39,6 +39,10 @@
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/style.css">
</head>
</head>
<body>
<body>
+ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
+ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
+ <!--OVK:FRAME-->
+ <!--OVK:BOX-->
<!-- Location Bar -->
<!-- Location Bar -->
<div id="loc-bar">
<div id="loc-bar">
<div class="loc-search">
<div class="loc-search">
@@ -49,6 +53,8 @@
@@ -49,6 +53,8 @@
</div>
</div>
</div>
</div>
+ <!--/OVK:BOX-->
+ <!--OVK:ANSWER-->
<!-- Main Content -->
<!-- Main Content -->
<main id="main">
<main id="main">
<!-- Tabs -->
<!-- Tabs -->
@@ -161,6 +167,7 @@
@@ -161,6 +167,7 @@
</section>
</section>
</div>
</div>
</main>
</main>
+ <!--/OVK:ANSWER-->
<p class="data-credit"><i class="fa-solid fa-database"></i> Data from USDA, OSM and store pricing research</p>
<p class="data-credit"><i class="fa-solid fa-database"></i> Data from USDA, OSM and store pricing research</p>
<div id="ov-footer"></div>
<div id="ov-footer"></div>
@@ -170,7 +177,11 @@
@@ -170,7 +177,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
- <script src="/js/app.js"></script>
+ <script src="/shared/kit.js" defer></script>
+<script src="/shared/kit-web.js" defer></script>
+<script src="/shared/kit-data-web.js" defer></script>
+<script src="/js/tool-frame.js" defer></script>
+<script src="/js/app.js"></script>
<script>
<script>
(function () {
(function () {
function getCookie(name) {
function getCookie(name) {
@@ -181,12 +192,15 @@
@@ -181,12 +192,15 @@
let user = null;
let user = null;
if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }
if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
- OpenVibeNavbar.init({ service: 'food', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Food.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });
+ // The brand is the site ("Tools"), not the subdomain's name: the same brand every framed tool
+ // page carries (owner ask 11).
+ OpenVibeNavbar.init({ service: 'food', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Food.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}', brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } });
}
}
if (typeof OpenVibeFooter !== 'undefined') {
if (typeof OpenVibeFooter !== 'undefined') {
try {
try {
OpenVibeFooter.init({
OpenVibeFooter.init({
service: 'food', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
service: 'food', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
+ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Food.OpenVibe', items: [
links: [{ heading: 'Food.OpenVibe', items: [
{ name: 'Find food', url: 'https://food.openvibe.tools' },
{ name: 'Find food', url: 'https://food.openvibe.tools' },
{ name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools' },
{ name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the food finder (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// The finder's page is one tool on one host: the head (the crumb, the name, one sentence, the terms),
+// the box (the place search), the answer (the food banks, stores, meal plan, food list and map), the
+// family, the folds and "People also use".
+//
+// The page's own parts are marked in public/index.html and moved into the frame's box and answer slot
+// here, so the page keeps its own script, its own ids and its own behaviour.
+//
+// The terms come from the app's real configuration and from the guard's quota table: the lookups
+// happen on our server (the food app proxies them to the maps satellite), and the rate is the one
+// the guard holds for the tools-map class.
+// ═══════════════════════════════════════════════════════════════
+
+const fs = require('fs');
+const path = require('path');
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const { canonicalHostFor, restampHead } = require('../../_shared/host-role');
+const { SPECS } = require('../../maps/server/descriptors');
+
+const esc = kit.esc;
+const HOST = 'food.openvibe.tools';
+const SITE = 'https://openvibe.tools';
+const FILE = path.join(__dirname, '..', 'public', 'index.html');
+const SPEC = SPECS.find((s) => s.id === 'food') || {};
+
+// The hubs this tool sits next to.
+const HUBS = [
+ { href: `${SITE}/maps-and-food`, label: 'Maps & Food', short: 'Maps' },
+ { href: 'https://maps.openvibe.tools', label: 'Survival map', short: 'Map' },
+ { href: 'https://openvibe.food', label: 'OpenVibe.Food', short: 'Food' },
+ { href: `${SITE}/`, label: 'All tools', short: 'All' },
+];
+
+/**
+ * The terms of the food finder, from the app's real configuration and the guard's quota table.
+ */
+function termsOf() {
+ return frame.terms({
+ work: 'server',
+ serverNote: 'Runs on our server: your search is looked up there',
+ quotaClass: SPEC.quotaClass || 'tools-map',
+ cost: SPEC.cost || 2,
+ unit: 'lookups',
+ });
+}
+
+/** The family: the hubs this tool sits next to. The page's own hub is the one marked. */
+function familyItems() {
+ return HUBS.map((h) => ({ ...h, current: h.href === 'https://openvibe.food' }));
+}
+
+/** "People also use": the other hubs, as rows. */
+function relatedItems() {
+ return HUBS.filter((h) => h.href !== 'https://openvibe.food')
+ .map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));
+}
+
+/**
+ * The whole frame for the finder's page.
+ * @param {string} box the page's own place search
+ * @param {string} answer the page's own tabs and panels
+ * @returns {string} HTML
+ */
+function frameFor(box, answer) {
+ const title = 'Food finder';
+ const task = 'Find free food near you: food banks, pantries, budget grocery stores and a meal plan.';
+ const faq = [
+ ['Is it free?', 'Yes. Thirty lookups a minute without an account.'],
+ ['Where does the data come from?', 'Public sources: food banks, the national food bank directory, and grocery stores that accept food assistance.'],
+ ['Is my search sent anywhere?', 'Only to our server, which does the lookups. Nothing is kept about you afterwards.'],
+ ['Can I use it offline?', 'No. The finder needs a connection: it looks places up as you search.'],
+ ];
+ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="food">${kit.tool({
+ head: frame.head({
+ icon: 'cart', hue: 25, family: 'places',
+ crumb: { href: SITE, label: 'Tools' },
+ title, task, facts: termsOf(),
+ }),
+ input: `<div class="ovk-food-box">${box}</div>`,
+ answer: `<div class="ovk-food-answer">${answer}</div>`,
+ family: kit.family({
+ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },
+ items: familyItems(), label: 'More tools',
+ }),
+ folds: frame.folds({
+ name: title,
+ about: 'The food finder puts food banks, community pantries, budget grocery stores, a meal planner and the food list on one page, so you can find what is free near you and plan with what you have.',
+ faq,
+ api: null,
+ }),
+ related: kit.list(relatedItems(), { title: 'People also use' }),
+ })}</div>`;
+}
+
+/** The page's own part between two markers. */
+function pick(html, re) {
+ const m = re.exec(html);
+ return m ? m[1] : '';
+}
+
+let _parts = null;
+
+/** The page's own parts, read once: the box and the answer. */
+function parts() {
+ if (_parts) return _parts;
+ const html = fs.readFileSync(FILE, 'utf8');
+ _parts = {
+ box: pick(html, /<!--OVK:BOX-->([\s\S]*?)<!--\/OVK:BOX-->/),
+ answer: pick(html, /<!--OVK:ANSWER-->([\s\S]*?)<!--\/OVK:ANSWER-->/),
+ page: html
+ .replace(/<!--OVK:BOX-->[\s\S]*?<!--\/OVK:BOX-->/, '')
+ .replace(/<!--OVK:ANSWER-->[\s\S]*?<!--\/OVK:ANSWER-->/, ''),
+ };
+ return _parts;
+}
+
+let _cache = null;
+
+/**
+ * The finder's page with the frame around it, stamped for this request's canonical host.
+ * @param {string} canonicalHost the host the page is about
+ * @returns {string} HTML
+ */
+function renderIndex(canonicalHost) {
+ if (_cache === null) {
+ const { box, answer, page: stripped } = parts();
+ _cache = stripped
+ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
+ .replace(/<\/head>/i, `${[
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
+ ].join('\n')}\n</head>`)
+ .replace(/<\/body>/i, `${[
+ '<script src="/shared/kit.js" defer></script>',
+ '<script src="/shared/kit-web.js" defer></script>',
+ '<script src="/shared/kit-data-web.js" defer></script>',
+ '<script src="/js/tool-frame.js"></script>',
+ ].join('\n')}\n</body>`);
+ }
+ return _cache;
+}
+
+/**
+ * The page with the frame, built fresh (no cache): the tests change the configuration and read the
+ * page again.
+ * @param {string} canonicalHost the host the page is about
+ * @returns {string} HTML
+ */
+function renderFrame(canonicalHost) {
+ const { box, answer, page } = parts();
+ const out = page
+ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
+ .replace(/<\/head>/i, `${[
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
+ ].join('\n')}\n</head>`)
+ .replace(/<\/body>/i, `${[
+ '<script src="/shared/kit.js" defer></script>',
+ '<script src="/shared/kit-web.js" defer></script>',
+ '<script src="/shared/kit-data-web.js" defer></script>',
+ '<script src="/js/tool-frame.js"></script>',
+ ].join('\n')}\n</body>`);
+ return canonicalHost ? restampHead(out, HOST, canonicalHost) : out;
+}
+
+/** Express handler: the page with the frame, stamped for this request's host. */
+function sendIndex(req, res) {
+ const canonical = canonicalHostFor(req, HOST);
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Vary', 'X-OV-Canonical-Host');
+ return res.send(renderIndex(canonical));
+}
+
+module.exports = { frameFor, termsOf, familyItems, relatedItems, renderIndex, renderFrame, sendIndex, HUBS, HOST };
@@ -120,8 +120,11 @@ app.use(analytics.middleware());
@@ -120,8 +120,11 @@ app.use(analytics.middleware());
const HOST = 'food.openvibe.tools';
const HOST = 'food.openvibe.tools';
app.use(hostGuard({ knows: (h) => h === HOST }));
app.use(hostGuard({ knows: (h) => h === HOST }));
// The page is OpenVibe.Food's now; the JSON routes below stay for whoever calls them.
// The page is OpenVibe.Food's now; the JSON routes below stay for whoever calls them.
-const FOOD_URL = (process.env.OV_FOOD_PUBLIC_URL || 'https://openvibe.food').replace(/\/+$/, '');
-const toFood = (req, res) => res.redirect(301, `${FOOD_URL}/`);
+// Where the page went: OpenVibe.Food (openvibe.food). Set OV_FOOD_PUBLIC_URL to an empty value to keep
+// the food finder's own page here (the frame around it, the same frame every other tool page has).
+const FOOD_URL = (process.env.OV_FOOD_PUBLIC_URL === '' ? '' : (process.env.OV_FOOD_PUBLIC_URL || 'https://openvibe.food')).replace(/\/+$/, '');
+const { sendIndex: sendFoodPage } = require('./frame');
+const toFood = (req, res) => (FOOD_URL ? res.redirect(301, `${FOOD_URL}/`) : sendFoodPage(req, res));
app.get(['/', '/index.html'], toFood);
app.get(['/', '/index.html'], toFood);
// Static files
// Static files
@@ -168,6 +171,12 @@ app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
@@ -168,6 +171,12 @@ app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js).
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
+
// What used to be the page's own paths go to OpenVibe.Food as well (the API answers JSON 404s).
// What used to be the page's own paths go to OpenVibe.Food as well (the API answers JSON 404s).
app.get('*', (req, res) => (req.path.startsWith('/api/') ? res.status(404).json({ error: 'Not found' }) : toFood(req, res)));
app.get('*', (req, res) => (req.path.startsWith('/api/') ? res.status(404).json({ error: 'Not found' }) : toFood(req, res)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// The tool frame on the food finder's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the
+// folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the lookups happen on our server and the rate
+// is the guard's quota table — and they follow it when it changes;
+// • the one button says the verb, never "Run".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { renderFrame } = require('../server/frame');
+const { startApp } = require('../../_shared/test/spawn');
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+// ── The page is a framed tool page, in the same order ──
+{
+ const html = renderFrame('food.openvibe.tools');
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');
+ assert.ok(html.includes('id="loc-input"'), 'the box has the place search');
+ assert.ok(html.includes('id="food-banks"'), 'the answer holds the food banks');
+ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');
+ assert.ok(/id="btn-loc-search"[^>]*>[\s\S]*?Find Food/.test(html), 'the one button says the verb');
+ assert.ok(!/>\s*Run\s*</.test(html), 'never Run');
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const facts = factsOf(renderFrame('food.openvibe.tools'));
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server: your search is looked up there'], 'free, and the lookups happen on our server');
+ assert.ok(facts.includes('30 lookups a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');
+ assert.ok(facts.includes('No sign-up'), 'no account needed');
+
+ const savedEnv = process.env.TOOLS_GUARD_LIMITS;
+ try {
+ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-map': { anonymous: { perMinute: 20, burst: 10, perDay: 0 } } });
+ assert.ok(factsOf(renderFrame('food.openvibe.tools')).includes('10 lookups a minute without an account'), 'the rate follows the guard\'s table');
+ } finally {
+ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;
+ else process.env.TOOLS_GUARD_LIMITS = savedEnv;
+ }
+}
+
+// ── The page the server sends: the frame, the title and the canonical ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-food-frame-'));
+ // The food app has no /api/health (its readiness needs the maps satellite): the page answers.
+ const app = await startApp('food', { DATA_DIR: tmp, OV_FOOD_PUBLIC_URL: '' }, undefined, { readyPath: '/' });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ const r = await get('food.openvibe.tools');
+ assert.strictEqual(r.status, 200, 'answers 200');
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), 'the frame\'s title');
+ assert.ok(r.body.includes('<link rel="canonical"'), 'a canonical');
+ assert.ok(r.body.includes('/shared/kit.css?'), 'the kit\'s look');
+ assert.ok(r.body.includes('/js/tool-frame.js'), 'the frame\'s behaviour');
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, 'one h1');
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('tool frame (food): the food finder framed, the terms from the config, the button says the verb: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -103,6 +103,7 @@ function rawGet(port, p, headers = {}) {
@@ -103,6 +103,7 @@ function rawGet(port, p, headers = {}) {
'_shared/tools/run.js': 'the satellites on loopback', '_shared/usage.js': 'Network user modules (configured)',
'_shared/tools/run.js': 'the satellites on loopback', '_shared/usage.js': 'Network user modules (configured)',
'_shared/egress.js': 'the egress guard itself',
'_shared/egress.js': 'the egress guard itself',
'_shared/billing.js': 'OpenVibe.Billing and Network token endpoint (configured)',
'_shared/billing.js': 'OpenVibe.Billing and Network token endpoint (configured)',
+ '_shared/public/tool-frame.js': 'the app\'s own sample file, on the app\'s own origin (same origin; no visitor-chosen host)',
};
};
const found = [];
const found = [];
const walk = (dir) => {
const walk = (dir) => {
@@ -38,14 +38,17 @@ body {
@@ -38,14 +38,17 @@ body {
flex-direction: column;
flex-direction: column;
}
}
+/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */
#app {
#app {
flex: 1;
flex: 1;
- max-width: 900px;
width: 100%;
width: 100%;
- margin: 0 auto;
- padding: 0 20px 40px;
}
}
+/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+
/* ── Hero ───────────────────────────────────────────────────── */
/* ── Hero ───────────────────────────────────────────────────── */
.hero {
.hero {
text-align: center;
text-align: center;
@@ -478,4 +481,7 @@ body {
@@ -478,4 +481,7 @@ body {
line-height: 1.5;
line-height: 1.5;
}
}
+
+/* The page is the shared tool frame: it starts under the navbar, not behind it. */
+.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
@@ -26,141 +26,9 @@
@@ -26,141 +26,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
<div id="app">
- <!-- Hero -->
- <section class="hero" id="hero">
- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-images"></i></div>
- <h1 id="hero-title">Img.OpenVibe</h1>
- <p class="hero-subtitle" id="hero-subtitle">Online Image Converter & Tools</p>
- </section>
-
- <!-- Tool Selector (visible on hub domain) -->
- <nav class="tool-tabs" id="tool-tabs">
- <button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>
- <button class="tool-tab" data-tool="compress"><i class="fa-solid fa-compress"></i> Compress</button>
- <button class="tool-tab" data-tool="resize"><i class="fa-solid fa-up-right-and-down-left-from-center"></i> Resize</button>
- <button class="tool-tab" data-tool="crop"><i class="fa-solid fa-scissors"></i> Crop</button>
- </nav>
-
- <!-- Upload Zone -->
- <section class="upload-zone" id="upload-zone">
- <div class="upload-area" id="upload-area">
- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
- <p class="upload-text">Drag & drop an image here</p>
- <p class="upload-hint">or click to browse — PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP</p>
- <input type="file" id="file-input" accept="image/*" hidden>
- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose File</button>
- </div>
- <div class="upload-info" id="upload-info" style="display:none">
- <div class="file-preview" id="file-preview"></div>
- <div class="file-details" id="file-details"></div>
- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>
- </div>
- </section>
-
- <!-- Tool Options -->
- <section class="options-panel" id="options-panel" style="display:none">
- <!-- Convert Options -->
- <div class="option-group" id="opt-convert">
- <label class="option-label">Output Format</label>
- <div class="format-grid" id="format-grid">
- <button class="format-btn" data-format="png">PNG</button>
- <button class="format-btn" data-format="jpg">JPG</button>
- <button class="format-btn" data-format="webp">WebP</button>
- <button class="format-btn" data-format="avif">AVIF</button>
- <button class="format-btn" data-format="gif">GIF</button>
- <button class="format-btn" data-format="tiff">TIFF</button>
- <button class="format-btn" data-format="bmp">BMP</button>
- <button class="format-btn" data-format="ico">ICO</button>
- </div>
- <label class="option-label">Quality <span id="quality-val">80</span>%</label>
- <input type="range" id="quality-slider" min="1" max="100" value="80" class="slider">
- </div>
-
- <!-- Compress Options -->
- <div class="option-group" id="opt-compress" style="display:none">
- <label class="option-label">Compression Level <span id="compress-val">75</span>%</label>
- <input type="range" id="compress-slider" min="1" max="100" value="75" class="slider">
- <p class="option-hint">Lower = smaller file size, higher = better quality</p>
- </div>
-
- <!-- Resize Options -->
- <div class="option-group" id="opt-resize" style="display:none">
- <div class="dimension-inputs">
- <div class="dim-field">
- <label class="option-label">Width (px)</label>
- <input type="number" id="resize-w" class="num-input" placeholder="auto" min="1" max="16384">
- </div>
- <span class="dim-x"><i class="fa-solid fa-xmark"></i></span>
- <div class="dim-field">
- <label class="option-label">Height (px)</label>
- <input type="number" id="resize-h" class="num-input" placeholder="auto" min="1" max="16384">
- </div>
- </div>
- <label class="option-label">Or scale by percentage</label>
- <input type="number" id="resize-pct" class="num-input num-input-sm" placeholder="e.g. 50" min="1" max="1000"> %
- <label class="option-label">Fit Mode</label>
- <select id="resize-fit" class="select-input">
- <option value="inside" selected>Inside (fit within bounds)</option>
- <option value="cover">Cover (fill & crop)</option>
- <option value="contain">Contain (fit with padding)</option>
- <option value="fill">Fill (stretch)</option>
- <option value="outside">Outside (cover minimum)</option>
- </select>
- </div>
-
- <!-- Crop Options -->
- <div class="option-group" id="opt-crop" style="display:none">
- <label class="option-label">Aspect Ratio Preset</label>
- <div class="aspect-grid" id="aspect-grid">
- <button class="aspect-btn" data-aspect="1:1">1:1</button>
- <button class="aspect-btn" data-aspect="16:9">16:9</button>
- <button class="aspect-btn" data-aspect="4:3">4:3</button>
- <button class="aspect-btn" data-aspect="3:2">3:2</button>
- <button class="aspect-btn" data-aspect="21:9">21:9</button>
- <button class="aspect-btn" data-aspect="9:16">9:16</button>
- <button class="aspect-btn" data-aspect="3:4">3:4</button>
- <button class="aspect-btn active" data-aspect="">Custom</button>
- </div>
- <div class="crop-coords" id="crop-coords">
- <div class="dim-field"><label class="option-label">Left</label><input type="number" id="crop-left" class="num-input" placeholder="0" min="0"></div>
- <div class="dim-field"><label class="option-label">Top</label><input type="number" id="crop-top" class="num-input" placeholder="0" min="0"></div>
- <div class="dim-field"><label class="option-label">Width</label><input type="number" id="crop-w" class="num-input" placeholder="auto" min="1"></div>
- <div class="dim-field"><label class="option-label">Height</label><input type="number" id="crop-h" class="num-input" placeholder="auto" min="1"></div>
- </div>
- </div>
-
- <!-- Process Button -->
- <button class="btn btn-primary btn-lg" id="process-btn" disabled>
- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Image</span>
- </button>
- </section>
-
- <!-- Processing State -->
- <section class="processing" id="processing" style="display:none">
- <div class="spinner"></div>
- <p>Processing your image...</p>
- </section>
-
- <!-- Result -->
- <section class="result-panel" id="result-panel" style="display:none">
- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>
- <div class="result-preview" id="result-preview"></div>
- <div class="result-info" id="result-info"></div>
- <div class="result-actions">
- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>
- <i class="fa-solid fa-download"></i> Download
- </a>
- <button class="btn btn-secondary" id="another-btn">
- <i class="fa-solid fa-rotate-left"></i> Process Another
- </button>
- </div>
- </section>
-
- <!-- Format Subdomains Grid (hub only) -->
- <section class="subdomains" id="subdomains">
- <h2>All Image Tools</h2>
- <div class="subdomain-grid" id="subdomain-grid"></div>
- </section>
+ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
+ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
+ <!--OVK:FRAME-->
</div>
</div>
<!-- Footer -->
<!-- Footer -->
@@ -171,7 +39,11 @@
@@ -171,7 +39,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
+ <script src="/shared/kit.js" defer></script>
+ <script src="/shared/kit-web.js" defer></script>
+ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/ov-jobs.js"></script>
<script src="/js/ov-jobs.js"></script>
+ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
</body>
</body>
</html>
</html>
@@ -1,7 +1,9 @@
@@ -1,7 +1,9 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Img.OpenVibe — Client Application
// Img.OpenVibe — Client Application
-// Domain-aware image processing SPA.
-// Detects hostname → fetches context → adapts branding + tools.
+// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,
+// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop
+// zone, the options and the one button) and the answer (the file it made, its size before and after,
+// and Download). The upload, the processing and the download are the app's own, unchanged.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function () {
(function () {
@@ -11,43 +13,19 @@
@@ -11,43 +13,19 @@
let ctx = null; // domain context from /api/context
let ctx = null; // domain context from /api/context
let selectedFile = null; // File object
let selectedFile = null; // File object
let selectedTool = 'convert';
let selectedTool = 'convert';
- let selectedFormat = 'png';
- let selectedAspect = '';
// ── DOM refs ─────────────────────────────────────────────
// ── DOM refs ─────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const $$ = (sel) => document.querySelectorAll(sel);
- const heroIcon = $('#hero-icon');
- const heroTitle = $('#hero-title');
- const heroSubtitle = $('#hero-subtitle');
- const toolTabs = $('#tool-tabs');
- const uploadArea = $('#upload-area');
- const uploadInfo = $('#upload-info');
const fileInput = $('#file-input');
const fileInput = $('#file-input');
- const browseBtn = $('#browse-btn');
- const clearBtn = $('#clear-btn');
const optionsPanel = $('#options-panel');
const optionsPanel = $('#options-panel');
const processBtn = $('#process-btn');
const processBtn = $('#process-btn');
- const processText = $('#process-text');
- const processingEl = $('#processing');
- const resultPanel = $('#result-panel');
- const resultPreview = $('#result-preview');
- const resultInfo = $('#result-info');
- const downloadBtn = $('#download-btn');
- const anotherBtn = $('#another-btn');
- const subdomainsSection = $('#subdomains');
- const subdomainGrid = $('#subdomain-grid');
-
- // Option elements
- const qualitySlider = $('#quality-slider');
- const qualityVal = $('#quality-val');
- const compressSlider = $('#compress-slider');
- const compressVal = $('#compress-val');
+ const clearBtn = $('#clear-btn');
+ const kit = () => window.OpenVibeKit;
// ── Init ─────────────────────────────────────────────────
// ── Init ─────────────────────────────────────────────────
async function init() {
async function init() {
- // Fetch domain context
try {
try {
const res = await fetch('/api/context');
const res = await fetch('/api/context');
ctx = await res.json();
ctx = await res.json();
@@ -55,88 +33,26 @@
@@ -55,88 +33,26 @@
ctx = { toolId: 'hub', brandName: 'Img.OpenVibe', defaultOp: 'convert', faIcon: 'fa-images', tools: [] };
ctx = { toolId: 'hub', brandName: 'Img.OpenVibe', defaultOp: 'convert', faIcon: 'fa-images', tools: [] };
}
}
- applyBranding();
- showUnavailableInputs();
initNavbar();
initNavbar();
initNotifications();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
- initToolTabs();
- initUpload();
+ showUnavailableInputs();
+ initDrop();
initOptions();
initOptions();
- initSubdomains();
initResult();
initResult();
reattach();
reattach();
}
}
- // ── Branding ─────────────────────────────────────────────
- function applyBranding() {
- document.title = ctx.seoTitle || `${ctx.brandName} — Image Tools`;
- heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon || 'fa-images'}"></i>`;
- heroTitle.textContent = ctx.brandName || 'Img.OpenVibe';
-
- // Set subtitle based on tool
- const subtitles = {
- hub: 'Online Image Converter & Tools',
- convert: 'Online Image Format Converter',
- compress: 'Online Image Compressor',
- resize: 'Online Image Resizer',
- crop: 'Online Image Cropper',
- favicon: 'Online Favicon Generator',
- };
- heroSubtitle.textContent = subtitles[ctx.toolId] || ctx.seoDescription || subtitles.hub;
-
- // If format-specific domain, set default format
- if (ctx.defaultFormat) {
- selectedFormat = ctx.defaultFormat;
- }
-
- // Set default tool from context
- selectedTool = ctx.defaultOp || 'convert';
-
- // Hide tool tabs on single-tool domains (format-specific ones show convert)
- const isFormatDomain = ctx.toolId && !['hub', 'convert', 'compress', 'resize', 'crop', 'favicon'].includes(ctx.toolId);
- if (isFormatDomain) {
- toolTabs.style.display = 'none';
- selectedTool = 'convert';
- }
-
- // Hide subdomains section on non-hub domains
- if (ctx.toolId !== 'hub') {
- subdomainsSection.style.display = 'none';
- }
-
- // Update meta description
- const metaDesc = document.querySelector('meta[name="description"]');
- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
- }
-
- // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up
- // front on the page that is about it, instead of letting the upload fail.
- function showUnavailableInputs() {
- const heicPage = ctx.toolId === 'heic';
- if (!ctx.inputs || ctx.inputs.heic !== false || !heicPage) return;
- const zone = document.getElementById('upload-zone');
- if (!zone || document.getElementById('input-notice')) return;
- const note = document.createElement('div');
- note.id = 'input-notice';
- note.className = 'tool-notice';
- note.setAttribute('role', 'status');
- note.textContent = 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.';
- zone.parentNode.insertBefore(note, zone);
- }
-
// ── Navbar ───────────────────────────────────────────────
// ── Navbar ───────────────────────────────────────────────
function initNavbar() {
function initNavbar() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
let user = null;
let user = null;
if (token) {
if (token) {
- try {
- const payload = JSON.parse(atob(token.split('.')[1]));
- user = payload;
- } catch { /* invalid token */ }
+ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }
}
}
- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").
+ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and
+ // code tool pages carry (owner ask 11).
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
OpenVibeNavbar.init({
OpenVibeNavbar.init({
service: 'img',
service: 'img',
@@ -145,21 +61,22 @@
@@ -145,21 +61,22 @@
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: ctx?.brandName || 'Img.OpenVibe' },
history: { type: 'tool', title: ctx?.brandName || 'Img.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
+ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
});
}
}
initFooter();
initFooter();
}
}
/* ---------- Footer (shared OpenVibeFooter) ---------- */
/* ---------- Footer (shared OpenVibeFooter) ---------- */
- // Full footer on the hub host, the compact strip on the single-tool hosts.
function initFooter() {
function initFooter() {
if (typeof OpenVibeFooter === 'undefined') return;
if (typeof OpenVibeFooter === 'undefined') return;
try {
try {
OpenVibeFooter.init({
OpenVibeFooter.init({
service: 'img',
service: 'img',
- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',
+ variant: 'compact',
mount: '#ov-footer',
mount: '#ov-footer',
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
+ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Img tools', items: [
links: [{ heading: 'Img tools', items: [
{ name: 'Convert', url: 'https://convert.openvibe.tools' },
{ name: 'Convert', url: 'https://convert.openvibe.tools' },
{ name: 'Compress', url: 'https://compress.openvibe.tools' },
{ name: 'Compress', url: 'https://compress.openvibe.tools' },
@@ -184,7 +101,6 @@
@@ -184,7 +101,6 @@
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
});
});
- // Mount bell into navbar if available
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
const mount = OpenVibeNavbar.getBellMount();
const mount = OpenVibeNavbar.getBellMount();
if (mount) {
if (mount) {
@@ -194,188 +110,143 @@
@@ -194,188 +110,143 @@
}
}
}
}
- // ── Tool Tabs ────────────────────────────────────────────
- function initToolTabs() {
- // Highlight active tab
- $$('.tool-tab').forEach(tab => {
- if (tab.dataset.tool === selectedTool) tab.classList.add('active');
- else tab.classList.remove('active');
-
- tab.addEventListener('click', () => {
- selectedTool = tab.dataset.tool;
- $$('.tool-tab').forEach(t => t.classList.toggle('active', t.dataset.tool === selectedTool));
- showToolOptions();
- updateProcessButton();
- });
+ // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up
+ // front on the page that is about it, instead of letting the upload fail.
+ function showUnavailableInputs() {
+ if (!ctx || ctx.toolId !== 'heic') return;
+ if (!ctx.inputs || ctx.inputs.heic !== false) return;
+ const card = document.querySelector('.ovk-tool-card');
+ if (!card || document.getElementById('input-notice') || !kit()) return;
+ const note = document.createElement('div');
+ note.id = 'input-notice';
+ note.innerHTML = kit().notice({
+ kind: 'warn',
+ text: 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.',
});
});
+ card.insertBefore(note, card.firstChild);
}
}
- // ── Upload ───────────────────────────────────────────────
- function initUpload() {
- // Drag & drop
- uploadArea.addEventListener('dragover', (e) => {
- e.preventDefault();
- uploadArea.classList.add('dragover');
- });
- uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
- uploadArea.addEventListener('drop', (e) => {
- e.preventDefault();
- uploadArea.classList.remove('dragover');
- if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
- });
-
- // Click to browse
- uploadArea.addEventListener('click', () => fileInput.click());
- browseBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });
+ // ── The box: the drop zone ───────────────────────────────
+ function initDrop() {
+ if (!fileInput) return;
fileInput.addEventListener('change', () => {
fileInput.addEventListener('change', () => {
- if (fileInput.files.length) handleFile(fileInput.files[0]);
+ if (fileInput.files && fileInput.files.length) handleFile(fileInput.files[0]);
});
});
-
- // Clear
- clearBtn.addEventListener('click', resetUpload);
+ if (clearBtn) clearBtn.addEventListener('click', resetUpload);
+
+ // "Try": the sample picture goes into the drop zone as if it had been chosen.
+ const sampleLink = document.querySelector('[data-ovk-sample]');
+ if (sampleLink && window.OVToolFrame) {
+ sampleLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), handleFile);
+ });
+ }
}
}
function handleFile(file) {
function handleFile(file) {
+ if (!file) return;
if (!file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
if (!file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
- alert('Please select an image file.');
+ fail('Please choose an image file.');
return;
return;
}
}
if (file.size > 50 * 1024 * 1024) {
if (file.size > 50 * 1024 * 1024) {
- alert('File too large. Maximum 50MB.');
+ fail('That file is too large. The maximum is 50 MB.');
return;
return;
}
}
selectedFile = file;
selectedFile = file;
- uploadArea.style.display = 'none';
- uploadInfo.style.display = 'flex';
-
- // Preview
- const preview = $('#file-preview');
- const details = $('#file-details');
- const objectUrl = URL.createObjectURL(file);
- preview.innerHTML = `<img src="${objectUrl}" alt="Preview">`;
- details.innerHTML = `
- <div class="fname">${escapeHtml(file.name)}</div>
- <div class="fmeta">${file.type} • ${formatBytes(file.size)}</div>
- `;
-
- optionsPanel.style.display = '';
- showToolOptions();
- updateProcessButton();
+ selectedTool = (ctx && ctx.defaultOp) || 'convert';
+ if (optionsPanel) optionsPanel.hidden = false;
+ if (processBtn) processBtn.disabled = false;
+ hideAnswer();
}
}
function resetUpload() {
function resetUpload() {
selectedFile = null;
selectedFile = null;
- fileInput.value = '';
- uploadArea.style.display = '';
- uploadInfo.style.display = 'none';
- optionsPanel.style.display = 'none';
- resultPanel.style.display = 'none';
- processingEl.style.display = 'none';
- processBtn.disabled = true;
+ if (fileInput) fileInput.value = '';
+ if (optionsPanel) optionsPanel.hidden = true;
+ if (processBtn) processBtn.disabled = true;
+ hideAnswer();
}
}
- // ── Options ──────────────────────────────────────────────
+ // ── The box: the options and the one button ─────────────
function initOptions() {
function initOptions() {
- // Quality slider
- qualitySlider.addEventListener('input', () => { qualityVal.textContent = qualitySlider.value; });
- compressSlider.addEventListener('input', () => { compressVal.textContent = compressSlider.value; });
-
- // Format buttons
- $$('.format-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- selectedFormat = btn.dataset.format;
- $$('.format-btn').forEach(b => b.classList.toggle('active', b.dataset.format === selectedFormat));
- });
- });
-
- // Aspect ratio buttons
- $$('.aspect-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- selectedAspect = btn.dataset.aspect;
- $$('.aspect-btn').forEach(b => b.classList.toggle('active', b.dataset.aspect === selectedAspect));
- // Show/hide custom coords
- $('#crop-coords').style.display = selectedAspect ? 'none' : 'grid';
- });
- });
+ // The format chips: the button follows the format that is picked ("Convert to PNG").
+ $$('input[name=format]').forEach((radio) => radio.addEventListener('change', updateVerb));
- // Process button
- processBtn.addEventListener('click', processImage);
+ // The quality slider: the kit shows the value; the app only reads it.
+ if (processBtn) processBtn.addEventListener('click', processImage);
}
}
- function showToolOptions() {
- // Hide all option groups
- ['opt-convert', 'opt-compress', 'opt-resize', 'opt-crop'].forEach(id => {
- const el = $(`#${id}`);
- if (el) el.style.display = 'none';
- });
+ function selectedFormat() {
+ const checked = document.querySelector('input[name=format]:checked');
+ return checked ? checked.value : ((ctx && ctx.defaultFormat) || 'png');
+ }
- // Show active tool options
- const optEl = $(`#opt-${selectedTool}`);
- if (optEl) optEl.style.display = '';
+ function updateVerb() {
+ if (!processBtn) return;
+ const span = processBtn.querySelector('span');
+ if (!span) return;
+ if (selectedTool === 'compress') span.textContent = 'Compress';
+ else if (selectedTool === 'resize') span.textContent = 'Resize';
+ else if (selectedTool === 'crop') span.textContent = 'Crop';
+ else if (ctx && ctx.toolId === 'favicon') span.textContent = 'Make favicon';
+ else span.textContent = `Convert to ${selectedFormat().toUpperCase()}`;
+ }
- // Pre-select format if domain-specific
- if (ctx.defaultFormat) {
- selectedFormat = ctx.defaultFormat;
- $$('.format-btn').forEach(b => b.classList.toggle('active', b.dataset.format === selectedFormat));
- }
+ function numberValue(name) {
+ const el = document.querySelector(`[name="${name}"]`);
+ return el && el.value !== '' ? Number(el.value) : null;
}
}
- function updateProcessButton() {
- processBtn.disabled = !selectedFile;
- const labels = { convert: 'Convert Image', compress: 'Compress Image', resize: 'Resize Image', crop: 'Crop Image' };
- processText.textContent = labels[selectedTool] || 'Process Image';
+ function radioValue(name) {
+ const el = document.querySelector(`input[name="${name}"]:checked`);
+ return el ? el.value : '';
}
}
// ── Process ──────────────────────────────────────────────
// ── Process ──────────────────────────────────────────────
async function processImage() {
async function processImage() {
if (!selectedFile) return;
if (!selectedFile) return;
- // Show processing state
- optionsPanel.style.display = 'none';
- resultPanel.style.display = 'none';
- processingEl.style.display = '';
- processBtn.disabled = true;
-
- const formData = new FormData();
- formData.append('file', selectedFile);
- formData.append('tool', selectedTool);
-
- // Add tool-specific options
+ const input = { tool: selectedTool };
if (selectedTool === 'convert') {
if (selectedTool === 'convert') {
- formData.append('format', selectedFormat);
- formData.append('quality', qualitySlider.value);
+ input.format = selectedFormat();
+ const q = numberValue('quality');
+ if (q != null) input.quality = q;
} else if (selectedTool === 'compress') {
} else if (selectedTool === 'compress') {
- formData.append('quality', compressSlider.value);
+ const q = numberValue('quality');
+ if (q != null) input.quality = q;
} else if (selectedTool === 'resize') {
} else if (selectedTool === 'resize') {
- const w = $('#resize-w').value;
- const h = $('#resize-h').value;
- const pct = $('#resize-pct').value;
- const fit = $('#resize-fit').value;
- if (pct) formData.append('percentage', pct);
+ const pct = numberValue('percentage');
+ if (pct != null) input.percentage = pct;
else {
else {
- if (w) formData.append('width', w);
- if (h) formData.append('height', h);
+ const w = numberValue('width');
+ const h = numberValue('height');
+ if (w != null) input.width = w;
+ if (h != null) input.height = h;
}
}
- formData.append('fit', fit);
+ const fit = document.querySelector('[name="fit"]');
+ if (fit && fit.value) input.fit = fit.value;
} else if (selectedTool === 'crop') {
} else if (selectedTool === 'crop') {
- if (selectedAspect) {
- formData.append('aspect', selectedAspect);
- } else {
- const l = $('#crop-left').value;
- const t = $('#crop-top').value;
- const w = $('#crop-w').value;
- const h = $('#crop-h').value;
- if (l) formData.append('left', l);
- if (t) formData.append('top', t);
- if (w) formData.append('width', w);
- if (h) formData.append('height', h);
+ const aspect = radioValue('aspect');
+ if (aspect) input.aspect = aspect;
+ else {
+ for (const k of ['left', 'top', 'width', 'height']) {
+ const v = numberValue(k);
+ if (v != null) input[k] = v;
+ }
}
}
}
}
- // Jobs: the work is accepted, followed live and survives a reload (?job=<id> in the address).
- const input = {};
- for (const [k, v] of formData.entries()) if (k !== 'file') input[k] = v;
+ if (processBtn) {
+ processBtn.disabled = true;
+ const span = processBtn.querySelector('span');
+ // The button keeps its verb while it works: "Compress…", not "Working…".
+ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;
+ }
+ showWorking();
+
try {
try {
const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile] });
const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile] });
OVJobs.remember(job.id);
OVJobs.remember(job.id);
@@ -391,41 +262,24 @@
@@ -391,41 +262,24 @@
function processingText(job) {
function processingText(job) {
const pct = job.progress && job.progress.percent;
const pct = job.progress && job.progress.percent;
- if (job.cancel_requested) return 'Cancelling...';
- if (job.state === 'queued') return 'Waiting for a free slot...';
+ if (job.cancel_requested) return 'Cancelling…';
+ if (job.state === 'queued') return 'Waiting for a free slot…';
const label = (job.progress && job.progress.message) || 'Processing your image';
const label = (job.progress && job.progress.message) || 'Processing your image';
- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;
- }
-
- function cancelButton() {
- let btn = processingEl.querySelector('.job-cancel');
- if (!btn) {
- btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'btn-secondary job-cancel';
- btn.textContent = 'Cancel';
- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });
- processingEl.appendChild(btn);
- }
- return btn;
+ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;
}
}
function followJob(job) {
function followJob(job) {
currentJob = job.id;
currentJob = job.id;
- optionsPanel.style.display = 'none';
- resultPanel.style.display = 'none';
- processingEl.style.display = '';
- processBtn.disabled = true;
- const label = processingEl.querySelector('p');
- const update = (j) => { if (label) label.textContent = processingText(j); };
- cancelButton().style.display = '';
- update(job);
+ hideAnswer();
+ showWorking();
if (watcher) watcher.close();
if (watcher) watcher.close();
watcher = OVJobs.watch(job.id, {
watcher = OVJobs.watch(job.id, {
- onUpdate: update,
+ onUpdate: (j) => {
+ const out = document.querySelector('.ovk-tool-out');
+ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));
+ },
onDone(done) {
onDone(done) {
watcher = null;
watcher = null;
- cancelButton().style.display = 'none';
if (done.state === 'succeeded') return showResult(jobResult(done));
if (done.state === 'succeeded') return showResult(jobResult(done));
OVJobs.forget();
OVJobs.forget();
failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
@@ -443,11 +297,19 @@
@@ -443,11 +297,19 @@
};
};
}
}
+ function resetVerb() {
+ const span = processBtn && processBtn.querySelector('span');
+ if (span) span.textContent = span.textContent.replace(/…$/, '');
+ }
+
function failProcessing(message) {
function failProcessing(message) {
- processingEl.style.display = 'none';
- optionsPanel.style.display = selectedFile ? '' : 'none';
- processBtn.disabled = !selectedFile;
- if (message) alert(`Error: ${message}`);
+ resetVerb();
+ if (processBtn) {
+ processBtn.disabled = !selectedFile;
+ updateVerb();
+ }
+ if (message) fail(message);
+ else hideAnswer();
}
}
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
@@ -457,89 +319,58 @@
@@ -457,89 +319,58 @@
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
}
}
- // ── Result ───────────────────────────────────────────────
- function initResult() {
- anotherBtn.addEventListener('click', () => {
- if (window.OVJobs) OVJobs.forget();
- resetUpload();
- toolTabs.scrollIntoView({ behavior: 'smooth' });
+ // ── The answer ───────────────────────────────────────────
+ function showWorking() {
+ const out = document.querySelector('.ovk-tool-out');
+ if (!out || !kit()) return;
+ out.innerHTML = kit().answer({
+ title: 'Working…',
+ body: '<p data-ovk-working>Your picture is being processed on our server. This page stays open until it is done.</p>',
});
});
}
}
- function showResult(data) {
- processingEl.style.display = 'none';
- resultPanel.style.display = '';
-
- // Preview image (if it's a previewable type)
- const previewable = ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/avif'];
- if (previewable.includes(data.output.mime)) {
- const img = document.createElement('img');
- img.src = data.download.previewUrl || data.download.downloadUrl;
- img.alt = 'Result';
- resultPreview.replaceChildren(img);
- } else {
- resultPreview.innerHTML = `<div style="font-size:48px;color:var(--accent);padding:24px"><i class="fa-solid fa-file-image"></i></div>`;
- }
+ function hideAnswer() {
+ const out = document.querySelector('.ovk-tool-out');
+ if (out) out.innerHTML = '';
+ }
- // Info
- let infoHtml = `<p>Format: <strong>${data.output.ext.toUpperCase()}</strong> • Size: <strong>${data.output.sizeKB} KB</strong></p>`;
- if (data.savings) {
- const pct = data.savings.savedPercent;
- if (pct > 0) {
- infoHtml += `<p class="savings">Saved ${pct}% (${formatBytes(data.savings.savedBytes)} smaller)</p>`;
- } else {
- infoHtml += `<p>File size unchanged or slightly larger — try a lower quality.</p>`;
- }
- }
- if (data.dimensions) {
- infoHtml += `<p>${data.dimensions.original.width}x${data.dimensions.original.height} → ${data.dimensions.resized.width}x${data.dimensions.resized.height}</p>`;
+ function fail(message) {
+ const out = document.querySelector('.ovk-tool-out');
+ if (!out || !kit()) return;
+ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });
+ }
+
+ function showResult(data) {
+ resetVerb();
+ if (processBtn) {
+ processBtn.disabled = false;
+ updateVerb();
}
}
- if (data.crop) {
- infoHtml += `<p>Cropped to ${data.crop.width}x${data.crop.height}</p>`;
+ const out = data.output || {};
+ const rows = [];
+ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);
+ if (data.dimensions) rows.push(['Size', `${data.dimensions.original.width}×${data.dimensions.original.height} → ${data.dimensions.resized.width}×${data.dimensions.resized.height}`]);
+ if (data.crop) rows.push(['Kept', `${data.crop.width}×${data.crop.height}`]);
+ const expires = data.download && data.download.expiresIn;
+ if (expires) {
+ const mins = Math.max(1, Math.round(expires / 60000));
+ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);
}
}
-
- const expires = data.download.expiresIn;
- const expiresStr = expires >= 3600000 ? `${Math.round(expires / 3600000)}h` : `${Math.round(expires / 60000)}m`;
- infoHtml += `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Download expires in ${expiresStr}</p>`;
-
- resultInfo.innerHTML = infoHtml;
-
- // Download link
- downloadBtn.href = data.download.downloadUrl;
- downloadBtn.download = '';
-
- resultPanel.scrollIntoView({ behavior: 'smooth' });
+ const html = window.OVToolFrame.fileAnswer({
+ title: 'Your picture is ready',
+ before: selectedFile ? selectedFile.size : null,
+ after: out.size != null ? out.size : null,
+ savedPercent: data.savings && data.savings.savedPercent,
+ rows,
+ downloadUrl: data.download && data.download.downloadUrl,
+ filename: data.download && data.download.filename,
+ });
+ window.OVToolFrame.show(html);
}
}
- // ── Subdomains Grid ──────────────────────────────────────
- function initSubdomains() {
- const tools = [
- { host: 'png.openvibe.tools', name: 'OpenVibePNG', icon: 'fa-file-image', desc: 'Convert to PNG' },
- { host: 'jpg.openvibe.tools', name: 'OpenVibeJPG', icon: 'fa-file-image', desc: 'Convert to JPG' },
- { host: 'webp.openvibe.tools', name: 'OpenVibeWebP', icon: 'fa-file-image', desc: 'Convert to WebP' },
- { host: 'avif.openvibe.tools', name: 'OpenVibeAVIF', icon: 'fa-file-image', desc: 'Convert to AVIF' },
- { host: 'heic.openvibe.tools', name: 'OpenVibeHEIC', icon: 'fa-file-image', desc: 'HEIC Converter' },
- { host: 'svg.openvibe.tools', name: 'OpenVibeSVG', icon: 'fa-shapes', desc: 'SVG Converter' },
- { host: 'gif.openvibe.tools', name: 'OpenVibeGIF', icon: 'fa-film', desc: 'GIF Converter' },
- { host: 'ico.openvibe.tools', name: 'OpenVibeICO', icon: 'fa-icons', desc: 'ICO Favicon Maker' },
- { host: 'tiff.openvibe.tools', name: 'OpenVibeTIFF', icon: 'fa-file-image', desc: 'Convert to TIFF' },
- { host: 'bmp.openvibe.tools', name: 'OpenVibeBMP', icon: 'fa-file-image', desc: 'Convert to BMP' },
- { host: 'compress.openvibe.tools', name: 'OpenVibeCompress', icon: 'fa-compress', desc: 'Compress Images' },
- { host: 'resize.openvibe.tools', name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center', desc: 'Resize Images' },
- { host: 'crop.openvibe.tools', name: 'OpenVibeCrop', icon: 'fa-scissors', desc: 'Crop Images' },
- { host: 'convert.openvibe.tools', name: 'OpenVibeConvert', icon: 'fa-arrows-rotate', desc: 'Format Converter' },
- { host: 'favicon.openvibe.tools', name: 'OpenVibeFavicon', icon: 'fa-icons', desc: 'Favicon Generator' },
- ];
-
- subdomainGrid.innerHTML = tools.map(t => `
- <a class="subdomain-card" href="https://${t.host}">
- <span class="sd-icon"><i class="fa-solid ${t.icon}"></i></span>
- <span class="sd-info">
- <span class="sd-name">${t.name}</span>
- <span class="sd-desc">${t.desc}</span>
- </span>
- </a>
- `).join('');
+ // ── Result ───────────────────────────────────────────────
+ function initResult() {
+ // Nothing to wire: the answer is drawn in the frame's answer slot.
}
}
// ── Helpers ──────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────
@@ -548,12 +379,6 @@
@@ -548,12 +379,6 @@
return match ? decodeURIComponent(match[1]) : null;
return match ? decodeURIComponent(match[1]) : null;
}
}
- function formatBytes(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
- }
-
function escapeHtml(str) {
function escapeHtml(str) {
const div = document.createElement('div');
const div = document.createElement('div');
div.textContent = str;
div.textContent = str;
new file mode 100644
new file mode 100644
Binary files /dev/null and b/apps/img/public/sample.png differ
Binary files /dev/null and b/apps/img/public/sample.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,314 @@
@@ -0,0 +1,314 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the image pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// Every image host — png, jpg, compress, resize, crop and the rest — is a tool page in the shared
+// frame: the head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone
+// and the tool's own options), the answer (the file it made, its size before and after, and Download),
+// the family, the folds and "People also use".
+//
+// The terms come from the app's real configuration: the file's lifetime from config.js's retention,
+// its size from config.js's upload limits, and the anonymous rate from the guard's quota table and the
+// tool's own cost in it (server/descriptors.js).
+// ═══════════════════════════════════════════════════════════════
+
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const config = require('./config');
+const { DOMAIN_MAP } = require('./domain-map');
+const { SPECS } = require('./descriptors');
+
+const HUB = 'https://img.openvibe.tools/';
+const esc = kit.esc;
+
+// The tools, in the order the family row shows them. `op` is the operation the host runs
+// (domain-map.js); `format` the one it writes by default.
+const TOOLS = [
+ {
+ id: 'convert', host: 'convert.openvibe.tools', name: 'Image Converter', short: 'Convert', icon: 'swap', hue: 190, op: 'convert',
+ task: 'Convert an image to PNG, JPG, WebP, AVIF, GIF, TIFF, BMP or ICO.',
+ about: 'The image converter writes the format you pick and nothing else: the picture is decoded, re-encoded and sent back as a file you can download.',
+ bullets: ['PNG keeps every pixel, JPG is small and universal, WebP and AVIF are smaller still.', 'A quality slider is there for the formats that lose a little to save bytes.'],
+ faq: [['Which format should I pick?', 'PNG for a picture with text or flat colours, JPG for a photograph you want small, WebP or AVIF when the file travels far.'],
+ ['Is my picture uploaded?', 'Yes, to our server, and it is deleted an hour later. Nothing is kept.'],
+ ['Can I convert a HEIC photo?', 'Yes, on a server with the HEIC decoder installed. The page says so when it is not.']],
+ },
+ {
+ id: 'compress', host: 'compress.openvibe.tools', name: 'Image Compressor', short: 'Compress', icon: 'zap', hue: 190, op: 'compress',
+ task: 'Make an image smaller without changing how it looks.',
+ about: 'The compressor re-encodes your picture at the quality you choose and shows the size before and after, so you can see what it saved.',
+ bullets: ['A lower level means a smaller file and a softer picture.', 'The original is never touched; you get a new file.'],
+ faq: [['How much smaller can it get?', 'A photograph often loses half its size; a flat picture with sharp edges loses less.'],
+ ['Does it change the resolution?', 'No. The picture keeps its dimensions; only the way it is encoded changes.']],
+ },
+ {
+ id: 'resize', host: 'resize.openvibe.tools', name: 'Image Resizer', short: 'Resize', icon: 'grid', hue: 190, op: 'resize',
+ task: 'Make an image a new size, in pixels or by a percentage.',
+ about: 'The resizer scales a picture to the width and height you give it, or to a percentage of what it is now.',
+ bullets: ['Five ways to fit: inside, cover, contain, fill or outside.', 'A picture is never enlarged past its own size unless you ask for it.'],
+ faq: [['What does "inside" mean?', 'The picture fits inside the box you typed; the other side keeps its ratio.'],
+ ['Can I make it bigger?', 'Yes, but a picture scaled up is softer than the original.']],
+ },
+ {
+ id: 'crop', host: 'crop.openvibe.tools', name: 'Image Cropper', short: 'Crop', icon: 'grid', hue: 190, op: 'crop',
+ task: 'Cut an image down to the part you want to keep.',
+ about: 'The cropper keeps a rectangle of your picture, or the largest centred piece with the ratio you choose.',
+ bullets: ['Nine ratios, from 1:1 to 21:9.', 'Type the rectangle yourself when the ratio is not the one you want.'],
+ faq: [['Which part is kept when I pick a ratio?', 'The largest centred piece with that ratio.'],
+ ['Can I crop to an exact rectangle?', 'Yes. Leave the ratio on "Custom" and type the four numbers.']],
+ },
+ {
+ id: 'favicon', host: 'favicon.openvibe.tools', name: 'Favicon Generator', short: 'Favicon', icon: 'star', hue: 190, op: 'convert', format: 'ico',
+ task: 'Turn a picture into the .ico file a website asks for.',
+ about: 'The favicon generator writes a multi-size ICO file from your picture, the file a browser tab and a bookmark ask a site for.',
+ bullets: ['One .ico file with the sizes browsers look for.', 'A PNG of the same picture comes with it for social cards.'],
+ faq: [['Where do I put the file?', 'In your site\'s root folder, next to your index.html, and you link it in the head.'],
+ ['What size should the picture be?', 'A square of at least 512 pixels; the generator resizes it for you.']],
+ },
+ {
+ id: 'png', host: 'png.openvibe.tools', name: 'PNG Converter', short: 'PNG', icon: 'image', hue: 190, op: 'convert', format: 'png',
+ task: 'Convert an image to PNG, the format that keeps every pixel.',
+ about: 'The PNG converter writes a lossless PNG from any picture you drop: JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF or SVG.',
+ bullets: ['Lossless: the pixels that come out are the pixels that went in.', 'The background is filled with white when the picture had none.'],
+ faq: [['Is a PNG bigger than a JPG?', 'Usually. PNG keeps every pixel, so it is bigger for a photograph and smaller for a logo.'],
+ ['Does it keep the transparency?', 'Yes, when the format you drop it in has transparency.']],
+ },
+ {
+ id: 'jpg', host: 'jpg.openvibe.tools', name: 'JPG Converter', short: 'JPG', icon: 'image', hue: 190, op: 'convert', format: 'jpg',
+ task: 'Convert an image to JPG, the format every program reads.',
+ about: 'The JPG converter writes a JPEG from any picture you drop, at the quality you pick.',
+ bullets: ['The format for photographs and for anything that has to be small.', 'Transparency is filled with white.'],
+ faq: [['What quality should I use?', '80 to 85 is invisible to the eye and small enough for the web.'],
+ ['Can it write a progressive JPG?', 'Yes, the file loads line by line while it downloads.']],
+ },
+ {
+ id: 'webp', host: 'webp.openvibe.tools', name: 'WebP Converter', short: 'WebP', icon: 'image', hue: 190, op: 'convert', format: 'webp',
+ task: 'Convert an image to WebP, the web format that is smaller than JPG.',
+ about: 'The WebP converter writes a modern web image format: smaller than JPG at the same look, and it keeps transparency.',
+ bullets: ['Every current browser reads WebP.', 'Usually a third smaller than the same picture as a JPG.'],
+ faq: [['Does every browser read WebP?', 'Every browser released since 2020 does.'],
+ ['Is WebP lossy?', 'You choose: a quality slider, like the JPG converter.']],
+ },
+ {
+ id: 'avif', host: 'avif.openvibe.tools', name: 'AVIF Converter', short: 'AVIF', icon: 'image', hue: 190, op: 'convert', format: 'avif',
+ task: 'Convert an image to AVIF, the smallest format the web has.',
+ about: 'The AVIF converter writes the modern image format browsers support: smaller than WebP at the same quality.',
+ bullets: ['The smallest files of the lot, and the slowest to write.', 'Transparency, like PNG.'],
+ faq: [['Is AVIF slow?', 'Yes. A big picture takes a while to encode, and the page says so while it works.'],
+ ['Which browsers read AVIF?', 'Chrome, Firefox, Safari 16 and every browser since.']],
+ },
+ {
+ id: 'heic', host: 'heic.openvibe.tools', name: 'HEIC Converter', short: 'HEIC', icon: 'image', hue: 190, op: 'convert', format: 'png',
+ task: 'Convert an iPhone HEIC photo to a picture every program can open.',
+ about: 'The HEIC converter reads the photos an iPhone saves and writes them as PNG or JPG.',
+ bullets: ['HEIC and HEIF, the formats iPhones and some cameras use.', 'Needs a HEIC decoder on the server; the page says so when there is none.'],
+ faq: [['Why can I not convert my photo?', 'The server needs the HEIC decoder. The page says so when it is missing.'],
+ ['What does it write?', 'PNG by default; the format grid is there if you want JPG instead.']],
+ },
+ {
+ id: 'svg', host: 'svg.openvibe.tools', name: 'SVG Converter', short: 'SVG', icon: 'image', hue: 190, op: 'convert', format: 'png',
+ task: 'Convert an SVG drawing to a picture you can use anywhere.',
+ about: 'The SVG converter rasterises a vector drawing, so you can use it as a PNG or a JPG in a program that cannot read vectors.',
+ bullets: ['Vector in, picture out.', 'The width and height of the drawing decide the size of the picture.'],
+ faq: [['Will the picture be sharp?', 'It is drawn at the size you ask for, so ask for the size you need.'],
+ ['Can I go the other way?', 'Not here: a picture turned into a vector is a different tool.']],
+ },
+ {
+ id: 'gif', host: 'gif.openvibe.tools', name: 'GIF Converter', short: 'GIF', icon: 'image', hue: 190, op: 'convert', format: 'gif',
+ task: 'Convert an image to GIF, the format every program still reads.',
+ about: 'The GIF converter writes an animated or still GIF from any picture you drop.',
+ bullets: ['Good for pictures with few colours and for short animations.', 'A picture of many colours becomes bigger, not smaller.'],
+ faq: [['Does it keep the animation?', 'Yes, when the file you drop is an animated picture.'],
+ ['Why is my GIF so big?', 'GIF has no compression for photographs. Use JPG or WebP for those.']],
+ },
+ {
+ id: 'ico', host: 'ico.openvibe.tools', name: 'ICO Converter', short: 'ICO', icon: 'image', hue: 190, op: 'convert', format: 'ico',
+ task: 'Convert an image to ICO, the icon file a website and a program use.',
+ about: 'The ICO converter writes a multi-size icon file from your picture.',
+ bullets: ['The sizes Windows and browsers look for, in one file.', 'A PNG of the same picture comes with it.'],
+ faq: [['What size should the picture be?', 'A square of 512 pixels or more; the sizes below it are made for you.'],
+ ['Can I make a favicon from a photo?', 'Yes, but a picture with one shape in it reads better as an icon.']],
+ },
+ {
+ id: 'tiff', host: 'tiff.openvibe.tools', name: 'TIFF Converter', short: 'TIFF', icon: 'image', hue: 190, op: 'convert', format: 'tiff',
+ task: 'Convert an image to TIFF, the format printers and scanners use.',
+ about: 'The TIFF converter writes an uncompressed, lossless picture that a printer or a scanner program reads.',
+ bullets: ['Lossless and unshrunk.', 'Big files: a picture is not compressed here.'],
+ faq: [['Why is the file so big?', 'TIFF stores the picture as it is, without compression.'],
+ ['Who needs TIFF?', 'Printers, scanners and publishers.']],
+ },
+ {
+ id: 'bmp', host: 'bmp.openvibe.tools', name: 'BMP Converter', short: 'BMP', icon: 'image', hue: 190, op: 'convert', format: 'bmp',
+ task: 'Convert an image to BMP, the oldest picture format there is.',
+ about: 'The BMP converter writes the uncompressed bitmap format that old programs and some tools still ask for.',
+ bullets: ['No compression: the file is as big as the picture.', 'Nothing is lost on the way.'],
+ faq: [['Why would I want a BMP?', 'Some old programs and device tools only read BMP.'],
+ ['Is the file big?', 'Yes. It is the picture in its own bytes, with no compression.']],
+ },
+];
+
+const BY_ID = new Map(TOOLS.map((t) => [t.id, t]));
+const BY_HOST = new Map(TOOLS.map((t) => [t.host, t]));
+const SPEC = new Map(SPECS.map((s) => [s.id, s]));
+
+// The hub is a tool page too: the converter, with the whole family under it.
+const HUB_TOOL = {
+ id: 'hub', name: 'Img.OpenVibe', short: 'All tools', icon: 'image', hue: 190, op: 'convert', format: 'png',
+ task: 'Convert an image to PNG, JPG, WebP, AVIF, GIF, TIFF, BMP or ICO — the other image tools are below.',
+ about: 'Every image tool in one place: convert a picture to another format, make it smaller, change its size or cut it down.',
+ faq: [['Which formats do you read?', 'PNG, JPG, WebP, AVIF, HEIC, HEIF, SVG, GIF, ICO, TIFF and BMP.'],
+ ['How long are my files kept?', 'An hour for a visitor, a day for a signed-in person. Then they are gone.'],
+ ['Is it free?', 'Yes. Every image tool here is free, with a fair-use limit per address so the server stays fast.']],
+};
+
+/** The tool a host is for (the aliases jpeg and heif point at jpg and heic). */
+function toolFor(host) {
+ const t = BY_HOST.get(host);
+ if (t) return t;
+ const ctx = DOMAIN_MAP[host];
+ if (!ctx) return null;
+ if (ctx.toolId === 'hub') return HUB_TOOL;
+ return BY_ID.get(ctx.toolId) || null;
+}
+
+/** The format the host writes by default. */
+const formatOf = (rec) => rec.format || (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat || 'png';
+
+/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */
+function termsOf(rec) {
+ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
+ // The size is the app's own upload limit (config.js); the count is the tool's own descriptor.
+ const maxBytes = config.upload.maxFileSize;
+ const maxFiles = (spec && spec.files && spec.files.max) || 1;
+ return frame.terms({
+ work: 'server',
+ ttlMs: config.retention.anonTTL,
+ maxBytes, maxFiles,
+ quotaClass: (spec && spec.quotaClass) || 'tools-job',
+ cost: (spec && spec.cost) || 1,
+ unit: rec.op === 'convert' ? 'conversions' : 'runs',
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ });
+}
+
+/** The format buttons of the convert box. */
+function formatChips(current) {
+ const formats = [['png', 'PNG'], ['jpg', 'JPG'], ['webp', 'WebP'], ['avif', 'AVIF'], ['gif', 'GIF'], ['tiff', 'TIFF'], ['bmp', 'BMP'], ['ico', 'ICO']];
+ return kit.chips({
+ name: 'format', single: true, label: 'Output format',
+ options: formats.map(([v, l]) => [v, l, v === current]),
+ });
+}
+
+/** A number field of the box, in the app's own field style. */
+function numberField(name, label, { min = 0, max = null, placeholder = '' } = {}) {
+ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`
+ + `<input type="number" class="num-input num-input-sm" name="${esc(name)}" value="" placeholder="${esc(placeholder)}"${min != null ? ` min="${min}"` : ''}${max != null ? ` max="${max}"` : ''}></label>`;
+}
+
+/** The options of the box, per operation. */
+function optionsFor(rec) {
+ if (rec.op === 'compress') return kit.rangeRow({ name: 'quality', label: 'Compression level', value: 75, min: 1, max: 100, step: 1 });
+ if (rec.op === 'resize') {
+ return numberField('width', 'Width (px)', { min: 1, max: 16384, placeholder: 'auto' })
+ + numberField('height', 'Height (px)', { min: 1, max: 16384, placeholder: 'auto' })
+ + numberField('percentage', 'Or scale by percentage', { min: 1, max: 1000, placeholder: 'e.g. 50' })
+ + kit.selectRow({ name: 'fit', label: 'Fit mode', value: 'inside', options: [['inside', 'Inside (fit within bounds)'], ['cover', 'Cover (fill and crop)'], ['contain', 'Contain (fit with padding)'], ['fill', 'Fill (stretch)'], ['outside', 'Outside (cover minimum)']] });
+ }
+ if (rec.op === 'crop') {
+ const ratios = [['1:1', '1:1'], ['16:9', '16:9'], ['4:3', '4:3'], ['3:2', '3:2'], ['21:9', '21:9'], ['9:16', '9:16'], ['3:4', '3:4'], ['', 'Custom']];
+ return kit.chips({ name: 'aspect', single: true, label: 'Aspect ratio', options: ratios.map(([v, l]) => [v, l, v === '']) })
+ + numberField('left', 'Left (px)', { min: 0, placeholder: '0' })
+ + numberField('top', 'Top (px)', { min: 0, placeholder: '0' })
+ + numberField('width', 'Width (px)', { min: 1, placeholder: 'auto' })
+ + numberField('height', 'Height (px)', { min: 1, placeholder: 'auto' });
+ }
+ return formatChips(formatOf(rec)) + kit.rangeRow({ name: 'quality', label: 'Quality', value: 80, min: 1, max: 100, step: 1 });
+}
+
+/** The one button of the page: it says the verb. */
+function verbFor(rec) {
+ if (rec.op === 'compress') return 'Compress';
+ if (rec.op === 'resize') return 'Resize';
+ if (rec.op === 'crop') return 'Crop';
+ if (rec.id === 'favicon') return 'Make favicon';
+ return `Convert to ${formatOf(rec).toUpperCase()}`;
+}
+
+/**
+ * The box: a drop zone for the picture, the tool's options and the one button.
+ * @param {object} rec the tool record
+ */
+function boxFor(rec) {
+ const accept = 'image/png,image/jpeg,image/webp,image/avif,image/tiff,image/bmp,image/gif,image/heic,image/heif,image/svg+xml,image/x-icon,image/vnd.microsoft.icon';
+ const drop = kit.drop({
+ name: 'file', accept, id: 'file-input',
+ label: 'Drop an image here',
+ hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP',
+ });
+ return `<form class="ovk-tool-form" id="ovk-img-form" enctype="multipart/form-data" data-ovk-tool-form>`
+ + drop
+ + `<p class="ovk-try"><span>Try</span><a href="/sample.png" data-ovk-sample="/sample.png">A sample image</a></p>`
+ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`
+ + `<div class="ovk-tool-buttons">${frame.verbButton(verbFor(rec), { id: 'process-btn', icon: 'zap' })}`
+ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`
+ + `</form>`;
+}
+
+/** The family: every image tool, with the one on the page marked. */
+function familyItems(rec) {
+ // The hub is the converter itself, so the converter is the tool the family marks there.
+ const current = rec.id === 'hub' ? 'convert' : rec.id;
+ return TOOLS.map((t) => ({
+ href: `https://${t.host}/`, label: t.name, short: t.short, current: t.id === current,
+ }));
+}
+
+/** "People also use": the tools next to this one in the family. */
+function relatedFor(rec) {
+ // The hub is the converter, so the converter is not "also" there.
+ const self = rec.id === 'hub' ? 'convert' : rec.id;
+ const at = TOOLS.findIndex((t) => t.id === self);
+ const out = [];
+ for (let i = 1; out.length < 3 && i < TOOLS.length + 1; i++) {
+ const other = TOOLS[(at + i) % TOOLS.length];
+ if (other.id !== self && other.id !== 'hub') out.push(other);
+ }
+ return out.map((t) => kit.row({ href: `https://${t.host}/`, title: t.name, sub: t.task }));
+}
+
+/**
+ * The whole frame for one host.
+ * @param {string} host the satellite's own hostname, e.g. 'png.openvibe.tools'
+ * @returns {string} HTML
+ */
+function frameFor(host) {
+ const rec = toolFor(host);
+ if (!rec) return '';
+ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
+ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';
+ const apiId = rec.id === 'hub' ? 'convert' : rec.id;
+ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(
+ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : (k === 'format' ? formatOf(rec) : '')]),
+ ) : {};
+ return frame.page({
+ id: rec.id,
+ icon: rec.icon, hue: rec.hue, family: 'image',
+ crumb: { href: HUB, label: 'Image tools' },
+ title: rec.name, task: rec.task, facts: termsOf(rec),
+ box: boxFor(rec),
+ familyTitle: 'More image tools',
+ familyMore: { href: HUB, label: `All ${TOOLS.length}` },
+ familyItems: familyItems(rec),
+ about: rec.about, bullets: rec.bullets, faq: rec.faq,
+ api: {
+ id: apiId, path: `/api/v1/tools/${apiId}/run`, fields, payload: { input },
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ },
+ related: relatedFor(rec),
+ });
+}
+
+module.exports = { frameFor, toolFor, termsOf, boxFor, verbFor, TOOLS, HUB_TOOL, optionsFor, formatChips };
@@ -267,6 +267,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -267,6 +267,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
},
}));
}));
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
+
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// tags and structured data (server/seo.js); the raw file carries only the hub's.
// tags and structured data (server/seo.js); the raw file carries only the hub's.
const { sendIndex } = require('./seo');
const { sendIndex } = require('./seo');
@@ -15,8 +15,10 @@
@@ -15,8 +15,10 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
+const serve = require('openvibe-shared/serve');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { DOMAIN_MAP } = require('./domain-map');
const { DOMAIN_MAP } = require('./domain-map');
+const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const PUBLIC = path.join(__dirname, '..', 'public');
const HUB_HOST = 'img.openvibe.tools';
const HUB_HOST = 'img.openvibe.tools';
@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:image" content="${image}">`,
`<meta name="twitter:image" content="${image}">`,
+ // The shared tool frame's look (openvibe-shared ≥ 4.57.1), from this app's own pin.
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
].join('\n');
].join('\n');
- const out = html.replace(/<\/head>/i, `${head}\n</head>`);
+ const out = html.replace(/<\/head>/i, `${head}\n</head>`)
+ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
+ .replace('<!--OVK:FRAME-->', frameFor(host) || '');
if (_cache.size > 100) _cache.clear();
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
_cache.set(key, out);
return out;
return out;
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+// The tool frame on the image pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • every image host is a tool page in the same frame: the head, the box, the answer slot, the family,
+// the folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its
+// size from the upload limits, the anonymous rate from the guard's quota table — and they follow it
+// when it changes;
+// • the one button says the verb, never "Run";
+// • the title, the canonical and the structured data stay.
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { frameFor, verbFor, TOOLS } = require('../server/frame');
+const { DOMAIN_MAP } = require('../server/domain-map');
+const config = require('../server/config');
+const { startApp } = require('../../_shared/test/spawn');
+
+const HOSTS = Object.keys(DOMAIN_MAP);
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+// ── Every image host is a framed tool page, in the same order ──
+{
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ assert.ok(html, `${host}: a frame`);
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('https://img.openvibe.tools/">Image tools'), `${host}: the crumb is the family`);
+ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
+ assert.ok(html.includes('data-ovk-sample'), `${host}: a sample to try`);
+ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
+ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
+ assert.ok(html.includes('About '), `${host}: the about fold`);
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ assert.strictEqual(TOOLS.length, 15, 'the family has fifteen image tools');
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const html = frameFor('png.openvibe.tools');
+ const facts = factsOf(html);
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'png: free, on our server, and the file\'s lifetime');
+ assert.ok(facts.includes('One file up to 50 MB'), 'png: the upload limit');
+ assert.ok(facts.includes('12 conversions a minute without an account'), 'png: the guard\'s anonymous rate');
+ assert.ok(facts.includes('No sign-up'), 'png: anonymous');
+
+ // The lifetime follows the cleanup config.
+ const saved = config.retention.anonTTL;
+ try {
+ config.retention.anonTTL = 30 * 60 * 1000;
+ assert.ok(factsOf(frameFor('png.openvibe.tools')).includes('Runs on our server; your file is deleted after 30 minutes'), 'the lifetime follows the config');
+ config.upload.maxFileSize = 10 * 1024 * 1024;
+ assert.ok(factsOf(frameFor('png.openvibe.tools')).includes('One file up to 10 MB'), 'the size follows the config');
+ } finally {
+ config.retention.anonTTL = saved;
+ config.upload.maxFileSize = 50 * 1024 * 1024;
+ }
+
+ // A tool that costs more in the quota table gets a lower rate.
+ const avif = factsOf(frameFor('avif.openvibe.tools'));
+ assert.ok(avif.includes('7 conversions a minute without an account'), 'avif: its own cost in the quota table');
+
+ // A tool that does not convert says runs, not conversions.
+ const compress = factsOf(frameFor('compress.openvibe.tools'));
+ assert.ok(compress.includes('12 runs a minute without an account'), 'compress: the rate is counted in runs');
+ assert.ok(!compress.some((f) => /conversion/.test(f)), 'compress: never a conversion');
+}
+
+// ── The one button says the verb ──
+{
+ assert.strictEqual(verbFor({ op: 'convert', id: 'png', format: 'png' }), 'Convert to PNG');
+ assert.strictEqual(verbFor({ op: 'compress', id: 'compress' }), 'Compress');
+ assert.strictEqual(verbFor({ op: 'resize', id: 'resize' }), 'Resize');
+ assert.strictEqual(verbFor({ op: 'crop', id: 'crop' }), 'Crop');
+ for (const host of HOSTS) {
+ const html = frameFor(host);
+ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);
+ assert.ok(m, `${host}: a verb button`);
+ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);
+ assert.ok(/^[A-Z]/.test(m[1]), `${host}: the button says the verb (${m[1]})`);
+ }
+}
+
+// ── The page the server sends: the frame, the title, the canonical and the structured data ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-frame-'));
+ const app = await startApp('img', { DATA_DIR: tmp });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ for (const host of HOSTS) {
+ const r = await get(host);
+ assert.strictEqual(r.status, 200, `${host}: answers 200`);
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
+ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
+ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
+ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log(`tool frame (img): ${HOSTS.length} image pages framed, the terms from the config, the verb buttons: all checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });
+
@@ -545,4 +545,33 @@ body {
@@ -545,4 +545,33 @@ body {
.header-btn { width: 30px; height: 30px; font-size: 12px; }
.header-btn { width: 30px; height: 30px; font-size: 12px; }
}
}
+
+/* ── The tool frame ──────────────────────────────────────────────
+ The map is in the frame's answer slot now, so the page is a normal scrolling page and the map
+ takes a fixed height of the screen instead of the whole of it. */
+html, body { height: auto; min-height: 100%; overflow: auto; }
+.ovk-page #main-content { height: 68vh; }
+.ovk-page #header { position: static; }
+.ovk-maps-box .search-container { max-width: 720px; margin: 0 auto; }
+.ovk-maps-box .search-box { max-width: none; }
+
+
+/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+
+
+/* The search box sits in the frame's box now: on a phone it wraps instead of running off the screen. */
+.ovk-maps-box .search-box input { min-width: 0; flex: 1 1 120px; }
+.ovk-maps-box .search-box select { max-width: 78px; }
+@media (max-width: 560px) {
+ .ovk-maps-box .search-box { flex-wrap: wrap; }
+ .ovk-maps-box .search-box input { flex: 1 1 100%; order: -1; }
+}
+
+
+/* The page is the shared tool frame: it starts under the navbar, not behind it. */
+#app { padding-top: calc(var(--ovk-nav-h, 52px) + 8px); }
+.ovk-page { padding-top: 12px; }
@media (prefers-reduced-motion: reduce) { .splash-content, .splash-icon, #splash-screen.fade-out, .welcome-icon, .user-marker, #detail-panel, .modal-content, .sp-item:not(.done):not(.error) { animation: none; } .search-progress .spinner { animation: none; opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .splash-content, .splash-icon, #splash-screen.fade-out, .welcome-icon, .user-marker, #detail-panel, .modal-content, .sp-item:not(.done):not(.error) { animation: none; } .search-progress .spinner { animation: none; opacity: .7; } }
@@ -35,6 +35,7 @@
@@ -35,6 +35,7 @@
</script>
</script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
+ <!-- Font Awesome 6 -->
<!-- Leaflet -->
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
@@ -63,7 +64,7 @@
@@ -63,7 +64,7 @@
<div id="splash-screen">
<div id="splash-screen">
<div class="splash-content">
<div class="splash-content">
<div class="splash-icon"><i class="fa-solid fa-person-shelter"></i></div>
<div class="splash-icon"><i class="fa-solid fa-person-shelter"></i></div>
- <h1 class="splash-title">OPENVIBE<span class="green">MAPS</span></h1>
+ <p class="splash-title">OPENVIBE<span class="green">MAPS</span></p>
<p class="splash-subtitle"><i class="fa-solid fa-map-location-dot"></i> Survival Map for North America</p>
<p class="splash-subtitle"><i class="fa-solid fa-map-location-dot"></i> Survival Map for North America</p>
<div class="splash-loader">
<div class="splash-loader">
<div class="loader-bar"><div class="loader-fill" id="loader-fill"></div></div>
<div class="loader-bar"><div class="loader-fill" id="loader-fill"></div></div>
@@ -71,7 +72,7 @@
@@ -71,7 +72,7 @@
</div>
</div>
<div class="splash-sources">
<div class="splash-sources">
<span><i class="fa-solid fa-database"></i> RIDB</span>
<span><i class="fa-solid fa-database"></i> RIDB</span>
- <span><i class="fa-solid fa-map"></i> OSM</span>
+ <span><i class="fa-brands fa-openstreetmap"></i> OSM</span>
<span><i class="fa-solid fa-campground"></i> FreeCampsites</span>
<span><i class="fa-solid fa-campground"></i> FreeCampsites</span>
<span><i class="fa-solid fa-globe"></i> iOverlander</span>
<span><i class="fa-solid fa-globe"></i> iOverlander</span>
<span><i class="fa-solid fa-bridge"></i> Bridges</span>
<span><i class="fa-solid fa-bridge"></i> Bridges</span>
@@ -90,7 +91,7 @@
@@ -90,7 +91,7 @@
<div id="disclaimer-overlay" class="hidden">
<div id="disclaimer-overlay" class="hidden">
<div class="disclaimer-box">
<div class="disclaimer-box">
<div class="disclaimer-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="disclaimer-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
- <h1 class="disclaimer-title">IMPORTANT DISCLAIMER</h1>
+ <h2 class="disclaimer-title">IMPORTANT DISCLAIMER</h2>
<div class="disclaimer-body">
<div class="disclaimer-body">
<p><strong>READ CAREFULLY BEFORE USING MAPS.OPENVIBE</strong></p>
<p><strong>READ CAREFULLY BEFORE USING MAPS.OPENVIBE</strong></p>
<p>Maps.OpenVibe is provided <strong>strictly for informational and educational purposes only</strong>.</p>
<p>Maps.OpenVibe is provided <strong>strictly for informational and educational purposes only</strong>.</p>
@@ -129,11 +130,27 @@
@@ -129,11 +130,27 @@
<span class="header-tagline"><i class="fa-solid fa-map-location-dot"></i> Survival Map</span>
<span class="header-tagline"><i class="fa-solid fa-map-location-dot"></i> Survival Map</span>
</div>
</div>
- <div class="search-container">
+
+ <div class="header-right">
+ <button id="btn-weather" class="header-btn" title="Weather"><i class="fa-solid fa-cloud-sun"></i></button>
+ <button id="btn-food" class="header-btn" title="Food Banks & Grocery"><i class="fa-solid fa-utensils"></i></button>
+ <button id="btn-favorites" class="header-btn" title="Favorites"><i class="fa-solid fa-heart"></i></button>
+ <button id="btn-add-spot" class="header-btn" title="Add Custom Spot"><i class="fa-solid fa-plus"></i></button>
+ <button id="btn-guide" class="header-btn" title="Survival Guide"><i class="fa-solid fa-book-skull"></i></button>
+ <button id="btn-stats" class="header-btn" title="Statistics"><i class="fa-solid fa-chart-pie"></i></button>
+ <button id="btn-theme" class="header-btn" title="Toggle theme"><i class="fa-solid fa-moon"></i></button>
+ </div>
+ </header>
+
+ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
+ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
+ <!--OVK:FRAME-->
+ <!--OVK:BOX-->
+ <div class="search-container">
<div class="search-box">
<div class="search-box">
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<input type="text" id="search-input" placeholder="Address, city, or coordinates..." autocomplete="off">
<input type="text" id="search-input" placeholder="Address, city, or coordinates..." autocomplete="off">
- <button id="btn-my-location" title="Use GPS"><i class="fa-solid fa-location-dot"></i></button>
+ <button id="btn-my-location" title="Use GPS"><i class="fa-solid fa-crosshairs"></i></button>
<select id="search-radius" title="Search radius">
<select id="search-radius" title="Search radius">
<option value="5">5 mi</option>
<option value="5">5 mi</option>
<option value="10">10 mi</option>
<option value="10">10 mi</option>
@@ -144,18 +161,10 @@
@@ -144,18 +161,10 @@
<button id="btn-search" class="btn-primary"><i class="fa-solid fa-person-shelter"></i><span>Search</span></button>
<button id="btn-search" class="btn-primary"><i class="fa-solid fa-person-shelter"></i><span>Search</span></button>
</div>
</div>
</div>
</div>
+ <!--/OVK:BOX-->
- <div class="header-right">
- <button id="btn-weather" class="header-btn" title="Weather"><i class="fa-solid fa-cloud-sun"></i></button>
- <button id="btn-food" class="header-btn" title="Food Banks & Grocery"><i class="fa-solid fa-utensils"></i></button>
- <button id="btn-favorites" class="header-btn" title="Favorites"><i class="fa-solid fa-heart"></i></button>
- <button id="btn-add-spot" class="header-btn" title="Add Custom Spot"><i class="fa-solid fa-plus"></i></button>
- <button id="btn-guide" class="header-btn" title="Survival Guide"><i class="fa-solid fa-book"></i></button>
- <button id="btn-stats" class="header-btn" title="Statistics"><i class="fa-solid fa-chart-column"></i></button>
- <button id="btn-theme" class="header-btn" title="Toggle theme"><i class="fa-solid fa-moon"></i></button>
- </div>
- </header>
+ <!--OVK:ANSWER-->
<!-- Filter Bar -->
<!-- Filter Bar -->
<div id="filter-bar">
<div id="filter-bar">
<div class="filter-section">
<div class="filter-section">
@@ -185,7 +194,7 @@
@@ -185,7 +194,7 @@
</select>
</select>
<button id="btn-heatmap" class="filter-tool-btn" title="Stealth heatmap"><i class="fa-solid fa-fire"></i></button>
<button id="btn-heatmap" class="filter-tool-btn" title="Stealth heatmap"><i class="fa-solid fa-fire"></i></button>
<button id="btn-crime-heatmap" class="filter-tool-btn" title="Crime/sketch heatmap"><i class="fa-solid fa-skull-crossbones"></i></button>
<button id="btn-crime-heatmap" class="filter-tool-btn" title="Crime/sketch heatmap"><i class="fa-solid fa-skull-crossbones"></i></button>
- <button id="btn-satellite" class="filter-tool-btn" title="Satellite view"><i class="fa-solid fa-satellite-dish"></i></button>
+ <button id="btn-satellite" class="filter-tool-btn" title="Satellite view"><i class="fa-solid fa-satellite"></i></button>
</div>
</div>
</div>
</div>
@@ -212,7 +221,7 @@
@@ -212,7 +221,7 @@
<div class="mini-stat"><i class="fa-solid fa-database"></i> 17+ Sources</div>
<div class="mini-stat"><i class="fa-solid fa-database"></i> 17+ Sources</div>
<div class="mini-stat"><i class="fa-solid fa-map-pin"></i> 100+ Spots</div>
<div class="mini-stat"><i class="fa-solid fa-map-pin"></i> 100+ Spots</div>
<div class="mini-stat"><i class="fa-solid fa-cloud-sun"></i> Live Weather</div>
<div class="mini-stat"><i class="fa-solid fa-cloud-sun"></i> Live Weather</div>
- <div class="mini-stat"><i class="fa-solid fa-earth-americas"></i> North America</div>
+ <div class="mini-stat"><i class="fa-solid fa-globe-americas"></i> North America</div>
</div>
</div>
</div>
</div>
<div id="results-list"></div>
<div id="results-list"></div>
@@ -222,14 +231,15 @@
@@ -222,14 +231,15 @@
<div id="map-container">
<div id="map-container">
<div id="map"></div>
<div id="map"></div>
<div class="map-ctrl-group">
<div class="map-ctrl-group">
- <button id="btn-map-locate" class="map-ctrl-btn" title="My location"><i class="fa-solid fa-map-location-dot"></i></button>
+ <button id="btn-map-locate" class="map-ctrl-btn" title="My location"><i class="fa-solid fa-street-view"></i></button>
<button id="btn-map-fullscreen" class="map-ctrl-btn" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>
<button id="btn-map-fullscreen" class="map-ctrl-btn" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>
</div>
</div>
<button id="btn-search-area" class="search-area-btn hidden">
<button id="btn-search-area" class="search-area-btn hidden">
- <i class="fa-solid fa-magnifying-glass"></i> Search This Area
+ <i class="fa-solid fa-magnifying-glass-location"></i> Search This Area
</button>
</button>
</div>
</div>
</main>
</main>
+ <!--/OVK:ANSWER-->
<!-- Footer -->
<!-- Footer -->
<footer id="footer">
<footer id="footer">
@@ -276,7 +286,7 @@
@@ -276,7 +286,7 @@
<button class="food-tab active" data-tab="food-banks"><i class="fa-solid fa-hand-holding-heart"></i> Food Banks</button>
<button class="food-tab active" data-tab="food-banks"><i class="fa-solid fa-hand-holding-heart"></i> Food Banks</button>
<button class="food-tab" data-tab="stores"><i class="fa-solid fa-cart-shopping"></i> Stores</button>
<button class="food-tab" data-tab="stores"><i class="fa-solid fa-cart-shopping"></i> Stores</button>
<button class="food-tab" data-tab="meal-plan"><i class="fa-solid fa-calendar-days"></i> Meal Plan</button>
<button class="food-tab" data-tab="meal-plan"><i class="fa-solid fa-calendar-days"></i> Meal Plan</button>
- <button class="food-tab" data-tab="food-db"><i class="fa-solid fa-utensils"></i> Food Database</button>
+ <button class="food-tab" data-tab="food-db"><i class="fa-solid fa-apple-whole"></i> Food Database</button>
</div>
</div>
<div id="food-banks" class="food-panel active"></div>
<div id="food-banks" class="food-panel active"></div>
<div id="stores" class="food-panel hidden"></div>
<div id="stores" class="food-panel hidden"></div>
@@ -351,15 +361,15 @@
@@ -351,15 +361,15 @@
<div id="guide-modal" class="modal hidden">
<div id="guide-modal" class="modal hidden">
<div class="modal-content modal-lg">
<div class="modal-content modal-lg">
<div class="modal-header">
<div class="modal-header">
- <h2><i class="fa-solid fa-book"></i> Survival Guide</h2>
+ <h2><i class="fa-solid fa-book-skull"></i> Survival Guide</h2>
<button class="modal-close"><i class="fa-solid fa-xmark"></i></button>
<button class="modal-close"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div class="modal-body">
<div class="modal-body">
<div class="guide-tabs">
<div class="guide-tabs">
<button class="guide-tab active" data-guide="stealth"><i class="fa-solid fa-eye-slash"></i> Stealth</button>
<button class="guide-tab active" data-guide="stealth"><i class="fa-solid fa-eye-slash"></i> Stealth</button>
<button class="guide-tab" data-guide="rules"><i class="fa-solid fa-gavel"></i> Rules</button>
<button class="guide-tab" data-guide="rules"><i class="fa-solid fa-gavel"></i> Rules</button>
- <button class="guide-tab" data-guide="gear"><i class="fa-solid fa-briefcase"></i> Gear</button>
- <button class="guide-tab" data-guide="safety"><i class="fa-solid fa-shield"></i> Safety</button>
+ <button class="guide-tab" data-guide="gear"><i class="fa-solid fa-backpack"></i> Gear</button>
+ <button class="guide-tab" data-guide="safety"><i class="fa-solid fa-shield-heart"></i> Safety</button>
<button class="guide-tab" data-guide="fire"><i class="fa-solid fa-fire"></i> Fire</button>
<button class="guide-tab" data-guide="fire"><i class="fa-solid fa-fire"></i> Fire</button>
<button class="guide-tab" data-guide="firstaid"><i class="fa-solid fa-kit-medical"></i> First Aid</button>
<button class="guide-tab" data-guide="firstaid"><i class="fa-solid fa-kit-medical"></i> First Aid</button>
</div>
</div>
@@ -372,7 +382,7 @@
@@ -372,7 +382,7 @@
<div id="stats-modal" class="modal hidden">
<div id="stats-modal" class="modal hidden">
<div class="modal-content modal-md">
<div class="modal-content modal-md">
<div class="modal-header">
<div class="modal-header">
- <h2><i class="fa-solid fa-chart-column"></i> Statistics</h2>
+ <h2><i class="fa-solid fa-chart-pie"></i> Statistics</h2>
<button class="modal-close"><i class="fa-solid fa-xmark"></i></button>
<button class="modal-close"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div class="modal-body">
<div class="modal-body">
@@ -395,6 +405,10 @@
@@ -395,6 +405,10 @@
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
+<script src="/shared/kit.js" defer></script>
+<script src="/shared/kit-web.js" defer></script>
+<script src="/shared/kit-data-web.js" defer></script>
+<script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
<script>
<script>
(function () {
(function () {
@@ -406,12 +420,15 @@
@@ -406,12 +420,15 @@
let user = null;
let user = null;
if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }
if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }
if (typeof OpenVibeNavbar !== 'undefined') {
if (typeof OpenVibeNavbar !== 'undefined') {
- OpenVibeNavbar.init({ service: 'maps', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Maps.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });
+ // The brand is the site ("Tools"), not the subdomain's name: the same brand every framed tool
+ // page carries (owner ask 11).
+ OpenVibeNavbar.init({ service: 'maps', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Maps.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}', brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } });
}
}
if (typeof OpenVibeFooter !== 'undefined') {
if (typeof OpenVibeFooter !== 'undefined') {
try {
try {
OpenVibeFooter.init({
OpenVibeFooter.init({
service: 'maps', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
service: 'maps', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
+ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Maps.OpenVibe', items: [
links: [{ heading: 'Maps.OpenVibe', items: [
{ name: 'Survival map', url: 'https://maps.openvibe.tools' },
{ name: 'Survival map', url: 'https://maps.openvibe.tools' },
{ name: 'OpenVibe.Food', url: 'https://openvibe.food' },
{ name: 'OpenVibe.Food', url: 'https://openvibe.food' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the survival map (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// The map is one tool on one host, and it is a map first: a sticky bar, a filter bar, a sidebar of
+// results and the map itself. The frame goes around it without changing what it does — the head
+// (the crumb, the name, one sentence, the terms), the box (the place search), the answer (the map
+// and the results), the family, the folds and "People also use".
+//
+// The map's own parts are marked in public/index.html and moved into the frame's box and answer
+// slot here, so the page keeps its own script, its own ids and its own behaviour.
+//
+// The terms come from the app's real configuration and from the guard's quota table: the lookups
+// happen on our server, and the rate is the one the guard holds for the tools-map class.
+// ═══════════════════════════════════════════════════════════════
+
+const fs = require('fs');
+const path = require('path');
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const { restampHead, canonicalHostFor } = require('../../_shared/host-role');
+const { SPECS } = require('./descriptors');
+
+const esc = kit.esc;
+const HOST = 'maps.openvibe.tools';
+const SITE = 'https://openvibe.tools';
+const FILE = path.join(__dirname, '..', 'public', 'index.html');
+const SPEC = SPECS.find((s) => s.id === 'maps') || {};
+
+// The hubs this tool sits next to (the gateway's own families).
+const HUBS = [
+ { href: `${SITE}/maps-and-food`, label: 'Maps & Food', short: 'Maps' },
+ { href: 'https://food.openvibe.tools', label: 'Food finder', short: 'Food' },
+ { href: `${SITE}/image-tools`, label: 'Image Tools', short: 'Image' },
+ { href: `${SITE}/pdf-tools`, label: 'PDF & Document Tools', short: 'PDF' },
+ { href: `${SITE}/`, label: 'All tools', short: 'All' },
+];
+
+/**
+ * The terms of the survival map, from the app's real configuration and the guard's quota table:
+ * the lookups happen on our server, and the rate is the one the guard holds for this class.
+ */
+function termsOf() {
+ return frame.terms({
+ work: 'server',
+ serverNote: 'Runs on our server: your search is looked up there',
+ quotaClass: SPEC.quotaClass || 'tools-map',
+ cost: SPEC.cost || 2,
+ unit: 'lookups',
+ });
+}
+
+/** The family: the hubs this tool sits next to. The page's own hub is the one marked. */
+function familyItems() {
+ // The family is the hubs this tool sits in: the maps and food hub holds it.
+ return HUBS.map((h, i) => ({ ...h, current: i === 0 }));
+}
+
+/** "People also use": the other hubs, as rows. */
+function relatedItems() {
+ return HUBS.filter((h) => h.href !== 'https://maps.openvibe.tools').slice(0, 3)
+ .map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));
+}
+
+/**
+ * The whole frame for the map's page.
+ * @param {string} box the page's own search box
+ * @param {string} answer the page's own filter bar and map
+ * @returns {string} HTML
+ */
+function frameFor(box, answer) {
+ const title = 'Maps.OpenVibe';
+ const task = 'Find free camping, dispersed campsites, shelters and other resources across North America.';
+ const faq = [
+ ['Is it free?', 'Yes. Thirty lookups a minute without an account.'],
+ ['What does the map look up?', 'Camping, shelters, water, food banks and the weather, from public sources: OpenStreetMap, RIDB, the US Forest Service, the National Park Service and more.'],
+ ['Is my search sent anywhere?', 'Only to our server, which does the lookups. Nothing is kept about you afterwards.'],
+ ['Is the map safe to rely on?', 'It is informational. Places may be closed, changed or wrong, and being in the wild carries real risk.'],
+ ];
+ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="maps">${kit.tool({
+ head: frame.head({
+ icon: 'map', hue: 150, family: 'places',
+ crumb: { href: SITE, label: 'Tools' },
+ title, task, facts: termsOf(),
+ }),
+ input: `<div class="ovk-maps-box">${box}</div>`,
+ answer: `<div class="ovk-maps-answer">${answer}</div>`,
+ family: kit.family({
+ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },
+ items: familyItems(), label: 'More tools',
+ }),
+ folds: frame.folds({
+ name: title,
+ about: 'The survival map puts free camping, dispersed campsites, shelters, bridges, rain cover, water, restrooms and food banks on one map of North America, with the weather where you are.',
+ faq,
+ api: null,
+ }),
+ related: kit.list(relatedItems(), { title: 'People also use' }),
+ })}</div>`;
+}
+
+/** The page's own part between two markers. */
+function pick(html, re) {
+ const m = re.exec(html);
+ return m ? m[1] : '';
+}
+
+let _parts = null;
+
+/** The page's own parts, read once: the box and the answer. */
+function parts() {
+ if (_parts) return _parts;
+ const html = fs.readFileSync(FILE, 'utf8');
+ _parts = {
+ box: pick(html, /<!--OVK:BOX-->([\s\S]*?)<!--\/OVK:BOX-->/),
+ answer: pick(html, /<!--OVK:ANSWER-->([\s\S]*?)<!--\/OVK:ANSWER-->/),
+ page: html
+ .replace(/<!--OVK:BOX-->[\s\S]*?<!--\/OVK:BOX-->/, '')
+ .replace(/<!--OVK:ANSWER-->[\s\S]*?<!--\/OVK:ANSWER-->/, ''),
+ };
+ return _parts;
+}
+
+let _cache = null;
+
+/**
+ * The map's page with the frame around it, stamped for this request's canonical host.
+ * @param {string} canonicalHost the host the page is about
+ * @returns {string} HTML
+ */
+function renderIndex(canonicalHost) {
+ if (_cache === null) {
+ const { box, answer, page: stripped } = parts();
+ _cache = stripped
+ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
+ .replace(/<\/head>/i, `${[
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
+ ].join('\n')}\n</head>`)
+ .replace(/<\/body>/i, `${[
+ '<script src="/shared/kit.js" defer></script>',
+ '<script src="/shared/kit-web.js" defer></script>',
+ '<script src="/shared/kit-data-web.js" defer></script>',
+ '<script src="/js/tool-frame.js"></script>',
+ ].join('\n')}\n</body>`);
+ }
+ return restampHead(_cache, HOST, canonicalHost);
+}
+
+/**
+ * The page with the frame, built fresh (no cache): the tests change the configuration and read the
+ * page again.
+ * @param {string} canonicalHost the host the page is about
+ * @returns {string} HTML
+ */
+function renderFrame(canonicalHost) {
+ const { box, answer, page } = parts();
+ const out = page
+ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
+ .replace(/<\/head>/i, `${[
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
+ ].join('\n')}\n</head>`)
+ .replace(/<\/body>/i, `${[
+ '<script src="/shared/kit.js" defer></script>',
+ '<script src="/shared/kit-web.js" defer></script>',
+ '<script src="/shared/kit-data-web.js" defer></script>',
+ '<script src="/js/tool-frame.js"></script>',
+ ].join('\n')}\n</body>`);
+ return canonicalHost ? restampHead(out, HOST, canonicalHost) : out;
+}
+
+/** Express handler: the page with the frame, stamped for this request's host. */
+function sendIndex(req, res) {
+ const canonical = canonicalHostFor(req, HOST);
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Vary', 'X-OV-Canonical-Host');
+ return res.send(renderIndex(canonical));
+}
+
+module.exports = { frameFor, termsOf, familyItems, relatedItems, renderIndex, renderFrame, sendIndex, HUBS, HOST };
@@ -20,7 +20,7 @@ const http = require('./egress');
@@ -20,7 +20,7 @@ const http = require('./egress');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
-const { hostGuard, stampedPage } = require('../../_shared/host-role');
+const { hostGuard } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@ -119,9 +119,17 @@ app.use(analytics.middleware());
@@ -119,9 +119,17 @@ app.use(analytics.middleware());
// page's canonical / og:url / JSON-LD follow it; aliases are redirected; other hosts go to the tools index.
// page's canonical / og:url / JSON-LD follow it; aliases are redirected; other hosts go to the tools index.
const HOST = 'maps.openvibe.tools';
const HOST = 'maps.openvibe.tools';
app.use(hostGuard({ knows: (h) => h === HOST }));
app.use(hostGuard({ knows: (h) => h === HOST }));
-const sendIndex = stampedPage(path.join(__dirname, '..', 'public', 'index.html'), HOST);
+// The page with the shared tool frame around it (openvibe-shared >= 4.57.1): the head, the box, the
+// answer slot, the family, the folds and "People also use".
+const { sendIndex } = require('./frame');
app.get(['/', '/index.html'], sendIndex);
app.get(['/', '/index.html'], sendIndex);
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js).
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
+
// ── Static files ───────────────────────────────────────────
// ── Static files ───────────────────────────────────────────
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
maxAge: '1h',
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+// The tool frame on the survival map's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the
+// folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the lookups happen on our server and the rate
+// is the guard's quota table — and they follow it when it changes;
+// • the one button says the verb, never "Run".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { renderFrame, termsOf } = require('../server/frame');
+const { SPECS } = require('../server/descriptors');
+const { startApp } = require('../../_shared/test/spawn');
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+// ── The page is a framed tool page, in the same order ──
+{
+ const html = renderFrame('maps.openvibe.tools');
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');
+ assert.ok(html.includes('id="search-input"'), 'the box has the place search');
+ assert.ok(html.includes('id="map-container"'), 'the answer holds the map');
+ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');
+ assert.ok(html.includes('id="btn-search"') && /<span>Search<\/span>/.test(html), 'the one button says the verb');
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const facts = factsOf(renderFrame('maps.openvibe.tools'));
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server: your search is looked up there'], 'free, and the lookups happen on our server');
+ assert.ok(facts.includes('30 lookups a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');
+ assert.ok(facts.includes('No sign-up'), 'no account needed');
+
+ // Change the configuration: the line follows it.
+ const savedEnv = process.env.TOOLS_GUARD_LIMITS;
+ try {
+ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-map': { anonymous: { perMinute: 20, burst: 10, perDay: 0 } } });
+ assert.ok(factsOf(renderFrame('maps.openvibe.tools')).includes('10 lookups a minute without an account'), 'the rate follows the guard\'s table');
+ } finally {
+ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;
+ else process.env.TOOLS_GUARD_LIMITS = savedEnv;
+ }
+ assert.ok(SPECS.find((s) => s.id === 'maps'), 'the map has a descriptor');
+}
+
+// ── The page the server sends: the frame, the title, the canonical and the structured data ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-maps-frame-'));
+ const app = await startApp('maps', { DATA_DIR: tmp });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ const r = await get('maps.openvibe.tools');
+ assert.strictEqual(r.status, 200, 'answers 200');
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), 'the frame\'s title');
+ assert.ok(r.body.includes('<link rel="canonical"'), 'a canonical');
+ assert.ok(r.body.includes('/shared/kit.css?'), 'the kit\'s look');
+ assert.ok(r.body.includes('/js/tool-frame.js'), 'the frame\'s behaviour');
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, 'one h1');
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('tool frame (maps): the survival map framed, the terms from the config, the button says the verb: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -896,4 +896,17 @@ textarea.large {
@@ -896,4 +896,17 @@ textarea.large {
.interconnect-banner .ic-links { margin-left: 0; }
.interconnect-banner .ic-links { margin-left: 0; }
}
}
+/* ── The shared tool frame (openvibe-shared >= 4.57.1) ── */
+/* The page's own section is the box now: it does not draw a card inside the frame's card. */
+.ovk-tool-card .tool-section { background: none; border: 0; padding: 0; margin: 0 0 12px; backdrop-filter: none; }
+.ovk-tool-card .tool-section:last-child { margin-bottom: 0; }
+.ovk-tool-out--live > #output, .ovk-tool-out--live > #result, .ovk-tool-out--live > #results { margin: 0; }
+/* The brand is the site ("Tools"), not the subdomain's name. */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+
+
+/* The page is the shared tool frame: its head starts under the navbar, not behind it. */
+.ovk-tool-head { margin-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .live-dot, .output-flash, .toast { animation: none; } }
@media (prefers-reduced-motion: reduce) { .live-dot, .output-flash, .toast { animation: none; } }
@@ -185,6 +185,9 @@ function initNavbar() {
@@ -185,6 +185,9 @@ function initNavbar() {
token,
token,
history: { type: 'tool', title: pageTitle() },
history: { type: 'tool', title: pageTitle() },
silentLogin: SILENT_LOGIN,
silentLogin: SILENT_LOGIN,
+ // The brand is the site ("Tools"), not the tool's own name: the same brand every framed
+ // tool page carries (owner ask 11).
+ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
});
} catch (e) { /* the page works without the navbar */ }
} catch (e) { /* the page works without the navbar */ }
if (typeof OpenVibeAccountSwitcher !== 'undefined') {
if (typeof OpenVibeAccountSwitcher !== 'undefined') {
@@ -219,7 +222,7 @@ function initFooter(opts = {}) {
@@ -219,7 +222,7 @@ function initFooter(opts = {}) {
links.push({ heading: 'Logo tools', items: LOGO_TOOLS });
links.push({ heading: 'Logo tools', items: LOGO_TOOLS });
}
}
try {
try {
- OpenVibeFooter.init({ service, variant: opts.variant || 'compact', links, apiBase: NETWORK, mount: '#ov-footer' });
+ OpenVibeFooter.init({ service, variant: opts.variant || 'compact', links, apiBase: NETWORK, mount: '#ov-footer', brandName: 'OpenVibe.Tools' });
} catch (e) { /* optional */ }
} catch (e) { /* optional */ }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the text and logo pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// The text app's pages are their own HTML files, one per tool (several hosts share one file). The page
+// keeps its own editor and its own live output; the frame goes around them: the head (the family crumb,
+// the tool's name, one sentence, the terms), the box (the page's own editor), the answer (the page's
+// live output, moved into the frame's answer slot by the shared browser glue), the family, the folds
+// and "People also use".
+//
+// The head's copy is the page's own: its title is the tool's name and its description is the one
+// sentence. The terms come from the app's real configuration: these tools run in the browser, so
+// nothing is sent, and the rate is the guard's own for the class they are in.
+// ═══════════════════════════════════════════════════════════════
+
+const fs = require('fs');
+const path = require('path');
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const { SPECS } = require('./descriptors');
+
+const HUB = 'https://text.openvibe.tools/';
+const esc = kit.esc;
+
+// The page each tool lives in, and the family row's short names.
+const PAGE_OF = {
+ fancy: 'fancy.html', zalgo: 'zalgo.html', bubble: 'bubble.html', ascii: 'ascii.html', symbols: 'symbols.html',
+ kaomoji: 'kaomoji.html', unicode: 'unicode.html', braille: 'braille.html', morse: 'morse.html', binary: 'binary.html',
+ case: 'case.html', count: 'count.html', reversetext: 'reverse.html', clean: 'clean.html', sort: 'sort.html',
+ compare: 'compare.html', json: 'json.html', markdown: 'markdown.html', escape: 'escape.html', slug: 'slug.html',
+ bio: 'bio.html', nickname: 'nickname.html', logo: 'logo-hub.html', wordmark: 'wordmark.html', title: 'title.html',
+ thumbnail: 'thumbnail.html', badge: 'badge.html', watermark: 'watermark.html', transparent: 'transparent.html',
+ peso: 'peso.html', spanish: 'spanish.html', slang: 'slang.html', ice: 'ice.html',
+};
+
+const SHORT = {
+ fancy: 'Fancy', zalgo: 'Zalgo', bubble: 'Bubble', ascii: 'ASCII', symbols: 'Symbols', kaomoji: 'Kaomoji',
+ unicode: 'Unicode', braille: 'Braille', morse: 'Morse', binary: 'Binary', case: 'Case', count: 'Count',
+ reversetext: 'Reverse', clean: 'Clean', sort: 'Sort', compare: 'Compare', json: 'JSON', markdown: 'Markdown',
+ escape: 'Escape', slug: 'Slug', bio: 'Bio', nickname: 'Nickname', logo: 'Logo', wordmark: 'Wordmark',
+ title: 'Title', thumbnail: 'Thumbnail', badge: 'Badge', watermark: 'Watermark', transparent: 'Transparent',
+ peso: 'Peso', spanish: 'Spanish', slang: 'Slang', ice: 'ICE',
+};
+
+const ICON = {
+ fancy: 'spark', zalgo: 'wave', bubble: 'spark', ascii: 'grid', symbols: 'list', kaomoji: 'star',
+ unicode: 'code', braille: 'grid', morse: 'zap', binary: 'code', case: 'code', count: 'chart',
+ reversetext: 'swap', clean: 'spark', sort: 'list', compare: 'swap', json: 'code', markdown: 'book',
+ escape: 'lock', slug: 'link', bio: 'user', nickname: 'tag', logo: 'palette', wordmark: 'palette',
+ title: 'pencil', thumbnail: 'image', badge: 'star', watermark: 'image', transparent: 'image',
+ peso: 'coin', spanish: 'book', slang: 'chat', ice: 'shield',
+};
+
+const SPEC = new Map(SPECS.map((s) => [s.id, s]));
+
+// The verb of each tool: the one button says what it does, never "Run".
+const VERB = {'fancy': 'Make fancy text', 'zalgo': 'Add Zalgo', 'bubble': 'Make bubble text', 'ascii': 'Make ASCII art', 'symbols': 'Make symbols', 'kaomoji': 'Make kaomoji', 'unicode': 'Make Unicode', 'braille': 'Make braille', 'morse': 'Make Morse code', 'binary': 'Make binary', 'case': 'Change the case', 'count': 'Count the words', 'reversetext': 'Reverse the text', 'clean': 'Clean the text', 'sort': 'Sort the lines', 'compare': 'Compare the text', 'json': 'Format the JSON', 'markdown': 'Make Markdown', 'escape': 'Escape the text', 'slug': 'Make a slug', 'bio': 'Make a bio', 'nickname': 'Make a nickname', 'logo': 'Make a logo', 'wordmark': 'Make a wordmark', 'title': 'Make a title', 'thumbnail': 'Make a thumbnail', 'badge': 'Make a badge', 'watermark': 'Add a watermark', 'transparent': 'Make it transparent', 'peso': 'Make peso art', 'spanish': 'Add Spanish accents', 'slang': 'Make slang', 'ice': 'Make an ICE card'};
+
+// The plain text of a page's element, without its tags.
+const textOf = (html) => String(html || '')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
+ .replace(/\s+/g, ' ').trim();
+
+/** The first group of a match, as plain text. */
+const pick = (html, re) => {
+ const m = re.exec(html);
+ return m ? textOf(m[1]) : '';
+};
+
+/**
+ * The terms of a text tool, from the app's real configuration: these tools run in the browser, so
+ * nothing is sent; the size of the text is the tool's own limit; and the rate is the guard's own for
+ * the class the tool is in (apps/_shared/guard/limits.js).
+ */
+function termsOf(toolId) {
+ const spec = SPEC.get(toolId);
+ const facts = frame.terms({
+ work: 'browser',
+ quotaClass: (spec && spec.quotaClass) || 'tools-run',
+ cost: (spec && spec.cost) || 1,
+ unit: 'runs',
+ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
+ });
+ // A tool with no server engine is not rate limited at all: it runs on the device. A tool with an
+ // engine is limited by the guard's own table, and says so that the rate is not read as the page's.
+ const at = facts.findIndex((f) => f.text.includes('a minute'));
+ if (at >= 0) {
+ facts[at] = spec && spec.api
+ ? { ...facts[at], text: `API: ${facts[at].text}` }
+ : { text: 'No limit: it runs on your device', icon: 'zap' };
+ }
+ // The size of the text the tool takes, from the tool's own limit.
+ const maxBytes = spec && spec.limits && spec.limits.maxInputBytes;
+ if (maxBytes) facts.push({ text: `Up to ${Math.round(maxBytes / 1024)} KB of text`, icon: 'file' });
+ return facts;
+}
+
+/** The family: every text tool, with the one on the page marked. */
+function familyItems(current, hostnameMap, nameOf) {
+ const items = [];
+ const seen = new Set();
+ for (const [host, file] of Object.entries(hostnameMap)) {
+ const toolId = TOOL_OF_FILE[file];
+ if (!toolId || seen.has(toolId)) continue;
+ seen.add(toolId);
+ items.push({ href: `https://${host}/`, label: nameOf(toolId), short: SHORT[toolId] || nameOf(toolId), current: toolId === current });
+ }
+ return items;
+}
+
+/** The tool a page file holds. */
+const TOOL_OF_FILE = Object.fromEntries(Object.entries(PAGE_OF).map(([id, file]) => [file, id]));
+
+const PUBLIC = path.join(__dirname, '..', 'public');
+let _names = null;
+
+/** Every tool's name and its one sentence, read from the page's own head. */
+function namesOf() {
+ if (_names) return _names;
+ _names = new Map();
+ for (const [id, file] of Object.entries(PAGE_OF)) {
+ let html = '';
+ try { html = fs.readFileSync(path.join(PUBLIC, file), 'utf8'); } catch { continue; }
+ _names.set(id, {
+ name: pick(html, /<h1[^>]*>([\s\S]*?)<\/h1>/) || id,
+ desc: pick(html, /<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i),
+ });
+ }
+ return _names;
+}
+
+/** The "People also use" of a page: its own related links (the footer's data-related). */
+function relatedOf(html) {
+ const m = /data-related="([^"]*)"/.exec(html);
+ if (!m) return [];
+ return m[1].split(';').map((pair) => {
+ const [title, href] = pair.split('|');
+ if (!title || !href) return null;
+ return kit.row({ href: href.trim(), title: title.trim(), sub: '' });
+ }).filter(Boolean).slice(0, 4);
+}
+
+/**
+ * The frame around a text page: the head, the box, the answer slot, the family, the folds and
+ * "People also use". The page's own editor and buttons stay inside the box; its live output moves
+ * into the answer slot (apps/_shared/public/tool-frame.js).
+ *
+ * @param {string} html the page's own HTML
+ * @param {string} host the host that serves it
+ * @param {object} [ctx] { hostnameMap, nameOf }
+ * @returns {string} the page with the frame around it
+ */
+function frameAround(html, host, ctx = {}) {
+ const file = ctx.file;
+ const toolId = TOOL_OF_FILE[file];
+ if (!toolId) return html;
+
+ const own = namesOf().get(toolId) || { name: toolId, desc: '' };
+ const name = (ctx.nameOf && ctx.nameOf(toolId)) || own.name;
+ const task = (ctx.descOf && ctx.descOf(toolId)) || pick(html, /<p class="subtitle"[^>]*>([\s\S]*?)<\/p>/) || own.desc;
+ const about = own.desc;
+
+ const head = frame.head({
+ icon: ICON[toolId] || 'code', hue: 140, family: 'text',
+ crumb: { href: HUB, label: 'Text tools' },
+ title: name, task, facts: termsOf(toolId),
+ });
+
+ const items = familyItems(toolId, ctx.hostnameMap || {}, ctx.nameOf || ((id) => (namesOf().get(id) || {}).name || id));
+ const family = kit.family({
+ title: 'More text tools', more: { href: HUB, label: 'All text tools' },
+ items,
+ });
+ const folds = frame.folds({
+ name, about,
+ faq: [
+ ['Is it free?', 'Yes. Every text tool here is free, and it runs in your browser.'],
+ ['Does my text go anywhere?', 'No. The work happens in your browser; nothing is sent to a server.'],
+ ['Can I call it from code?', 'Some of these tools have an API: POST /api/v1/tools/<tool>/run. The <a href="https://openvibe.tools/developers">API docs</a> has the list.'],
+ ],
+ api: {
+ id: toolId, path: `/api/v1/tools/${toolId}/run`, fields: 'text',
+ payload: { input: { text: 'Hello, World!' } }, needsAccount: false,
+ },
+ });
+ // "People also use": the page's own related links, or the tools next to this one in the family.
+ let related = relatedOf(html);
+ if (!related.length) {
+ related = items.filter((it) => !it.current).slice(0, 3)
+ .map((it) => kit.row({ href: it.href, title: it.label, sub: '' }));
+ }
+
+ // The page's own main button says the verb, the way the other tools' buttons do.
+ const verb = VERB[toolId] || 'Make it';
+ const withVerb = html.replace(/(<button[^>]*class="[^"]*generate-btn[^"]*"[^>]*>)([\s\S]*?)(<\/button>)/i,
+ // The button's own text is the last text in it: the icon, then the word.
+ (m, open, mid, close) => `${open}${mid.replace(/>([^<>]*)$/, (_, text) => `>${esc(verb)}`)}${close}`);
+
+ // The head replaces the page's own header (the page keeps one h1: the frame's), the box holds the
+ // page's own editor and buttons, and the answer slot waits for the page's live output.
+ let out = withVerb;
+ // The page's own header and crumb go: the frame's head takes their place, and the page keeps one h1.
+ out = out.replace(/<nav class="crumbs"[^>]*>[\s\S]*?<\/nav>\s*/i, '');
+ out = out.replace(/<header class="tool-head"[^>]*>[\s\S]*?<\/header>\s*/i, '');
+ out = out.replace(/<div class="page-header"[^>]*>[\s\S]*?<\/div>\s*/i, '');
+ out = out.replace(/<div class="hub-hero"[^>]*>[\s\S]*?<\/div>\s*/i, '');
+ // A text tool's example: a word to try it on. The page's own field takes it (the kit's try
+ // fills the field and the page answers).
+ const tryRow = /id="input"/.test(html)
+ ? kit.tries({ name: 'text', target: '#input', items: [{ label: 'OpenVibe', value: 'OpenVibe' }] })
+ : '';
+ const open = `${head}\n<div class="ovk-tool-card">\n${tryRow}`;
+ if (/<div class="tool-wrap"[^>]*>/i.test(out)) {
+ out = out.replace(/<div class="tool-wrap"[^>]*>/i, (m) => `${m}\n${open}`);
+ } else {
+ out = out.replace(/<body[^>]*>/i, (m) => `${m}\n${open}`);
+ }
+
+ const tail = `</div>\n<div class="ovk-tool-out"></div>\n${family}\n${kit.tool ? `<div class="ovk-tool-folds">${folds.join('')}</div>` : ''}\n`
+ + (related.length ? `<div class="ovk-tool-related">${kit.list(related, { title: 'People also use' })}</div>` : '')
+ + `\n`;
+ out = out.replace(/<div id="ov-footer"/i, `${tail}<div id="ov-footer"`);
+
+ // The kit's look and behaviour, from this app's own pin.
+ out = out.replace(/<\/head>/i, `${[
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
+ ].join('\n')}\n</head>`);
+ out = out.replace(/<\/body>/i, `${[
+ '<script src="/shared/kit.js" defer></script>',
+ '<script src="/shared/kit-web.js" defer></script>',
+ '<script src="/shared/kit-data-web.js" defer></script>',
+ '<script src="/js/tool-frame.js" defer></script>',
+ ].join('\n')}\n</body>`);
+
+ return out;
+}
+
+module.exports = { frameAround, termsOf, familyItems, relatedOf, namesOf, PAGE_OF, TOOL_OF_FILE, SHORT, VERB, HUB };
@@ -144,6 +144,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -144,6 +144,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
},
}));
}));
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every app.
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
+
// ── Hostname-based routing ───────────────────────────────────
// ── Hostname-based routing ───────────────────────────────────
// Pages are stamped with a canonical + structured data on the way out (server/seo.js). Several
// Pages are stamped with a canonical + structured data on the way out (server/seo.js). Several
// hosts deliberately share one file (smallcaps/fancy, glitch/zalgo); without a canonical those
// hosts deliberately share one file (smallcaps/fancy, glitch/zalgo); without a canonical those
@@ -19,8 +19,10 @@
@@ -19,8 +19,10 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
+const { frameAround } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const PUBLIC = path.join(__dirname, '..', 'public');
+let HOSTNAME_MAP = {};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
/** file → the first hostname that serves it; that host is the canonical one. */
/** file → the first hostname that serves it; that host is the canonical one. */
@@ -38,7 +40,7 @@ const _cache = new Map();
@@ -38,7 +40,7 @@ const _cache = new Map();
* Read a page and stamp in canonical, social tags and JSON-LD.
* Read a page and stamp in canonical, social tags and JSON-LD.
* Falls back to the untouched file if anything goes wrong — SEO extras must never break serving.
* Falls back to the untouched file if anything goes wrong — SEO extras must never break serving.
*/
*/
-function renderPage(file, primaryHost) {
+function renderPage(file, primaryHost, hostnameMap) {
const key = `${file}::${primaryHost}`;
const key = `${file}::${primaryHost}`;
if (_cache.has(key)) return _cache.get(key);
if (_cache.has(key)) return _cache.get(key);
@@ -89,7 +91,13 @@ function renderPage(file, primaryHost) {
@@ -89,7 +91,13 @@ function renderPage(file, primaryHost) {
return !new RegExp(`(?:rel|property|name)=["']${m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(html);
return !new RegExp(`(?:rel|property|name)=["']${m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(html);
}).join('\n');
}).join('\n');
- const out = html.replace(/<\/head>/i, `${wanted}\n</head>`);
+ let out = html.replace(/<\/head>/i, `${wanted}\n</head>`);
+
+ // The shared tool frame around the page (openvibe-shared >= 4.57.1): the head, the box, the
+ // answer slot, the family, the folds and "People also use". The page's own editor and its live
+ // output stay where they are.
+ out = frameAround(out, primaryHost, { file, hostnameMap: hostnameMap || HOSTNAME_MAP });
+
if (_cache.size > 100) _cache.clear();
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
_cache.set(key, out);
return out;
return out;
@@ -97,6 +105,7 @@ function renderPage(file, primaryHost) {
@@ -97,6 +105,7 @@ function renderPage(file, primaryHost) {
/** Express handler factory: serves the host's page with its SEO tags applied. */
/** Express handler factory: serves the host's page with its SEO tags applied. */
function pageSender(hostnameMap) {
function pageSender(hostnameMap) {
+ HOSTNAME_MAP = hostnameMap;
const primary = buildPrimaryHosts(hostnameMap);
const primary = buildPrimaryHosts(hostnameMap);
const has = (h) => Object.prototype.hasOwnProperty.call(hostnameMap, h);
const has = (h) => Object.prototype.hasOwnProperty.call(hostnameMap, h);
return function sendPage(req, res) {
return function sendPage(req, res) {
@@ -104,7 +113,7 @@ function pageSender(hostnameMap) {
@@ -104,7 +113,7 @@ function pageSender(hostnameMap) {
const file = (has(hostname) && hostnameMap[hostname]) || 'index.html';
const file = (has(hostname) && hostnameMap[hostname]) || 'index.html';
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Vary', 'X-OV-Canonical-Host');
res.setHeader('Vary', 'X-OV-Canonical-Host');
- const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname));
+ const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname), hostnameMap);
if (!rendered) return res.sendFile(path.join(PUBLIC, file));
if (!rendered) return res.sendFile(path.join(PUBLIC, file));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(rendered);
res.send(rendered);
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+// The tool frame on the text pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • every text tool's page is a tool page in the same frame: the head, the box, the answer slot, the
+// family, the folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the work happens in the browser, the size of
+// the text is the tool's own limit, the rate is the guard's quota table — and they follow it when
+// it changes;
+// • the one button says the verb, never "Run".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { frameAround, termsOf, PAGE_OF, TOOL_OF_FILE, VERB } = require('../server/frame');
+const { HOSTNAME_MAP } = require('../server/hosts');
+const { SPECS } = require('../server/descriptors');
+const { startApp } = require('../../_shared/test/spawn');
+
+/** The hosts that are a tool's own page (the hubs are their own thing). */
+const TOOL_HOSTS = Object.entries(HOSTNAME_MAP).filter(([host, file]) => TOOL_OF_FILE[file]);
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+/** The page a host serves, with the frame around it. */
+function pageOf(host, file) {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'public', file), 'utf8');
+ return frameAround(html, host, { file, hostnameMap: HOSTNAME_MAP });
+}
+
+// ── Every text tool's page is a framed tool page, in the same order ──
+{
+ for (const [host, file] of TOOL_HOSTS) {
+ const html = pageOf(host, file);
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('https://text.openvibe.tools/">Text tools'), `${host}: the crumb is the family`);
+ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
+ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ assert.ok(TOOL_HOSTS.length >= 60, `the text app has ${TOOL_HOSTS.length} tool pages`);
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const spec = SPECS.find((s) => s.id === 'fancy');
+ const facts = factsOf(pageOf('fancy.openvibe.tools', 'fancy.html'));
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs in your browser: nothing is sent'], 'fancy: free, and it runs in the browser');
+ assert.ok(facts.includes('API: 120 runs a minute without an account'), 'fancy: the rate from the guard\'s table');
+ assert.ok(facts.includes(`Up to ${Math.round(spec.limits.maxInputBytes / 1024)} KB of text`), 'fancy: the size from the tool\'s own limit');
+
+ // Change the configuration: the line follows it.
+ const saved = spec.limits.maxInputBytes;
+ try {
+ spec.limits.maxInputBytes = 5 * 1024;
+ assert.ok(factsOf(pageOf('fancy.openvibe.tools', 'fancy.html')).includes('Up to 5 KB of text'), 'the size follows the config');
+ } finally {
+ spec.limits.maxInputBytes = saved;
+ }
+ const savedEnv = process.env.TOOLS_GUARD_LIMITS;
+ try {
+ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-run': { anonymous: { perMinute: 40, burst: 20, perDay: 0 } } });
+ assert.ok(factsOf(pageOf('fancy.openvibe.tools', 'fancy.html')).includes('API: 40 runs a minute without an account'), 'the rate follows the guard\'s table');
+ } finally {
+ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;
+ else process.env.TOOLS_GUARD_LIMITS = savedEnv;
+ }
+
+ // A tool that runs on the device only is not rate limited at all.
+ const logo = factsOf(pageOf('logo.openvibe.tools', 'logo-hub.html'));
+ assert.ok(logo.includes('No limit: it runs on your device'), 'logo: no server, so no limit to claim');
+}
+
+// ── The one button says the verb ──
+{
+ let withButton = 0;
+ for (const [host, file] of TOOL_HOSTS) {
+ const html = pageOf(host, file);
+ const toolId = TOOL_OF_FILE[file];
+ assert.ok(!/>\s*Run\s*</.test(html), `${host}: never Run`);
+ const m = /<button[^>]*class="[^"]*generate-btn[^"]*"[^>]*>[\s\S]*?<i[^>]*><\/i>\s*([^<]+?)\s*<\/button>/.exec(html);
+ if (!m) continue; // a live or gallery page: it has no action button to name
+ withButton += 1;
+ assert.strictEqual(m[1], VERB[toolId], `${host}: the button says the verb`);
+ assert.notStrictEqual(m[1], 'Generate', `${host}: not a generic "Generate"`);
+ }
+ assert.ok(withButton >= 10, `only ${withButton} pages have an action button`);
+ assert.strictEqual(VERB.escape, 'Escape the text', 'escape: the verb');
+ assert.ok(/<i class="fas fa-bolt"><\/i>\s*Escape the text\s*<\/button>/.test(pageOf('escape.openvibe.tools', 'escape.html')), 'escape: the button says the verb');
+ assert.ok(/<i class="fas fa-bolt"><\/i>\s*Make a slug\s*<\/button>/.test(pageOf('slug.openvibe.tools', 'slug.html')), 'slug: the button says the verb');
+}
+
+// ── The page the server sends: the frame, the title, the canonical and the structured data ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-text-frame-'));
+ const app = await startApp('text', { DATA_DIR: tmp });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ for (const host of ['escape.openvibe.tools', 'fancy.openvibe.tools', 'slug.openvibe.tools', 'logo.openvibe.tools']) {
+ const r = await get(host);
+ assert.strictEqual(r.status, 200, `${host}: answers 200`);
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
+ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
+ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
+ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
+ assert.ok(r.body.includes('/js/tool-frame.js'), `${host}: the frame's behaviour`);
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log(`tool frame (text): ${TOOL_HOSTS.length} text pages framed, the terms from the config, the button says the verb: all checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -413,4 +413,13 @@ a:hover { text-decoration: underline; }
@@ -413,4 +413,13 @@ a:hover { text-decoration: underline; }
.footer-inner { flex-direction: column; text-align: center; }
.footer-inner { flex-direction: column; text-align: center; }
}
}
+
+/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
+.openvibe-navbar .openvibe-navbar-brand .b-core,
+.openvibe-navbar .openvibe-navbar-brand .b-dot,
+.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
+
+
+/* The page is the shared tool frame: it starts under the navbar, not behind it. */
+.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner, .progress-bar.indeterminate { animation: none; opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .spinner, .progress-bar.indeterminate { animation: none; opacity: .7; } }
@@ -23,83 +23,9 @@
@@ -23,83 +23,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
<div id="app">
- <!-- Hero -->
- <section class="hero">
- <div class="hero-icon"><i class="fa-solid fa-circle-play"></i></div>
- <h1>YT.OpenVibe</h1>
- <p class="hero-subtitle">YouTube Downloader</p>
- </section>
-
- <!-- URL Input -->
- <section class="url-section">
- <div class="url-input-wrap">
- <i class="fa-solid fa-link url-icon"></i>
- <input type="url" id="url-input" class="url-input" placeholder="Paste YouTube URL here..." autocomplete="off">
- <button class="btn btn-primary" id="fetch-btn"><i class="fa-solid fa-magnifying-glass"></i> <span class="btn-label">Fetch</span></button>
- </div>
- <p class="url-hint">Supports youtube.com and youtu.be links</p>
- </section>
-
- <!-- Loading -->
- <section class="loading" id="loading" style="display:none">
- <div class="spinner"></div>
- <p>Fetching video info...</p>
- </section>
-
- <!-- Video Info -->
- <section class="video-card" id="video-card" style="display:none">
- <div class="video-thumb" id="video-thumb"></div>
- <div class="video-info" id="video-info"></div>
- </section>
-
- <!-- Format Selection -->
- <section class="format-section" id="format-section" style="display:none">
- <h3>Choose Format</h3>
- <div class="format-groups">
- <div class="format-group">
- <h4><i class="fa-solid fa-video"></i> Video</h4>
- <div class="format-list" id="video-formats"></div>
- </div>
- <div class="format-group">
- <h4><i class="fa-solid fa-music"></i> Audio Only</h4>
- <div class="format-list" id="audio-formats"></div>
- </div>
- </div>
- <button class="btn btn-primary btn-lg" id="download-btn">
- <i class="fa-solid fa-download"></i> Download
- </button>
- </section>
-
- <!-- Download Progress -->
- <section class="progress-section" id="progress-section" style="display:none">
- <h3 id="progress-title">Downloading...</h3>
- <div class="progress-bar-wrap">
- <div class="progress-bar" id="progress-bar"></div>
- </div>
- <div class="progress-stats" id="progress-stats"></div>
- </section>
-
- <!-- Download Ready -->
- <section class="done-section" id="done-section" style="display:none">
- <div class="done-icon"><i class="fa-solid fa-circle-check"></i></div>
- <h3>Ready!</h3>
- <p id="done-info"></p>
- <div class="done-actions">
- <a class="btn btn-primary btn-lg" id="save-btn" href="#" download>
- <i class="fa-solid fa-download"></i> Save File
- </a>
- <button class="btn btn-secondary" id="new-btn">
- <i class="fa-solid fa-rotate-left"></i> Download Another
- </button>
- </div>
- </section>
-
- <!-- Error -->
- <section class="error-section" id="error-section" style="display:none">
- <div class="error-icon"><i class="fa-solid fa-circle-exclamation"></i></div>
- <p id="error-msg"></p>
- <button class="btn btn-secondary" id="retry-btn"><i class="fa-solid fa-rotate-left"></i> Try Again</button>
- </section>
+ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
+ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
+ <!--OVK:FRAME-->
</div>
</div>
<!-- Footer -->
<!-- Footer -->
@@ -114,7 +40,11 @@
@@ -114,7 +40,11 @@
<script src="/shared/ov-icons.js" defer></script>
<script src="/shared/ov-icons.js" defer></script>
<script src="/shared/island.js" defer></script>
<script src="/shared/island.js" defer></script>
<script src="/shared/ui.js" defer></script>
<script src="/shared/ui.js" defer></script>
+ <script src="/shared/kit.js" defer></script>
+ <script src="/shared/kit-web.js" defer></script>
+ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/download-state.js"></script>
<script src="/js/download-state.js"></script>
+ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
</body>
</body>
</html>
</html>
@@ -393,12 +393,16 @@
@@ -393,12 +393,16 @@
apiBase: 'https://openvibe.network',
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: 'YT.OpenVibe' },
history: { type: 'tool', title: 'YT.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
+ // The brand is the site ("Tools"), not the subdomain's name: the same brand every
+ // framed tool page carries (owner ask 11).
+ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
});
}
}
if (typeof OpenVibeFooter !== 'undefined') {
if (typeof OpenVibeFooter !== 'undefined') {
try {
try {
OpenVibeFooter.init({
OpenVibeFooter.init({
service: 'yt', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
service: 'yt', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
+ brandName: 'OpenVibe.Tools',
links: [{ heading: 'YT.OpenVibe', items: [
links: [{ heading: 'YT.OpenVibe', items: [
{ name: 'Video downloader', url: 'https://yt.openvibe.tools' },
{ name: 'Video downloader', url: 'https://yt.openvibe.tools' },
{ name: 'Audio converter', url: 'https://audio.openvibe.tools' },
{ name: 'Audio converter', url: 'https://audio.openvibe.tools' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// The tool frame for the YouTube downloader (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
+//
+// The downloader is one tool on two hosts (yt.openvibe.tools and youtube-downloader.openvibe.tools),
+// so its page is a tool page in the shared frame: the head (the crumb, the name, one sentence, the
+// terms), the box (the link field and the app's own steps), the answer (the file it made and Download),
+// the family, the folds and "People also use".
+//
+// The page around the tool changes; the download itself does not. The app's own sections keep their
+// ids, so its script still finds them.
+//
+// The terms come from the app's real configuration: the file's lifetime from the cleanup config
+// (config.js retention), its size and length from the download limits, and the rate from the guard's
+// quota table and the tool's own cost in it.
+// ═══════════════════════════════════════════════════════════════
+
+const kit = require('openvibe-shared/kit');
+const serve = require('openvibe-shared/serve');
+const frame = require('../../_shared/frame')({ kit, serve });
+const config = require('./config');
+const { SPECS } = require('./descriptors');
+
+const esc = kit.esc;
+const SPEC = SPECS.find((s) => s.id === 'yt') || {};
+const SITE = 'https://openvibe.tools';
+
+// The hubs the other tools live in (the gateway's own families).
+const HUBS = [
+ { href: `${SITE}/video-tools`, label: 'Video Tools', short: 'Video' },
+ { href: `${SITE}/audio-tools`, label: 'Audio Tools', short: 'Audio' },
+ { href: `${SITE}/image-tools`, label: 'Image Tools', short: 'Image' },
+ { href: `${SITE}/pdf-tools`, label: 'PDF & Document Tools', short: 'PDF' },
+ { href: `${SITE}/text-tools`, label: 'Text & Logo Tools', short: 'Text' },
+];
+
+/** The length of a video in the words a person reads: "3 hours", "12 minutes". */
+function lengthOf(seconds) {
+ const s = Number(seconds) || 0;
+ if (s >= 3600) return `${Math.round(s / 3600)} hour${Math.round(s / 3600) === 1 ? '' : 's'}`;
+ if (s >= 60) return `${Math.round(s / 60)} minute${Math.round(s / 60) === 1 ? '' : 's'}`;
+ return `${s} second${s === 1 ? '' : 's'}`;
+}
+
+/**
+ * The terms of the downloader, from the app's real configuration and the guard's quota table.
+ * @returns {Array<{text: string, icon: string}>}
+ */
+function termsOf() {
+ const facts = frame.terms({
+ work: 'server',
+ ttlMs: config.retention.fileTTL,
+ maxBytes: config.download.maxFilesize * 1024 * 1024,
+ maxFiles: 1,
+ quotaClass: SPEC.quotaClass || 'tools-download',
+ cost: SPEC.cost || 50,
+ unit: 'downloads',
+ });
+ // The length of the video is the download limit the app enforces.
+ const at = facts.findIndex((f) => f.text.includes('up to'));
+ const limit = { text: `Up to ${lengthOf(config.download.maxDuration)} long`, icon: 'clock' };
+ if (at >= 0) facts.splice(at + 1, 0, limit);
+ else facts.push(limit);
+ return facts;
+}
+
+/** The family: the tool hubs this tool sits next to. */
+function familyItems() {
+ return HUBS.map((h, i) => ({ ...h, current: i === 0 }));
+}
+
+/** "People also use": the hubs, as rows. */
+function relatedItems() {
+ return HUBS.slice(1, 4).map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));
+}
+
+/**
+ * The box: the link field, the one button, and the app's own steps (the video card, the formats, the
+ * progress and the error). The ids are the app's, so its script keeps working.
+ */
+function boxFor() {
+ return `<form class="ovk-tool-form" id="ovk-yt-form" onsubmit="return false">
+ <label class="ovk-tool-label" for="url-input">Video link</label>
+ <div class="ovk-tool-row">
+ <span class="ovk-tool-field"><input id="url-input" name="url" type="url" placeholder="Paste a YouTube link here…" autocomplete="off" spellcheck="false"></span>
+ ${frame.verbButton('Fetch the video', { id: 'fetch-btn', icon: 'search' })}
+ </div>
+ <p class="ovk-try"><span>Try</span><a href="?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Daqz-KE-bpKQ" data-ovk-try="https://www.youtube.com/watch?v=aqz-KE-bpKQ" data-ovk-name="url" data-ovk-for="#url-input">Big Buck Bunny</a></p>
+ <section class="loading" id="loading" style="display:none"><div class="spinner"></div><p>Fetching video info…</p></section>
+ <section class="video-card" id="video-card" style="display:none">
+ <div class="video-thumb" id="video-thumb"></div>
+ <div class="video-info" id="video-info"></div>
+ </section>
+ <section class="format-section" id="format-section" style="display:none">
+ <h3>Choose a format</h3>
+ <div class="format-groups">
+ <div class="format-group">
+ <h4><i class="fa-solid fa-video"></i> Video</h4>
+ <div class="format-list" id="video-formats"></div>
+ </div>
+ <div class="format-group">
+ <h4><i class="fa-solid fa-music"></i> Audio only</h4>
+ <div class="format-list" id="audio-formats"></div>
+ </div>
+ </div>
+ <div class="ovk-tool-buttons">${frame.verbButton('Start the download', { id: 'download-btn', icon: 'download' })}</div>
+ </section>
+ <section class="progress-section" id="progress-section" style="display:none">
+ <h3 id="progress-title">Downloading…</h3>
+ <div class="progress-bar-wrap"><div class="progress-bar" id="progress-bar"></div></div>
+ <div class="progress-stats" id="progress-stats"></div>
+ </section>
+ <section class="error-section" id="error-section" style="display:none">
+ <div class="error-icon"><i class="fa-solid fa-circle-exclamation"></i></div>
+ <p id="error-msg"></p>
+ <button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="retry-btn"><i class="fa-solid fa-arrow-rotate-left"></i> <span>Try again</span></button>
+ </section>
+ </form>`;
+}
+
+/** The answer: the file it made, its size, and Download. */
+function answerFor() {
+ return `<section class="ovk-answer" id="done-section" style="display:none" aria-live="polite">
+ <div class="ovk-answer-head">
+ <h2>Your file is ready</h2>
+ <span class="ovk-answer-acts"><a class="ovk-btn ovk-btn--primary" id="save-btn" href="#" download>${kit.icon('download')}<span>Download</span></a></span>
+ </div>
+ <dl class="ovk-answer-rows"><div><dt>File</dt><dd id="done-info">Your file is ready</dd></div></dl>
+ <div class="ovk-answer-body"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="new-btn"><i class="fa-solid fa-arrow-rotate-left"></i> <span>Download another</span></button></div>
+ </section>`;
+}
+
+/**
+ * The whole frame for the downloader's page.
+ * @param {string} [host] the host that serves it
+ * @returns {string} HTML
+ */
+function frameFor(host) {
+ const title = 'YouTube Downloader';
+ const task = 'Save a YouTube video as MP4 or WebM, or keep just the audio as MP3.';
+ const faq = [
+ ['Is it free?', 'Yes. A download a minute without an account, and more when you sign in.'],
+ ['How long is my file kept?', 'About an hour, so you can save it. Then it is deleted.'],
+ ['How big can the video be?', `Up to ${frame.size(config.download.maxFilesize * 1024 * 1024)} and ${lengthOf(config.download.maxDuration)} long.`],
+ ['Can I download any video?', 'Your own uploads, public-domain works and videos whose licence allows copies. You are responsible for having the right to save what you download.'],
+ ];
+ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="yt">${kit.tool({
+ head: frame.head({
+ icon: 'play', hue: 355, family: 'media',
+ crumb: { href: SITE, label: 'Tools' },
+ title, task, facts: termsOf(),
+ }),
+ input: boxFor(),
+ answer: answerFor(),
+ family: kit.family({
+ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },
+ items: familyItems(), label: 'More tools',
+ }),
+ folds: frame.folds({
+ name: title,
+ about: 'The downloader fetches one video with yt-dlp on our server and hands you the file as MP4, WebM, MP3, M4A, Opus or FLAC. The file is kept for about an hour and then deleted.',
+ faq,
+ api: null,
+ }),
+ related: kit.list(relatedItems(), { title: 'People also use' }),
+ })}</div>`;
+}
+
+module.exports = { frameFor, termsOf, boxFor, answerFor, familyItems, relatedItems, HUBS, lengthOf };
@@ -310,6 +310,12 @@ app.get(['/', '/index.html'], (req, res) => {
@@ -310,6 +310,12 @@ app.get(['/', '/index.html'], (req, res) => {
if (req.path !== '/') return res.redirect(301, '/');
if (req.path !== '/') return res.redirect(301, '/');
return seo.sendIndex(req, res);
return seo.sendIndex(req, res);
});
});
+
+// The tool frame's browser glue (apps/_shared/public/tool-frame.js).
+app.get('/js/tool-frame.js', (_req, res) => {
+ res.set('Cache-Control', 'no-cache');
+ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
+});
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
res.status(404).set('Cache-Control', 'no-cache').type('html')
res.status(404).set('Cache-Control', 'no-cache').type('html')
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { canonicalHostFor, requestHost, isLocalHost } = require('../../_shared/host-role');
const { canonicalHostFor, requestHost, isLocalHost } = require('../../_shared/host-role');
+const serve = require('openvibe-shared/serve');
+const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const PUBLIC = path.join(__dirname, '..', 'public');
@@ -107,6 +109,9 @@ function headTags(canonicalHost) {
@@ -107,6 +109,9 @@ function headTags(canonicalHost) {
`<meta name="twitter:title" content="${esc(TITLE)}">`,
`<meta name="twitter:title" content="${esc(TITLE)}">`,
`<meta name="twitter:description" content="${esc(DESCRIPTION)}">`,
`<meta name="twitter:description" content="${esc(DESCRIPTION)}">`,
`<meta name="twitter:image" content="${image}">`,
`<meta name="twitter:image" content="${image}">`,
+ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.
+ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
+ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(ldScript),
...ld.map(ldScript),
].join('\n ');
].join('\n ');
}
}
@@ -129,7 +134,11 @@ function baseHtml() {
@@ -129,7 +134,11 @@ function baseHtml() {
function renderIndex(canonicalHost) {
function renderIndex(canonicalHost) {
if (process.env.NODE_ENV !== 'development' && _cache.has(canonicalHost)) return _cache.get(canonicalHost);
if (process.env.NODE_ENV !== 'development' && _cache.has(canonicalHost)) return _cache.get(canonicalHost);
- const out = baseHtml().replace('<!--OV:HEAD-->', headTags(canonicalHost)).replace('<!--OV:CONTENT-->', contentBlock());
+ const out = baseHtml()
+ .replace('<!--OV:HEAD-->', headTags(canonicalHost))
+ .replace('<!--OV:CONTENT-->', contentBlock())
+ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
+ .replace('<!--OVK:FRAME-->', frameFor(canonicalHost));
if (_cache.size > 50) _cache.clear();
if (_cache.size > 50) _cache.clear();
_cache.set(canonicalHost, out);
_cache.set(canonicalHost, out);
return out;
return out;
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+// The tool frame on the YouTube downloader's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
+// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the
+// folds and "People also use", in that order;
+// • the terms come from the app's real configuration — the file's lifetime from the cleanup config,
+// its size and the video's length from the download limits, the rate from the guard's quota table —
+// and they follow it when it changes;
+// • the one button says the verb, never "Run".
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { frameFor, termsOf } = require('../server/frame');
+const config = require('../server/config');
+const { startApp } = require('../../_shared/test/spawn');
+
+/** The frame's order: each marker appears once, and in this order. */
+function inOrder(html, parts) {
+ let at = -1;
+ for (const p of parts) {
+ const i = html.indexOf(p, at + 1);
+ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
+ at = i;
+ }
+}
+
+/** The head's terms, as text. */
+function factsOf(html) {
+ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
+ if (!m) return [];
+ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+// ── The page is a framed tool page, in the same order ──
+{
+ const html = frameFor('yt.openvibe.tools');
+ inOrder(html, [
+ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
+ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
+ ]);
+ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');
+ assert.ok(html.includes('id="url-input"'), 'the box has the link field');
+ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');
+ assert.ok(html.includes('id="save-btn"') && html.includes('<span>Download</span>'), 'the answer downloads the file');
+ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
+}
+
+// ── The terms come from the app's own configuration ──
+{
+ const facts = factsOf(frameFor('yt.openvibe.tools'));
+ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'free, on our server, and the file\'s lifetime');
+ assert.ok(facts.includes('One file up to 2 GB'), 'the size from the download limits');
+ assert.ok(facts.includes('Up to 3 hours long'), 'the length from the download limits');
+ assert.ok(facts.includes('1 download a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');
+
+ const savedTtl = config.retention.fileTTL;
+ const savedSize = config.download.maxFilesize;
+ const savedDuration = config.download.maxDuration;
+ try {
+ config.retention.fileTTL = 30 * 60 * 1000;
+ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('Runs on our server; your file is deleted after 30 minutes'), 'the lifetime follows the config');
+ config.download.maxFilesize = 512;
+ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('One file up to 512 MB'), 'the size follows the config');
+ config.download.maxDuration = 600;
+ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('Up to 10 minutes long'), 'the length follows the config');
+ } finally {
+ config.retention.fileTTL = savedTtl;
+ config.download.maxFilesize = savedSize;
+ config.download.maxDuration = savedDuration;
+ }
+}
+
+// ── The one button says the verb ──
+{
+ const html = frameFor('yt.openvibe.tools');
+ const buttons = [...html.matchAll(/<button[^>]*>([\s\S]*?)<\/button>/g)]
+ .map((m) => m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<[^>]+>/g, '').trim())
+ .filter(Boolean);
+ assert.ok(buttons.includes('Fetch the video'), 'the box button says the verb');
+ assert.ok(buttons.includes('Start the download'), 'the download button says the verb');
+ assert.ok(!buttons.includes('Run'), 'never Run');
+ assert.ok(/<span>Download<\/span>/.test(html), 'the answer downloads the file');
+}
+
+// ── The page the server sends: the frame, the title, the canonical and the structured data ──
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-yt-frame-'));
+ const app = await startApp('yt', { DATA_DIR: tmp });
+ const get = (host) => new Promise((resolve) => {
+ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (d) => { body += d; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ });
+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
+ req.on('error', () => resolve({ status: 0, body: '' }));
+ });
+ try {
+ for (const host of ['yt.openvibe.tools', 'youtube-downloader.openvibe.tools']) {
+ const r = await get(host);
+ assert.strictEqual(r.status, 200, `${host}: answers 200`);
+ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
+ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
+ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
+ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
+ assert.ok(r.body.includes('/js/tool-frame.js'), `${host}: the frame's behaviour`);
+ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
+ }
+ } finally {
+ await app.kill();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('tool frame (yt): the downloader page framed, the terms from the config, the buttons say the verb: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });