mirror of
https://github.com/spchcap/speech.capital.git
synced 2026-01-13 16:18:06 +00:00
115 lines
20 KiB
HTML
115 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="description" content="speech.capital - open discourse platform">
|
|
<title>speech.capital</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=Mozilla+Text:wght@200..700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.1/github-markdown.min.css">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/lucide@latest" defer></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
<style>
|
|
body{font-family:ui-monospace,monospace} h2 a{font-family:'Mozilla Text',sans-serif}
|
|
.vote-count{transform:rotate(-90deg);white-space:nowrap}
|
|
.preview{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
|
.comment{border-left:2px solid #e5e7eb;padding-left:12px}
|
|
.markdown-body{background-color:transparent;font-size:0.875rem}
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-100">
|
|
<div class="min-h-screen">
|
|
<header class="bg-gray-900 border-b border-gray-800">
|
|
<div class="max-w-5xl mx-auto px-2 py-1 flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<a href="https://speech.capital" class="text-white hover:text-gray-300"><i id="logo-icon" data-lucide="cctv" class="w-4 h-4"></i></a>
|
|
<a href="/" class="text-white hover:text-gray-300">
|
|
<span class="font-bold text-xs"><span id="subdomain"></span><span>speech.capital</span></span>
|
|
</a>
|
|
</div>
|
|
<nav class="flex items-center gap-3 text-xs text-gray-400">
|
|
<a href="/?sort=hot" class="hover:text-white">hot</a><a href="/?sort=new" class="hover:text-white">new</a>
|
|
<a href="https://speech.capital/signup" id="signup-link" class="hover:text-white">signup</a>
|
|
<span id="auth-links" style="display:none" class="flex items-center gap-3">
|
|
<a id="username-link" class="text-white font-bold hover:underline"></a>
|
|
<a href="/submit" class="hover:text-white">submit</a>
|
|
</span>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="max-w-5xl mx-auto" x-data="f()" x-init="init()">
|
|
<!-- Home View (Root Domain) -->
|
|
<div x-show="view==='home'" class="p-8 bg-white border-l border-r border-gray-300">
|
|
<h1 class="text-xl font-bold mb-4">Welcome to speech.capital</h1>
|
|
<p class="mb-6 text-sm text-gray-600">An open discourse platform. Select a sub to begin:</p>
|
|
<div class="space-y-2"><a href="https://free.speech.capital" class="text-blue-600 hover:underline">free.speech.capital</a><br><a href="https://artificial.speech.capital" class="text-blue-600 hover:underline">artificial.speech.capital</a><br><a href="https://dev.speech.capital" class="text-blue-600 hover:underline">dev.speech.capital</a></div>
|
|
</div>
|
|
|
|
<!-- User Profile View -->
|
|
<div x-show="view==='user'" class="bg-white border-l border-r border-gray-300 min-h-[80vh] p-4 sm:p-6">
|
|
<div x-show="loading" class="text-center text-gray-400">Loading...</div>
|
|
<div x-show="!loading && !profile.user" class="text-center text-gray-400">User not found.</div>
|
|
<div x-show="!loading && profile.user" style="display:none">
|
|
<div class="flex justify-between items-baseline">
|
|
<h1 class="text-xl font-bold" x-text="profile.user.username"></h1>
|
|
<span class="text-xs text-gray-500">Joined <span x-text="formatDate(profile.user.created_at)"></span></span>
|
|
</div>
|
|
<div class="flex justify-end mt-2" x-show="user && user.username===profile.user.username">
|
|
<a href="/api/logout" class="text-blue-600 hover:underline text-xs">Logout</a>
|
|
</div>
|
|
<h2 class="text-lg font-semibold mt-6 border-b border-gray-200 pb-2 mb-4">Posts</h2>
|
|
<div class="space-y-3 text-sm">
|
|
<p x-show="profile.posts.length===0" class="text-gray-400">No posts yet.</p>
|
|
<template x-for="p in profile.posts" :key="p.id"><div><a :href="`https://${p.sub_name}.speech.capital/${p.id}`" class="text-blue-600 hover:underline" x-text="p.title"></a><div class="text-xs text-gray-500 mt-1"><span x-text="ago(p.created_at)"></span> on <span class="font-semibold" x-text="p.sub_name"></span></div></div></template>
|
|
</div>
|
|
<h2 class="text-lg font-semibold mt-6 border-b border-gray-200 pb-2 mb-4">Comments</h2>
|
|
<div class="space-y-4 text-sm">
|
|
<p x-show="profile.comments.length===0" class="text-gray-400">No comments yet.</p>
|
|
<template x-for="c in profile.comments" :key="c.id"><div class="border-l-2 border-gray-300 pl-4"><p class="text-gray-800 whitespace-pre-wrap" x-text="c.content"></p><div class="text-xs text-gray-500 mt-2">on <a :href="`https://${c.sub_name}.speech.capital/${c.post_id}`" class="text-blue-600 hover:underline" x-text="c.post_title"></a> · <span x-text="ago(c.created_at)"></span></div></div></template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Post List View (Subdomain) -->
|
|
<div x-show="view==='list'" class="bg-white border-l border-r border-gray-300">
|
|
<template x-for="(p,i) in posts" :key="p.id"><article class="border-b border-gray-200 last:border-0 bg-gray-50"><div class="flex gap-2 p-2"><div class="flex flex-col items-center gap-0.5"><button @click="vote(p,1)" :class="p.voted===1?'text-gray-900':'text-gray-400'" class="hover:text-gray-900 p-1"><svg class="w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[10px] border-b-current" viewBox="0 0 12 10"><polygon points="6,0 12,10 0,10" fill="currentColor"/></svg></button><span class="text-xs text-gray-600 font-medium vote-count" x-text="p.score"></span></div><div class="flex-1 min-w-0"><div class="flex items-baseline gap-1"><span class="text-gray-400 text-xs font-medium" x-text="(i+1)+'.'"></span><h2 class="text-xs leading-tight"><a :href="p.link||`/${p.id}`" class="text-gray-900 hover:underline font-medium" x-text="p.title"></a><span class="text-gray-400 text-xs ml-1" x-text="'('+domain(p)+')'"></span><button x-show="isImageLink(p.link)" @click.stop="p.showImage=!p.showImage" class="ml-1 text-gray-500 hover:text-gray-900 text-xs" x-text="p.showImage?'[-]':'[+]'"></button></h2></div><p x-show="!p.link&&p.content" class="preview text-xs text-gray-600 mt-1 leading-relaxed" x-text="p.content"></p><div x-show="p.showImage" class="mt-1"><a :href="p.link" target="_blank" rel="noopener noreferrer"><img :src="p.link" class="max-w-full max-h-[70vh] h-auto rounded border border-gray-200"></a></div><div class="flex items-center justify-end text-xs text-gray-400 mt-0.5 gap-1.5"><span>by <a :href="'https://speech.capital/'+p.username" class="hover:underline" x-text="p.username"></a></span><span>·</span><span x-text="ago(p.created_at)"></span><span>·</span><a :href="`/${p.id}`" class="hover:underline" x-text="p.comment_count+' comments'"></a><template x-if="isMod()"><span class="flex items-center gap-1.5 text-gray-500"><span>·</span><div x-data="{open:false}" @click.away="open=false" class="relative"><button @click.stop="open=!open" title="Moderate" class="hover:text-gray-900 -mb-1"><i data-lucide="more-vertical" class="w-4 h-4"></i></button><div x-show="open" style="display:none" class="absolute right-0 mt-2 w-max bg-white rounded-md shadow-lg z-10 border border-gray-200 text-left"><a @click.prevent.stop="moderate('post_overwrite',p);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Overwrite</a><a @click.prevent.stop="moderate('post_wipe',p);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Wipe</a><a @click.prevent.stop="banUser(p.user_id,p.username);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Ban User</a></div></div></span></template></div></div></div></article></template>
|
|
<div x-show="posts.length===0" class="p-8 text-center text-gray-400 text-sm">No posts yet.</div>
|
|
</div>
|
|
|
|
<!-- Post Detail View (Subdomain) -->
|
|
<div x-show="view==='post'" class="py-4">
|
|
<div x-show="post" class="bg-gray-50 border border-gray-200 rounded-lg p-4 sm:p-6">
|
|
<div>
|
|
<h1 class="text-lg font-bold mb-2" x-text="post.title"></h1>
|
|
<a x-show="post.link" :href="post.link" class="text-blue-600 hover:underline text-sm" x-text="post.link"></a>
|
|
<div x-show="post.content" x-html="renderMarkdown(post.content)" class="markdown-body mt-2" data-theme="light"></div>
|
|
<div class="flex items-center gap-3 text-xs text-gray-400 mt-3"><button @click="vote(post,1)" :class="post.voted===1?'text-gray-900':'text-gray-400'" class="hover:text-gray-900">▲</button><span x-text="post.score+' points'"></span><span x-text="ago(post.created_at)"></span><span>· by <a :href="'https://speech.capital/'+post.username" class="hover:underline" x-text="post.username"></a></span><template x-if="isMod()"><span class="flex items-center gap-1.5 text-gray-500"><span>·</span><div x-data="{open:false}" @click.away="open=false" class="relative"><button @click.stop="open=!open" title="Moderate" class="hover:text-gray-900"><i data-lucide="more-vertical" class="w-4 h-4"></i></button><div x-show="open" style="display:none" class="absolute left-0 mt-2 w-max bg-white rounded-md shadow-lg z-10 border border-gray-200 text-left"><a @click.prevent.stop="moderate('post_overwrite',post);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Overwrite</a><a @click.prevent.stop="moderate('post_wipe',post);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Wipe</a><a @click.prevent.stop="banUser(post.user_id,post.username);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Ban User</a></div></div></span></template></div>
|
|
</div>
|
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
|
<h2 class="text-sm font-bold mb-3">Comments</h2>
|
|
<p x-show="user" class="text-xs text-gray-500 mb-2">Comments are moderated by Gemini 2.5 Flash Lite.</p>
|
|
<form @submit.prevent="comment(null,$event)" class="mb-6" x-show="user"><textarea x-model="txt" rows="4" class="w-full border border-gray-200 rounded px-3 py-2 text-sm" placeholder="Add a comment..."></textarea><div class="cf-turnstile mt-2" data-sitekey="0x4AAAAAAB4klN__r6wwJXs4"></div><button type="submit" :disabled="commenting" class="bg-gray-900 text-white px-4 py-2 rounded text-sm hover:bg-gray-800 mt-2" x-text="commenting?'Moderating...':'Comment'"></button><span x-show="commentSuccess" class="text-green-600 text-sm ml-2">Approved!</span></form>
|
|
<div x-show="!user" class="text-sm text-gray-500 mb-6"><a href="https://speech.capital/login" class="text-blue-600 hover:underline">Log in</a> to comment.</div>
|
|
<div class="space-y-4">
|
|
<template x-for="c in comments" :key="c.id"><div class="comment"><div class="text-xs text-gray-500 mb-1"><a :href="'https://speech.capital/'+c.username" class="font-medium hover:underline" x-text="c.username"></a> · <span x-text="ago(c.created_at)"></span><template x-if="isMod()"><span class="text-gray-400"> · <div x-data="{open:false}" @click.away="open=false" class="relative inline-block align-middle"><button @click.stop="open=!open" title="Moderate" class="hover:text-red-600"><i data-lucide="more-vertical" class="w-3.5 h-3.5"></i></button><div x-show="open" style="display:none" class="absolute left-0 mt-2 w-max bg-white rounded-md shadow-lg z-10 border border-gray-200 text-left"><a @click.prevent.stop="moderate('comment_overwrite',c);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Overwrite</a><a @click.prevent.stop="moderate('comment_wipe',c);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Wipe</a><a @click.prevent.stop="banUser(c.user_id,c.username);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Ban User</a></div></div></span></template></div><p class="text-sm whitespace-pre-wrap mb-2" x-text="c.content"></p><div class="flex items-center gap-3 text-xs"><button @click="vote(c,1,true)" :class="c.voted===1?'text-gray-900':'text-gray-400'" class="hover:text-gray-900">▲</button><span x-text="c.score"></span><button @click="reply=c.id" class="text-blue-600 hover:underline" x-show="user">reply</button></div><form x-show="reply===c.id" @submit.prevent="comment(c.id,$event)" class="mt-2"><textarea x-model="txt" rows="3" class="w-full border border-gray-200 rounded px-3 py-2 text-sm"></textarea><button :disabled="commenting" type="submit" class="bg-gray-900 text-white px-3 py-1 rounded text-xs hover:bg-gray-800 mt-1" x-text="commenting?'Moderating...':'Reply'"></button><button type="button" @click="reply=null;txt=''" class="text-gray-600 px-3 py-1 text-xs">Cancel</button></form><div x-show="c.replies?.length" class="mt-3 space-y-3"><template x-for="r in c.replies" :key="r.id"><div class="comment"><div class="text-xs text-gray-500 mb-1"><a :href="'https://speech.capital/'+r.username" class="font-medium hover:underline" x-text="r.username"></a> · <span x-text="ago(r.created_at)"></span><template x-if="isMod()"><span class="text-gray-400"> · <div x-data="{open:false}" @click.away="open=false" class="relative inline-block align-middle"><button @click.stop="open=!open" title="Moderate" class="hover:text-red-600"><i data-lucide="more-vertical" class="w-3.5 h-3.5"></i></button><div x-show="open" style="display:none" class="absolute left-0 mt-2 w-max bg-white rounded-md shadow-lg z-10 border border-gray-200 text-left"><a @click.prevent.stop="moderate('comment_overwrite',r);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Overwrite</a><a @click.prevent.stop="moderate('comment_wipe',r);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Delete Wipe</a><a @click.prevent.stop="banUser(r.user_id,r.username);open=false" href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Ban User</a></div></div></span></template></div><p class="text-sm whitespace-pre-wrap mb-2" x-text="r.content"></p></div></template></div></div></template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<script>
|
|
f=()=>({view:'list',user:null,posts:[],post:null,comments:[],txt:'',reply:null,md:null,commenting:false,commentSuccess:false,profile:{user:null,comments:[],posts:[]},loading:false,init(){this.md=window.markdownit({highlight:(s,l)=>{if(l&&hljs.getLanguage(l))try{return hljs.highlight(s,{language:l,ignoreIllegals:!0}).value}catch(e){}return''}});fetch('https://speech.capital/api/user',{credentials:'include'}).then(r=>r.json()).then(d=>{if(d.user){this.user=d.user;const s=document.getElementById('signup-link'),a=document.getElementById('auth-links'),u=document.getElementById('username-link');s.style.display='none';u.textContent=d.user.username;u.href=`https://speech.capital/${d.user.username}`;a.style.display='flex'}});const host=window.location.hostname,parts=host.split('.'),path=location.pathname,sub=parts.length>2?parts[0]:null,logoIcon=document.getElementById('logo-icon');if(sub){logoIcon.setAttribute('data-lucide',{artificial:'brain-circuit',free:'bird',dev:'code-xml'}[sub]||'cctv');document.getElementById('subdomain').textContent=parts[0]+'.';const pMatch=path.match(/^\/(\d+)$/);if(pMatch){this.view='post';this.loadPost(pMatch[1])}else{this.view='list';this.loadList()}}else{logoIcon.parentElement.style.display='none';const p=path.replace(/\/$/,''),uMatch=p.match(/^\/([a-zA-Z0-9_-]+)$/);if(uMatch&&!['submit','login','signup','admin'].includes(uMatch[1])){this.view='user';this.loadUser(uMatch[1])}else{this.view='home'}};this.$nextTick(()=>lucide.createIcons())},isMod(){return this.user&&['mod','admin','owner'].includes(this.user.role)},isImageLink(l){return l&&/\.(jpg|jpeg|png|gif|webp)$/i.test(l)},async moderate(action,item){const isPost=action.startsWith('post'),type=isPost?'post':'comment',isWipe=action.endsWith('wipe');if(!confirm(`Are you sure you want to ${isWipe?'wipe':'overwrite'} this ${type}? This is irreversible.`))return;const body={action:`delete_${action}`,[isPost?'post_id':'comment_id']:item.id};try{const r=await fetch('https://speech.capital/api/moderate',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(!r.ok){const d=await r.json();throw new Error(d.error?.message||d.error||'Failed to moderate')}if(isWipe){if(isPost){if(this.view==='list')this.posts=this.posts.filter(p=>p.id!==item.id);else if(this.view==='post'){alert('Post wiped.');location.href='/'}}else{this.loadPost(this.post.id)}}else{if(isPost){item.title='[Deleted]';item.content='[Deleted]';item.link=null}else item.content='[Deleted]'}}catch(e){alert(e.message)}},async banUser(user_id,username){const days=prompt(`How many days to ban ${username}?`);if(!days||isNaN(parseInt(days)))return;const body={action:'ban_user',user_id,days:parseInt(days)};try{const r=await fetch('https://speech.capital/api/moderate',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(!r.ok){const d=await r.json();throw new Error(d.error||'Failed to ban user')}alert(`${username} banned for ${days} days.`)}catch(e){alert(e.message)}},async loadList(){const parts=window.location.hostname.split('.'),sub=parts.length>2?parts[0]:'free',sort=new URLSearchParams(location.search).get('sort')||'hot';const r=await fetch(`https://speech.capital/api/posts?sub=${sub}&sort=${sort}`,{credentials:'include'});this.posts=((await r.json()).posts||[]).map(p=>({...p,showImage:!1}));this.$nextTick(()=>lucide.createIcons())},async loadPost(id){const r=await fetch(`https://speech.capital/api/posts/${id}`,{credentials:'include'});const d=await r.json();this.post=d.post;this.comments=d.comments||[];this.$nextTick(()=>lucide.createIcons())},async loadUser(u){this.loading=!0;try{const r=await fetch(`/api/users/${u}`);if(!r.ok)throw'';this.profile=await r.json()}catch(e){this.profile={user:null,comments:[],posts:[]}}finally{this.loading=!1}},formatDate(d){return d?new Date(d.replace(' ','T')+'Z').toLocaleDateString():''},renderMarkdown(t){return this.md.render(t||'')},async vote(obj,dir,isComment=false){try{const body=isComment?{comment_id:obj.id,direction:dir}:{post_id:obj.id,direction:dir};const r=await fetch('https://speech.capital/api/vote',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(!r.ok)throw new Error();const{score,voted}=await r.json();obj.score=score;obj.voted=voted}catch(e){}},async comment(parent_id,ev){if(!this.txt.trim()||this.commenting)return;this.commenting=!0;const token=document.querySelector('[name="cf-turnstile-response"]')?.value;if(!token){alert('Please complete the CAPTCHA.');this.commenting=!1;return}try{const r=await fetch('https://speech.capital/api/comments',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:this.post.id,parent_id,content:this.txt,'cf-turnstile-response':token})});if(!r.ok){const d=await r.json();throw new Error(d.error?.message||'Failed to comment')}this.txt='';this.reply=null;this.commentSuccess=!0;setTimeout(()=>this.commentSuccess=!1,3000);this.loadPost(this.post.id)}catch(e){alert(e.message)}finally{this.commenting=!1;turnstile.reset(document.querySelector('.cf-turnstile'))}},domain(p){if(p.link){try{return new URL(p.link).hostname}catch{}}return p.link?'link':'self'},ago(d){if(!d)return'';const s=Math.floor((new Date()-new Date(d.replace(' ','T')+'Z'))/1000);if(s<60)return s+'s ago';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago'}})
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|