mirror of
https://github.com/spchcap/speech.capital.git
synced 2026-01-13 16:18:06 +00:00
Feat: Add inline image previews for link posts
This commit is contained in:
@@ -76,7 +76,7 @@
|
||||
|
||||
<!-- 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></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 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><button @click.stop="moderate('post',p)" title="Delete post" class="hover:text-red-600">delete</button><span>·</span><button @click.stop="banUser(p.user_id,p.username)" :title="`Ban ${p.username}`" class="hover:text-red-600">ban</button></span></template></div></div></div></article></template>
|
||||
<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><button @click.stop="moderate('post',p)" title="Delete post" class="hover:text-red-600">delete</button><span>·</span><button @click.stop="banUser(p.user_id,p.username)" :title="`Ban ${p.username}`" class="hover:text-red-600">ban</button></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>
|
||||
|
||||
@@ -103,8 +103,7 @@
|
||||
</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;if(parts.length<=2){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'}}else{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()}}},isMod(){return this.user&&['mod','admin','owner'].includes(this.user.role)},async moderate(type,item){if(!confirm(`Are you sure you want to delete this ${type}?`))return;const isPost=type==='post',body={action:isPost?'delete_post':'delete_comment',[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)throw new Error('Failed to delete');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||[]},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||[]},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 form=ev.target,token=form.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);turnstile.reset(form.querySelector('.cf-turnstile'))}finally{this.commenting=!1}},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'}})
|
||||
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;if(parts.length<=2){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'}}else{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()}}},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(type,item){if(!confirm(`Are you sure you want to delete this ${type}?`))return;const isPost=type==='post',body={action:isPost?'delete_post':'delete_comment',[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)throw new Error('Failed to delete');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}))},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||[]},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 form=ev.target,token=form.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);turnstile.reset(form.querySelector('.cf-turnstile'))}finally{this.commenting=!1}},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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user