This commit is contained in:
2026-05-08 18:18:36 -03:00
parent c4bb073ca1
commit 5e10af882b
26 changed files with 3150 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Meme Protocol Review</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="/assets/styles.css">
<script type="module" src="/assets/admin.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL_REVIEW</div>
<div class="admin-actions">
<button class="secondary-action" id="refresh-review" type="button">REFRESH</button>
<button class="primary-action" id="approve-selected" type="button">APPROVE_SELECTED</button>
<button class="secondary-action danger-action" id="delete-selected" type="button">DELETE_SELECTED</button>
</div>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<span id="review-status">PENDING_REVIEW</span>
<span class="live"><span class="pulse"></span><span id="review-count">0 ITEMS</span></span>
</div>
<section class="meme-grid" id="review-grid" aria-label="Pending meme review"></section>
</main>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
const token = location.pathname.split('/').pop();
history.replaceState(null, '', '/admin');
const grid = document.querySelector('#review-grid');
const reviewStatus = document.querySelector('#review-status');
const reviewCount = document.querySelector('#review-count');
const selected = new Set();
document.querySelector('#refresh-review').addEventListener('click', loadPending);
document.querySelector('#approve-selected').addEventListener('click', () => moderateSelected('approve'));
document.querySelector('#delete-selected').addEventListener('click', () => moderateSelected('delete'));
grid.addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-select-id]');
if (!checkbox) return;
if (checkbox.checked) selected.add(checkbox.dataset.selectId);
else selected.delete(checkbox.dataset.selectId);
});
grid.addEventListener('click', (event) => {
const action = event.target.closest('[data-admin-action]');
if (!action) return;
moderate([action.dataset.id], action.dataset.adminAction);
});
await loadPending();
async function loadPending() {
selected.clear();
reviewStatus.textContent = 'FETCHING_PENDING_QUEUE';
const response = await fetch('/api/admin/pending', { headers: adminHeaders() });
const payload = await response.json();
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_AUTH_FAILED';
grid.innerHTML = '<div class="empty-state">ADMIN_TOKEN_INVALID</div>';
return;
}
reviewStatus.textContent = 'PENDING_REVIEW';
reviewCount.textContent = `${payload.total} ITEMS`;
renderPending(payload.memes);
}
function renderPending(memes) {
if (memes.length === 0) {
grid.innerHTML = '<div class="empty-state">NO_PENDING_MEMES</div>';
return;
}
grid.replaceChildren(...memes.map((meme) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.innerHTML = `
<div class="card-head">
<label class="review-select">
<input type="checkbox" data-select-id="${meme.id}">
<span>ID: ${shortId(meme.id)}</span>
</label>
<span class="card-meta age">SCORE ${meme.moderationScore}</span>
</div>
<div class="image-frame">
<img src="/admin-media/${token}/${meme.id}" alt="Pending meme ${shortId(meme.id)}" loading="lazy">
</div>
<div class="review-body">
<p>${escapeText(meme.moderationReason || 'Queued for review.')}</p>
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat">${formatBytes(meme.byteSize)}</span>
</div>
<div class="action-group">
<button class="text-action text-action-accent icon-only-action" type="button" data-admin-action="approve" data-id="${meme.id}" aria-label="Approve meme" title="Approve meme"><span class="material-symbols-outlined" aria-hidden="true">check</span></button>
<button class="text-action icon-only-action danger-action" type="button" data-admin-action="delete" data-id="${meme.id}" aria-label="Delete meme" title="Delete meme"><span class="material-symbols-outlined" aria-hidden="true">delete</span></button>
</div>
</div>
`;
return article;
}));
}
async function moderateSelected(action) {
if (selected.size === 0) return;
await moderate([...selected], action);
}
async function moderate(ids, action) {
const endpoint = action === 'approve' ? '/api/admin/approve' : '/api/admin/delete';
const response = await fetch(endpoint, {
method: 'POST',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_ACTION_FAILED';
return;
}
await loadPending();
}
function adminHeaders() {
return { 'X-Admin-Token': token };
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function formatBytes(value) {
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)}MB`;
return `${Math.ceil(value / 1024)}KB`;
}
function escapeText(value) {
const span = document.createElement('span');
span.textContent = value;
return span.innerHTML;
}
+308
View File
@@ -0,0 +1,308 @@
const PAGE_SIZE = 12;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
const grid = document.querySelector('#meme-grid');
const feedLoader = document.querySelector('#feed-loader');
const feedLoaderLabel = document.querySelector('#feed-loader-label');
const uploadModal = document.querySelector('#upload-modal');
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 lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image');
const lightboxDownload = document.querySelector('#lightbox-download');
const lightboxId = document.querySelector('#lightbox-id');
const scrollIndicator = document.querySelector('#scroll-indicator');
const statStatus = document.querySelector('#stat-status');
const statLatency = document.querySelector('#stat-latency');
const statNodes = document.querySelector('#stat-nodes');
const statMemes = document.querySelector('#stat-memes');
let currentPage = 0;
let totalPages = 1;
let isLoading = false;
let latestValidationRun = 0;
document.querySelector('#open-upload').addEventListener('click', () => {
formStatus.textContent = '';
uploadModal.showModal();
});
document.querySelector('#close-upload').addEventListener('click', closeUpload);
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
fileInput.addEventListener('change', async () => {
const validationRun = ++latestValidationRun;
const file = fileInput.files?.[0];
fileLabel.textContent = file ? file.name : 'SELECT PNG / JPEG';
formStatus.textContent = file ? 'INSPECTING_IMAGE...' : '';
const validationError = await validateClientFile(file);
if (validationRun === latestValidationRun) {
formStatus.textContent = validationError || '';
}
});
uploadForm.addEventListener('submit', async (event) => {
event.preventDefault();
const file = fileInput.files?.[0];
const validationError = await validateClientFile(file);
if (validationError) {
formStatus.textContent = validationError;
return;
}
submitUpload.disabled = true;
formStatus.textContent = 'UPLOADING...';
try {
const formData = new FormData();
formData.append('meme', file);
const response = await fetch('/api/memes', { method: 'POST', body: formData });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Upload failed.');
if (payload.meme?.status === 'approved') {
closeUpload();
await loadMemes(1, { reset: true });
} else {
uploadForm.reset();
fileLabel.textContent = 'SELECT PNG / JPEG';
formStatus.textContent = `${payload.message || 'QUEUED_FOR_REVIEW'} MEME_CONSENSUS_SCORE: ${payload.meme?.moderationScore ?? '--'}`;
}
} catch (error) {
formStatus.textContent = error.message.toUpperCase();
} finally {
submitUpload.disabled = false;
}
});
grid.addEventListener('click', async (event) => {
const viewButton = event.target.closest('[data-view-id]');
if (!viewButton) return;
const card = viewButton.closest('.meme-card');
lightboxImage.src = viewButton.dataset.viewUrl;
lightboxImage.alt = card.querySelector('img').alt;
lightboxDownload.href = viewButton.dataset.downloadUrl;
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
lightbox.showModal();
await recordView(viewButton.dataset.viewId);
});
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadNextPage();
}
}, { rootMargin: '420px 0px' });
connectLiveCounters();
updateScrollIndicator();
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
window.addEventListener('resize', updateScrollIndicator);
refreshStatus();
window.setInterval(refreshStatus, 5000);
await loadMemes(1, { reset: true });
observer.observe(feedLoader);
async function loadNextPage() {
if (isLoading || currentPage >= totalPages) return;
await loadMemes(currentPage + 1);
}
async function loadMemes(page, options = {}) {
if (isLoading) return;
isLoading = true;
setLoader('FETCHING_NEXT_BLOCK...', true);
try {
const response = await fetch(`/api/memes?page=${page}&pageSize=${PAGE_SIZE}`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Feed error.');
currentPage = payload.page;
totalPages = payload.totalPages;
renderMemes(payload.memes, { append: !options.reset });
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
updateScrollIndicator();
} catch {
if (options.reset) grid.innerHTML = `<div class="empty-state">FEED_ERROR</div>`;
setLoader('FEED_ERROR', false);
} finally {
grid.ariaBusy = 'false';
isLoading = false;
}
}
function renderMemes(memes, options = {}) {
if (!options.append && memes.length === 0) {
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
return;
}
const cards = memes.map((meme, index) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.dataset.memeId = meme.id;
article.innerHTML = `
<div class="card-head">
<div class="card-id">
<span class="node active node-score-${scoreBucket(meme.moderationScore)}" tabindex="0" data-tooltip="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" title="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" aria-label="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} out of 100"></span>
<span class="card-meta">ID: ${shortId(meme.id)}</span>
</div>
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
</div>
<div class="image-frame">
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy">
</div>
<div class="card-actions">
<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="download"><span class="material-symbols-outlined" aria-hidden="true">download</span>${formatCount(meme.downloadCount)}</span>
</div>
<div class="action-group">
<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 text-action-accent 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>
</div>
</div>
`;
if (options.append && index === 0) article.tabIndex = -1;
return article;
});
if (options.append) {
grid.append(...cards);
} else {
grid.replaceChildren(...cards);
}
}
async function validateClientFile(file) {
if (!file) return 'SELECT A FILE.';
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
if (file.size > MAX_FILE_BYTES) return 'IMAGE EXCEEDS 5MB.';
try {
const dimensions = await readImageDimensions(file);
if (dimensions.width !== dimensions.height) return 'IMAGE MUST BE SQUARE.';
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EXCEEDS 6000x6000.';
} catch {
return 'IMAGE COULD NOT BE INSPECTED.';
}
return '';
}
function readImageDimensions(file) {
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(file);
image.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: image.naturalWidth, height: image.naturalHeight });
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('invalid image'));
};
image.src = url;
});
}
function closeUpload() {
uploadForm.reset();
fileLabel.textContent = 'SELECT PNG / JPEG';
formStatus.textContent = '';
uploadModal.close();
}
async function recordView(id) {
try {
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
const payload = await response.json();
if (response.ok) updateCounters(payload.meme);
} catch {
// Live counter updates are best-effort; the lightbox should still open.
}
}
function connectLiveCounters() {
if (!('EventSource' in window)) return;
const source = new EventSource('/api/events');
source.addEventListener('metric', (event) => {
updateCounters(JSON.parse(event.data));
});
}
function updateCounters(meme) {
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
if (element.dataset.counterKind === 'view') {
element.lastChild.textContent = formatCount(meme.viewCount);
}
if (element.dataset.counterKind === 'download') {
element.lastChild.textContent = formatCount(meme.downloadCount);
}
}
}
function setLoader(label, spinning) {
feedLoaderLabel.textContent = label;
feedLoader.classList.toggle('is-spinning', spinning);
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function relativeAge(value) {
const elapsed = Math.max(1, Date.now() - new Date(value).getTime());
const minutes = Math.floor(elapsed / 60000);
if (minutes < 60) return `${minutes}M AGO`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}H AGO`;
return `${Math.floor(hours / 24)}D AGO`;
}
function formatCount(value) {
const count = Number.isFinite(value) ? value : 0;
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
function scoreBucket(value) {
const score = Number.isFinite(value) ? value : 50;
if (score >= 90) return 5;
if (score >= 75) return 4;
if (score >= 60) return 3;
if (score >= 40) return 2;
if (score >= 20) return 1;
return 0;
}
async function refreshStatus() {
const started = performance.now();
try {
const response = await fetch('/api/status', { cache: 'no-store' });
const status = await response.json();
if (!response.ok) throw new Error('status failed');
statStatus.textContent = status.ok ? 'ONLINE' : 'DEGRADED';
statLatency.textContent = `${Math.max(1, Math.round(performance.now() - started))}MS`;
statNodes.textContent = formatCount(status.liveClients);
statMemes.textContent = formatCount(status.memeCount);
} catch {
statStatus.textContent = 'OFFLINE';
statLatency.textContent = '--MS';
statNodes.textContent = '--';
statMemes.textContent = '--';
}
}
function updateScrollIndicator() {
const segments = [...scrollIndicator.querySelectorAll('span')];
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
const progress = Math.min(1, Math.max(0, window.scrollY / maxScroll));
const activeIndex = Math.min(segments.length - 1, Math.round(progress * (segments.length - 1)));
segments.forEach((segment, index) => {
segment.classList.toggle('active', index === activeIndex);
});
}
+695
View File
@@ -0,0 +1,695 @@
:root {
color-scheme: dark;
--void: #050505;
--background: #131313;
--surface-lowest: #0e0e0e;
--surface-low: #1c1b1b;
--surface: #201f1f;
--surface-high: #2a2a2a;
--surface-highest: #353534;
--text: #e5e2e1;
--muted: #b9ccb2;
--outline: #84967e;
--outline-variant: #3b4b37;
--primary: #00ff41;
--primary-dim: #00e639;
--on-primary: #003907;
--secondary: #00e0ff;
--error: #ffb4ab;
--gutter: 16px;
--desktop-margin: 32px;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--void);
color: var(--text);
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
line-height: 1.5;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
}
button,
a,
input {
font: inherit;
}
button,
a {
cursor: pointer;
}
.topbar {
position: fixed;
z-index: 20;
inset: 0 0 auto;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--desktop-margin);
background: rgb(19 19 19 / 90%);
border-bottom: 1px solid var(--outline-variant);
backdrop-filter: blur(12px);
}
.brand,
.primary-action,
.secondary-action,
.status-line,
.card-meta,
.card-stat,
.side-terminal,
.upload-rules,
.form-status,
.lightbox-toolbar {
font-size: 12px;
line-height: 1.2;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.brand {
color: var(--primary-dim);
letter-spacing: 0.2em;
}
.primary-action,
.secondary-action,
.icon-action {
border: 1px solid var(--outline-variant);
border-radius: 0;
min-height: 36px;
background: transparent;
color: var(--muted);
text-decoration: none;
}
.primary-action {
padding: 0 24px;
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
}
.primary-action:hover,
.primary-action:focus-visible {
background: var(--text);
border-color: var(--text);
}
.secondary-action {
padding: 0 18px;
}
.secondary-action:hover,
.secondary-action:focus-visible,
.icon-action:hover,
.icon-action:focus-visible {
border-color: var(--primary-dim);
color: var(--primary-dim);
background: var(--surface-high);
}
.icon-action {
width: 36px;
height: 36px;
}
.material-symbols-outlined {
display: inline-block;
font-family: "Material Symbols Outlined";
font-size: 18px;
font-style: normal;
font-weight: normal;
line-height: 1;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
direction: ltr;
font-feature-settings: "liga";
-webkit-font-feature-settings: "liga";
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 20;
-webkit-font-smoothing: antialiased;
}
.shell {
width: min(100%, 1440px);
margin: 0 auto;
padding: 96px var(--desktop-margin) 48px;
}
.status-line {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--gutter);
padding-bottom: 8px;
color: var(--outline);
font-size: 10px;
border-bottom: 1px solid var(--outline-variant);
}
.live {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pulse {
width: 6px;
height: 6px;
background: var(--primary);
animation: pulse 1.4s steps(2, end) infinite;
}
.meme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
margin-top: 32px;
}
.meme-card {
overflow: hidden;
background: var(--surface-lowest);
border: 1px solid #1a1a1a;
box-shadow: 0 0 10px rgb(0 255 65 / 10%);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
background: var(--surface);
border-bottom: 1px solid var(--outline-variant);
}
.card-id {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
color: var(--muted);
}
.node {
position: relative;
flex: 0 0 auto;
width: 8px;
height: 8px;
background: var(--outline-variant);
}
.node.active {
background: var(--primary);
}
.node-score-0 {
opacity: 0.25;
}
.node-score-1 {
opacity: 0.4;
}
.node-score-2 {
opacity: 0.55;
}
.node-score-3 {
opacity: 0.72;
}
.node-score-4 {
opacity: 0.88;
}
.node-score-5 {
opacity: 1;
box-shadow: 0 0 8px rgb(0 255 65 / 45%);
}
.node::after {
position: absolute;
z-index: 5;
left: 50%;
bottom: calc(100% + 10px);
width: max-content;
max-width: 220px;
padding: 6px 8px;
border: 1px solid var(--outline-variant);
background: #111;
color: var(--text);
content: attr(data-tooltip);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
line-height: 1.3;
opacity: 0;
pointer-events: none;
text-transform: uppercase;
transform: translate(-50%, 4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.node:hover::after,
.node:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
}
.card-meta {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.age {
flex: 0 0 auto;
color: var(--outline);
}
.image-frame {
aspect-ratio: 1 / 1;
overflow: hidden;
background: #000;
}
.image-frame img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
opacity: 0.9;
transition: transform 700ms ease, opacity 200ms ease;
}
.meme-card:hover img {
transform: scale(1.05);
opacity: 1;
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 12px;
}
.stats {
display: flex;
gap: 14px;
color: var(--muted);
}
.card-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
letter-spacing: 0;
}
.card-stat .material-symbols-outlined {
color: var(--outline);
font-size: 16px;
}
.action-group {
display: flex;
gap: 8px;
}
.text-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
border: 1px solid #1a1a1a;
background: transparent;
color: var(--muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-decoration: none;
}
.icon-only-action {
width: 36px;
height: 36px;
padding: 0;
}
.text-action-accent {
border-color: var(--primary-dim);
color: var(--primary-dim);
}
.text-action:hover,
.text-action:focus-visible {
border-color: var(--primary-dim);
color: var(--primary-dim);
background: var(--surface-high);
}
.feed-loader {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
min-height: 104px;
margin-top: 24px;
color: var(--outline);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.loader-ring {
width: 32px;
height: 32px;
border: 2px solid transparent;
border-top-color: var(--primary);
border-radius: 9999px;
opacity: 0;
}
.feed-loader.is-spinning .loader-ring {
animation: spin 850ms linear infinite;
opacity: 1;
}
.side-terminal {
position: fixed;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 192px;
padding-left: 8px;
border-left: 1px solid var(--outline-variant);
color: var(--outline);
font-size: 10px;
opacity: 0.5;
pointer-events: none;
}
.side-terminal .active {
margin-top: 8px;
color: var(--primary-dim);
text-decoration: underline;
}
.scroll-indicator {
position: fixed;
right: 16px;
top: 50%;
display: flex;
flex-direction: column;
gap: 8px;
transform: translateY(-50%);
opacity: 0.3;
}
.scroll-indicator span {
display: block;
width: 4px;
height: 16px;
background: var(--outline-variant);
transition: height 160ms ease, background-color 160ms ease;
}
.scroll-indicator span.active {
height: 32px;
background: var(--primary);
}
dialog {
border: 0;
padding: 0;
background: transparent;
color: inherit;
}
dialog[open] {
animation: dialog-in 140ms ease-out;
}
dialog::backdrop {
background: rgb(0 0 0 / 72%);
backdrop-filter: blur(4px);
animation: backdrop-in 140ms ease-out;
}
.modal-panel {
width: min(520px, calc(100vw - 32px));
background: #111;
border: 1px solid var(--outline-variant);
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
}
.modal-header,
.lightbox-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
border-bottom: 1px solid var(--outline-variant);
background: var(--surface);
}
.modal-header h2 {
margin: 0;
font-family: Geist, Inter, system-ui, sans-serif;
font-size: 24px;
line-height: 1.2;
letter-spacing: 0;
}
.file-drop {
display: flex;
min-height: 160px;
margin: 16px;
align-items: center;
justify-content: center;
border: 1px dashed var(--outline);
background: var(--surface-lowest);
color: var(--muted);
}
.file-drop input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.upload-rules,
.form-status {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0 16px;
color: var(--outline);
font-size: 10px;
}
.form-status {
min-height: 18px;
margin-bottom: 16px;
color: var(--error);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.danger-action {
border-color: var(--error);
color: var(--error);
}
.review-select {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
}
.review-select input {
accent-color: var(--primary);
}
.review-body {
min-height: 76px;
padding: 12px;
border-top: 1px solid var(--outline-variant);
color: var(--outline);
font-size: 11px;
line-height: 1.45;
}
.review-body p {
margin: 0;
}
.lightbox {
width: min(1120px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
background: #080808;
border: 1px solid var(--outline-variant);
box-shadow: 0 0 24px rgb(0 255 65 / 14%);
}
.lightbox img {
display: block;
width: 100%;
max-height: calc(100vh - 144px);
object-fit: contain;
background: #000;
}
.lightbox-download {
display: flex;
width: max-content;
margin: 12px 12px 12px auto;
align-items: center;
gap: 6px;
}
.empty-state {
grid-column: 1 / -1;
padding: 48px 16px;
border: 1px solid var(--outline-variant);
color: var(--outline);
text-align: center;
}
[hidden] {
display: none !important;
}
@keyframes pulse {
50% {
opacity: 0.25;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes dialog-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 900px) {
.topbar,
.shell {
padding-left: 16px;
padding-right: 16px;
}
.meme-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.side-terminal,
.scroll-indicator {
display: none;
}
}
@media (max-width: 560px) {
.topbar {
height: 60px;
}
.brand {
font-size: 10px;
letter-spacing: 0.14em;
}
.admin-actions .secondary-action,
.admin-actions .primary-action {
min-height: 30px;
padding-inline: 8px;
font-size: 9px;
}
.primary-action,
.secondary-action {
padding-inline: 14px;
}
.shell {
padding-top: 84px;
}
.status-line,
.card-actions {
align-items: stretch;
flex-direction: column;
}
.meme-grid {
grid-template-columns: 1fr;
}
.action-group,
.stats {
justify-content: space-between;
}
}
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Meme Protocol</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="/assets/styles.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL</div>
<button class="primary-action" id="open-upload" type="button">SUBMIT</button>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<span>PROTOCOL_STREAM_V.1.04</span>
<span class="live"><span class="pulse"></span>LIVE_FEED</span>
</div>
<section class="meme-grid" id="meme-grid" aria-label="Meme feed"></section>
<div class="feed-loader" id="feed-loader" aria-live="polite">
<div class="loader-ring" aria-hidden="true"></div>
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
</div>
</main>
<aside class="side-terminal" aria-live="polite">
<div>STATUS: <span id="stat-status">SYNCING</span></div>
<div>LATENCY: <span id="stat-latency">--MS</span></div>
<div>NODES: <span id="stat-nodes">--</span></div>
<div>MEMES: <span id="stat-memes">--</span></div>
<div class="active" id="stat-pun">MEMETICALLY_ACTIVE</div>
</aside>
<div class="scroll-indicator" id="scroll-indicator" aria-hidden="true">
<span class="active"></span><span></span><span></span><span></span>
</div>
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
<form class="modal-panel" id="upload-form" method="dialog">
<div class="modal-header">
<h2 id="upload-title">SUBMIT_MEME</h2>
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
</div>
<label class="file-drop" for="meme-input">
<span id="file-label">SELECT PNG / JPEG</span>
<input id="meme-input" name="meme" type="file" accept="image/png,image/jpeg" required>
</label>
<div class="upload-rules">
<span>MAX_SIZE: 5MB</span>
<span>REQUIRED_RATIO: 1:1 SQUARE</span>
<span>OUTPUT_FORMAT: WEBP</span>
<span>MAX_DIMENSIONS: 6000x6000</span>
</div>
<div class="modal-actions">
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
<button class="primary-action" id="submit-upload" type="submit">UPLOAD</button>
</div>
<p class="form-status" id="form-status" role="status"></p>
</form>
</dialog>
<dialog class="lightbox" id="lightbox" aria-label="Meme viewer">
<div class="lightbox-toolbar">
<span id="lightbox-id"></span>
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
</div>
<img id="lightbox-image" alt="">
<a class="primary-action lightbox-download" id="lightbox-download" href="#"><span class="material-symbols-outlined" aria-hidden="true">download</span><span>DOWNLOAD</span></a>
</dialog>
</body>
</html>