Add runnable local twitter clone demo (node + static UI)
This commit is contained in:
29
example_projects/twitter_clone_local/README.md
Normal file
29
example_projects/twitter_clone_local/README.md
Normal 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": "..." }`
|
||||
109
example_projects/twitter_clone_local/public/app.js
Normal file
109
example_projects/twitter_clone_local/public/app.js
Normal 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}`;
|
||||
});
|
||||
66
example_projects/twitter_clone_local/public/index.html
Normal file
66
example_projects/twitter_clone_local/public/index.html
Normal 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>
|
||||
75
example_projects/twitter_clone_local/public/styles.css
Normal file
75
example_projects/twitter_clone_local/public/styles.css
Normal 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; }
|
||||
}
|
||||
226
example_projects/twitter_clone_local/server.js
Normal file
226
example_projects/twitter_clone_local/server.js
Normal 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`);
|
||||
});
|
||||
Reference in New Issue
Block a user