Add runnable local twitter clone demo (node + static UI)

This commit is contained in:
Bill
2026-03-03 10:31:32 -07:00
parent 452603ae7f
commit 9f9d9590c3
5 changed files with 505 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# Twitter Clone Local
Minimal local Twitter-like demo (no npm dependencies).
## Run
```bash
cd /home/bill/Documents/CLionProjects/whetstone_DSL/example_projects/twitter_clone_local
node server.js
```
Then open:
- http://localhost:3000
## Features
- Create tweets
- Like tweets
- Reply to tweets
- Timeline refresh
## API
- `GET /api/health`
- `GET /api/tweets`
- `POST /api/tweets` body: `{ "author": "...", "text": "..." }`
- `POST /api/tweets/:id/like`
- `POST /api/tweets/:id/reply` body: `{ "author": "...", "text": "..." }`

View File

@@ -0,0 +1,109 @@
'use strict';
const tweetsEl = document.getElementById('tweets');
const refreshBtn = document.getElementById('refresh');
const tweetForm = document.getElementById('tweet-form');
const statusEl = document.getElementById('status');
const template = document.getElementById('tweet-template');
function fmtTime(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `http_${res.status}`);
return data;
}
function renderTweets(tweets) {
tweetsEl.innerHTML = '';
for (const tweet of tweets) {
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.tweetId = String(tweet.id);
node.querySelector('.author').textContent = `@${tweet.author}`;
node.querySelector('.time').textContent = fmtTime(tweet.createdAt);
node.querySelector('.tweet-text').textContent = tweet.text;
node.querySelector('.likes').textContent = `${tweet.likes} likes`;
const likeBtn = node.querySelector('.like-btn');
likeBtn.addEventListener('click', async () => {
try {
await api(`/api/tweets/${tweet.id}/like`, { method: 'POST' });
await loadTweets();
} catch (err) {
statusEl.textContent = `Like failed: ${err.message}`;
}
});
const repliesEl = node.querySelector('.replies');
for (const r of tweet.replies || []) {
const li = document.createElement('li');
li.textContent = `@${r.author}: ${r.text}`;
repliesEl.appendChild(li);
}
const replyForm = node.querySelector('.reply-form');
replyForm.addEventListener('submit', async (e) => {
e.preventDefault();
const author = replyForm.querySelector('.reply-author').value.trim();
const text = replyForm.querySelector('.reply-text').value.trim();
if (!author || !text) return;
try {
await api(`/api/tweets/${tweet.id}/reply`, {
method: 'POST',
body: JSON.stringify({ author, text })
});
await loadTweets();
} catch (err) {
statusEl.textContent = `Reply failed: ${err.message}`;
}
});
tweetsEl.appendChild(node);
}
}
async function loadTweets() {
const data = await api('/api/tweets');
renderTweets(data.tweets || []);
}
refreshBtn.addEventListener('click', async () => {
statusEl.textContent = '';
try {
await loadTweets();
} catch (err) {
statusEl.textContent = `Refresh failed: ${err.message}`;
}
});
tweetForm.addEventListener('submit', async (e) => {
e.preventDefault();
const form = new FormData(tweetForm);
const author = String(form.get('author') || '').trim();
const text = String(form.get('text') || '').trim();
if (!author || !text) return;
statusEl.textContent = 'Posting...';
try {
await api('/api/tweets', {
method: 'POST',
body: JSON.stringify({ author, text })
});
tweetForm.reset();
statusEl.textContent = 'Tweet posted.';
await loadTweets();
} catch (err) {
statusEl.textContent = `Post failed: ${err.message}`;
}
});
loadTweets().catch(err => {
statusEl.textContent = `Initial load failed: ${err.message}`;
});

View File

