Initial commit
This commit is contained in:
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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., &rsquo; -> ')
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
'’': '\u2019', // '
|
||||
'‘': '\u2018', // '
|
||||
'”': '\u201D', // "
|
||||
'“': '\u201C', // "
|
||||
'–': '\u2013', // –
|
||||
'—': '\u2014', // —
|
||||
'…': '\u2026', // …
|
||||
' ': ' ',
|
||||
'©': '\u00A9', // ©
|
||||
'®': '\u00AE', // ®
|
||||
'™': '\u2122', // ™
|
||||
'€': '\u20AC', // €
|
||||
'£': '\u00A3', // £
|
||||
'¥': '\u00A5', // ¥
|
||||
'¢': '\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 ({ or {)
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user