diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b191ceb --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ae14f68..940d633 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ 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 ```sh @@ -16,7 +24,7 @@ The server listens on `http://localhost:8080` by default. - `HOST`: bind address, default `0.0.0.0` - `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`. -- `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. - `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` @@ -56,7 +64,7 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public - `GET /api/memes/`: returns public metadata for one approved meme. - `GET /meme/`: opens an approved meme in the viewer and provides its shareable permalink. - `GET /media/`: returns the normalized WebP image for one approved meme. -- `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`. +- `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`. diff --git a/meme-api.skill.md b/meme-api.skill.md index 36ee175..5b59773 100644 --- a/meme-api.skill.md +++ b/meme-api.skill.md @@ -1,6 +1,6 @@ # 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 @@ -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 metadata: `GET __API_BASE_URL__/api/memes/{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 @@ -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`. -## 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: diff --git a/openapi.json b/openapi.json index 99c581f..77d5176 100644 --- a/openapi.json +++ b/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "The Meme Protocol API", - "version": "1.0.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." + "version": "1.1.0", + "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": [ { @@ -13,7 +13,7 @@ "tags": [ { "name": "memes", - "description": "Approved meme discovery and submission" + "description": "Approved meme discovery and addition" }, { "name": "discovery", @@ -87,8 +87,8 @@ "tags": [ "memes" ], - "summary": "Submit 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.", + "summary": "Add a meme", + "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": { "required": true, "content": { @@ -120,7 +120,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemeSubmitResponse" + "$ref": "#/components/schemas/MemeAddResponse" } } } @@ -130,7 +130,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemeSubmitResponse" + "$ref": "#/components/schemas/MemeAddResponse" } } } @@ -438,7 +438,7 @@ } } }, - "MemeSubmitResponse": { + "MemeAddResponse": { "type": "object", "required": [ "meme", diff --git a/package-lock.json b/package-lock.json index 0c673ab..8e7ff22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meme-protocol", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meme-protocol", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "sharp": "^0.34.5" }, diff --git a/package.json b/package.json index 5721791..83d5299 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meme-protocol", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Self-hosted meme gallery for The Meme Protocol.", "type": "module", diff --git a/public/assets/app.js b/public/assets/app.js index 58a6e34..85e6e84 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -5,6 +5,7 @@ const VIEW_DWELL_MS = 1000; const VIEW_THRESHOLD = 0.9; const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000; const VIEW_DEDUPE_PREFIX = 'meme-viewed:'; +const NEXT_PAGE_LOADER_MIN_MS = 300; const SAFE_TYPES = new Set(['image/png', 'image/jpeg']); const grid = document.querySelector('#meme-grid'); @@ -15,17 +16,28 @@ const uploadForm = document.querySelector('#upload-form'); const fileInput = document.querySelector('#meme-input'); const fileLabel = document.querySelector('#file-label'); 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 lightboxImage = document.querySelector('#lightbox-image'); +const lightboxCopy = document.querySelector('#lightbox-copy'); const lightboxDownload = document.querySelector('#lightbox-download'); const lightboxShare = document.querySelector('#lightbox-share'); 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 shareTitle = document.querySelector('#share-title'); +const shareLinkLabel = document.querySelector('#share-link-label'); const shareLink = document.querySelector('#share-link'); const shareStatus = document.querySelector('#share-status'); const copyShareLinkButton = document.querySelector('#copy-share-link'); const scrollIndicator = document.querySelector('#scroll-indicator'); +const actionStatus = document.querySelector('#action-status'); let currentPage = 0; let totalPages = 1; @@ -33,6 +45,8 @@ let isLoading = false; let latestValidationRun = 0; let activeShareTrigger = null; let shareStatusTimer = null; +let actionStatusTimer = null; +let loreContent = ''; const viewedThisSession = new Set(); const pendingViewTimers = new Map(); const copiedFeedbackTimers = new WeakMap(); @@ -45,10 +59,22 @@ document.querySelector('#close-upload').addEventListener('click', closeUpload); document.querySelector('#cancel-upload').addEventListener('click', closeUpload); document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close()); document.querySelector('#close-share').addEventListener('click', () => shareModal.close()); +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); 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 () => { const validationRun = ++latestValidationRun; @@ -70,7 +96,7 @@ uploadForm.addEventListener('submit', async (event) => { return; } - submitUpload.disabled = true; + addMemeButton.disabled = true; formStatus.textContent = 'UPLOADING...'; try { @@ -90,14 +116,20 @@ uploadForm.addEventListener('submit', async (event) => { } catch (error) { formStatus.textContent = error.message.toUpperCase(); } finally { - submitUpload.disabled = false; + addMemeButton.disabled = false; } }); 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]'); if (shareButton) { - await openShareDialog(shareButton.dataset.shareId, shareButton); + await openMemeShareDialog(shareButton.dataset.shareId, shareButton); return; } @@ -108,7 +140,9 @@ grid.addEventListener('click', async (event) => { id: viewButton.dataset.viewId, url: viewButton.dataset.viewUrl, 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); refreshStatus(); window.setInterval(refreshStatus, 5000); -await openSharedMemeFromUrl(); +await openSharedContentFromUrl(); await loadMemes(1, { reset: true }); observer.observe(feedLoader); @@ -140,6 +174,7 @@ async function loadNextPage() { async function loadMemes(page, options = {}) { if (isLoading) return; isLoading = true; + const loaderStartedAt = performance.now(); setLoader('FETCHING_NEXT_BLOCK...', true); try { @@ -150,6 +185,10 @@ async function loadMemes(page, options = {}) { currentPage = payload.page; totalPages = payload.totalPages; 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); updateScrollIndicator(); } 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 = {}) { if (!options.append && memes.length === 0) { unobserveViewedImages(); @@ -185,13 +228,14 @@ function renderMemes(memes, options = {}) {
- ${formatCount(meme.viewCount)} - ${formatCount(meme.downloadCount)} + ${formatCount(meme.viewCount)} + ${formatCount(meme.downloadCount)}
+ - +
`; @@ -208,7 +252,12 @@ function renderMemes(memes, options = {}) { 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})\/?$/); 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.alt = alt; + lightboxCopy.dataset.imageUrl = url; lightboxDownload.href = downloadUrl; 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(); await recordViewOnce(id); } -async function openShareDialog(id, triggerButton = null) { +async function openMemeShareDialog(id, triggerButton = null) { 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; - 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); shareStatusTimer = null; shareStatus.textContent = ''; @@ -252,8 +320,8 @@ async function copyActiveShareLink() { try { await copyText(shareLink.value); shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD'; - showCopiedFeedback(copyShareLinkButton); - if (activeShareTrigger) showCopiedFeedback(activeShareTrigger); + showCopiedFeedback(copyShareLinkButton, 'Share link copied'); + if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied'); window.clearTimeout(shareStatusTimer); shareStatusTimer = window.setTimeout(() => { 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 label = button.querySelector('[data-copy-label]'); if (!icon) return; @@ -280,8 +445,8 @@ function showCopiedFeedback(button) { icon.textContent = 'check'; if (label) label.textContent = 'COPIED'; - if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied'); - if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied'); + if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', copiedLabel); + if (button.dataset.defaultTitle) button.setAttribute('title', copiedLabel); button.classList.add('is-copied'); window.clearTimeout(copiedFeedbackTimers.get(button)); @@ -308,9 +473,29 @@ async function copyText(value) { return; } - shareLink.focus(); - shareLink.select(); - if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.'); + const fallback = document.createElement('textarea'); + fallback.value = value; + 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) { @@ -464,13 +649,19 @@ function connectLiveCounters() { function updateCounters(meme) { 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') { - element.lastChild.textContent = formatCount(meme.viewCount); + count.textContent = formatCount(meme.viewCount); } 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) { diff --git a/public/assets/downloads/homo-reseticus-v2.zip b/public/assets/downloads/homo-reseticus-v2.zip new file mode 100644 index 0000000..5b779d6 Binary files /dev/null and b/public/assets/downloads/homo-reseticus-v2.zip differ diff --git a/public/assets/downloads/homo-reseticus.zip b/public/assets/downloads/homo-reseticus.zip new file mode 100644 index 0000000..66637d5 Binary files /dev/null and b/public/assets/downloads/homo-reseticus.zip differ diff --git a/public/assets/favicon/apple-touch-icon.png b/public/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000..9d60fc3 Binary files /dev/null and b/public/assets/favicon/apple-touch-icon.png differ diff --git a/public/assets/favicon/favicon-96x96.png b/public/assets/favicon/favicon-96x96.png new file mode 100644 index 0000000..ecbe220 Binary files /dev/null and b/public/assets/favicon/favicon-96x96.png differ diff --git a/public/assets/favicon/favicon.ico b/public/assets/favicon/favicon.ico new file mode 100644 index 0000000..acd55b3 Binary files /dev/null and b/public/assets/favicon/favicon.ico differ diff --git a/public/assets/favicon/favicon.svg b/public/assets/favicon/favicon.svg new file mode 100644 index 0000000..f3fe6a0 --- /dev/null +++ b/public/assets/favicon/favicon.svg @@ -0,0 +1,3 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/public/assets/favicon/web-app-manifest-192x192.png b/public/assets/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000..cf72b5b Binary files /dev/null and b/public/assets/favicon/web-app-manifest-192x192.png differ diff --git a/public/assets/favicon/web-app-manifest-512x512.png b/public/assets/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000..5b8aad3 Binary files /dev/null and b/public/assets/favicon/web-app-manifest-512x512.png differ diff --git a/public/assets/meme-protocol-lore.txt b/public/assets/meme-protocol-lore.txt new file mode 100644 index 0000000..704361e --- /dev/null +++ b/public/assets/meme-protocol-lore.txt @@ -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. diff --git a/public/assets/social-preview-v1.png b/public/assets/social-preview-v1.png new file mode 100644 index 0000000..cfeb58a Binary files /dev/null and b/public/assets/social-preview-v1.png differ diff --git a/public/assets/styles.css b/public/assets/styles.css index 8096daa..a61e493 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -18,6 +18,7 @@ --error: #ffb4ab; --gutter: 16px; --desktop-margin: 32px; + --footer-height: 44px; } * { @@ -29,6 +30,10 @@ body { min-height: 100%; } +html { + scroll-padding-bottom: calc(var(--footer-height) + 24px); +} + body { margin: 0; background: var(--void); @@ -148,7 +153,7 @@ a { .shell { width: min(100%, 1440px); 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 { @@ -162,6 +167,33 @@ a { 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 { display: inline-flex; align-items: center; @@ -379,14 +411,19 @@ a { color: var(--primary); } +.text-action:disabled { + cursor: wait; + opacity: 0.65; +} + .feed-loader { display: flex; flex-direction: column; justify-content: center; align-items: center; - gap: 16px; - min-height: 104px; - margin-top: 24px; + gap: 12px; + min-height: 64px; + margin-top: 16px; color: var(--outline); font-size: 10px; font-weight: 600; @@ -395,24 +432,31 @@ a { } .loader-ring { + display: none; width: 32px; height: 32px; border: 2px solid transparent; border-top-color: var(--primary); border-radius: 9999px; - opacity: 0; } .feed-loader.is-spinning .loader-ring { + display: block; animation: spin 850ms linear infinite; - opacity: 1; } .repo-footer { + position: fixed; + z-index: 20; + inset: auto 0 0; display: flex; + min-height: calc(var(--footer-height) + env(safe-area-inset-bottom)); + align-items: center; justify-content: center; - margin-top: -8px; - padding-bottom: 8px; + padding: 0 16px env(safe-area-inset-bottom); + border-top: 1px solid var(--outline-variant); + background: var(--void); + backdrop-filter: blur(12px); } .repo-footer a { @@ -535,6 +579,10 @@ dialog::backdrop { width: min(640px, calc(100vw - 32px)); } +.pet-panel { + width: min(580px, calc(100vw - 32px)); +} + .modal-header, .lightbox-toolbar { display: flex; @@ -546,6 +594,15 @@ dialog::backdrop { background: var(--surface); } +.lightbox-toolbar > span { + min-width: 0; + overflow-wrap: anywhere; +} + +.lightbox-toolbar .icon-action { + flex: 0 0 auto; +} + .modal-header h2 { margin: 0; font-family: Geist, Inter, system-ui, sans-serif; @@ -642,6 +699,88 @@ dialog::backdrop { 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 { display: flex; justify-content: flex-end; @@ -697,11 +836,16 @@ dialog::backdrop { box-shadow: 0 0 24px rgb(0 255 65 / 14%); } +.lightbox[open] { + display: flex; + flex-direction: column; +} + .lightbox img { display: block; width: auto; max-width: 100%; - max-height: calc(100vh - 144px); + max-height: calc(100vh - 176px); margin: 0 auto; object-fit: contain; background: #000; @@ -709,9 +853,68 @@ dialog::backdrop { .lightbox-actions { 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; - 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 { diff --git a/public/index.html b/public/index.html index 17421b6..b55aed4 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ - + @@ -18,10 +18,20 @@ + + + + + + + + + + @@ -32,12 +42,15 @@
THE_MEME_PROTOCOL
- +
- PROTOCOL_STREAM_V.1.04 + LIVE_FEED
@@ -56,14 +69,15 @@ FETCHING_NEXT_BLOCK... -
+ +