openvibers/openvibe.mediahub Commits
Pics: a picture's action bar, the gallery's search field fixed, and a designed empty gallery (#359)
Changes
@@@ -247,7 +247,7 @@.ovp-gc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }.ovp-tag { display: inline-flex; align-items: center; min-height: 26px; padding: 0 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--ovp-dim); font-size: 12.5px; font-weight: 600; text-decoration: none; }.ovp-tag:hover { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }--.ovp-gallery-empty { padding: 30px 0; }++.ovp-gallery-empty { margin: 8px 0 24px; }.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-fields { display: none; }.ovp-drop--compact .ovp-drop-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 14px; }.ovp-drop--compact .ovp-drop-title { margin: 0; font-size: 16px; }@@@ -270,5 +270,37 @@.ovp-drop--compact .ovp-drop-inner p { margin: 0; }.ovp-drop--signin.ovp-drop--compact .ovp-drop-title { display: none; }.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }--.ovp-gallery-search input { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 8px; min-height: 44px; padding: 8px 12px; }.ovp-btn { min-height: 44px; }++++/* The picture's action bar (owner's design pass, 11-09): who published it and how many saw it on the left, Like (a heart++ that fills in Pics pink, with its count) and Share on the right; under it, quiet buttons to make something with the++ picture; the comments as a card whose head holds the one button. */++.ovp-pic-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin: 14px 0 10px; }++.ovp-pic-who { display: flex; align-items: center; gap: 10px; min-width: 0; }++.ovp-pic-av { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: color-mix(in srgb, var(--ovp-pink) 26%, var(--ovp-card)); color: var(--text-primary, #e6edf7); font-weight: 800; }++.ovp-pic-who-txt { display: grid; min-width: 0; line-height: 1.25; }++.ovp-pic-who-txt b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovp-pic-who-txt small { color: var(--ovp-dim); font-size: 13px; }++.ovp-pic-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; }++.ovp-glyph { display: inline-grid; place-items: center; flex: none; }++.ovp-glyph svg { display: block; }++.ovp-like { gap: 8px; }++.ovp-like-n { padding-left: 8px; border-left: 1px solid var(--ovp-line); color: var(--ovp-dim); font-variant-numeric: tabular-nums; }++.ovp-like[aria-pressed="true"] { border-color: color-mix(in srgb, var(--ovp-pink) 60%, transparent); background: color-mix(in srgb, var(--ovp-pink) 14%, transparent); }++.ovp-like[aria-pressed="true"] .ovp-glyph { color: var(--ovp-pink); }++.ovp-like[aria-pressed="true"] .ovp-glyph svg { fill: currentColor; }++.ovp-pic-status:empty { display: none; }++.ovp-pic-status { margin: 0 0 8px; color: var(--ovp-dim); font-size: 14px; }++.ovp-pic-make { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 14px; }++.ovp-pic-make .ovk-btn, .ovp-comments-head .ovk-btn { gap: 8px; }++.ovp-comments { padding: 14px 16px; border: 1px solid var(--ovp-line); border-radius: 16px; background: var(--ovp-card); }++.ovp-comments-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }++.ovp-comments-head h2 { margin: 0; }++.ovp-comments-list:empty { display: none; }++.ovp-comments-list { margin-top: 12px; display: grid; gap: 8px; }++.ovp-comments-list p { margin: 0; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); overflow-wrap: anywhere; }++@media (max-width: 560px) {++ .ovp-pic-acts { margin-left: 0; width: 100%; }++ .ovp-pic-acts > * { flex: 1; justify-content: center; }++ .ovp-pic-make .ovk-btn { flex: 1 1 auto; justify-content: center; }++}@@@ -48,7 +48,9 @@if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to like this picture.' : 'Could not update your like. Try again.');var data = await response.json();document.querySelector('[data-like-count]').textContent = data.likes;-- like.setAttribute('aria-pressed', String(!removing)); like.textContent = removing ? 'Like' : 'Liked'; status.textContent = '';++ like.setAttribute('aria-pressed', String(!removing));++ var word = like.querySelector('[data-like-word]'); if (word) word.textContent = removing ? 'Like' : 'Liked'; else like.textContent = removing ? 'Like' : 'Liked';++ status.textContent = '';} catch (error) { status.textContent = error.message; }finally { like.disabled = false; }});@@@ -9,6 +9,7 @@*/const { html, raw } = require('../render/html');const glyphs = require('openvibe-shared/glyphs');++const kit = require('openvibe-shared/kit');const labelsUi = require('../render/labels-ui');const PUBLIC = "i.listed = true AND i.state = 'active' AND i.audience = 'public' AND EXISTS (SELECT 1 FROM shares sh WHERE sh.service = 'mediahub' AND sh.kind = 'picture' AND sh.resource_id = i.id AND sh.audience = 'public')";const isPublic = (row) => Boolean(row.listed && row.state === 'active' && row.audience === 'public');@@@ -76,7 +77,9 @@ function render(pics, result) {<nav class="ovp-gallery-tabs" aria-label="Gallery order">${['popular', 'new', 'rising'].map((sort) => html`<a href="${href(sort)}" ${sort === result.sort ? raw('aria-current="page"') : ''}>${sort[0].toUpperCase() + sort.slice(1)}</a>`)}</nav></div>${result.tag ? html`<p class="ovp-gallery-tagged">Tagged <b>#${result.tag}</b> <a href="${href(result.sort).replace(/&?tag=[^&]*/, '')}">Clear</a></p>` : ''}<div class="ovp-gallery" data-gallery>${cards(pics, result.rows)}</div>--${!result.rows.length ? html`<p class="ovp-gallery-empty">${result.q || result.tag ? 'No public pictures match yet. Try another search.' : 'The gallery is ready for its first public picture. Upload one, then choose Publish to gallery on its page.'}</p>` : ''}++${!result.rows.length ? raw(`<div class="ovp-gallery-empty">${result.q || result.tag++ ? kit.empty({ icon: 'search', title: 'No public pictures match yet', text: 'Try another word or tag, or see everything published so far.', action: { href: '/', label: 'Clear the search', icon: 'image' } })++ : kit.empty({ icon: 'image', title: 'The gallery is waiting for its first picture', text: 'Upload a picture, then choose Publish to gallery on its page. Until you do, it stays unlisted.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } })}</div>`) : ''}${result.next ? html`<a class="ovp-btn" data-gallery-next href="${href(result.sort, result.next)}">More pictures</a>` : ''}<p role="status" data-gallery-status></p></section>`;}module.exports = { PUBLIC, isPublic, tags, parseTags, options, browse, counts, publish, wire, cards, render };@@@ -41,6 +41,9 @@ const { createPicsApi } = require('./api');const { createPicVariants } = require('./variants');const kit = require('openvibe-shared/kit');const gallery = require('./gallery');++const glyphs = require('openvibe-shared/glyphs');++/** A line glyph from Shared's set, decorative (its button says what it does). */++const glyph = (name, size = 16) => raw(`<span class="ovp-glyph" aria-hidden="true">${glyphs.svg(name, { size })}</span>`);const showcase = require('openvibe-shared/showcase');const { PATHS: POLICY_PATHS, policyPage } = require('./policy');@@@ -402,13 +405,20 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l${labelsUi.chips(labelsUi.inForce(row))}<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure><p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>--${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }))}--${gallery.isPublic(row) ? html`<p>${row.author_name || 'OpenVibe member'} · ${(await gallery.counts(s, row.id)).views} views · <span data-like-count>${(await gallery.counts(s, row.id)).likes}</span> likes</p><button class="ovp-btn" data-like="${row.slug}">Like</button><p role="status" data-like-status></p>` : ''}--${!hidden ? html`<p class="ovp-gallery-tabs"><a class="ovp-btn" href="https://openvibe.studio/image-tools?picture=${row.slug}">Edit with AI</a><a class="ovp-btn" href="https://openvibe.meme/make?picture=${row.slug}">Make a meme</a><a class="ovp-btn" href="https://openvibe.cam/">Take a photo</a></p>` : ''}++${await (async () => {++ const pub = gallery.isPublic(row);++ const n = pub ? await gallery.counts(s, row.id) : null;++ const who = row.author_name || 'OpenVibe member';++ const share = raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }));++ return html`<div class="ovp-pic-bar">${pub ? html`<div class="ovp-pic-who"><span class="ovp-pic-av" aria-hidden="true">${who.trim().charAt(0).toUpperCase() || 'O'}</span><span class="ovp-pic-who-txt"><b>${who}</b><small>${n.views} ${n.views === 1 ? 'view' : 'views'}</small></span></div>` : ''}++<div class="ovp-pic-acts">${pub ? html`<button type="button" class="ovk-btn ovk-btn--quiet ovp-like" data-like="${row.slug}" aria-pressed="false">${glyph('heart', 18)}<span data-like-word>Like</span><span class="ovp-like-n" data-like-count>${n.likes}</span></button>` : ''}${share}</div></div>++${pub ? html`<p class="ovp-pic-status" role="status" data-like-status></p>` : ''}++${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this picture"><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.studio/image-tools?picture=${row.slug}">${glyph('spark', 17)}<span>Edit with AI</span></a><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.meme/make?picture=${row.slug}">${glyph('laugh', 17)}<span>Make a meme</span></a><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.cam/">${glyph('camera', 17)}<span>Take a photo</span></a></nav>` : ''}`;++})()}${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}</article>--${gallery.isPublic(row) ? html`<section class="ovp-section"><h2>Comments</h2><button class="ovp-btn" data-comments="${row.id}">Show Community discussion</button><div data-comments-list aria-live="polite"></div></section>` : ''}++${gallery.isPublic(row) ? html`<section class="ovp-section ovp-comments" aria-labelledby="h-comments"><div class="ovp-comments-head"><h2 id="h-comments">Comments</h2><button type="button" class="ovk-btn ovk-btn--quiet" data-comments="${row.id}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2><p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p><div class="ovp-link-grid">
@@ -247,7 +247,7 @@
@@ -247,7 +247,7 @@
.ovp-gc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.ovp-gc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.ovp-tag { display: inline-flex; align-items: center; min-height: 26px; padding: 0 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--ovp-dim); font-size: 12.5px; font-weight: 600; text-decoration: none; }
.ovp-tag { display: inline-flex; align-items: center; min-height: 26px; padding: 0 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--ovp-dim); font-size: 12.5px; font-weight: 600; text-decoration: none; }
.ovp-tag:hover { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }
.ovp-tag:hover { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }
-.ovp-gallery-empty { padding: 30px 0; }
+.ovp-gallery-empty { margin: 8px 0 24px; }
.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-fields { display: none; }
.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-fields { display: none; }
.ovp-drop--compact .ovp-drop-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 14px; }
.ovp-drop--compact .ovp-drop-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 14px; }
.ovp-drop--compact .ovp-drop-title { margin: 0; font-size: 16px; }
.ovp-drop--compact .ovp-drop-title { margin: 0; font-size: 16px; }
@@ -270,5 +270,37 @@
@@ -270,5 +270,37 @@
.ovp-drop--compact .ovp-drop-inner p { margin: 0; }
.ovp-drop--compact .ovp-drop-inner p { margin: 0; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-title { display: none; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-title { display: none; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }
-.ovp-gallery-search input { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 8px; min-height: 44px; padding: 8px 12px; }
.ovp-btn { min-height: 44px; }
.ovp-btn { min-height: 44px; }
+
+/* The picture's action bar (owner's design pass, 11-09): who published it and how many saw it on the left, Like (a heart
+ that fills in Pics pink, with its count) and Share on the right; under it, quiet buttons to make something with the
+ picture; the comments as a card whose head holds the one button. */
+.ovp-pic-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin: 14px 0 10px; }
+.ovp-pic-who { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.ovp-pic-av { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: color-mix(in srgb, var(--ovp-pink) 26%, var(--ovp-card)); color: var(--text-primary, #e6edf7); font-weight: 800; }
+.ovp-pic-who-txt { display: grid; min-width: 0; line-height: 1.25; }
+.ovp-pic-who-txt b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovp-pic-who-txt small { color: var(--ovp-dim); font-size: 13px; }
+.ovp-pic-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; }
+.ovp-glyph { display: inline-grid; place-items: center; flex: none; }
+.ovp-glyph svg { display: block; }
+.ovp-like { gap: 8px; }
+.ovp-like-n { padding-left: 8px; border-left: 1px solid var(--ovp-line); color: var(--ovp-dim); font-variant-numeric: tabular-nums; }
+.ovp-like[aria-pressed="true"] { border-color: color-mix(in srgb, var(--ovp-pink) 60%, transparent); background: color-mix(in srgb, var(--ovp-pink) 14%, transparent); }
+.ovp-like[aria-pressed="true"] .ovp-glyph { color: var(--ovp-pink); }
+.ovp-like[aria-pressed="true"] .ovp-glyph svg { fill: currentColor; }
+.ovp-pic-status:empty { display: none; }
+.ovp-pic-status { margin: 0 0 8px; color: var(--ovp-dim); font-size: 14px; }
+.ovp-pic-make { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 14px; }
+.ovp-pic-make .ovk-btn, .ovp-comments-head .ovk-btn { gap: 8px; }
+.ovp-comments { padding: 14px 16px; border: 1px solid var(--ovp-line); border-radius: 16px; background: var(--ovp-card); }
+.ovp-comments-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
+.ovp-comments-head h2 { margin: 0; }
+.ovp-comments-list:empty { display: none; }
+.ovp-comments-list { margin-top: 12px; display: grid; gap: 8px; }
+.ovp-comments-list p { margin: 0; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); overflow-wrap: anywhere; }
+@media (max-width: 560px) {
+ .ovp-pic-acts { margin-left: 0; width: 100%; }
+ .ovp-pic-acts > * { flex: 1; justify-content: center; }
+ .ovp-pic-make .ovk-btn { flex: 1 1 auto; justify-content: center; }
+}
@@ -48,7 +48,9 @@
@@ -48,7 +48,9 @@
if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to like this picture.' : 'Could not update your like. Try again.');
if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to like this picture.' : 'Could not update your like. Try again.');
var data = await response.json();
var data = await response.json();
document.querySelector('[data-like-count]').textContent = data.likes;
document.querySelector('[data-like-count]').textContent = data.likes;
- like.setAttribute('aria-pressed', String(!removing)); like.textContent = removing ? 'Like' : 'Liked'; status.textContent = '';
+ like.setAttribute('aria-pressed', String(!removing));
+ var word = like.querySelector('[data-like-word]'); if (word) word.textContent = removing ? 'Like' : 'Liked'; else like.textContent = removing ? 'Like' : 'Liked';
+ status.textContent = '';
} catch (error) { status.textContent = error.message; }
} catch (error) { status.textContent = error.message; }
finally { like.disabled = false; }
finally { like.disabled = false; }
});
});
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
*/
*/
const { html, raw } = require('../render/html');
const { html, raw } = require('../render/html');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
+const kit = require('openvibe-shared/kit');
const labelsUi = require('../render/labels-ui');
const labelsUi = require('../render/labels-ui');
const PUBLIC = "i.listed = true AND i.state = 'active' AND i.audience = 'public' AND EXISTS (SELECT 1 FROM shares sh WHERE sh.service = 'mediahub' AND sh.kind = 'picture' AND sh.resource_id = i.id AND sh.audience = 'public')";
const PUBLIC = "i.listed = true AND i.state = 'active' AND i.audience = 'public' AND EXISTS (SELECT 1 FROM shares sh WHERE sh.service = 'mediahub' AND sh.kind = 'picture' AND sh.resource_id = i.id AND sh.audience = 'public')";
const isPublic = (row) => Boolean(row.listed && row.state === 'active' && row.audience === 'public');
const isPublic = (row) => Boolean(row.listed && row.state === 'active' && row.audience === 'public');
@@ -76,7 +77,9 @@ function render(pics, result) {
@@ -76,7 +77,9 @@ function render(pics, result) {
<nav class="ovp-gallery-tabs" aria-label="Gallery order">${['popular', 'new', 'rising'].map((sort) => html`<a href="${href(sort)}" ${sort === result.sort ? raw('aria-current="page"') : ''}>${sort[0].toUpperCase() + sort.slice(1)}</a>`)}</nav></div>
<nav class="ovp-gallery-tabs" aria-label="Gallery order">${['popular', 'new', 'rising'].map((sort) => html`<a href="${href(sort)}" ${sort === result.sort ? raw('aria-current="page"') : ''}>${sort[0].toUpperCase() + sort.slice(1)}</a>`)}</nav></div>
${result.tag ? html`<p class="ovp-gallery-tagged">Tagged <b>#${result.tag}</b> <a href="${href(result.sort).replace(/&?tag=[^&]*/, '')}">Clear</a></p>` : ''}
${result.tag ? html`<p class="ovp-gallery-tagged">Tagged <b>#${result.tag}</b> <a href="${href(result.sort).replace(/&?tag=[^&]*/, '')}">Clear</a></p>` : ''}
<div class="ovp-gallery" data-gallery>${cards(pics, result.rows)}</div>
<div class="ovp-gallery" data-gallery>${cards(pics, result.rows)}</div>
-${!result.rows.length ? html`<p class="ovp-gallery-empty">${result.q || result.tag ? 'No public pictures match yet. Try another search.' : 'The gallery is ready for its first public picture. Upload one, then choose Publish to gallery on its page.'}</p>` : ''}
+${!result.rows.length ? raw(`<div class="ovp-gallery-empty">${result.q || result.tag
+ ? kit.empty({ icon: 'search', title: 'No public pictures match yet', text: 'Try another word or tag, or see everything published so far.', action: { href: '/', label: 'Clear the search', icon: 'image' } })
+ : kit.empty({ icon: 'image', title: 'The gallery is waiting for its first picture', text: 'Upload a picture, then choose Publish to gallery on its page. Until you do, it stays unlisted.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } })}</div>`) : ''}
${result.next ? html`<a class="ovp-btn" data-gallery-next href="${href(result.sort, result.next)}">More pictures</a>` : ''}<p role="status" data-gallery-status></p></section>`;
${result.next ? html`<a class="ovp-btn" data-gallery-next href="${href(result.sort, result.next)}">More pictures</a>` : ''}<p role="status" data-gallery-status></p></section>`;
}
}
module.exports = { PUBLIC, isPublic, tags, parseTags, options, browse, counts, publish, wire, cards, render };
module.exports = { PUBLIC, isPublic, tags, parseTags, options, browse, counts, publish, wire, cards, render };
@@ -41,6 +41,9 @@ const { createPicsApi } = require('./api');
@@ -41,6 +41,9 @@ const { createPicsApi } = require('./api');
const { createPicVariants } = require('./variants');
const { createPicVariants } = require('./variants');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const gallery = require('./gallery');
const gallery = require('./gallery');
+const glyphs = require('openvibe-shared/glyphs');
+/** A line glyph from Shared's set, decorative (its button says what it does). */
+const glyph = (name, size = 16) => raw(`<span class="ovp-glyph" aria-hidden="true">${glyphs.svg(name, { size })}</span>`);
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
@@ -402,13 +405,20 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
@@ -402,13 +405,20 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
${labelsUi.chips(labelsUi.inForce(row))}
${labelsUi.chips(labelsUi.inForce(row))}
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
-${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }))}
-${gallery.isPublic(row) ? html`<p>${row.author_name || 'OpenVibe member'} · ${(await gallery.counts(s, row.id)).views} views · <span data-like-count>${(await gallery.counts(s, row.id)).likes}</span> likes</p><button class="ovp-btn" data-like="${row.slug}">Like</button><p role="status" data-like-status></p>` : ''}
-${!hidden ? html`<p class="ovp-gallery-tabs"><a class="ovp-btn" href="https://openvibe.studio/image-tools?picture=${row.slug}">Edit with AI</a><a class="ovp-btn" href="https://openvibe.meme/make?picture=${row.slug}">Make a meme</a><a class="ovp-btn" href="https://openvibe.cam/">Take a photo</a></p>` : ''}
+${await (async () => {
+ const pub = gallery.isPublic(row);
+ const n = pub ? await gallery.counts(s, row.id) : null;
+ const who = row.author_name || 'OpenVibe member';
+ const share = raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }));
+ return html`<div class="ovp-pic-bar">${pub ? html`<div class="ovp-pic-who"><span class="ovp-pic-av" aria-hidden="true">${who.trim().charAt(0).toUpperCase() || 'O'}</span><span class="ovp-pic-who-txt"><b>${who}</b><small>${n.views} ${n.views === 1 ? 'view' : 'views'}</small></span></div>` : ''}
+<div class="ovp-pic-acts">${pub ? html`<button type="button" class="ovk-btn ovk-btn--quiet ovp-like" data-like="${row.slug}" aria-pressed="false">${glyph('heart', 18)}<span data-like-word>Like</span><span class="ovp-like-n" data-like-count>${n.likes}</span></button>` : ''}${share}</div></div>
+${pub ? html`<p class="ovp-pic-status" role="status" data-like-status></p>` : ''}
+${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this picture"><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.studio/image-tools?picture=${row.slug}">${glyph('spark', 17)}<span>Edit with AI</span></a><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.meme/make?picture=${row.slug}">${glyph('laugh', 17)}<span>Make a meme</span></a><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.cam/">${glyph('camera', 17)}<span>Take a photo</span></a></nav>` : ''}`;
+})()}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
</article>
-${gallery.isPublic(row) ? html`<section class="ovp-section"><h2>Comments</h2><button class="ovp-btn" data-comments="${row.id}">Show Community discussion</button><div data-comments-list aria-live="polite"></div></section>` : ''}
+${gallery.isPublic(row) ? html`<section class="ovp-section ovp-comments" aria-labelledby="h-comments"><div class="ovp-comments-head"><h2 id="h-comments">Comments</h2><button type="button" class="ovk-btn ovk-btn--quiet" data-comments="${row.id}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p>
<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p>
<div class="ovp-link-grid">
<div class="ovp-link-grid">