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.
@@ -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.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 193 KiB |
@@ -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.
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>`,
|
||||||
|
|||||||
@@ -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}`));
|
||||||
|
|||||||