73 lines
No EOL
11 KiB
HTML
73 lines
No EOL
11 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>particles drifting through silence — exploration</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: #0a0a0f; color: #3a3a4a; font-family: ui-monospace, monospace; overflow: hidden; }
|
||
#grid { padding: 20px; font-size: 12px; line-height: 1.4; white-space: pre; }
|
||
.c { display: inline-block; transition: all 0.3s; }
|
||
.c.active { color: #fde68a; text-shadow: 0 0 6px rgba(253,230,138,0.5); }
|
||
.c.strong { color: #34d399; text-shadow: 0 0 10px rgba(52,211,153,0.6); font-weight: bold; }
|
||
.c.dead { color: #1a1a2a; }
|
||
#info { position: fixed; bottom: 10px; left: 20px; color: #3a3a4a; font-size: 11px; }
|
||
#controls { position: fixed; top: 10px; right: 20px; color: #666; font-size: 11px; }
|
||
#controls span { cursor: pointer; margin-left: 12px; color: #fde68a; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="grid"></div>
|
||
<div id="info">neurameba · physarum exploration</div>
|
||
<div id="controls"><span id="play-btn">play</span><span id="reset-btn">reset</span></div>
|
||
<script>
|
||
const text = "# motd — milestone\n\n## what's built\n\nTerminal-aesthetic social platform. Everything happens in a terminal UI — no chrome, no nav bars. Commands in, text out.\n\n### the shell\n\n- Full terminal interface in the browser — dark background, monospace, command input at the bottom\n- Centered terminal layout, 800px default width\n- Drag-to-resize from left/right edges — invisible 8px hit zones, min 400px\n- `/terminal --width` — set width in px, %, or reset to default\n- Width persisted in localStorage\n- Mobile: 100% width, no resize\n- All visual settings (colors, font, prompt format, welcome message) driven by `config.json`\n- Client-side config system — localStorage overrides merged with server defaults\n- `/config export` — download config as JSON\n- `/config import` — load config from JSON file, validated\n- `/config reset` — restore server defaults\n- `/+` `/−` — font size increase/decrease (8-32px, persisted)\n- Command history with up/down arrow keys\n- Tab completion for all commands\n- Full sidebar — motd.social branding, commands, doc links, footer\n- Right-click context menu on interactive elements (usernames, tags, posts, doc links, media)\n- `/clear` wipes the terminal\n- `/help` and `/menu` show available commands\n- Favicon: m> SVG, dynamically generated from accent color\n- Guest landing shows last 5 posts on page load\n- 5 posts shown after login/register\n\n### auth\n\n- `/register` — interactive prompts, password masked via CSS (no browser autofill interference)\n- `/login` — session stored in sessionStorage (gone when tab closes)\n- `/login-permanently` — session stored in localStorage (persists)\n- `/logout` — clears session from both storages\n- Session restored on page load if valid\n- Browser password save triggered after successful login/register\n- Prompt updates: `guest@motd >` becomes `username@motd >`\n- Passwords hashed with bcrypt, validated (3-20 char usernames, 8+ char passwords)\n- Username blacklist: `motd*` prefix regex + fixed list of reserved names\n- Rate limiting: auth (20/15min), posts (30/min), uploads (10/min)\n- Auth via Bearer token in Authorization header\n\n### profiles\n\n- `/profile` — view your own profile with ASCII avatar and stats\n- `/profile @user` — view another user's profile\n- `/avatar` — upload profile picture (PNG), processed to ASCII art for terminal display\n- `/settings` — update display name, bio, confirm_post preference\n- `/find <query>` — search users by username or display name\n- ASCII avatars: uploaded image converted to ASCII, or deterministic hash pattern as fallback\n\n### posts & feed\n\n- Dual-purpose input: text without `/` is a post, text with `/` is a command\n- `confirm_post` preference (default on) — shows \"Post this? (y/n)\" before posting\n- `/post <text>` — always posts immediately, skips confirmation\n- `/feed` — chronological feed, public (no login required). Authenticated users get kill-filtered results, guests get unfiltered\n- `/reply <id> <text>` — reply with grey quote block of parent post\n- `/re <text>` — reply to last displayed post (no ID needed)\n- `/goto <id>` — jump to a specific post\n- Tags use [brackets]: `[rust]`, `[music]`, `[coding]`\n- Tags stripped from displayed content, shown only as clickable links below\n- Tags auto-extracted from post content and auto-assigned to categories\n- Short alphanumeric IDs (6-8 chars) for posts and media\n\n### post lifecycle\n\n- Active: posts visible in feed for 30 days\n- Archived: moved to archive table, searchable for 90 more days\n- Purged: permanently deleted after 120 days total\n- Cron job runs daily — archives, purges, cleans dead filter references\n- `/find --archive` searches archived posts\n\n### discovery\n\n- `/find <query>` — search users and posts\n- `/find --archive <query>` — search archived posts\n- `/tree -cat` — browse categories and tags with clickable bracket links\n- `/link <target>` — universal navigation (user, tag, post, doc)\n- Categories: coding, creative, meta, general — with seeded tag mappings\n\n### media\n\n- `/avatar` — upload profile picture (PNG only)\n- `/upload` — upload PNG (5MB), MP3 (10MB), MP4 (25MB)\n- PNG pipeline: original (max 1MB), thumbnail (80px), large (800px), ASCII art\n- `/attach <media_id>` — attach media to a post (inline command)\n- `/view` — view original media (ASCII art + inline image)\n- `/play` / `/pause` — audio and video playback with terminal progress bar\n- `/expand media` — expand all media in view inline\n- `/expand nada` — collapse all inline media\n- `/expand <id>` — expand media from a specific post\n- `/rmedia <id>` — delete your uploaded media\n- Media deleted on account termination and post purge\n\n### hidden moderation (/kill)\n\n- `/kill @username` — hides user's content from your feed silently\n- `/kill <post_id>` — hides a specific post\n- filter_score decays over time (7-day half-life), threshold at 10\n- Users filtered by enough people get suppressed from everyone's feed — silently, no ban, no notification\n- The word \"kill\" never appears in the database or API — stored as \"filters\" and \"filter_score\"\n\n### bookmarks\n\n- `/bookmark <target>` — bookmark a user, tag, post, or doc\n- `/unbookmark <target>` — remove a bookmark\n- `/bookmarks` — view all bookmarks grouped by type\n- `/bookmarks --type` — filter by user, tag, post, or doc\n- Bookmarks are private — no counts, no notifications, nobody sees them\n- Bookmarked posts show [archived] or [expired] status when applicable\n- Deduplication — bookmarking the same thing twice is a no-op\n- Right-click context menu: bookmark/unbookmark on any interactive element\n\n### docs\n\n- `/read <name>` — inline markdown reader\n- `/read` — list available docs\n- Docs served from `public/docs/` as markdown files\n- Available: about, api, commands, milestone, roadmap, plugins, changelog, terms, support\n- Terms include GDPR (EU rights, Helsinki servers) and age requirement (16+)\n\n### account management\n\n- `/terminate-account` — permanent account deletion\n- Cascading wipe: bookmarks, posts, media files, sessions, credentials, user record\n- Username confirmation required\n- Username freed immediately\n\n### api\n\nAll endpoints return `{ ok: true, data: {...} }` or `{ ok: false, error: \"...\" }`.\n\n```\nPOST /api/auth/register\nPOST /api/auth/login\nPOST /api/auth/logout\nGET /api/auth/me\n\nGET /api/profile/search?q=\nGET /api/profile/:username\nPUT /api/profile\n\nPOST /api/filter\nGET /api/filter\n\nPOST /api/posts\nGET /api/posts/feed (public, auth optional)\nGET /api/posts/search?q=&archive=\nGET /api/posts/:id\nGET /api/posts/:id/thread\n\nPOST /api/avatar\nGET /api/avatar/:username\n\nPOST /api/media/upload\nGET /api/media/:id\nGET /api/media/:id/thumb\nGET /api/media/:id/large\nGET /api/media/:id/ascii\n\nPOST /api/bookmarks\nDELETE /api/bookmarks\nGET /api/bookmarks?type=\n\nGET /api/docs\nGET /api/docs/:name\n\nGET /api/categories\n\nDELETE /api/account\n\nGET /api/commands\n```\n\n### tech\n\n- Node.js + Express backend\n- libSQL (SQLite-compatible) database\n- Vanilla JS single-page app — no framework\n- System monospace fonts only — no external font loading\n- Session-based auth with cookie + bearer token support\n- API-first: every feature accessible through the API\n- Daily cron for post lifecycle management\n- Rate limiting on auth, posts, and uploads\n- Deploy script with DB backup and auto-rollback\n- Server: Hetzner Helsinki, systemd + nginx + certbot\n";
|
||
const passes = [{"t":0,"r":4,"c":59,"a":"hold","s":0.18838486372922505,"ps":9,"e":100.1570789098338,"pr":1.1},{"t":0,"r":154,"c":10,"a":"hold","s":0.21900182847725905,"ps":9,"e":100.40201462781808,"pr":1.1},{"t":0,"r":168,"c":9,"a":"hold","s":0.2061279488488965,"ps":9,"e":100.29902359079117,"pr":1.1},{"t":0,"r":43,"c":0,"a":"died","s":0,"ps":8,"e":100,"pr":1},{"t":1,"r":4,"c":59,"a":"hold","s":0.18082651245075224,"ps":8,"e":100.40369100943981,"pr":1.05},{"t":1,"r":154,"c":10,"a":"hold","s":0.24160578959914963,"ps":9,"e":100.98486094461127,"pr":1.1},{"t":1,"r":168,"c":9,"a":"hold","s":0.20553408381076443,"ps":8,"e":100.74329626127728,"pr":1.05},{"t":2,"r":4,"c":59,"a":"retracted","s":0.18838486372922505,"ps":7,"e":100.86076991927361,"pr":1},{"t":2,"r":154,"c":10,"a":"retracted","s":0.24160578959914963,"ps":8,"e":101.71770726140447,"pr":1.05},{"t":2,"r":168,"c":9,"a":"retracted","s":0.2061279488488965,"ps":7,"e":101.34231985206846,"pr":1}];
|
||
const lines = text.split('\n');
|
||
const gridEl = document.getElementById('grid');
|
||
const charEls = [];
|
||
for (let r = 0; r < lines.length; r++) {
|
||
const row = [];
|
||
for (let c = 0; c < lines[r].length; c++) {
|
||
const s = document.createElement('span');
|
||
s.className = 'c';
|
||
s.textContent = lines[r][c];
|
||
row.push(s);
|
||
gridEl.appendChild(s);
|
||
}
|
||
charEls.push(row);
|
||
gridEl.appendChild(document.createTextNode('\n'));
|
||
}
|
||
let tick = -1, playing = false, iv;
|
||
function apply(t) {
|
||
for (const r of charEls) for (const e of r) e.className = 'c';
|
||
const active = new Map();
|
||
for (const p of passes) {
|
||
if (p.t > t) break;
|
||
const k = p.r+','+p.c;
|
||
if (p.a === 'died' || p.a === 'retracted') active.set(k, 'dead');
|
||
else if (p.ps > 16) active.set(k, 'strong');
|
||
else active.set(k, 'active');
|
||
}
|
||
for (const [k, cls] of active) {
|
||
const [r, c] = k.split(',').map(Number);
|
||
if (charEls[r]?.[c]) charEls[r][c].className = 'c ' + cls;
|
||
}
|
||
document.getElementById('info').textContent = 'tick ' + t + ' · ' + [...active.values()].filter(v=>v!=='dead').length + ' alive';
|
||
}
|
||
function play() {
|
||
if (playing) return;
|
||
playing = true;
|
||
document.getElementById('play-btn').textContent = 'pause';
|
||
const max = passes.length > 0 ? passes[passes.length-1].t : 0;
|
||
iv = setInterval(() => { tick++; if (tick > max) { pause(); return; } apply(tick); }, 900);
|
||
}
|
||
function pause() { playing = false; clearInterval(iv); document.getElementById('play-btn').textContent = 'play'; }
|
||
function reset() { pause(); tick = -1; for (const r of charEls) for (const e of r) e.className = 'c'; document.getElementById('info').textContent = 'neurameba'; }
|
||
document.getElementById('play-btn').addEventListener('click', () => playing ? pause() : play());
|
||
document.getElementById('reset-btn').addEventListener('click', reset);
|
||
setTimeout(play, 1000);
|
||
</script>
|
||
</body>
|
||
</html> |