feat: release meme protocol v1.1.0

Add one-click meme copying, complete viewer actions, fixed-footer pagination, protocol lore, and the Homo Reseticus download chooser.

Install dedicated social and favicon assets, expand local demo data, and synchronize the changelog, documentation, package, and OpenAPI release metadata.
This commit is contained in:
2026-08-14 18:54:37 -03:00
parent 0bf215555a
commit 48f05bcf8a
24 changed files with 702 additions and 81 deletions
+24
View File
@@ -0,0 +1,24 @@
# Changelog
All notable changes to The Meme Protocol are documented here. Versions follow Semantic Versioning.
## [1.1.0] - 2026-08-14
### Added
- One-click image copying from feed cards and the full-image viewer.
- Copy, download, share, view-count, and download-count controls in the full-image viewer.
- `DOWNLOAD PET` and `READ LORE` protocol menu actions.
- Shareable `/lore` viewer backed by a downloadable plain-text lore file.
- Site-styled pet download chooser offering the original and V2 Homo Reseticus ZIP packages, with a Petdex availability link.
- Eighteen generated demo memes, including automatic top-up of older demo-only stores, so local scrolling exercises pagination.
- A 300ms minimum spinner display for subsequent pages, preventing imperceptible flashes on fast local connections.
- Dedicated `1200x630` social preview metadata for homepage and lore shares, replacing the source repository logo.
- A complete, cache-versioned favicon suite for browser tabs, bookmarks, Apple Touch icons, and installed web apps.
### Changed
- Fixed the source footer to the viewport bottom and reserved enough scroll space to keep the final images and feed loader unobscured.
- Expanded meme IDs in the full-image viewer while retaining compact IDs on feed cards.
- Renamed the user-facing `SUBMIT` / `SUBMIT_MEME` language to `ADD MEME` / `ADD_MEME`.
- Updated package and OpenAPI versions from `1.0.0` to `1.1.0` for this backwards-compatible feature release.
+10 -2
View File
@@ -2,6 +2,14 @@
Small self-hosted meme gallery matching the `stitch_the_meme_protocol` desktop mockup direction. Small self-hosted meme gallery matching the `stitch_the_meme_protocol` desktop mockup direction.
Current release: `1.1.0`.
The browser feed supports one-click image copy, downloads, shareable meme links, full-image viewing with live view/download counts, a fixed source footer, a two-build Homo Reseticus pet download chooser, and shareable lore at `/lore`.
Homepage and lore social shares use the versioned `1200x630` asset at `/assets/social-preview-v1.png`. Individual meme permalinks continue to use their own meme image.
Browser tabs, bookmarks, Apple home-screen links, and installed web apps use the supplied favicon suite under `/assets/favicon/`; the legacy `/favicon.ico` URL remains available for automatic browser requests.
## Run locally ## Run locally
```sh ```sh
@@ -16,7 +24,7 @@ The server listens on `http://localhost:8080` by default.
- `HOST`: bind address, default `0.0.0.0` - `HOST`: bind address, default `0.0.0.0`
- `DATA_DIR`: disk storage root, default `./data` - `DATA_DIR`: disk storage root, default `./data`
- `SITE_URL`: public canonical site URL used for SEO metadata, sitemaps, feeds, `llms.txt`, OpenAPI servers, and API skill instructions. Production should use `https://bitsforfree.com`. - `SITE_URL`: public canonical site URL used for SEO metadata, sitemaps, feeds, `llms.txt`, OpenAPI servers, and API skill instructions. Production should use `https://bitsforfree.com`.
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot - `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes. When enabled, empty and legacy demo-only stores are filled to 18 samples so local pagination can be tested.
- `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs. - `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs.
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review. - `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
- `OPENAI_MODERATION_MODEL`: moderation vision model, default `gpt-4o-mini` - `OPENAI_MODERATION_MODEL`: moderation vision model, default `gpt-4o-mini`
@@ -56,7 +64,7 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public
- `GET /api/memes/<sha256>`: returns public metadata for one approved meme. - `GET /api/memes/<sha256>`: returns public metadata for one approved meme.
- `GET /meme/<sha256>`: opens an approved meme in the viewer and provides its shareable permalink. - `GET /meme/<sha256>`: opens an approved meme in the viewer and provides its shareable permalink.
- `GET /media/<sha256>`: returns the normalized WebP image for one approved meme. - `GET /media/<sha256>`: 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`. - `POST /api/memes`: adds one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`.
API uploads use the exact same path as the browser form: persistent IP upload quotas, file size checks, image dimension and pixel checks, metadata-stripping WebP normalization, AI moderation, and admin-review queueing. A published upload returns `201`; a queued upload returns `202` with `moderationReason`; moderation rejection returns `422` with `moderationReason`. API uploads use the exact same path as the browser form: persistent IP upload quotas, file size checks, image dimension and pixel checks, metadata-stripping WebP normalization, AI moderation, and admin-review queueing. A published upload returns `201`; a queued upload returns `202` with `moderationReason`; moderation rejection returns `422` with `moderationReason`.
+4 -4
View File
@@ -1,6 +1,6 @@
# The Meme Protocol API Skill # The Meme Protocol API Skill
Use this skill when an agent needs to read approved memes from The Meme Protocol or submit a meme image to the public upload API. Use this skill when an agent needs to read approved memes from The Meme Protocol or add a meme image through the public upload API.
## Discovery ## Discovery
@@ -9,7 +9,7 @@ Use this skill when an agent needs to read approved memes from The Meme Protocol
- Public meme list: `GET __API_BASE_URL__/api/memes` - Public meme list: `GET __API_BASE_URL__/api/memes`
- Public meme metadata: `GET __API_BASE_URL__/api/memes/{id}` - Public meme metadata: `GET __API_BASE_URL__/api/memes/{id}`
- Public meme image: `GET __API_BASE_URL__/media/{id}` - Public meme image: `GET __API_BASE_URL__/media/{id}`
- Submit meme: `POST __API_BASE_URL__/api/memes` - Add meme: `POST __API_BASE_URL__/api/memes`
## Read Memes ## Read Memes
@@ -21,9 +21,9 @@ Use each meme's `url` field to fetch the normalized WebP image. Public image URL
Read endpoints are rate-limited per IP. Respect `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` headers. Prefer small page sizes and back off on `429`. Read endpoints are rate-limited per IP. Respect `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` headers. Prefer small page sizes and back off on `429`.
## Submit Memes ## Add Memes
Submit with `POST __API_BASE_URL__/api/memes` as `multipart/form-data`. The image file field must be named `meme`. Add a meme with `POST __API_BASE_URL__/api/memes` as `multipart/form-data`. The image file field must be named `meme`.
Example: Example:
+8 -8
View File
@@ -2,8 +2,8 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "The Meme Protocol API", "title": "The Meme Protocol API",
"version": "1.0.0", "version": "1.1.0",
"description": "Public API for listing approved memes and submitting PNG/JPEG meme uploads. Uploads follow the same moderation, image validation, normalization, and IP quota rules as the browser form." "description": "Public API for listing approved memes and adding PNG/JPEG meme uploads. Uploads follow the same moderation, image validation, normalization, and IP quota rules as the browser form."
}, },
"servers": [ "servers": [
{ {
@@ -13,7 +13,7 @@
"tags": [ "tags": [
{ {
"name": "memes", "name": "memes",
"description": "Approved meme discovery and submission" "description": "Approved meme discovery and addition"
}, },
{ {
"name": "discovery", "name": "discovery",
@@ -87,8 +87,8 @@
"tags": [ "tags": [
"memes" "memes"
], ],
"summary": "Submit a meme", "summary": "Add a meme",
"description": "Submits one PNG or JPEG image using the same path as the browser upload form. The request must be multipart/form-data with a file field named meme. The server enforces the same size, dimension, pixel, moderation, normalization, and per-IP upload limits as human submissions.", "description": "Adds one PNG or JPEG image using the same path as the browser upload form. The request must be multipart/form-data with a file field named meme. The server enforces the same size, dimension, pixel, moderation, normalization, and per-IP upload limits as browser additions.",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -120,7 +120,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/MemeSubmitResponse" "$ref": "#/components/schemas/MemeAddResponse"
} }
} }
} }
@@ -130,7 +130,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/MemeSubmitResponse" "$ref": "#/components/schemas/MemeAddResponse"
} }
} }
} }
@@ -438,7 +438,7 @@
} }
} }
}, },
"MemeSubmitResponse": { "MemeAddResponse": {
"type": "object", "type": "object",
"required": [ "required": [
"meme", "meme",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meme-protocol", "name": "meme-protocol",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meme-protocol", "name": "meme-protocol",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "meme-protocol", "name": "meme-protocol",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Self-hosted meme gallery for The Meme Protocol.", "description": "Self-hosted meme gallery for The Meme Protocol.",
"type": "module", "type": "module",
+216 -25
View File
@@ -5,6 +5,7 @@ const VIEW_DWELL_MS = 1000;
const VIEW_THRESHOLD = 0.9; const VIEW_THRESHOLD = 0.9;
const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000; const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000;
const VIEW_DEDUPE_PREFIX = 'meme-viewed:'; const VIEW_DEDUPE_PREFIX = 'meme-viewed:';
const NEXT_PAGE_LOADER_MIN_MS = 300;
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']); const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
const grid = document.querySelector('#meme-grid'); const grid = document.querySelector('#meme-grid');
@@ -15,17 +16,28 @@ const uploadForm = document.querySelector('#upload-form');
const fileInput = document.querySelector('#meme-input'); const fileInput = document.querySelector('#meme-input');
const fileLabel = document.querySelector('#file-label'); const fileLabel = document.querySelector('#file-label');
const formStatus = document.querySelector('#form-status'); const formStatus = document.querySelector('#form-status');
const submitUpload = document.querySelector('#submit-upload'); const addMemeButton = document.querySelector('#add-meme-button');
const lightbox = document.querySelector('#lightbox'); const lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image'); const lightboxImage = document.querySelector('#lightbox-image');
const lightboxCopy = document.querySelector('#lightbox-copy');
const lightboxDownload = document.querySelector('#lightbox-download'); const lightboxDownload = document.querySelector('#lightbox-download');
const lightboxShare = document.querySelector('#lightbox-share'); const lightboxShare = document.querySelector('#lightbox-share');
const lightboxId = document.querySelector('#lightbox-id'); const lightboxId = document.querySelector('#lightbox-id');
const lightboxViewCount = document.querySelector('#lightbox-view-count');
const lightboxDownloadCount = document.querySelector('#lightbox-download-count');
const petModal = document.querySelector('#pet-modal');
const loreModal = document.querySelector('#lore-modal');
const loreText = document.querySelector('#lore-text');
const loreCopy = document.querySelector('#lore-copy');
const loreShare = document.querySelector('#lore-share');
const shareModal = document.querySelector('#share-modal'); const shareModal = document.querySelector('#share-modal');
const shareTitle = document.querySelector('#share-title');
const shareLinkLabel = document.querySelector('#share-link-label');
const shareLink = document.querySelector('#share-link'); const shareLink = document.querySelector('#share-link');
const shareStatus = document.querySelector('#share-status'); const shareStatus = document.querySelector('#share-status');
const copyShareLinkButton = document.querySelector('#copy-share-link'); const copyShareLinkButton = document.querySelector('#copy-share-link');
const scrollIndicator = document.querySelector('#scroll-indicator'); const scrollIndicator = document.querySelector('#scroll-indicator');
const actionStatus = document.querySelector('#action-status');
let currentPage = 0; let currentPage = 0;
let totalPages = 1; let totalPages = 1;
@@ -33,6 +45,8 @@ let isLoading = false;
let latestValidationRun = 0; let latestValidationRun = 0;
let activeShareTrigger = null; let activeShareTrigger = null;
let shareStatusTimer = null; let shareStatusTimer = null;
let actionStatusTimer = null;
let loreContent = '';
const viewedThisSession = new Set(); const viewedThisSession = new Set();
const pendingViewTimers = new Map(); const pendingViewTimers = new Map();
const copiedFeedbackTimers = new WeakMap(); const copiedFeedbackTimers = new WeakMap();
@@ -45,10 +59,22 @@ document.querySelector('#close-upload').addEventListener('click', closeUpload);
document.querySelector('#cancel-upload').addEventListener('click', closeUpload); document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close()); document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
document.querySelector('#close-share').addEventListener('click', () => shareModal.close()); document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
document.querySelector('#open-pet').addEventListener('click', () => petModal.showModal());
document.querySelector('#close-pet').addEventListener('click', () => petModal.close());
document.querySelector('#open-lore').addEventListener('click', openLoreDialog);
document.querySelector('#close-lore').addEventListener('click', () => loreModal.close());
copyShareLinkButton.addEventListener('click', copyActiveShareLink); copyShareLinkButton.addEventListener('click', copyActiveShareLink);
lightboxShare.addEventListener('click', async () => { lightboxShare.addEventListener('click', async () => {
await openShareDialog(lightboxShare.dataset.shareId, lightboxShare); await openMemeShareDialog(lightboxShare.dataset.shareId, lightboxShare);
}); });
lightboxCopy.addEventListener('click', () => copyImage(lightboxCopy.dataset.imageUrl, lightboxCopy));
loreCopy.addEventListener('click', copyLore);
loreShare.addEventListener('click', () => openShareDialog({
path: '/lore',
title: 'SHARE_LORE',
label: 'LORE_LINK',
triggerButton: loreShare
}));
fileInput.addEventListener('change', async () => { fileInput.addEventListener('change', async () => {
const validationRun = ++latestValidationRun; const validationRun = ++latestValidationRun;
@@ -70,7 +96,7 @@ uploadForm.addEventListener('submit', async (event) => {
return; return;
} }
submitUpload.disabled = true; addMemeButton.disabled = true;
formStatus.textContent = 'UPLOADING...'; formStatus.textContent = 'UPLOADING...';
try { try {
@@ -90,14 +116,20 @@ uploadForm.addEventListener('submit', async (event) => {
} catch (error) { } catch (error) {
formStatus.textContent = error.message.toUpperCase(); formStatus.textContent = error.message.toUpperCase();
} finally { } finally {
submitUpload.disabled = false; addMemeButton.disabled = false;
} }
}); });
grid.addEventListener('click', async (event) => { grid.addEventListener('click', async (event) => {
const copyButton = event.target.closest('[data-copy-image-url]');
if (copyButton) {
await copyImage(copyButton.dataset.copyImageUrl, copyButton);
return;
}
const shareButton = event.target.closest('[data-share-id]'); const shareButton = event.target.closest('[data-share-id]');
if (shareButton) { if (shareButton) {
await openShareDialog(shareButton.dataset.shareId, shareButton); await openMemeShareDialog(shareButton.dataset.shareId, shareButton);
return; return;
} }
@@ -108,7 +140,9 @@ grid.addEventListener('click', async (event) => {
id: viewButton.dataset.viewId, id: viewButton.dataset.viewId,
url: viewButton.dataset.viewUrl, url: viewButton.dataset.viewUrl,
downloadUrl: viewButton.dataset.downloadUrl, downloadUrl: viewButton.dataset.downloadUrl,
alt: card.querySelector('img').alt alt: card.querySelector('img').alt,
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
}); });
}); });
@@ -128,7 +162,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true });
window.addEventListener('resize', updateScrollIndicator); window.addEventListener('resize', updateScrollIndicator);
refreshStatus(); refreshStatus();
window.setInterval(refreshStatus, 5000); window.setInterval(refreshStatus, 5000);
await openSharedMemeFromUrl(); await openSharedContentFromUrl();
await loadMemes(1, { reset: true }); await loadMemes(1, { reset: true });
observer.observe(feedLoader); observer.observe(feedLoader);
@@ -140,6 +174,7 @@ async function loadNextPage() {
async function loadMemes(page, options = {}) { async function loadMemes(page, options = {}) {
if (isLoading) return; if (isLoading) return;
isLoading = true; isLoading = true;
const loaderStartedAt = performance.now();
setLoader('FETCHING_NEXT_BLOCK...', true); setLoader('FETCHING_NEXT_BLOCK...', true);
try { try {
@@ -150,6 +185,10 @@ async function loadMemes(page, options = {}) {
currentPage = payload.page; currentPage = payload.page;
totalPages = payload.totalPages; totalPages = payload.totalPages;
renderMemes(payload.memes, { append: !options.reset }); renderMemes(payload.memes, { append: !options.reset });
if (page > 1) {
const remainingLoaderTime = NEXT_PAGE_LOADER_MIN_MS - (performance.now() - loaderStartedAt);
if (remainingLoaderTime > 0) await delay(remainingLoaderTime);
}
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false); setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
updateScrollIndicator(); updateScrollIndicator();
} catch { } catch {
@@ -161,6 +200,10 @@ async function loadMemes(page, options = {}) {
} }
} }
function delay(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function renderMemes(memes, options = {}) { function renderMemes(memes, options = {}) {
if (!options.append && memes.length === 0) { if (!options.append && memes.length === 0) {
unobserveViewedImages(); unobserveViewedImages();
@@ -185,13 +228,14 @@ function renderMemes(memes, options = {}) {
</div> </div>
<div class="card-actions"> <div class="card-actions">
<div class="stats"> <div class="stats">
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span>${formatCount(meme.viewCount)}</span> <span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>${formatCount(meme.viewCount)}</span></span>
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span>${formatCount(meme.downloadCount)}</span> <span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>${formatCount(meme.downloadCount)}</span></span>
</div> </div>
<div class="action-group"> <div class="action-group">
<button class="text-action icon-only-action" type="button" data-copy-image-url="${meme.url}" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a> <a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button> <button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button> <button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" data-view-count="${meme.viewCount}" data-download-count="${meme.downloadCount}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
</div> </div>
</div> </div>
`; `;
@@ -208,7 +252,12 @@ function renderMemes(memes, options = {}) {
observeViewedImages(cards); observeViewedImages(cards);
} }
async function openSharedMemeFromUrl() { async function openSharedContentFromUrl() {
if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') {
await openLoreDialog();
return;
}
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/); const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (!match) return; if (!match) return;
@@ -225,20 +274,39 @@ async function openSharedMemeFromUrl() {
} }
} }
async function openLightbox({ id, url, downloadUrl, alt }) { async function openLightbox({ id, url, downloadUrl, alt, viewCount = 0, downloadCount = 0 }) {
lightboxImage.src = url; lightboxImage.src = url;
lightboxImage.alt = alt; lightboxImage.alt = alt;
lightboxCopy.dataset.imageUrl = url;
lightboxDownload.href = downloadUrl; lightboxDownload.href = downloadUrl;
lightboxShare.dataset.shareId = id; lightboxShare.dataset.shareId = id;
lightboxId.textContent = `ID: ${shortId(id)}`; lightboxViewCount.dataset.counterId = id;
lightboxViewCount.dataset.counterKind = 'view';
lightboxViewCount.querySelector('[data-count]').textContent = formatCount(viewCount);
lightboxDownloadCount.dataset.counterId = id;
lightboxDownloadCount.dataset.counterKind = 'download';
lightboxDownloadCount.querySelector('[data-count]').textContent = formatCount(downloadCount);
lightboxId.textContent = `ID: 0x${id.toUpperCase()}`;
setActionStatus('', lightboxCopy);
if (!lightbox.open) lightbox.showModal(); if (!lightbox.open) lightbox.showModal();
await recordViewOnce(id); await recordViewOnce(id);
} }
async function openShareDialog(id, triggerButton = null) { async function openMemeShareDialog(id, triggerButton = null) {
if (!isMemeId(id)) return; if (!isMemeId(id)) return;
return openShareDialog({
path: `/meme/${id}`,
title: 'SHARE_MEME',
label: 'MEME_LINK',
triggerButton
});
}
async function openShareDialog({ path, title, label, triggerButton = null }) {
activeShareTrigger = triggerButton; activeShareTrigger = triggerButton;
shareLink.value = new URL(`/meme/${id}`, window.location.origin).href; shareTitle.textContent = title;
shareLinkLabel.textContent = label;
shareLink.value = new URL(path, window.location.origin).href;
window.clearTimeout(shareStatusTimer); window.clearTimeout(shareStatusTimer);
shareStatusTimer = null; shareStatusTimer = null;
shareStatus.textContent = ''; shareStatus.textContent = '';
@@ -252,8 +320,8 @@ async function copyActiveShareLink() {
try { try {
await copyText(shareLink.value); await copyText(shareLink.value);
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD'; shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
showCopiedFeedback(copyShareLinkButton); showCopiedFeedback(copyShareLinkButton, 'Share link copied');
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger); if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied');
window.clearTimeout(shareStatusTimer); window.clearTimeout(shareStatusTimer);
shareStatusTimer = window.setTimeout(() => { shareStatusTimer = window.setTimeout(() => {
shareStatus.textContent = ''; shareStatus.textContent = '';
@@ -268,7 +336,104 @@ async function copyActiveShareLink() {
} }
} }
function showCopiedFeedback(button) { async function openLoreDialog() {
setActionStatus('', loreCopy);
if (!loreModal.open) loreModal.showModal();
if (loreContent) return;
try {
const response = await fetch('/assets/meme-protocol-lore.txt');
if (!response.ok) throw new Error('Lore unavailable.');
loreContent = await response.text();
loreText.textContent = loreContent;
} catch {
loreText.textContent = 'LORE_TRANSMISSION_UNAVAILABLE';
setActionStatus('LORE_TRANSMISSION_UNAVAILABLE', loreCopy);
}
}
async function copyLore() {
if (!loreContent) await openLoreDialog();
if (!loreContent) return;
loreCopy.disabled = true;
try {
await copyText(loreContent);
showCopiedFeedback(loreCopy, 'Lore copied');
setActionStatus('LORE_COPIED_TO_CLIPBOARD', loreCopy);
} catch {
setActionStatus('LORE_COPY_UNAVAILABLE — USE_DOWNLOAD', loreCopy);
} finally {
loreCopy.disabled = false;
}
}
async function copyImage(url, button) {
if (!url || !button) return;
button.disabled = true;
setActionStatus('', button);
try {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
throw new Error('Image clipboard unavailable.');
}
const png = fetch(url)
.then((response) => {
if (!response.ok) throw new Error('Image fetch failed.');
return response.blob();
})
.then(convertImageBlobToPng);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
showCopiedFeedback(button, 'Meme image copied');
setActionStatus('MEME_IMAGE_COPIED_TO_CLIPBOARD', button);
} catch {
setActionStatus('IMAGE_COPY_UNAVAILABLE — USE_DOWNLOAD', button);
} finally {
button.disabled = false;
}
}
async function convertImageBlobToPng(blob) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas unavailable.');
let image;
let cleanup = () => {};
if ('createImageBitmap' in window) {
image = await createImageBitmap(blob);
cleanup = () => image.close();
} else {
const objectUrl = URL.createObjectURL(blob);
image = await loadImage(objectUrl);
cleanup = () => URL.revokeObjectURL(objectUrl);
}
try {
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
return await new Promise((resolve, reject) => {
canvas.toBlob((png) => {
if (png) resolve(png);
else reject(new Error('PNG conversion failed.'));
}, 'image/png');
});
} finally {
cleanup();
}
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Image decode failed.'));
image.src = url;
});
}
function showCopiedFeedback(button, copiedLabel = 'Copied') {
const icon = button.querySelector('.material-symbols-outlined'); const icon = button.querySelector('.material-symbols-outlined');
const label = button.querySelector('[data-copy-label]'); const label = button.querySelector('[data-copy-label]');
if (!icon) return; if (!icon) return;
@@ -280,8 +445,8 @@ function showCopiedFeedback(button) {
icon.textContent = 'check'; icon.textContent = 'check';
if (label) label.textContent = 'COPIED'; if (label) label.textContent = 'COPIED';
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied'); if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', copiedLabel);
if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied'); if (button.dataset.defaultTitle) button.setAttribute('title', copiedLabel);
button.classList.add('is-copied'); button.classList.add('is-copied');
window.clearTimeout(copiedFeedbackTimers.get(button)); window.clearTimeout(copiedFeedbackTimers.get(button));
@@ -308,9 +473,29 @@ async function copyText(value) {
return; return;
} }
shareLink.focus(); const fallback = document.createElement('textarea');
shareLink.select(); fallback.value = value;
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.'); fallback.setAttribute('readonly', '');
fallback.className = 'clipboard-fallback';
document.body.append(fallback);
fallback.select();
const copied = document.execCommand('copy');
fallback.remove();
if (!copied) throw new Error('Clipboard unavailable.');
}
function setActionStatus(message, triggerButton = null) {
window.clearTimeout(actionStatusTimer);
for (const status of document.querySelectorAll('[data-action-status], #action-status')) {
status.textContent = '';
}
const target = triggerButton?.closest('dialog')?.querySelector('[data-action-status]') || actionStatus;
target.textContent = message;
if (!message) return;
actionStatusTimer = window.setTimeout(() => {
target.textContent = '';
actionStatusTimer = null;
}, 3200);
} }
async function validateClientFile(file) { async function validateClientFile(file) {
@@ -464,13 +649,19 @@ function connectLiveCounters() {
function updateCounters(meme) { function updateCounters(meme) {
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) { for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
const count = element.querySelector('[data-count]');
if (!count) continue;
if (element.dataset.counterKind === 'view') { if (element.dataset.counterKind === 'view') {
element.lastChild.textContent = formatCount(meme.viewCount); count.textContent = formatCount(meme.viewCount);
} }
if (element.dataset.counterKind === 'download') { if (element.dataset.counterKind === 'download') {
element.lastChild.textContent = formatCount(meme.downloadCount); count.textContent = formatCount(meme.downloadCount);
} }
} }
for (const button of document.querySelectorAll(`[data-view-id="${meme.id}"]`)) {
button.dataset.viewCount = String(meme.viewCount);
button.dataset.downloadCount = String(meme.downloadCount);
}
} }
function setLoader(label, spinning) { function setLoader(label, spinning) {
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+9
View File
@@ -0,0 +1,9 @@
THE MEME PROTOCOL // LORE PLACEHOLDER
The stream remembers what the timeline forgets.
Every meme enters as a signal, sheds its metadata, and waits for consensus.
The worthy survive as immutable blocks of shared context. The rest dissolve
back into the noise.
This is placeholder lore. A future transmission will replace it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+214 -11
View File
@@ -18,6 +18,7 @@
--error: #ffb4ab; --error: #ffb4ab;
--gutter: 16px; --gutter: 16px;
--desktop-margin: 32px; --desktop-margin: 32px;
--footer-height: 44px;
} }
* { * {
@@ -29,6 +30,10 @@ body {
min-height: 100%; min-height: 100%;
} }
html {
scroll-padding-bottom: calc(var(--footer-height) + 24px);
}
body { body {
margin: 0; margin: 0;
background: var(--void); background: var(--void);
@@ -148,7 +153,7 @@ a {
.shell { .shell {
width: min(100%, 1440px); width: min(100%, 1440px);
margin: 0 auto; margin: 0 auto;
padding: 96px var(--desktop-margin) 48px; padding: 96px var(--desktop-margin) calc(var(--footer-height) + 16px + env(safe-area-inset-bottom));
} }
.status-line { .status-line {
@@ -162,6 +167,33 @@ a {
border-bottom: 1px solid var(--outline-variant); border-bottom: 1px solid var(--outline-variant);
} }
.protocol-menu {
display: flex;
align-items: center;
gap: 16px;
}
.protocol-menu a,
.protocol-menu button {
appearance: none;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-decoration: none;
}
.protocol-menu a:hover,
.protocol-menu a:focus-visible,
.protocol-menu button:hover,
.protocol-menu button:focus-visible {
color: var(--primary-dim);
text-decoration: underline;
text-underline-offset: 3px;
}
.live { .live {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -379,14 +411,19 @@ a {
color: var(--primary); color: var(--primary);
} }
.text-action:disabled {
cursor: wait;
opacity: 0.65;
}
.feed-loader { .feed-loader {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 16px; gap: 12px;
min-height: 104px; min-height: 64px;
margin-top: 24px; margin-top: 16px;
color: var(--outline); color: var(--outline);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -395,24 +432,31 @@ a {
} }
.loader-ring { .loader-ring {
display: none;
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 2px solid transparent; border: 2px solid transparent;
border-top-color: var(--primary); border-top-color: var(--primary);
border-radius: 9999px; border-radius: 9999px;
opacity: 0;
} }
.feed-loader.is-spinning .loader-ring { .feed-loader.is-spinning .loader-ring {
display: block;
animation: spin 850ms linear infinite; animation: spin 850ms linear infinite;
opacity: 1;
} }
.repo-footer { .repo-footer {
position: fixed;
z-index: 20;
inset: auto 0 0;
display: flex; display: flex;
min-height: calc(var(--footer-height) + env(safe-area-inset-bottom));
align-items: center;
justify-content: center; justify-content: center;
margin-top: -8px; padding: 0 16px env(safe-area-inset-bottom);
padding-bottom: 8px; border-top: 1px solid var(--outline-variant);
background: var(--void);
backdrop-filter: blur(12px);
} }
.repo-footer a { .repo-footer a {
@@ -535,6 +579,10 @@ dialog::backdrop {
width: min(640px, calc(100vw - 32px)); width: min(640px, calc(100vw - 32px));
} }
.pet-panel {
width: min(580px, calc(100vw - 32px));
}
.modal-header, .modal-header,
.lightbox-toolbar { .lightbox-toolbar {
display: flex; display: flex;
@@ -546,6 +594,15 @@ dialog::backdrop {
background: var(--surface); background: var(--surface);
} }
.lightbox-toolbar > span {
min-width: 0;
overflow-wrap: anywhere;
}
.lightbox-toolbar .icon-action {
flex: 0 0 auto;
}
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
font-family: Geist, Inter, system-ui, sans-serif; font-family: Geist, Inter, system-ui, sans-serif;
@@ -642,6 +699,88 @@ dialog::backdrop {
color: var(--primary-dim); color: var(--primary-dim);
} }
.pet-downloads {
display: grid;
gap: 12px;
padding: 16px;
}
.pet-prompt {
margin: 0 0 4px;
color: var(--outline);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.pet-footnote {
margin: 4px 0 0;
color: var(--outline);
font-size: 9px;
line-height: 1.5;
}
.pet-footnote a {
color: var(--muted);
text-underline-offset: 2px;
}
.pet-footnote a:hover,
.pet-footnote a:focus-visible {
color: var(--primary-dim);
}
.pet-download {
display: flex;
min-width: 0;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--outline-variant);
background: var(--surface-lowest);
color: var(--muted);
text-decoration: none;
}
.pet-download:hover,
.pet-download:focus-visible {
border-color: var(--primary-dim);
background: var(--surface-high);
color: var(--primary-dim);
}
.pet-download > span:first-child {
display: grid;
min-width: 0;
gap: 6px;
}
.pet-download strong,
.pet-download small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-download strong {
font-size: 12px;
letter-spacing: 0.1em;
}
.pet-download small {
color: var(--outline);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
}
.pet-download > .material-symbols-outlined {
flex: 0 0 auto;
}
.modal-actions { .modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -697,11 +836,16 @@ dialog::backdrop {
box-shadow: 0 0 24px rgb(0 255 65 / 14%); box-shadow: 0 0 24px rgb(0 255 65 / 14%);
} }
.lightbox[open] {
display: flex;
flex-direction: column;
}
.lightbox img { .lightbox img {
display: block; display: block;
width: auto; width: auto;
max-width: 100%; max-width: 100%;
max-height: calc(100vh - 144px); max-height: calc(100vh - 176px);
margin: 0 auto; margin: 0 auto;
object-fit: contain; object-fit: contain;
background: #000; background: #000;
@@ -709,9 +853,68 @@ dialog::backdrop {
.lightbox-actions { .lightbox-actions {
display: flex; display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 12px 12px 4px;
}
.lorebox {
width: min(760px, calc(100vw - 32px));
}
.lore-text {
min-height: 260px;
max-height: calc(100vh - 176px);
margin: 0;
overflow: auto;
padding: 24px;
background: #080808;
color: var(--text);
font: inherit;
font-size: 13px;
line-height: 1.7;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.lore-actions {
justify-content: flex-end; justify-content: flex-end;
gap: 8px; }
margin: 12px;
.viewer-status {
min-height: 16px;
margin: 0 12px 8px;
color: var(--primary-dim);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.action-toast {
position: fixed;
z-index: 19;
right: 16px;
bottom: calc(var(--footer-height) + 12px + env(safe-area-inset-bottom));
max-width: calc(100vw - 32px);
margin: 0;
padding: 6px 8px;
background: var(--surface);
color: var(--primary-dim);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.action-toast:empty {
display: none;
}
.clipboard-fallback {
position: fixed;
inset: auto auto 0 -9999px;
} }
.empty-state { .empty-state {
+81 -14
View File
@@ -9,7 +9,7 @@
<meta name="application-name" content="__SEO_TITLE__"> <meta name="application-name" content="__SEO_TITLE__">
<meta name="theme-color" content="#00ff41"> <meta name="theme-color" content="#00ff41">
<link rel="canonical" href="__SEO_CANONICAL__"> <link rel="canonical" href="__SEO_CANONICAL__">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest?v=20260814">
<link rel="service-desc" type="application/openapi+json" href="/openapi.json"> <link rel="service-desc" type="application/openapi+json" href="/openapi.json">
<link rel="help" type="text/markdown" href="/meme-api.skill.md"> <link rel="help" type="text/markdown" href="/meme-api.skill.md">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
@@ -18,10 +18,20 @@
<meta property="og:description" content="__SEO_DESCRIPTION__"> <meta property="og:description" content="__SEO_DESCRIPTION__">
<meta property="og:url" content="__SEO_CANONICAL__"> <meta property="og:url" content="__SEO_CANONICAL__">
<meta property="og:image" content="__SEO_IMAGE__"> <meta property="og:image" content="__SEO_IMAGE__">
<meta property="og:image:type" content="__SEO_IMAGE_TYPE__">
<meta property="og:image:width" content="__SEO_IMAGE_WIDTH__">
<meta property="og:image:height" content="__SEO_IMAGE_HEIGHT__">
<meta property="og:image:alt" content="__SEO_IMAGE_ALT__">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="__SEO_TITLE__"> <meta name="twitter:title" content="__SEO_TITLE__">
<meta name="twitter:description" content="__SEO_DESCRIPTION__"> <meta name="twitter:description" content="__SEO_DESCRIPTION__">
<meta name="twitter:image" content="__SEO_IMAGE__"> <meta name="twitter:image" content="__SEO_IMAGE__">
<meta name="twitter:image:alt" content="__SEO_IMAGE_ALT__">
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg?v=20260814">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png?v=20260814">
<link rel="shortcut icon" href="/favicon.ico?v=20260814">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png?v=20260814">
<meta name="apple-mobile-web-app-title" content="Meme Protocol">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
@@ -32,12 +42,15 @@
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">THE_MEME_PROTOCOL</div> <div class="brand">THE_MEME_PROTOCOL</div>
<button class="primary-action" id="open-upload" type="button">SUBMIT</button> <button class="primary-action" id="open-upload" type="button">ADD MEME</button>
</header> </header>
<main class="shell"> <main class="shell">
<div class="status-line" aria-live="polite"> <div class="status-line" aria-live="polite">
<span>PROTOCOL_STREAM_V.1.04</span> <nav class="protocol-menu" aria-label="Protocol menu">
<button id="open-pet" type="button">DOWNLOAD PET</button>
<button id="open-lore" type="button">READ LORE</button>
</nav>
<span class="live"><span class="pulse"></span>LIVE_FEED</span> <span class="live"><span class="pulse"></span>LIVE_FEED</span>
</div> </div>
@@ -56,14 +69,15 @@
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span> <span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
</div> </div>
<footer class="repo-footer">
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
<span>SOURCE</span>
</a>
</footer>
</main> </main>
<footer class="repo-footer">
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
<span>SOURCE</span>
</a>
</footer>
<aside class="side-terminal" aria-live="polite"> <aside class="side-terminal" aria-live="polite">
<div>STATUS: <span data-stat="status">SYNCING</span></div> <div>STATUS: <span data-stat="status">SYNCING</span></div>
<div>LATENCY: <span data-stat="latency">--MS</span></div> <div>LATENCY: <span data-stat="latency">--MS</span></div>
@@ -79,7 +93,7 @@
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title"> <dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
<form class="modal-panel" id="upload-form" method="dialog"> <form class="modal-panel" id="upload-form" method="dialog">
<div class="modal-header"> <div class="modal-header">
<h2 id="upload-title">SUBMIT_MEME</h2> <h2 id="upload-title">ADD_MEME</h2>
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button> <button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
</div> </div>
<label class="file-drop" for="meme-input"> <label class="file-drop" for="meme-input">
@@ -96,7 +110,7 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button> <button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
<button class="primary-action" id="submit-upload" type="submit">UPLOAD</button> <button class="primary-action" id="add-meme-button" type="submit">ADD MEME</button>
</div> </div>
<p class="form-status" id="form-status" role="status"></p> <p class="form-status" id="form-status" role="status"></p>
</form> </form>
@@ -109,9 +123,60 @@
</div> </div>
<img id="lightbox-image" alt=""> <img id="lightbox-image" alt="">
<div class="lightbox-actions"> <div class="lightbox-actions">
<a class="text-action icon-only-action" id="lightbox-download" href="#" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a> <div class="stats">
<button class="text-action icon-only-action" id="lightbox-share" type="button" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button> <span class="card-stat" id="lightbox-view-count"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>0</span></span>
<span class="card-stat" id="lightbox-download-count"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>0</span></span>
</div>
<div class="action-group">
<button class="text-action icon-only-action" id="lightbox-copy" type="button" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" id="lightbox-download" href="#" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" id="lightbox-share" type="button" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
</div>
</div> </div>
<p class="viewer-status" data-action-status role="status" aria-live="polite"></p>
</dialog>
<dialog class="modal" id="pet-modal" aria-labelledby="pet-title">
<div class="modal-panel pet-panel">
<div class="modal-header">
<h2 id="pet-title">DOWNLOAD_PET</h2>
<button class="icon-action" id="close-pet" type="button" aria-label="Close">X</button>
</div>
<div class="pet-downloads">
<p class="pet-prompt">SELECT_PET_BUILD</p>
<a class="pet-download" href="/assets/downloads/homo-reseticus.zip" download="homo-reseticus.zip">
<span>
<strong>HOMO_RESETICUS</strong>
<small>ORIGINAL // ZIP // 805 KB</small>
</span>
<span class="material-symbols-outlined" aria-hidden="true">download</span>
</a>
<a class="pet-download" href="/assets/downloads/homo-reseticus-v2.zip" download="homo-reseticus-v2.zip">
<span>
<strong>HOMO_RESETICUS_V2</strong>
<small>VERSION_2 // ZIP // 731 KB</small>
</span>
<span class="material-symbols-outlined" aria-hidden="true">download</span>
</a>
<p class="pet-footnote">This pet can also be downloaded from <a href="https://petdex.dev/u/homo-reseticus" rel="noopener noreferrer" target="_blank">petdex.dev/u/homo-reseticus</a>.</p>
</div>
</div>
</dialog>
<dialog class="lightbox lorebox" id="lore-modal" aria-labelledby="lore-title">
<div class="lightbox-toolbar">
<span id="lore-title">MEME_PROTOCOL_LORE.TXT</span>
<button class="icon-action" id="close-lore" type="button" aria-label="Close">X</button>
</div>
<pre class="lore-text" id="lore-text">FETCHING_LORE...</pre>
<div class="lightbox-actions lore-actions">
<div class="action-group">
<button class="text-action icon-only-action" id="lore-copy" type="button" aria-label="Copy lore" title="Copy lore"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" href="/assets/meme-protocol-lore.txt" download="meme-protocol-lore.txt" aria-label="Download lore" title="Download lore"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" id="lore-share" type="button" aria-label="Share lore" title="Share lore"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
</div>
</div>
<p class="viewer-status" data-action-status role="status" aria-live="polite"></p>
</dialog> </dialog>
<dialog class="modal" id="share-modal" aria-labelledby="share-title"> <dialog class="modal" id="share-modal" aria-labelledby="share-title">
@@ -121,7 +186,7 @@
<button class="icon-action" id="close-share" type="button" aria-label="Close">X</button> <button class="icon-action" id="close-share" type="button" aria-label="Close">X</button>
</div> </div>
<div class="share-body"> <div class="share-body">
<label for="share-link">MEME_LINK</label> <label for="share-link" id="share-link-label">MEME_LINK</label>
<div class="share-link-row"> <div class="share-link-row">
<input id="share-link" type="text" readonly spellcheck="false" aria-describedby="share-status"> <input id="share-link" type="text" readonly spellcheck="false" aria-describedby="share-status">
<button class="primary-action share-copy" id="copy-share-link" type="button"> <button class="primary-action share-copy" id="copy-share-link" type="button">
@@ -133,5 +198,7 @@
</div> </div>
</div> </div>
</dialog> </dialog>
<p class="action-toast" id="action-status" role="status" aria-live="polite"></p>
</body> </body>
</html> </html>
+18 -2
View File
@@ -75,6 +75,18 @@ const server = http.createServer(async (req, res) => {
})); }));
} }
if (req.method === 'GET' && (url.pathname === '/lore' || url.pathname === '/lore/')) {
const nonce = crypto.randomBytes(16).toString('base64');
withSecurityHeaders(res, { scriptNonce: nonce });
return sendHtml(res, 200, renderIndex({
template: indexTemplate,
baseUrl,
nonce,
approvedCount: store.count('approved'),
lore: true
}));
}
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/); const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (req.method === 'GET' && memePageMatch) { if (req.method === 'GET' && memePageMatch) {
const meme = store.get(memePageMatch[1]); const meme = store.get(memePageMatch[1]);
@@ -112,6 +124,10 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, manifest(baseUrl)); return sendJson(res, 200, manifest(baseUrl));
} }
if (req.method === 'GET' && url.pathname === '/favicon.ico') {
return sendFile(res, './public/assets/favicon/favicon.ico');
}
if (req.method === 'GET' && url.pathname === '/feed.json') { if (req.method === 'GET' && url.pathname === '/feed.json') {
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes)); return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
} }
@@ -203,7 +219,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && url.pathname === '/api/memes') { if (req.method === 'POST' && url.pathname === '/api/memes') {
noIndex(res); noIndex(res);
const { meme, quota } = await submitMeme(req); const { meme, quota } = await addMeme(req);
return sendJson(res, meme.status === 'approved' ? 201 : 202, { return sendJson(res, meme.status === 'approved' ? 201 : 202, {
meme, meme,
quota, quota,
@@ -361,7 +377,7 @@ function broadcastMetric(meme) {
} }
} }
async function submitMeme(req) { async function addMeme(req) {
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10); const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
if (!Number.isFinite(contentLength) || contentLength <= 0) { if (!Number.isFinite(contentLength) || contentLength <= 0) {
const error = new Error('Missing upload size.'); const error = new Error('Missing upload size.');
+3
View File
@@ -6,10 +6,13 @@ const TYPES = new Map([
['.html', 'text/html; charset=utf-8'], ['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'], ['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'], ['.js', 'text/javascript; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8'],
['.png', 'image/png'], ['.png', 'image/png'],
['.jpg', 'image/jpeg'], ['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'], ['.webp', 'image/webp'],
['.svg', 'image/svg+xml'],
['.zip', 'application/zip'],
['.ico', 'image/x-icon'] ['.ico', 'image/x-icon']
]); ]);
+11 -3
View File
@@ -2,12 +2,20 @@ import { validateImage } from './image.js';
import { normalizeToWebp } from './normalize.js'; import { normalizeToWebp } from './normalize.js';
import { encodePng } from './png.js'; import { encodePng } from './png.js';
const DEMO_COUNT = 12; const DEMO_COUNT = 18;
export async function seedDemoMemes(store) { export async function seedDemoMemes(store) {
if (store.count() > 0) return; const total = store.count();
const existing = store.list({ page: 1, pageSize: Math.max(DEMO_COUNT, total) }).memes;
const isDemoOnlyStore = existing.length === total && existing.every((meme) => meme.demo);
if (total > 0 && !isDemoOnlyStore) return;
const existingNames = new Set(existing.map((meme) => meme.originalName));
for (let index = 0; index < DEMO_COUNT; index += 1) { for (let index = 0; index < DEMO_COUNT; index += 1) {
const originalName = `protocol-sample-${String(index + 1).padStart(2, '0')}.png`;
if (existingNames.has(originalName)) continue;
const width = 960; const width = 960;
const height = 960; const height = 960;
const buffer = encodePng(width, height, pixelFactory(index)); const buffer = encodePng(width, height, pixelFactory(index));
@@ -22,7 +30,7 @@ export async function seedDemoMemes(store) {
await store.save({ await store.save({
buffer: normalized.buffer, buffer: normalized.buffer,
image: normalized.image, image: normalized.image,
originalName: `protocol-sample-${String(index + 1).padStart(2, '0')}.png`, originalName,
originalMime: image.mime, originalMime: image.mime,
createdAt, createdAt,
demo: true demo: true
+48 -8
View File
@@ -1,6 +1,9 @@
const SITE_NAME = 'The Meme Protocol'; const SITE_NAME = 'The Meme Protocol';
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.'; const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree'; const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
const SOCIAL_IMAGE_PATH = '/assets/social-preview-v1.png';
const FAVICON_PATH = '/assets/favicon';
const FAVICON_VERSION = '20260814';
export function publicBaseUrl(req) { export function publicBaseUrl(req) {
if (process.env.SITE_URL) return cleanBase(process.env.SITE_URL); if (process.env.SITE_URL) return cleanBase(process.env.SITE_URL);
@@ -13,14 +16,20 @@ export function publicBaseUrl(req) {
return cleanBase(`${proto}://${host || 'localhost:8080'}`); return cleanBase(`${proto}://${host || 'localhost:8080'}`);
} }
export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null }) { export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null, lore = false }) {
const home = `${baseUrl}/`; const home = `${baseUrl}/`;
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : home; const canonical = meme ? `${baseUrl}/meme/${meme.id}` : lore ? `${baseUrl}/lore` : home;
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}/assets/yoonect-logo.png`; const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}${SOCIAL_IMAGE_PATH}`;
const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : SITE_NAME; const imageType = meme ? meme.mime : 'image/png';
const imageWidth = meme ? meme.width : 1200;
const imageHeight = meme ? meme.height : 630;
const imageAlt = meme ? `Meme ${shortId(meme.id)}` : `${SITE_NAME} — memes bring us together`;
const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : lore ? `Lore | ${SITE_NAME}` : SITE_NAME;
const description = meme const description = meme
? `View and share meme ${shortId(meme.id)} on ${SITE_NAME}.` ? `View and share meme ${shortId(meme.id)} on ${SITE_NAME}.`
: SITE_DESCRIPTION; : lore
? `Read, copy, download, and share the lore of ${SITE_NAME}.`
: SITE_DESCRIPTION;
const pageMetadata = meme const pageMetadata = meme
? { ? {
'@type': 'ImageObject', '@type': 'ImageObject',
@@ -33,6 +42,20 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu
height: meme.height, height: meme.height,
isPartOf: { '@id': `${home}#website` } isPartOf: { '@id': `${home}#website` }
} }
: lore
? {
'@type': 'CreativeWork',
'@id': `${canonical}#lore`,
name: `${SITE_NAME} Lore`,
url: canonical,
description,
encoding: {
'@type': 'MediaObject',
contentUrl: `${baseUrl}/assets/meme-protocol-lore.txt`,
encodingFormat: 'text/plain'
},
isPartOf: { '@id': `${home}#website` }
}
: { : {
'@type': 'CollectionPage', '@type': 'CollectionPage',
'@id': `${canonical}#collection`, '@id': `${canonical}#collection`,
@@ -72,6 +95,10 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(description)) .replaceAll('__SEO_DESCRIPTION__', escapeHtml(description))
.replaceAll('__SEO_CANONICAL__', canonical) .replaceAll('__SEO_CANONICAL__', canonical)
.replaceAll('__SEO_IMAGE__', image) .replaceAll('__SEO_IMAGE__', image)
.replaceAll('__SEO_IMAGE_TYPE__', imageType)
.replaceAll('__SEO_IMAGE_WIDTH__', String(imageWidth))
.replaceAll('__SEO_IMAGE_HEIGHT__', String(imageHeight))
.replaceAll('__SEO_IMAGE_ALT__', escapeHtml(imageAlt))
.replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd))) .replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd)))
.replaceAll('__CSP_NONCE__', nonce); .replaceAll('__CSP_NONCE__', nonce);
} }
@@ -102,6 +129,7 @@ export function llmsTxt(baseUrl) {
`- API skill: ${baseUrl}/meme-api.skill.md`, `- API skill: ${baseUrl}/meme-api.skill.md`,
`- JSON feed: ${baseUrl}/feed.json`, `- JSON feed: ${baseUrl}/feed.json`,
`- Sitemap: ${baseUrl}/sitemap.xml`, `- Sitemap: ${baseUrl}/sitemap.xml`,
`- Lore: ${baseUrl}/lore`,
`- Source: ${REPO_URL}`, `- Source: ${REPO_URL}`,
'', '',
'Crawler guidance:', 'Crawler guidance:',
@@ -125,10 +153,16 @@ export function manifest(baseUrl) {
theme_color: '#00ff41', theme_color: '#00ff41',
icons: [ icons: [
{ {
src: `${baseUrl}/assets/yoonect-logo.png`, src: `${baseUrl}${FAVICON_PATH}/web-app-manifest-192x192.png?v=${FAVICON_VERSION}`,
sizes: '1447x712', sizes: '192x192',
type: 'image/png', type: 'image/png',
purpose: 'any' purpose: 'maskable'
},
{
src: `${baseUrl}${FAVICON_PATH}/web-app-manifest-512x512.png?v=${FAVICON_VERSION}`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
} }
] ]
}; };
@@ -161,6 +195,12 @@ export function sitemapXml(baseUrl, memes) {
` <lastmod>${xmlEscape(latest)}</lastmod>`, ` <lastmod>${xmlEscape(latest)}</lastmod>`,
' </url>' ' </url>'
].join('\n'), ].join('\n'),
[
' <url>',
` <loc>${xmlEscape(`${baseUrl}/lore`)}</loc>`,
` <lastmod>${xmlEscape(latest)}</lastmod>`,
' </url>'
].join('\n'),
...memes.slice(0, 1000).map((meme) => [ ...memes.slice(0, 1000).map((meme) => [
' <url>', ' <url>',
` <loc>${xmlEscape(`${baseUrl}/meme/${meme.id}`)}</loc>`, ` <loc>${xmlEscape(`${baseUrl}/meme/${meme.id}`)}</loc>`,
+50 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { feedJson, renderIndex, sitemapXml } from '../src/seo.js'; import fs from 'node:fs';
import { feedJson, manifest, renderIndex, sitemapXml } from '../src/seo.js';
const id = 'a'.repeat(64); const id = 'a'.repeat(64);
const meme = { const meme = {
@@ -8,6 +9,7 @@ const meme = {
createdAt: '2026-07-24T12:00:00.000Z', createdAt: '2026-07-24T12:00:00.000Z',
width: 1200, width: 1200,
height: 900, height: 900,
mime: 'image/webp',
moderationScore: 92 moderationScore: 92
}; };
const template = [ const template = [
@@ -34,10 +36,57 @@ test('renders a meme permalink with meme-specific share metadata', () => {
assert.match(html, /nonce="test-nonce"/); assert.match(html, /nonce="test-nonce"/);
}); });
test('renders the homepage with the dedicated social preview image', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'home-nonce',
approvedCount: 12
});
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/);
assert.doesNotMatch(html, /yoonect-logo\.png/);
});
test('uses the supplied favicon suite in the page and web app manifest', () => {
const index = fs.readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
const appManifest = manifest('https://memes.example');
assert.match(index, /\/assets\/favicon\/favicon\.svg\?v=20260814/);
assert.match(index, /\/assets\/favicon\/favicon-96x96\.png\?v=20260814/);
assert.match(index, /\/favicon\.ico\?v=20260814/);
assert.match(index, /\/assets\/favicon\/apple-touch-icon\.png\?v=20260814/);
assert.doesNotMatch(index, /rel="icon"[^>]+yoonect-logo\.png/);
assert.deepEqual(appManifest.icons.map(({ sizes, purpose }) => ({ sizes, purpose })), [
{ sizes: '192x192', purpose: 'maskable' },
{ sizes: '512x512', purpose: 'maskable' }
]);
assert.match(appManifest.icons[0].src, /web-app-manifest-192x192\.png\?v=20260814$/);
assert.match(appManifest.icons[1].src, /web-app-manifest-512x512\.png\?v=20260814$/);
});
test('renders a shareable lore page with text-file metadata', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'lore-nonce',
approvedCount: 1,
lore: true
});
assert.match(html, /<title>Lore \| The Meme Protocol<\/title>/);
assert.match(html, /canonical" href="https:\/\/memes\.example\/lore"/);
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/);
assert.match(html, /"@type":"CreativeWork"/);
assert.match(html, /https:\/\/memes\.example\/assets\/meme-protocol-lore\.txt/);
});
test('discovery feeds point readers to viewer permalinks', () => { test('discovery feeds point readers to viewer permalinks', () => {
const feed = feedJson('https://memes.example', [meme]); const feed = feedJson('https://memes.example', [meme]);
const sitemap = sitemapXml('https://memes.example', [meme]); const sitemap = sitemapXml('https://memes.example', [meme]);
assert.match(sitemap, /https:\/\/memes\.example\/lore/);
assert.equal(feed.items[0].url, `https://memes.example/meme/${id}`); assert.equal(feed.items[0].url, `https://memes.example/meme/${id}`);
assert.equal(feed.items[0].image, `https://memes.example/media/${id}`); assert.equal(feed.items[0].image, `https://memes.example/media/${id}`);
assert.match(sitemap, new RegExp(`https://memes\\.example/meme/${id}`)); assert.match(sitemap, new RegExp(`https://memes\\.example/meme/${id}`));