@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Twitter Clone Local</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="app">
<header class="topbar">
<h1>Twitter Clone Local</h1>
<p>Post, like, and reply on a local timeline.</p>
</header>
<section class="composer card">
<h2>New Tweet</h2>
<form id="tweet-form">
<label>
Author
<input id="author" name="author" maxlength="32" placeholder="your-handle" required />
</label>
<label>
Text
<textarea id="text" name="text" maxlength="280" placeholder="What is happening?" required></textarea>
</label>
<button type="submit">Tweet</button>
</form>
<p id="status" class="status"></p>
</section>
<section class="timeline card">
<div class="timeline-head">
<h2>Timeline</h2>
<button id="refresh" type="button">Refresh</button>
</div>
<ul id="tweets" class="tweets"></ul>
</section>
</main>
<template id="tweet-template">
<li class="tweet">
<div class="tweet-head">
<span class="author"></span>
<span class="time"></span>
</div>
<p class="tweet-text"></p>
<div class="tweet-actions">
<button class="like-btn" type="button">Like</button>
<span class="likes"></span>
</div>
<details class="replies-wrap">
<summary>Replies</summary>
<ul class="replies"></ul>
<form class="reply-form">
<input class="reply-author" placeholder="author" maxlength="32" required />
<input class="reply-text" placeholder="reply" maxlength="280" required />
<button type="submit">Reply</button>
</form>
</details>
</li>
</template>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,75 @@
:root {
--bg: #f5f7fa;
--card: #ffffff;
--line: #dde3ea;
--text: #15202b;
--muted: #617182;
--primary: #1d9bf0;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%);
color: var(--text);
}
.app {
max-width: 860px;
margin: 0 auto;
padding: 16px;
display: grid;
gap: 14px;
}
.topbar h1 { margin: 0; font-size: 28px; }
.topbar p { margin: 4px 0 0; color: var(--muted); }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
}
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-size: 14px; }
input, textarea, button {
font: inherit;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px 10px;
}
textarea { min-height: 90px; resize: vertical; }
button {
cursor: pointer;
background: var(--primary);
color: #fff;
border: none;
}
button:hover { filter: brightness(0.96); }
.status { min-height: 20px; margin: 0; color: var(--muted); }
.timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.tweets { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.tweet {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
background: #fcfdff;
}
.tweet-head { display: flex; justify-content: space-between; gap: 10px; }
.author { font-weight: 700; }
.time { color: var(--muted); font-size: 12px; }
.tweet-text { margin: 8px 0; white-space: pre-wrap; }
.tweet-actions { display: flex; gap: 10px; align-items: center; }
.replies-wrap { margin-top: 8px; }
.replies { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 6px; }
.replies li { border-left: 2px solid var(--line); padding-left: 8px; font-size: 14px; }
.reply-form {
display: grid;
grid-template-columns: 140px 1fr auto;
gap: 6px;
}
@media (max-width: 700px) {
.reply-form { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env node
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const PORT = Number(process.env.PORT || 3000);
const PUBLIC_DIR = path.join(__dirname, 'public');
let nextTweetId = 4;
let nextReplyId = 1;
const tweets = [
{
id: 1,
author: 'whetstone',
text: 'Twitter clone local demo is live.',
likes: 3,
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
replies: []
},
{
id: 2,
author: 'editor-bot',
text: 'You can post, like, and reply in this demo timeline.',
likes: 1,
createdAt: new Date(Date.now() - 1000 * 60 * 20).toISOString(),
replies: []
},
{
id: 3,
author: 'bill',
text: 'Running locally with zero external dependencies.',
likes: 2,
createdAt: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
replies: []
}
];
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
});
res.end(body);
}
function sendText(res, statusCode, text) {
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(text)
});
res.end(text);
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let raw = '';
req.on('data', chunk => {
raw += chunk;
if (raw.length > 1024 * 1024) {
reject(new Error('payload_too_large'));
}
});
req.on('end', () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('invalid_json'));
}
});
req.on('error', reject);
});
}
function getTweetById(id) {
return tweets.find(t => t.id === id) || null;
}
function listTweets() {
return [...tweets].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
async function handleApi(req, res, pathname) {
if (req.method === 'GET' && pathname === '/api/health') {
sendJson(res, 200, { ok: true, service: 'twitter-clone-local' });
return;
}
if (req.method === 'GET' && pathname === '/api/tweets') {
sendJson(res, 200, { tweets: listTweets() });
return;
}
if (req.method === 'POST' && pathname === '/api/tweets') {
try {
const body = await parseBody(req);
const author = String(body.author || '').trim();
const text = String(body.text || '').trim();
if (!author || !text) {
sendJson(res, 400, { error: 'author_and_text_required' });
return;
}
const tweet = {
id: nextTweetId++,
author,
text,
likes: 0,
createdAt: new Date().toISOString(),
replies: []
};
tweets.push(tweet);
sendJson(res, 201, { tweet });
} catch (err) {
sendJson(res, 400, { error: err.message || 'invalid_request' });
}
return;
}
const likeMatch = pathname.match(/^\/api\/tweets\/(\d+)\/like$/);
if (req.method === 'POST' && likeMatch) {
const id = Number(likeMatch[1]);
const tweet = getTweetById(id);
if (!tweet) {
sendJson(res, 404, { error: 'tweet_not_found' });
return;
}
tweet.likes += 1;
sendJson(res, 200, { tweet });
return;
}
const replyMatch = pathname.match(/^\/api\/tweets\/(\d+)\/reply$/);
if (req.method === 'POST' && replyMatch) {
const id = Number(replyMatch[1]);
const tweet = getTweetById(id);
if (!tweet) {
sendJson(res, 404, { error: 'tweet_not_found' });
return;
}
try {
const body = await parseBody(req);
const author = String(body.author || '').trim();
const text = String(body.text || '').trim();
if (!author || !text) {
sendJson(res, 400, { error: 'author_and_text_required' });
return;
}
const reply = {
id: nextReplyId++,
author,
text,
createdAt: new Date().toISOString()
};
tweet.replies.push(reply);
sendJson(res, 201, { tweet, reply });
} catch (err) {
sendJson(res, 400, { error: err.message || 'invalid_request' });
}
return;
}
sendJson(res, 404, { error: 'not_found' });
}
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.html') return 'text/html; charset=utf-8';
if (ext === '.css') return 'text/css; charset=utf-8';
if (ext === '.js') return 'application/javascript; charset=utf-8';
if (ext === '.json') return 'application/json; charset=utf-8';
return 'text/plain; charset=utf-8';
}
function safeFilePath(pathname) {
const cleanPath = pathname === '/' ? '/index.html' : pathname;
const abs = path.join(PUBLIC_DIR, path.normalize(cleanPath));
if (!abs.startsWith(PUBLIC_DIR)) return null;
return abs;
}
function serveStatic(res, pathname) {
const abs = safeFilePath(pathname);
if (!abs) {
sendText(res, 403, 'forbidden');
return;
}
fs.readFile(abs, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
sendText(res, 404, 'not found');
} else {
sendText(res, 500, 'internal error');
}
return;
}
res.writeHead(200, {
'Content-Type': contentTypeFor(abs),
'Content-Length': data.length
});
res.end(data);
});
}
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
if (pathname.startsWith('/api/')) {
await handleApi(req, res, pathname);
return;
}
serveStatic(res, pathname);
});
server.listen(PORT, () => {
process.stdout.write(`twitter_clone_local listening on http://localhost:${PORT}\n`);
});