openvibers/openvibe.network Commits
Your account: What you see, one choice per content label for every site (#511)
Changes
@@@ -402,3 +402,18 @@ button.td-stat:hover span { color: var(--accent-light); }.consent-licence-field select { flex: 1; min-width: 0; }}++++/* What you see (L1): a row per label, its name and what it covers, and three equal choices. Phone first: the choices++ under the words; wider, beside them. 44 px targets. */++#label-prefs { display: grid; gap: 10px; margin-top: 10px; }++.label-pref { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; }++.label-pref-text { display: grid; gap: 2px; min-width: 0; }++.label-pref-text b { font-size: .95rem; }++.label-pref-text span { font-size: .82rem; color: var(--text-muted, #8b93ad); }++.label-pref-seg { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }++.label-pref-seg button { all: unset; box-sizing: border-box; min-height: 44px; display: grid; place-items: center; border-radius: 9px; cursor: pointer; font-weight: 650; font-size: .88rem; color: var(--text-secondary, #a8b3c7); }++.label-pref-seg button:hover { color: var(--text-primary, #e6edf7); }++.label-pref-seg button:focus-visible { outline: 2px solid var(--accent, #60a5fa); outline-offset: 1px; }++.label-pref-seg button.is-on { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #111a2c); box-shadow: 0 1px 3px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #60a5fa) 55%, transparent); }++.label-prefs-status.is-bad { color: var(--danger-text, #f87171); }++@media (min-width: 640px) { .label-pref { grid-template-columns: minmax(0, 1fr) 260px; align-items: center; } }nnew file mode 100644@@@ -0,0 +1,88 @@++/* ═══════════════════════════════════════════════════════════++ OpenVibe.Network — account hub (/my): "What you see" (L1, labels and choices, 2026-11-02).++ One row per content label, three equal choices (Show, Blur until tapped, Hide), saved at once with++ PUT /api/v1/me/label-prefs (network.label-prefs@1, all six each time). Every OpenVibe site reads the same choice++ (openvibe-shared ov-labels.js). Classic script, global scope: my/core.js's apiFetch; profile.js starts it.++ ═══════════════════════════════════════════════════════════ */++const LABEL_ROWS = [++ ['nudity', 'Nudity', 'Nudity or sexual content.'],++ ['language', 'Strong language', 'Swearing and slurs.'],++ ['graphic', 'Graphic', 'Graphic violence or injury.'],++ ['18+', '18+', 'Adults only.'],++ ['flashing', 'Flashing lights', 'Flashing lights or rapid motion.'],++ ['spoilers', 'Spoilers', 'It gives away a story.'],++];++const LABEL_CHOICES = [['show', 'Show'], ['blur', 'Blur'], ['hide', 'Hide']];++let labelPrefs = null;++++function labelPrefsStatus(text, bad) {++ const el = document.getElementById('label-prefs-status');++ if (!el) return;++ el.textContent = text || '';++ el.classList.toggle('is-bad', !!bad);++}++++function renderLabelPrefs() {++ const box = document.getElementById('label-prefs');++ if (!box || !labelPrefs) return;++ box.replaceChildren(...LABEL_ROWS.map(([id, name, what]) => {++ const row = document.createElement('div');++ row.className = 'label-pref';++ const text = document.createElement('div');++ text.className = 'label-pref-text';++ const b = document.createElement('b'); b.textContent = name;++ const s = document.createElement('span'); s.textContent = what;++ text.append(b, s);++ const seg = document.createElement('div');++ seg.className = 'label-pref-seg';++ seg.setAttribute('role', 'radiogroup');++ seg.setAttribute('aria-label', name);++ for (const [value, label] of LABEL_CHOICES) {++ const btn = document.createElement('button');++ btn.type = 'button';++ btn.textContent = label;++ btn.setAttribute('role', 'radio');++ const on = labelPrefs[id] === value;++ btn.setAttribute('aria-checked', String(on));++ if (on) btn.className = 'is-on';++ btn.addEventListener('click', () => saveLabelPref(id, value));++ seg.append(btn);++ }++ row.append(text, seg);++ return row;++ }));++}++++async function saveLabelPref(id, value) {++ if (!labelPrefs || labelPrefs[id] === value) return;++ const before = { ...labelPrefs };++ labelPrefs = { ...labelPrefs, [id]: value };++ renderLabelPrefs();++ labelPrefsStatus('Saving…', false);++ try {++ const data = await apiFetch('/api/v1/me/label-prefs', { method: 'PUT', body: JSON.stringify({ prefs: labelPrefs }) });++ if (data && data.prefs) labelPrefs = data.prefs;++ renderLabelPrefs();++ const name = (LABEL_ROWS.find(([k]) => k === id) || [id, id])[1];++ labelPrefsStatus(`${name}: ${value === 'show' ? 'shown' : value === 'blur' ? 'blurred until you tap' : 'hidden'}, on every OpenVibe site.`, false);++ try { sessionStorage.removeItem('ov_label_prefs'); } catch { /* the sites read it again on their next page */ }++ } catch (err) {++ labelPrefs = before;++ renderLabelPrefs();++ labelPrefsStatus('Could not save that choice (' + (err && err.status ? 'HTTP ' + err.status : 'network error') + '). Nothing changed.', true);++ }++}++++async function loadLabelPrefs() {++ const box = document.getElementById('label-prefs');++ if (!box) return;++ try {++ const data = await apiFetch('/api/v1/me/label-prefs');++ labelPrefs = data && data.prefs ? data.prefs : null;++ if (!labelPrefs) throw new Error('no prefs');++ renderLabelPrefs();++ } catch (err) {++ box.innerHTML = '<p class="muted">Your choices could not be loaded. Until they load, everything shows.</p>';++ labelPrefsStatus('Could not load your choices' + (err && err.status ? ' (HTTP ' + err.status + ')' : '') + '.', true);++ }++}@@@ -42,6 +42,7 @@ async function loadUser() {renderMerge();renderExport(); renderDeletion();loadConsent();++ loadLabelPrefs();if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');loadNotifPrefs(); loadQuietHours();loadLinked();@@@ -315,6 +315,14 @@<div id="consent-choices"><div class="loading-spinner">Loading your choices…</div></div><p class="muted consent-status" id="consent-status" role="status"></p></div>++ <!-- What you see (L1, 2026-11-02): one row per content label, three equal choices, saved at once. The web default++ is Show for every label; the choice follows the account to every OpenVibe site (GET/PUT /api/v1/me/label-prefs). -->++ <div class="card" id="labels-card">++ <h3><span class="icon"><i class="fa-solid fa-eye"></i></span> What you see</h3>++ <p>Creators label what they post. For each label, choose what you see on every OpenVibe site: show it, blur it until you tap, or hide it. What you post yourself always shows to you.</p>++ <div id="label-prefs"><p class="muted">Loading your choices…</p></div>++ <p class="muted label-prefs-status" id="label-prefs-status" role="status"></p>++ </div><div class="card"><h3><span class="icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI preferences</h3><p style="margin-bottom:12px">How OpenVibe AI writes for you when you ask it for something, such as a blog draft. Saved to your account and used on every OpenVibe site.</p>@@@ -454,6 +462,7 @@<script src="/js/my/themes.js" defer></script><script src="/js/my/profile.js" defer></script><script src="/js/my/consent.js" defer></script>++<script src="/js/my/labels.js" defer></script><script src="/js/my/security.js" defer></script><script src="/js/my/approvals.js" defer></script><script src="/js/my/boot.js" defer></script>nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++// "What you see" on /my (L1): a card beside "Your data choices", a row per content label with three equal choices,++// saved at once with all six (network.label-prefs@1), and a failed save puts the old choice back.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'labels.js'), 'utf8');++const profile = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'profile.js'), 'utf8');++++assert.ok(html.includes('<div class="card" id="labels-card">') && html.includes('What you see'), 'the card');++assert.ok(html.indexOf('id="consent-card"') < html.indexOf('id="labels-card"'), 'beside Your data choices');++assert.ok(html.includes('<script src="/js/my/labels.js" defer></script>'), 'its script');++assert.ok(profile.includes('loadLabelPrefs();'), 'started with the page');++++for (const id of ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']) assert.ok(js.includes(`['${id}', `), id);++assert.ok(js.includes("[['show', 'Show'], ['blur', 'Blur'], ['hide', 'Hide']]"), 'three equal choices');++assert.ok(js.includes("setAttribute('role', 'radio')") && js.includes("setAttribute('aria-checked'"), 'a radio group a screen reader can read');++assert.ok(js.includes("apiFetch('/api/v1/me/label-prefs', { method: 'PUT', body: JSON.stringify({ prefs: labelPrefs }) })"), 'all six, saved at once');++assert.ok(/catch \(err\) \{\s*labelPrefs = before;/.test(js), 'a failed save puts the old choice back');++assert.ok(js.includes('Until they load, everything shows.'), 'says what happens when it cannot load');++++console.log('my-labels-ui: all passed');@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/my/…).--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);
@@ -402,3 +402,18 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -402,3 +402,18 @@ button.td-stat:hover span { color: var(--accent-light); }
.consent-licence-field select { flex: 1; min-width: 0; }
.consent-licence-field select { flex: 1; min-width: 0; }
}
}
+
+/* What you see (L1): a row per label, its name and what it covers, and three equal choices. Phone first: the choices
+ under the words; wider, beside them. 44 px targets. */
+#label-prefs { display: grid; gap: 10px; margin-top: 10px; }
+.label-pref { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; }
+.label-pref-text { display: grid; gap: 2px; min-width: 0; }
+.label-pref-text b { font-size: .95rem; }
+.label-pref-text span { font-size: .82rem; color: var(--text-muted, #8b93ad); }
+.label-pref-seg { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
+.label-pref-seg button { all: unset; box-sizing: border-box; min-height: 44px; display: grid; place-items: center; border-radius: 9px; cursor: pointer; font-weight: 650; font-size: .88rem; color: var(--text-secondary, #a8b3c7); }
+.label-pref-seg button:hover { color: var(--text-primary, #e6edf7); }
+.label-pref-seg button:focus-visible { outline: 2px solid var(--accent, #60a5fa); outline-offset: 1px; }
+.label-pref-seg button.is-on { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #111a2c); box-shadow: 0 1px 3px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #60a5fa) 55%, transparent); }
+.label-prefs-status.is-bad { color: var(--danger-text, #f87171); }
+@media (min-width: 640px) { .label-pref { grid-template-columns: minmax(0, 1fr) 260px; align-items: center; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+/* ═══════════════════════════════════════════════════════════
+ OpenVibe.Network — account hub (/my): "What you see" (L1, labels and choices, 2026-11-02).
+ One row per content label, three equal choices (Show, Blur until tapped, Hide), saved at once with
+ PUT /api/v1/me/label-prefs (network.label-prefs@1, all six each time). Every OpenVibe site reads the same choice
+ (openvibe-shared ov-labels.js). Classic script, global scope: my/core.js's apiFetch; profile.js starts it.
+ ═══════════════════════════════════════════════════════════ */
+const LABEL_ROWS = [
+ ['nudity', 'Nudity', 'Nudity or sexual content.'],
+ ['language', 'Strong language', 'Swearing and slurs.'],
+ ['graphic', 'Graphic', 'Graphic violence or injury.'],
+ ['18+', '18+', 'Adults only.'],
+ ['flashing', 'Flashing lights', 'Flashing lights or rapid motion.'],
+ ['spoilers', 'Spoilers', 'It gives away a story.'],
+];
+const LABEL_CHOICES = [['show', 'Show'], ['blur', 'Blur'], ['hide', 'Hide']];
+let labelPrefs = null;
+
+function labelPrefsStatus(text, bad) {
+ const el = document.getElementById('label-prefs-status');
+ if (!el) return;
+ el.textContent = text || '';
+ el.classList.toggle('is-bad', !!bad);
+}
+
+function renderLabelPrefs() {
+ const box = document.getElementById('label-prefs');
+ if (!box || !labelPrefs) return;
+ box.replaceChildren(...LABEL_ROWS.map(([id, name, what]) => {
+ const row = document.createElement('div');
+ row.className = 'label-pref';
+ const text = document.createElement('div');
+ text.className = 'label-pref-text';
+ const b = document.createElement('b'); b.textContent = name;
+ const s = document.createElement('span'); s.textContent = what;
+ text.append(b, s);
+ const seg = document.createElement('div');
+ seg.className = 'label-pref-seg';
+ seg.setAttribute('role', 'radiogroup');
+ seg.setAttribute('aria-label', name);
+ for (const [value, label] of LABEL_CHOICES) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = label;
+ btn.setAttribute('role', 'radio');
+ const on = labelPrefs[id] === value;
+ btn.setAttribute('aria-checked', String(on));
+ if (on) btn.className = 'is-on';
+ btn.addEventListener('click', () => saveLabelPref(id, value));
+ seg.append(btn);
+ }
+ row.append(text, seg);
+ return row;
+ }));
+}
+
+async function saveLabelPref(id, value) {
+ if (!labelPrefs || labelPrefs[id] === value) return;
+ const before = { ...labelPrefs };
+ labelPrefs = { ...labelPrefs, [id]: value };
+ renderLabelPrefs();
+ labelPrefsStatus('Saving…', false);
+ try {
+ const data = await apiFetch('/api/v1/me/label-prefs', { method: 'PUT', body: JSON.stringify({ prefs: labelPrefs }) });
+ if (data && data.prefs) labelPrefs = data.prefs;
+ renderLabelPrefs();
+ const name = (LABEL_ROWS.find(([k]) => k === id) || [id, id])[1];
+ labelPrefsStatus(`${name}: ${value === 'show' ? 'shown' : value === 'blur' ? 'blurred until you tap' : 'hidden'}, on every OpenVibe site.`, false);
+ try { sessionStorage.removeItem('ov_label_prefs'); } catch { /* the sites read it again on their next page */ }
+ } catch (err) {
+ labelPrefs = before;
+ renderLabelPrefs();
+ labelPrefsStatus('Could not save that choice (' + (err && err.status ? 'HTTP ' + err.status : 'network error') + '). Nothing changed.', true);
+ }
+}
+
+async function loadLabelPrefs() {
+ const box = document.getElementById('label-prefs');
+ if (!box) return;
+ try {
+ const data = await apiFetch('/api/v1/me/label-prefs');
+ labelPrefs = data && data.prefs ? data.prefs : null;
+ if (!labelPrefs) throw new Error('no prefs');
+ renderLabelPrefs();
+ } catch (err) {
+ box.innerHTML = '<p class="muted">Your choices could not be loaded. Until they load, everything shows.</p>';
+ labelPrefsStatus('Could not load your choices' + (err && err.status ? ' (HTTP ' + err.status + ')' : '') + '.', true);
+ }
+}
@@ -42,6 +42,7 @@ async function loadUser() {
@@ -42,6 +42,7 @@ async function loadUser() {
renderMerge();
renderMerge();
renderExport(); renderDeletion();
renderExport(); renderDeletion();
loadConsent();
loadConsent();
+ loadLabelPrefs();
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
loadNotifPrefs(); loadQuietHours();
loadNotifPrefs(); loadQuietHours();
loadLinked();
loadLinked();
@@ -315,6 +315,14 @@
@@ -315,6 +315,14 @@
<div id="consent-choices"><div class="loading-spinner">Loading your choices…</div></div>
<div id="consent-choices"><div class="loading-spinner">Loading your choices…</div></div>
<p class="muted consent-status" id="consent-status" role="status"></p>
<p class="muted consent-status" id="consent-status" role="status"></p>
</div>
</div>
+ <!-- What you see (L1, 2026-11-02): one row per content label, three equal choices, saved at once. The web default
+ is Show for every label; the choice follows the account to every OpenVibe site (GET/PUT /api/v1/me/label-prefs). -->
+ <div class="card" id="labels-card">
+ <h3><span class="icon"><i class="fa-solid fa-eye"></i></span> What you see</h3>
+ <p>Creators label what they post. For each label, choose what you see on every OpenVibe site: show it, blur it until you tap, or hide it. What you post yourself always shows to you.</p>
+ <div id="label-prefs"><p class="muted">Loading your choices…</p></div>
+ <p class="muted label-prefs-status" id="label-prefs-status" role="status"></p>
+ </div>
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI preferences</h3>
<h3><span class="icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI preferences</h3>
<p style="margin-bottom:12px">How OpenVibe AI writes for you when you ask it for something, such as a blog draft. Saved to your account and used on every OpenVibe site.</p>
<p style="margin-bottom:12px">How OpenVibe AI writes for you when you ask it for something, such as a blog draft. Saved to your account and used on every OpenVibe site.</p>
@@ -454,6 +462,7 @@
@@ -454,6 +462,7 @@
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/consent.js" defer></script>
+<script src="/js/my/labels.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="/js/my/boot.js" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+// "What you see" on /my (L1): a card beside "Your data choices", a row per content label with three equal choices,
+// saved at once with all six (network.label-prefs@1), and a failed save puts the old choice back.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'labels.js'), 'utf8');
+const profile = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'profile.js'), 'utf8');
+
+assert.ok(html.includes('<div class="card" id="labels-card">') && html.includes('What you see'), 'the card');
+assert.ok(html.indexOf('id="consent-card"') < html.indexOf('id="labels-card"'), 'beside Your data choices');
+assert.ok(html.includes('<script src="/js/my/labels.js" defer></script>'), 'its script');
+assert.ok(profile.includes('loadLabelPrefs();'), 'started with the page');
+
+for (const id of ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']) assert.ok(js.includes(`['${id}', `), id);
+assert.ok(js.includes("[['show', 'Show'], ['blur', 'Blur'], ['hide', 'Hide']]"), 'three equal choices');
+assert.ok(js.includes("setAttribute('role', 'radio')") && js.includes("setAttribute('aria-checked'"), 'a radio group a screen reader can read');
+assert.ok(js.includes("apiFetch('/api/v1/me/label-prefs', { method: 'PUT', body: JSON.stringify({ prefs: labelPrefs }) })"), 'all six, saved at once');
+assert.ok(/catch \(err\) \{\s*labelPrefs = before;/.test(js), 'a failed save puts the old choice back');
+assert.ok(js.includes('Until they load, everything shows.'), 'says what happens when it cannot load');
+
+console.log('my-labels-ui: all passed');
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// The module map, in load order (public/js/my/…).
-const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
+const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);