diff --git a/example_projects/twitter_clone_local/README.md b/example_projects/twitter_clone_local/README.md new file mode 100644 index 0000000..2d53885 --- /dev/null +++ b/example_projects/twitter_clone_local/README.md @@ -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": "..." }` diff --git a/example_projects/twitter_clone_local/public/app.js b/example_projects/twitter_clone_local/public/app.js new file mode 100644 index 0000000..de87546 --- /dev/null +++ b/example_projects/twitter_clone_local/public/app.js @@ -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}`; +}); diff --git a/example_projects/twitter_clone_local/public/index.html b/example_projects/twitter_clone_local/public/index.html new file mode 100644 index 0000000..bd0b160 --- /dev/null +++ b/example_projects/twitter_clone_local/public/index.html @@ -0,0 +1,66 @@ + + + + + + Twitter Clone Local + + + +
+
+

Twitter Clone Local

+

Post, like, and reply on a local timeline.

+
+ +
+

New Tweet

+
+ + + +
+

+
+ +
+
+

Timeline

+ +
+ +
+
+ + + + + + diff --git a/example_projects/twitter_clone_local/public/styles.css b/example_projects/twitter_clone_local/public/styles.css new file mode 100644 index 0000000..d32d714 --- /dev/null +++ b/example_projects/twitter_clone_local/public/styles.css @@ -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; } +} diff --git a/example_projects/twitter_clone_local/server.js b/example_projects/twitter_clone_local/server.js new file mode 100644 index 0000000..72fc5c9 --- /dev/null +++ b/example_projects/twitter_clone_local/server.js @@ -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`); +});