From 0bf215555a454ecba119abaa1d501c705d9aac25 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Fri, 24 Jul 2026 15:37:22 -0300 Subject: [PATCH] feat: add shareable meme permalinks --- README.md | 1 + public/assets/app.js | 140 +++++++++++++++++++++++++++++++++++++-- public/assets/styles.css | 84 +++++++++++++++++++++-- public/index.html | 25 ++++++- server.js | 15 +++++ src/seo.js | 58 ++++++++++------ tests/seo.test.js | 44 ++++++++++++ 7 files changed, 334 insertions(+), 33 deletions(-) create mode 100644 tests/seo.test.js diff --git a/README.md b/README.md index f141da6..ae14f68 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public - `GET /api/memes?page=1&pageSize=12`: lists approved memes only. `pageSize` is capped at `48`. - `GET /api/memes/`: returns public metadata for one approved meme. +- `GET /meme/`: opens an approved meme in the viewer and provides its shareable permalink. - `GET /media/`: returns the normalized WebP image for one approved meme. - `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`. diff --git a/public/assets/app.js b/public/assets/app.js index 9ba2122..58a6e34 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -19,15 +19,23 @@ const submitUpload = document.querySelector('#submit-upload'); const lightbox = document.querySelector('#lightbox'); const lightboxImage = document.querySelector('#lightbox-image'); const lightboxDownload = document.querySelector('#lightbox-download'); +const lightboxShare = document.querySelector('#lightbox-share'); const lightboxId = document.querySelector('#lightbox-id'); +const shareModal = document.querySelector('#share-modal'); +const shareLink = document.querySelector('#share-link'); +const shareStatus = document.querySelector('#share-status'); +const copyShareLinkButton = document.querySelector('#copy-share-link'); const scrollIndicator = document.querySelector('#scroll-indicator'); let currentPage = 0; let totalPages = 1; let isLoading = false; let latestValidationRun = 0; +let activeShareTrigger = null; +let shareStatusTimer = null; const viewedThisSession = new Set(); const pendingViewTimers = new Map(); +const copiedFeedbackTimers = new WeakMap(); document.querySelector('#open-upload').addEventListener('click', () => { formStatus.textContent = ''; @@ -36,6 +44,11 @@ document.querySelector('#open-upload').addEventListener('click', () => { document.querySelector('#close-upload').addEventListener('click', closeUpload); document.querySelector('#cancel-upload').addEventListener('click', closeUpload); document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close()); +document.querySelector('#close-share').addEventListener('click', () => shareModal.close()); +copyShareLinkButton.addEventListener('click', copyActiveShareLink); +lightboxShare.addEventListener('click', async () => { + await openShareDialog(lightboxShare.dataset.shareId, lightboxShare); +}); fileInput.addEventListener('change', async () => { const validationRun = ++latestValidationRun; @@ -82,15 +95,21 @@ uploadForm.addEventListener('submit', async (event) => { }); grid.addEventListener('click', async (event) => { + const shareButton = event.target.closest('[data-share-id]'); + if (shareButton) { + await openShareDialog(shareButton.dataset.shareId, shareButton); + return; + } + const viewButton = event.target.closest('[data-view-id]'); if (!viewButton) return; const card = viewButton.closest('.meme-card'); - lightboxImage.src = viewButton.dataset.viewUrl; - lightboxImage.alt = card.querySelector('img').alt; - lightboxDownload.href = viewButton.dataset.downloadUrl; - lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`; - lightbox.showModal(); - await recordViewOnce(viewButton.dataset.viewId); + await openLightbox({ + id: viewButton.dataset.viewId, + url: viewButton.dataset.viewUrl, + downloadUrl: viewButton.dataset.downloadUrl, + alt: card.querySelector('img').alt + }); }); const observer = new IntersectionObserver((entries) => { @@ -109,6 +128,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true }); window.addEventListener('resize', updateScrollIndicator); refreshStatus(); window.setInterval(refreshStatus, 5000); +await openSharedMemeFromUrl(); await loadMemes(1, { reset: true }); observer.observe(feedLoader); @@ -170,7 +190,8 @@ function renderMemes(memes, options = {}) {
- + +
`; @@ -187,6 +208,111 @@ function renderMemes(memes, options = {}) { observeViewedImages(cards); } +async function openSharedMemeFromUrl() { + const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/); + if (!match) return; + + try { + const response = await fetch(`/api/memes/${match[1]}`); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || 'Meme not found.'); + await openLightbox({ + ...payload.meme, + alt: `Shared meme ${shortId(payload.meme.id)}` + }); + } catch { + window.location.replace('/'); + } +} + +async function openLightbox({ id, url, downloadUrl, alt }) { + lightboxImage.src = url; + lightboxImage.alt = alt; + lightboxDownload.href = downloadUrl; + lightboxShare.dataset.shareId = id; + lightboxId.textContent = `ID: ${shortId(id)}`; + if (!lightbox.open) lightbox.showModal(); + await recordViewOnce(id); +} + +async function openShareDialog(id, triggerButton = null) { + if (!isMemeId(id)) return; + activeShareTrigger = triggerButton; + shareLink.value = new URL(`/meme/${id}`, window.location.origin).href; + window.clearTimeout(shareStatusTimer); + shareStatusTimer = null; + shareStatus.textContent = ''; + resetCopiedFeedback(copyShareLinkButton); + if (!shareModal.open) shareModal.showModal(); +} + +async function copyActiveShareLink() { + if (!shareLink.value) return; + copyShareLinkButton.disabled = true; + try { + await copyText(shareLink.value); + shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD'; + showCopiedFeedback(copyShareLinkButton); + if (activeShareTrigger) showCopiedFeedback(activeShareTrigger); + window.clearTimeout(shareStatusTimer); + shareStatusTimer = window.setTimeout(() => { + shareStatus.textContent = ''; + shareStatusTimer = null; + }, 2600); + } catch { + shareLink.focus(); + shareLink.select(); + shareStatus.textContent = 'COPY_UNAVAILABLE — SELECT_AND_COPY_THE_LINK'; + } finally { + copyShareLinkButton.disabled = false; + } +} + +function showCopiedFeedback(button) { + const icon = button.querySelector('.material-symbols-outlined'); + const label = button.querySelector('[data-copy-label]'); + if (!icon) return; + + button.dataset.defaultIcon ||= icon.textContent.trim(); + if (label) button.dataset.defaultLabel ||= label.textContent.trim(); + button.dataset.defaultAriaLabel ||= button.getAttribute('aria-label') || ''; + button.dataset.defaultTitle ||= button.getAttribute('title') || ''; + + icon.textContent = 'check'; + if (label) label.textContent = 'COPIED'; + if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied'); + if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied'); + button.classList.add('is-copied'); + + window.clearTimeout(copiedFeedbackTimers.get(button)); + copiedFeedbackTimers.set(button, window.setTimeout(() => { + resetCopiedFeedback(button); + }, 2600)); +} + +function resetCopiedFeedback(button) { + const icon = button.querySelector('.material-symbols-outlined'); + const label = button.querySelector('[data-copy-label]'); + window.clearTimeout(copiedFeedbackTimers.get(button)); + copiedFeedbackTimers.delete(button); + if (icon && button.dataset.defaultIcon) icon.textContent = button.dataset.defaultIcon; + if (label && button.dataset.defaultLabel) label.textContent = button.dataset.defaultLabel; + if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', button.dataset.defaultAriaLabel); + if (button.dataset.defaultTitle) button.setAttribute('title', button.dataset.defaultTitle); + button.classList.remove('is-copied'); +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + shareLink.focus(); + shareLink.select(); + if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.'); +} + async function validateClientFile(file) { if (!file) return 'SELECT A FILE.'; if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.'; diff --git a/public/assets/styles.css b/public/assets/styles.css index f879602..8096daa 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -339,12 +339,14 @@ a { } .text-action { + appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 10px; border: 1px solid #1a1a1a; + border-radius: 0; background: transparent; color: var(--muted); font-size: 10px; @@ -371,6 +373,12 @@ a { background: var(--surface-high); } +.text-action.is-copied { + border-color: var(--primary); + background: rgb(0 255 65 / 12%); + color: var(--primary); +} + .feed-loader { display: flex; flex-direction: column; @@ -523,6 +531,10 @@ dialog::backdrop { box-shadow: 0 0 18px rgb(0 255 65 / 12%); } +.share-panel { + width: min(640px, calc(100vw - 32px)); +} + .modal-header, .lightbox-toolbar { display: flex; @@ -576,6 +588,60 @@ dialog::backdrop { color: var(--error); } +.share-body { + padding: 18px 16px 16px; +} + +.share-body label, +.share-status { + color: var(--outline); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.share-link-row { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.share-link-row input { + min-width: 0; + flex: 1 1 auto; + height: 40px; + padding: 0 12px; + border: 1px solid var(--outline-variant); + border-radius: 0; + outline: 0; + background: var(--surface-lowest); + color: var(--text); + font-size: 12px; +} + +.share-link-row input:focus { + border-color: var(--primary-dim); +} + +.share-copy { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; +} + +.share-copy:disabled { + cursor: wait; + opacity: 0.65; +} + +.share-status { + min-height: 16px; + margin: 10px 0 0; + color: var(--primary-dim); +} + .modal-actions { display: flex; justify-content: flex-end; @@ -641,12 +707,11 @@ dialog::backdrop { background: #000; } -.lightbox-download { +.lightbox-actions { display: flex; - width: max-content; - margin: 12px 12px 12px auto; - align-items: center; - gap: 6px; + justify-content: flex-end; + gap: 8px; + margin: 12px; } .empty-state { @@ -752,6 +817,15 @@ dialog::backdrop { padding-inline: 14px; } + .share-link-row { + align-items: stretch; + flex-direction: column; + } + + .share-copy { + justify-content: center; + } + .shell { padding-top: 84px; } diff --git a/public/index.html b/public/index.html index a60b5c3..17421b6 100644 --- a/public/index.html +++ b/public/index.html @@ -108,7 +108,30 @@ - DOWNLOAD + + + + + diff --git a/server.js b/server.js index b830650..fd1527b 100644 --- a/server.js +++ b/server.js @@ -75,6 +75,21 @@ const server = http.createServer(async (req, res) => { })); } + const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/); + if (req.method === 'GET' && memePageMatch) { + const meme = store.get(memePageMatch[1]); + if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found'); + const nonce = crypto.randomBytes(16).toString('base64'); + withSecurityHeaders(res, { scriptNonce: nonce }); + return sendHtml(res, 200, renderIndex({ + template: indexTemplate, + baseUrl, + nonce, + approvedCount: store.count('approved'), + meme: publicMeme(meme) + })); + } + if (req.method === 'GET' && url.pathname === '/openapi.json') { res.setHeader('Cache-Control', 'public, max-age=300'); return sendJson(res, 200, openApiSpecFor(baseUrl)); diff --git a/src/seo.js b/src/seo.js index f50e065..a32d869 100644 --- a/src/seo.js +++ b/src/seo.js @@ -13,36 +13,54 @@ export function publicBaseUrl(req) { return cleanBase(`${proto}://${host || 'localhost:8080'}`); } -export function renderIndex({ template, baseUrl, nonce, approvedCount }) { - const canonical = `${baseUrl}/`; - const image = `${baseUrl}/assets/yoonect-logo.png`; - const jsonLd = { - '@context': 'https://schema.org', - '@graph': [ - { - '@type': 'WebSite', - '@id': `${canonical}#website`, - name: SITE_NAME, +export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null }) { + const home = `${baseUrl}/`; + const canonical = meme ? `${baseUrl}/meme/${meme.id}` : home; + const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}/assets/yoonect-logo.png`; + const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : SITE_NAME; + const description = meme + ? `View and share meme ${shortId(meme.id)} on ${SITE_NAME}.` + : SITE_DESCRIPTION; + const pageMetadata = meme + ? { + '@type': 'ImageObject', + '@id': `${canonical}#meme`, + name: `Meme ${shortId(meme.id)}`, + contentUrl: image, url: canonical, - description: SITE_DESCRIPTION, - inLanguage: 'en' - }, - { + uploadDate: meme.createdAt, + width: meme.width, + height: meme.height, + isPartOf: { '@id': `${home}#website` } + } + : { '@type': 'CollectionPage', '@id': `${canonical}#collection`, name: SITE_NAME, url: canonical, description: SITE_DESCRIPTION, - isPartOf: { '@id': `${canonical}#website` }, + isPartOf: { '@id': `${home}#website` }, about: ['memes', 'internet culture', 'image gallery', 'community moderation'], numberOfItems: approvedCount + }; + const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'WebSite', + '@id': `${home}#website`, + name: SITE_NAME, + url: home, + description: SITE_DESCRIPTION, + inLanguage: 'en' }, + pageMetadata, { '@type': 'SoftwareApplication', name: SITE_NAME, applicationCategory: 'MultimediaApplication', operatingSystem: 'Web', - url: canonical, + url: home, codeRepository: REPO_URL, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } } @@ -50,8 +68,8 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount }) { }; return template - .replaceAll('__SEO_TITLE__', escapeHtml(SITE_NAME)) - .replaceAll('__SEO_DESCRIPTION__', escapeHtml(SITE_DESCRIPTION)) + .replaceAll('__SEO_TITLE__', escapeHtml(title)) + .replaceAll('__SEO_DESCRIPTION__', escapeHtml(description)) .replaceAll('__SEO_CANONICAL__', canonical) .replaceAll('__SEO_IMAGE__', image) .replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd))) @@ -125,7 +143,7 @@ export function feedJson(baseUrl, memes) { description: SITE_DESCRIPTION, items: memes.slice(0, 50).map((meme) => ({ id: meme.id, - url: `${baseUrl}/media/${meme.id}`, + url: `${baseUrl}/meme/${meme.id}`, image: `${baseUrl}/media/${meme.id}`, title: `Meme ${shortId(meme.id)}`, content_text: `Approved meme with MEME_CONSENSUS_SCORE ${meme.moderationScore}/100.`, @@ -145,7 +163,7 @@ export function sitemapXml(baseUrl, memes) { ].join('\n'), ...memes.slice(0, 1000).map((meme) => [ ' ', - ` ${xmlEscape(`${baseUrl}/media/${meme.id}`)}`, + ` ${xmlEscape(`${baseUrl}/meme/${meme.id}`)}`, ` ${xmlEscape(meme.createdAt)}`, ' ' ].join('\n')) diff --git a/tests/seo.test.js b/tests/seo.test.js new file mode 100644 index 0000000..61d5206 --- /dev/null +++ b/tests/seo.test.js @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { feedJson, renderIndex, sitemapXml } from '../src/seo.js'; + +const id = 'a'.repeat(64); +const meme = { + id, + createdAt: '2026-07-24T12:00:00.000Z', + width: 1200, + height: 900, + moderationScore: 92 +}; +const template = [ + '__SEO_TITLE__', + '', + '', + '', + '' +].join(''); + +test('renders a meme permalink with meme-specific share metadata', () => { + const html = renderIndex({ + template, + baseUrl: 'https://memes.example', + nonce: 'test-nonce', + approvedCount: 1, + meme + }); + + assert.match(html, /Meme 0xAAAA\.\.\.AAAA \| The Meme Protocol<\/title>/); + assert.match(html, new RegExp(`canonical" href="https://memes\\.example/meme/${id}"`)); + assert.match(html, new RegExp(`og:image" content="https://memes\\.example/media/${id}"`)); + assert.match(html, /"@type":"ImageObject"/); + assert.match(html, /nonce="test-nonce"/); +}); + +test('discovery feeds point readers to viewer permalinks', () => { + const feed = feedJson('https://memes.example', [meme]); + const sitemap = sitemapXml('https://memes.example', [meme]); + + assert.equal(feed.items[0].url, `https://memes.example/meme/${id}`); + assert.equal(feed.items[0].image, `https://memes.example/media/${id}`); + assert.match(sitemap, new RegExp(`https://memes\\.example/meme/${id}`)); +});