openvibers/openvibe.network Commits

Your account: What you see, one choice per content label for every site (#511)

OP OpenVibers committed 11 days ago · db2b5bc848 · 6 files, +138 −1

* Your account: What you see, a choice per content label (show, blur, hide) for every OpenVibe site

* Merge remote-tracking branch 'origin/main' into refresh-511

Changes

ddiff --git a/public/css/my.css b/public/css/my.css
iindex 74e3f44d..bac861c3 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -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; } }
ddiff --git a/public/js/my/labels.js b/public/js/my/labels.js
nnew file mode 100644
iindex 00000000..32abdb07
---- /dev/null
++++ b/public/js/my/labels.js
@@@ -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);
++ }
++}
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 82f9dcbf..2a43cd76 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -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();
ddiff --git a/public/my.html b/public/my.html
iindex 4565516c..3b407d49 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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>
ddiff --git a/test/my-labels-ui.test.js b/test/my-labels-ui.test.js
nnew file mode 100644
iindex 00000000..4a559bad
---- /dev/null
++++ b/test/my-labels-ui.test.js
@@@ -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');
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex 576f049e..8626129f 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -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();
// 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]);