Initial commit

This commit is contained in:
root
2026-01-26 08:34:48 +01:00
commit 387314581f
216 changed files with 81204 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useEffect, useRef, TextareaHTMLAttributes } from 'react';
export default function AutoTextarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
};
useEffect(() => {
adjustHeight();
}, [props.value]);
return (
<textarea
{...props}
ref={textareaRef}
// Ensure 1 row minimum and hide scrollbars to prevent jitter
rows={props.rows || 1}
onInput={(e) => {
adjustHeight();
props.onInput?.(e);
}}
style={{
...props.style,
resize: 'none',
overflow: 'hidden',
minHeight: 'auto', // Override specific min-heights if they conflict
}}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useRef, useEffect, useState } from 'react';
interface BlurredVideoProps {
src: string;
onClick?: (e: React.MouseEvent<HTMLVideoElement>) => void;
}
export default function BlurredVideo({ src, onClick }: BlurredVideoProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mainVideoRef = useRef<HTMLVideoElement>(null);
const bgVideoRef = useRef<HTMLVideoElement>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const mainVideo = mainVideoRef.current;
const bgVideo = bgVideoRef.current;
if (mainVideo && bgVideo) {
// Sync playback between main and background videos
const syncTime = () => {
if (Math.abs(mainVideo.currentTime - bgVideo.currentTime) > 0.1) {
bgVideo.currentTime = mainVideo.currentTime;
}
};
const handlePlay = () => bgVideo.play().catch(() => {});
const handlePause = () => bgVideo.pause();
const handleLoaded = () => setIsLoaded(true);
mainVideo.addEventListener('seeked', syncTime);
mainVideo.addEventListener('play', handlePlay);
mainVideo.addEventListener('pause', handlePause);
mainVideo.addEventListener('loadeddata', handleLoaded);
return () => {
mainVideo.removeEventListener('seeked', syncTime);
mainVideo.removeEventListener('play', handlePlay);
mainVideo.removeEventListener('pause', handlePause);
mainVideo.removeEventListener('loadeddata', handleLoaded);
};
}
}, []);
return (
<div ref={containerRef} className="blurred-video-container">
{/* Background blurred video */}
<video
ref={bgVideoRef}
src={src}
autoPlay
muted
loop
playsInline
preload="metadata"
className="blurred-video-bg"
aria-hidden="true"
style={{ opacity: isLoaded ? 1 : 0 }}
/>
{/* Main video */}
<video
ref={mainVideoRef}
src={src}
autoPlay
muted
loop
playsInline
preload="metadata"
className="blurred-video-main"
onClick={onClick}
title="Click to toggle sound"
/>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
'use client';
import { useState, useEffect } from 'react';
import AutoTextarea from '@/components/AutoTextarea';
import { Post, Attachment } from '@/lib/types';
import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
import { VideoEmbed } from '@/components/VideoEmbed';
import { formatFullHandle } from '@/lib/utils/handle';
interface MediaAttachment extends Attachment {
mimeType?: string;
}
interface ComposeProps {
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void;
replyingTo?: Post | null;
onCancelReply?: () => void;
placeholder?: string;
isReply?: boolean;
}
export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) {
const [content, setContent] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [linkPreview, setLinkPreview] = useState<any>(null);
const [fetchingPreview, setFetchingPreview] = useState(false);
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
const [isNsfw, setIsNsfw] = useState(false);
const [canPostNsfw, setCanPostNsfw] = useState(false);
const [isNsfwNode, setIsNsfwNode] = useState(false);
const maxLength = 600;
const remaining = maxLength - content.length;
// Check if user can post NSFW content and if node is NSFW
useEffect(() => {
fetch('/api/settings/nsfw')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.nsfwEnabled) {
setCanPostNsfw(true);
}
})
.catch(() => {});
fetch('/api/node')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.isNsfw) {
setIsNsfwNode(true);
}
})
.catch(() => {});
}, []);
// Detect URLs in content
useEffect(() => {
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
const matches = content.match(urlRegex);
if (matches && matches[0]) {
const url = matches[0];
if (url !== lastDetectedUrl) {
setLastDetectedUrl(url);
fetchPreview(url);
}
} else if (!content.trim()) {
setLinkPreview(null);
setLastDetectedUrl(null);
}
}, [content, lastDetectedUrl]);
const fetchPreview = async (url: string) => {
setFetchingPreview(true);
try {
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`);
if (res.ok) {
const data = await res.json();
setLinkPreview(data);
}
} catch (err) {
console.error('Preview error', err);
} finally {
setFetchingPreview(false);
}
};
const handleSubmit = async () => {
if (!content.trim() || isPosting || isUploading) return;
setIsPosting(true);
await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
setContent('');
setAttachments([]);
setLinkPreview(null);
setLastDetectedUrl(null);
setIsNsfw(false);
setIsPosting(false);
};
const handleMediaSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = '';
if (files.length === 0) return;
const remainingSlots = Math.max(0, 4 - attachments.length);
const selectedFiles = files.slice(0, remainingSlots);
if (selectedFiles.length === 0) return;
setUploadError(null);
setIsUploading(true);
const uploaded: MediaAttachment[] = [];
for (const file of selectedFiles) {
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok || !data.media?.id) {
throw new Error(data.error || 'Upload failed');
}
uploaded.push({
id: data.media.id,
url: data.media.url || data.url,
altText: data.media.altText ?? null,
mimeType: data.media.mimeType ?? file.type,
});
} catch (error) {
console.error('Upload failed', error);
setUploadError('One or more uploads failed. Try again.');
}
}
setAttachments((prev) => [...prev, ...uploaded].slice(0, 4));
setIsUploading(false);
};
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((item) => item.id !== id));
};
return (
<div className={`compose ${isReply ? 'reply-compose' : ''}`}>
{replyingTo && !isReply && (
<div className="compose-reply-target">
<div className="compose-reply-info">
Replying to <span className="compose-reply-handle">{formatFullHandle(replyingTo.author.handle)}</span>
</div>
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
Cancel
</button>
</div>
)}
<AutoTextarea
className="compose-input"
placeholder={placeholder}
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={maxLength + 50} // Allow some overflow for better UX
/>
{attachments.length > 0 && (
<div className="compose-media-grid">
{attachments.map((item) => {
const isVideo = item.mimeType?.startsWith('video/');
return (
<div className="compose-media-item" key={item.id}>
{isVideo ? (
<video src={item.url} muted playsInline preload="metadata" />
) : (
<img src={item.url} alt={item.altText || 'Upload preview'} />
)}
<button
type="button"
className="compose-media-remove"
onClick={() => handleRemoveAttachment(item.id)}
>
x
</button>
</div>
);
})}
</div>
)}
{linkPreview && (
<div className="compose-link-preview">
<button
type="button"
className="compose-link-preview-remove"
onClick={() => setLinkPreview(null)}
>
x
</button>
<VideoEmbed url={linkPreview.url} />
{!linkPreview.url.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
<div className="link-preview-card mini">
{linkPreview.image && (
<div className="link-preview-image">
<img src={linkPreview.image} alt="" />
</div>
)}
<div className="link-preview-info">
<div className="link-preview-title">{linkPreview.title}</div>
<div className="link-preview-url">{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}</div>
</div>
</div>
)}
</div>
)}
{uploadError && (
<div className="compose-media-error">{uploadError}</div>
)}
<div className="compose-footer">
<div className="compose-footer-left">
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
{remaining}
</span>
{canPostNsfw && !isNsfwNode && (
<label className="compose-nsfw-toggle" title="Mark as sensitive content">
<input
type="checkbox"
checked={isNsfw}
onChange={(e) => setIsNsfw(e.target.checked)}
/>
<AlertTriangle size={16} />
<span>NSFW</span>
</label>
)}
</div>
<div className="compose-actions">
<label className="compose-media-button" title="Add media">
{isUploading ? '...' : <ImageIcon size={20} />}
<input
type="file"
accept="image/*,video/mp4,video/webm,video/quicktime"
multiple
onChange={handleMediaSelect}
disabled={isUploading || attachments.length >= 4}
className="compose-media-input"
/>
</label>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={!content.trim() || remaining < 0 || isPosting || isUploading}
>
{isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'}
</button>
</div>
</div>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
export const ShieldIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
export const HomeIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
export const SearchIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
export const BellIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
export const UserIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
export const HeartIcon = ({ filled }: { filled?: boolean }) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
export const RepeatIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
export const MessageIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
);
export const FlagIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 5v16" />
<path d="M5 5h11l-1 4 1 4H5" />
</svg>
);
export const TrendingIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
);
export const UsersIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
export const SynapsisLogo = () => (
<span
role="img"
aria-label="Logo"
style={{
width: 28,
height: 28,
display: 'block',
backgroundColor: 'var(--accent)',
maskImage: 'url(/logo.svg)',
maskRepeat: 'no-repeat',
maskSize: 'contain',
maskPosition: 'center',
WebkitMaskImage: 'url(/logo.svg)',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
WebkitMaskPosition: 'center',
}}
/>
);
export const ArrowLeftIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
);
export const CalendarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
export const BookOpenIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
);
export const SettingsIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
export const TrashIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
export const BotIcon = ({ size = 20 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
);
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { usePathname } from 'next/navigation';
import { Sidebar } from './Sidebar';
import { RightSidebar } from './RightSidebar';
import { useAuth } from '@/lib/contexts/AuthContext';
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const { loading } = useAuth();
const pathname = usePathname();
// Paths that should NOT have the app layout
const isStandalone =
pathname === '/login' ||
pathname === '/register' ||
pathname?.startsWith('/install') ||
pathname?.startsWith('/admin');
if (loading) {
return (
<div style={{
height: '100vh',
width: '100vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--background)'
}}>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
border: '2px solid var(--border)',
borderTopColor: 'var(--accent)',
animation: 'spin 0.8s linear infinite'
}} />
<style jsx>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
if (isStandalone) {
return <>{children}</>;
}
return (
<div className="layout">
<Sidebar />
<main className="main">
{children}
</main>
<RightSidebar />
</div>
);
}
+605
View File
@@ -0,0 +1,605 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
import { Post } from '@/lib/types';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed';
import BlurredVideo from '@/components/BlurredVideo';
import { formatFullHandle, NODE_DOMAIN } from '@/lib/utils/handle';
// Component for link preview image that hides on error
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) return null;
return (
<div className="link-preview-image">
<img
src={src}
alt={alt}
onError={() => setHasError(true)}
/>
</div>
);
}
interface PostCardProps {
post: Post;
onLike?: (id: string, currentLiked: boolean) => void;
onRepost?: (id: string, currentReposted: boolean) => void;
onComment?: (post: Post) => void;
onDelete?: (id: string) => void;
onHide?: (id: string) => void; // Called when post should be hidden (block/mute)
isDetail?: boolean;
}
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail }: PostCardProps) {
const { user: currentUser } = useAuth();
const { showToast } = useToast();
const [liked, setLiked] = useState(post.isLiked || false);
const [reposted, setReposted] = useState(post.isReposted || false);
const [reporting, setReporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
// Sync state if post changes (e.g. after a re-render from parent)
useEffect(() => {
setLiked(post.isLiked || false);
setReposted(post.isReposted || false);
}, [post.isLiked, post.isReposted, post.id]);
const formatTime = (dateStr: string | Date) => {
const date = new Date(dateStr);
if (isNaN(date.getTime())) {
return '';
}
const now = new Date();
const diff = now.getTime() - date.getTime();
// If post is in the future (minor clock skew), show "now"
if (diff < 0) {
return 'now';
}
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
};
const handleLike = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const currentLiked = liked;
setLiked(!currentLiked);
onLike?.(post.id, currentLiked); // Pass current state before toggle
};
const handleRepost = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const currentReposted = reposted;
setReposted(!currentReposted);
onRepost?.(post.id, currentReposted); // Pass current state before toggle
};
const handleComment = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onComment?.(post);
};
const handleReport = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (reporting) return;
const reason = window.prompt('Why are you reporting this post?');
if (!reason) return;
setReporting(true);
try {
const res = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
});
if (!res.ok) {
if (res.status === 401) {
showToast('Please log in to report.', 'error');
} else {
showToast('Report failed. Please try again.', 'error');
}
} else {
showToast('Report submitted. Thank you.', 'success');
}
} catch {
showToast('Report failed. Please try again.', 'error');
} finally {
setReporting(false);
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (deleting) return;
if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) return;
setDeleting(true);
try {
const res = await fetch(`/api/posts/${post.id}`, {
method: 'DELETE',
});
if (res.ok) {
onDelete?.(post.id);
} else {
const data = await res.json();
showToast(data.error || 'Failed to delete post', 'error');
}
} catch {
showToast('Failed to delete post', 'error');
} finally {
setDeleting(false);
}
};
const handleBlockUser = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
showToast('Please log in to block users', 'error');
return;
}
try {
const res = await fetch(`/api/users/${post.author.handle}/block`, {
method: 'POST',
});
if (res.ok) {
showToast(`Blocked @${post.author.handle}`, 'success');
onHide?.(post.id);
} else {
showToast('Failed to block user', 'error');
}
} catch {
showToast('Failed to block user', 'error');
}
};
const handleMuteUser = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
showToast('Please log in to mute users', 'error');
return;
}
// For now, muting a user is the same as blocking but with different messaging
// Could be expanded to just hide posts without breaking follows
try {
const res = await fetch(`/api/users/${post.author.handle}/block`, {
method: 'POST',
});
if (res.ok) {
showToast(`Muted @${post.author.handle}`, 'success');
onHide?.(post.id);
} else {
showToast('Failed to mute user', 'error');
}
} catch {
showToast('Failed to mute user', 'error');
}
};
const handleMuteNode = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
if (!currentUser) {
showToast('Please log in to mute nodes', 'error');
return;
}
// Extract node domain from the post
const nodeDomain = post.nodeDomain || (post.author.handle.includes('@')
? post.author.handle.split('@')[1]
: null);
if (!nodeDomain) {
showToast('Cannot determine node for this post', 'error');
return;
}
try {
const res = await fetch('/api/settings/muted-nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: nodeDomain }),
});
if (res.ok) {
showToast(`Muted node: ${nodeDomain}`, 'success');
onHide?.(post.id);
} else {
showToast('Failed to mute node', 'error');
}
} catch {
showToast('Failed to mute node', 'error');
}
};
const postUrl = `/${post.author.handle}/posts/${post.id}`;
// Get the full handle for profile links (includes domain for remote users)
const getProfileHandle = () => {
// If handle already has domain, use it
if (post.author.handle.includes('@')) {
return post.author.handle;
}
// If this is a swarm post from a DIFFERENT node, append the node domain
if (post.nodeDomain && post.nodeDomain !== NODE_DOMAIN) {
return `${post.author.handle}@${post.nodeDomain}`;
}
// Local user
return post.author.handle;
};
const profileHandle = getProfileHandle();
// Decode HTML entities from federated posts (e.g., &amp;rsquo; -> ')
const decodeHtmlEntities = (text: string): string => {
const entities: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#039;': "'",
'&apos;': "'",
'&rsquo;': '\u2019', // '
'&lsquo;': '\u2018', // '
'&rdquo;': '\u201D', // "
'&ldquo;': '\u201C', // "
'&ndash;': '\u2013', //
'&mdash;': '\u2014', // —
'&hellip;': '\u2026', // …
'&nbsp;': ' ',
'&copy;': '\u00A9', // ©
'&reg;': '\u00AE', // ®
'&trade;': '\u2122', // ™
'&euro;': '\u20AC', // €
'&pound;': '\u00A3', // £
'&yen;': '\u00A5', // ¥
'&cent;': '\u00A2', // ¢
};
// First decode named entities
let decoded = text;
for (const [entity, char] of Object.entries(entities)) {
decoded = decoded.replace(new RegExp(entity, 'g'), char);
}
// Decode numeric entities (&#123; or &#x7B;)
decoded = decoded.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)));
decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
// Strip HTML tags (remote posts may contain <p>, <br>, <a> etc.)
decoded = decoded.replace(/<br\s*\/?>/gi, '\n');
decoded = decoded.replace(/<\/p>\s*<p>/gi, '\n\n');
decoded = decoded.replace(/<[^>]+>/g, '');
return decoded.trim();
};
const renderContent = (content: string, hidePreviewUrl?: string) => {
const decoded = decodeHtmlEntities(content);
const parts = decoded.split(/(https?:\/\/[^\s]+)/g);
return parts.map((part, index) => {
if (part.match(/^https?:\/\/[^\s]+$/)) {
// If this URL matches the link preview URL, hide it entirely
if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) {
return null;
}
// Extract just the domain (TLD)
try {
const url = new URL(part);
const domain = url.hostname.replace(/^www\./, '');
return (
<a
key={`url-${index}`}
href={part}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={part}
>
{domain}
</a>
);
} catch {
// Fallback if URL parsing fails
return (
<a
key={`url-${index}`}
href={part}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
}
// Handle newlines
if (part.includes('\n')) {
return part.split('\n').map((line, lineIndex, arr) => (
<span key={`text-${index}-${lineIndex}`}>
{line}
{lineIndex < arr.length - 1 && <br />}
</span>
));
}
return <span key={`text-${index}`}>{part}</span>;
});
};
return (
<article className={`post ${isDetail ? 'detail' : ''}`}>
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
<div className="post-header">
<Link href={`/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div>
</Link>
<div className="post-author">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle}
</Link>
{post.bot && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
fontSize: '10px',
padding: '2px 6px',
borderRadius: '4px',
background: 'var(--accent-muted)',
color: 'var(--accent)',
fontWeight: 500,
}}
title={`AI Account: ${post.bot.name}`}
>
<Bot size={12} />
AI Account
</span>
)}
</div>
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)} · {formatTime(post.createdAt)}</span>
</div>
{currentUser && currentUser.id !== post.author.id && (
<div style={{ position: 'relative', marginLeft: 'auto' }}>
<button
className="post-menu-btn"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(!showMenu);
}}
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: 'var(--foreground-tertiary)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<MoreHorizontal size={18} />
</button>
{showMenu && (
<>
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99,
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMenu(false);
}}
/>
<div
className="post-menu-dropdown"
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: '4px',
background: 'var(--background-secondary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
minWidth: '180px',
zIndex: 100,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<button
onClick={handleMuteUser}
style={{
width: '100%',
padding: '10px 14px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'var(--foreground)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<VolumeX size={16} />
Mute
</button>
<button
onClick={handleBlockUser}
style={{
width: '100%',
padding: '10px 14px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'var(--foreground)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<UserX size={16} />
Block
</button>
{(post.nodeDomain || post.author.handle.includes('@')) && (
<button
onClick={handleMuteNode}
style={{
width: '100%',
padding: '10px 14px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: 'var(--foreground)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '10px',
borderTop: '1px solid var(--border)',
}}
>
<Globe size={16} />
Mute node
</button>
)}
</div>
</>
)}
</div>
)}
</div>
{post.replyTo && (
<div className="post-reply-to">
Replied to <Link href={`/${post.replyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(post.replyTo.author.handle)}</Link>
</div>
)}
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
{post.media && post.media.length > 0 && (
<div className="post-media-grid">
{post.media.map((item) => {
const isVideo = item.mimeType?.startsWith('video/');
return (
<div className="post-media-item" key={item.id}>
{isVideo ? (
<BlurredVideo
src={item.url}
onClick={(e) => {
e.stopPropagation();
const video = e.currentTarget;
video.muted = !video.muted;
}}
/>
) : (
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
)}
</div>
);
})}
</div>
)}
{post.linkPreviewUrl && (
<VideoEmbed url={post.linkPreviewUrl} />
)}
{post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
<a
href={post.linkPreviewUrl}
target="_blank"
rel="noopener noreferrer"
className="link-preview-card"
onClick={(e) => e.stopPropagation()}
>
{post.linkPreviewImage && (
<LinkPreviewImage src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} />
)}
<div className="link-preview-info">
<div className="link-preview-title">{post.linkPreviewTitle ? decodeHtmlEntities(post.linkPreviewTitle) : ''}</div>
{post.linkPreviewDescription && (
<div className="link-preview-description">{decodeHtmlEntities(post.linkPreviewDescription)}</div>
)}
<div className="link-preview-url">
{new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
</div>
</div>
</a>
)}
<div className="post-actions">
<button className="post-action" onClick={handleComment}>
<MessageIcon />
<span>{post.repliesCount || ''}</span>
</button>
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost}>
<RepeatIcon />
<span>{(post.repostsCount - (post.isReposted ? 1 : 0)) + (reposted ? 1 : 0) || ''}</span>
</button>
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
<HeartIcon filled={liked} />
<span>{(post.likesCount - (post.isLiked ? 1 : 0)) + (liked ? 1 : 0) || ''}</span>
</button>
<button className="post-action" onClick={handleReport} disabled={reporting}>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
{(currentUser?.id === post.author.id || (post.bot && currentUser?.id === post.bot.ownerId)) && (
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
<TrashIcon />
<span>{deleting ? '...' : ''}</span>
</button>
)}
</div>
</article>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
interface Admin {
handle: string;
displayName: string | null;
avatarUrl: string | null;
}
export function RightSidebar() {
const fallbackDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A federated social network node.';
const [nodeInfo, setNodeInfo] = useState({
name: process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node',
description: fallbackDescription,
longDescription: '',
rules: '',
bannerUrl: '',
admins: [] as Admin[],
});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/node', { cache: 'no-store' })
.then(res => res.json())
.then(data => {
setNodeInfo(prev => ({
...prev,
...data,
name: data?.name ?? prev.name,
description: data?.description ?? prev.description,
longDescription: data?.longDescription ?? prev.longDescription,
rules: data?.rules ?? prev.rules,
bannerUrl: data?.bannerUrl ?? prev.bannerUrl,
admins: data?.admins ?? [],
}));
})
.catch(() => { })
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<aside className="aside">
<div className="card" style={{ overflow: 'hidden', padding: 0, height: '300px' }}>
<div style={{
height: '140px',
background: 'var(--background-tertiary)',
borderBottom: '1px solid var(--border)',
}} />
<div style={{ padding: '16px' }}>
<div style={{ height: '24px', width: '60%', background: 'var(--background-tertiary)', borderRadius: '4px', marginBottom: '12px' }} />
<div style={{ height: '16px', width: '90%', background: 'var(--background-tertiary)', borderRadius: '4px', marginBottom: '8px' }} />
<div style={{ height: '16px', width: '75%', background: 'var(--background-tertiary)', borderRadius: '4px' }} />
</div>
</div>
</aside>
);
}
return (
<aside className="aside">
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
{nodeInfo.bannerUrl && (
<div
style={{
height: '140px',
background: `url(${nodeInfo.bannerUrl}) center/cover no-repeat`,
borderBottom: '1px solid var(--border)',
}}
/>
)}
<div style={{ padding: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Welcome to {nodeInfo.name}</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
{nodeInfo.description}
</p>
{nodeInfo.longDescription && (
<div style={{ marginTop: '16px', fontSize: '13px', color: 'var(--foreground-secondary)', lineHeight: 1.5 }}>
{nodeInfo.longDescription.split('\n').map((line, i) => (
<p key={i} style={{ marginBottom: '8px' }}>{line}</p>
))}
</div>
)}
{nodeInfo.rules && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-hover)' }}>
<h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', color: 'var(--foreground-tertiary)', marginBottom: '8px', letterSpacing: '0.05em' }}>
Node Rules
</h4>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.5 }}>
{nodeInfo.rules.split('\n').map((rule, i) => (
<div key={i} style={{ marginBottom: '4px', display: 'flex', gap: '8px' }}>
<span></span>
<span>{rule}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="card" style={{ marginTop: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Network Info</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
Running <a href="https://synapsis.social" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Synapsis v0.1.0</a>
</p>
{nodeInfo.admins.length > 0 && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-hover)' }}>
<h4 style={{ fontSize: '11px', fontWeight: 700, textTransform: 'uppercase', color: 'var(--foreground-tertiary)', marginBottom: '12px', letterSpacing: '0.05em' }}>
Admins
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{nodeInfo.admins.map((admin) => (
<Link
key={admin.handle}
href={`/${admin.handle}`}
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
>
<img
src={admin.avatarUrl || `https://api.dicebear.com/7.x/identicon/svg?seed=${admin.handle}`}
alt={admin.displayName || admin.handle}
width={32}
height={32}
style={{ borderRadius: '50%', objectFit: 'cover' }}
/>
<div>
<div style={{ fontWeight: 500, fontSize: '14px' }}>
{admin.displayName || admin.handle}
</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '12px' }}>
@{admin.handle}
</div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</aside>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
import { formatFullHandle } from '@/lib/utils/handle';
export function Sidebar() {
const { user, isAdmin } = useAuth();
const pathname = usePathname();
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
useEffect(() => {
fetch('/api/node')
.then(res => res.json())
.then(data => {
setCustomLogoUrl(data.logoUrl || null);
})
.catch(() => {
setCustomLogoUrl(null);
});
}, []);
// Home is exact match
const isHome = pathname === '/';
return (
<aside className="sidebar">
<Link href="/" className="logo" style={{ minHeight: '42px' }}>
{customLogoUrl === undefined ? null : customLogoUrl ? (
<img src={customLogoUrl} alt="Logo" style={{ maxWidth: '200px', maxHeight: '50px', objectFit: 'contain' }} />
) : (
<Image src="/logotext.svg" alt="Synapsis" width={185} height={42} priority />
)}
</Link>
<nav>
<Link href="/" className={`nav-item ${isHome ? 'active' : ''}`}>
<HomeIcon />
<span>Home</span>
</Link>
<Link href="/explore" className={`nav-item ${pathname?.startsWith('/explore') ? 'active' : ''}`}>
<SearchIcon />
<span>Explore</span>
</Link>
{user && (
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`}>
<BellIcon />
<span>Notifications</span>
</Link>
)}
{user && (
<Link href="/settings/bots" className={`nav-item ${pathname?.startsWith('/settings/bots') ? 'active' : ''}`}>
<BotIcon />
<span>Bots</span>
</Link>
)}
{user ? (
<Link href={`/${user.handle}`} className={`nav-item ${pathname === '/' + user.handle ? 'active' : ''}`}>
<UserIcon />
<span>Profile</span>
</Link>
) : (
<Link href="/login" className={`nav-item ${pathname === '/login' ? 'active' : ''}`}>
<UserIcon />
<span>Login</span>
</Link>
)}
{isAdmin && (
<Link href="/admin" className={`nav-item ${pathname?.startsWith('/admin') ? 'active' : ''}`}>
<ShieldIcon />
<span>Admin</span>
</Link>
)}
{user && (
<Link href="/settings" className={`nav-item ${pathname?.startsWith('/settings') ? 'active' : ''}`}>
<SettingsIcon />
<span>Settings</span>
</Link>
)}
</nav>
{user && (
<div style={{ marginTop: 'auto', paddingTop: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0 }}>
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
)}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatFullHandle(user.handle)}</div>
</div>
</div>
</div>
)}
</aside>
);
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
interface VideoEmbedProps {
url: string;
}
export function VideoEmbed({ url }: VideoEmbedProps) {
const getYouTubeId = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const getVimeoId = (url: string) => {
const regExp = /(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/(?:[^\/]*)\/videos\/|album\/(?:\d+)\/video\/|video\/|)(\d+)(?:$|\/|\?)/;
const match = url.match(regExp);
return match ? match[1] : null;
};
const youtubeId = getYouTubeId(url);
const vimeoId = getVimeoId(url);
if (youtubeId) {
return (
<div className="video-embed-container" onClick={(e) => e.stopPropagation()}>
<iframe
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</div>
);
}
if (vimeoId) {
return (
<div className="video-embed-container" onClick={(e) => e.stopPropagation()}>
<iframe
src={`https://player.vimeo.com/video/${vimeoId}`}
title="Vimeo video player"
frameBorder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
></iframe>
</div>
);
}
return null;
}