544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
|
import AutoTextarea from '@/components/AutoTextarea';
|
|
|
|
interface User {
|
|
id: string;
|
|
handle: string;
|
|
displayName: string;
|
|
avatarUrl?: string;
|
|
}
|
|
|
|
interface MediaItem {
|
|
id: string;
|
|
url: string;
|
|
altText?: string | null;
|
|
}
|
|
|
|
interface FeedMeta {
|
|
score: number;
|
|
reasons: string[];
|
|
engagement: {
|
|
likes: number;
|
|
reposts: number;
|
|
replies: number;
|
|
};
|
|
}
|
|
|
|
interface Post {
|
|
id: string;
|
|
content: string;
|
|
createdAt: string;
|
|
likesCount: number;
|
|
repostsCount: number;
|
|
repliesCount: number;
|
|
author: User;
|
|
media?: MediaItem[];
|
|
feedMeta?: FeedMeta;
|
|
linkPreviewUrl?: string | null;
|
|
linkPreviewTitle?: string | null;
|
|
linkPreviewDescription?: string | null;
|
|
linkPreviewImage?: string | null;
|
|
}
|
|
|
|
// Icons as simple SVG components
|
|
|
|
|
|
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>
|
|
);
|
|
|
|
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>
|
|
);
|
|
|
|
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>
|
|
);
|
|
|
|
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>
|
|
);
|
|
|
|
|
|
|
|
function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) => void; onRepost: (id: string) => void }) {
|
|
const [liked, setLiked] = useState(false);
|
|
const [reposted, setReposted] = useState(false);
|
|
const [reporting, setReporting] = useState(false);
|
|
|
|
const formatTime = (dateStr: string) => {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (minutes < 1) 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 = () => {
|
|
setLiked(!liked);
|
|
onLike(post.id);
|
|
};
|
|
|
|
const handleRepost = () => {
|
|
setReposted(!reposted);
|
|
onRepost(post.id);
|
|
};
|
|
|
|
const handleReport = async () => {
|
|
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) {
|
|
alert('Please log in to report.');
|
|
} else {
|
|
alert('Report failed. Please try again.');
|
|
}
|
|
} else {
|
|
alert('Report submitted. Thank you.');
|
|
}
|
|
} catch {
|
|
alert('Report failed. Please try again.');
|
|
} finally {
|
|
setReporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<article className="post">
|
|
<div className="post-header">
|
|
<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>
|
|
<div className="post-author">
|
|
<Link href={`/${post.author.handle}`} className="post-handle">
|
|
{post.author.displayName || post.author.handle}
|
|
</Link>
|
|
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="post-content">{post.content}</div>
|
|
{post.feedMeta?.reasons?.length ? (
|
|
<div className="post-reasons">
|
|
{post.feedMeta.reasons.map((reason, index) => (
|
|
<span key={`${post.id}-reason-${index}`} className="post-reason-chip">
|
|
{reason}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{post.media && post.media.length > 0 && (
|
|
<div className="post-media-grid">
|
|
{post.media.map((item) => (
|
|
<div className="post-media-item" key={item.id}>
|
|
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{post.linkPreviewUrl && (
|
|
<a href={post.linkPreviewUrl} target="_blank" rel="noopener noreferrer" className="link-preview-card">
|
|
{post.linkPreviewImage && (
|
|
<div className="link-preview-image">
|
|
<img src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} />
|
|
</div>
|
|
)}
|
|
<div className="link-preview-info">
|
|
<div className="link-preview-title">{post.linkPreviewTitle}</div>
|
|
{post.linkPreviewDescription && (
|
|
<div className="link-preview-description">{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={() => { }}>
|
|
<MessageIcon />
|
|
<span>{post.repliesCount || ''}</span>
|
|
</button>
|
|
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost}>
|
|
<RepeatIcon />
|
|
<span>{post.repostsCount + (reposted ? 1 : 0) || ''}</span>
|
|
</button>
|
|
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
|
|
<HeartIcon filled={liked} />
|
|
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
|
|
</button>
|
|
<button className="post-action" onClick={handleReport} disabled={reporting}>
|
|
<FlagIcon />
|
|
<span>{reporting ? '...' : ''}</span>
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
type Attachment = {
|
|
id: string;
|
|
url: string;
|
|
altText?: string | null;
|
|
};
|
|
|
|
function Compose({ onPost }: { onPost: (content: string, mediaIds: string[], linkPreview?: any) => void }) {
|
|
const [content, setContent] = useState('');
|
|
const [isPosting, setIsPosting] = useState(false);
|
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
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 maxLength = 400;
|
|
const remaining = maxLength - content.length;
|
|
|
|
// 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]);
|
|
|
|
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);
|
|
setContent('');
|
|
setAttachments([]);
|
|
setLinkPreview(null);
|
|
setLastDetectedUrl(null);
|
|
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: Attachment[] = [];
|
|
|
|
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,
|
|
});
|
|
} 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">
|
|
<AutoTextarea
|
|
className="compose-input"
|
|
placeholder="What's happening?"
|
|
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) => (
|
|
<div className="compose-media-item" key={item.id}>
|
|
<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>
|
|
<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">
|
|
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
|
|
{remaining}
|
|
</span>
|
|
<div className="compose-actions">
|
|
<label className="btn btn-ghost btn-sm compose-media-button">
|
|
{isUploading ? 'Uploading...' : 'Add media'}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
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...' : 'Post'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Home() {
|
|
const { user } = useAuth();
|
|
const [posts, setPosts] = useState<Post[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
|
const [feedMeta, setFeedMeta] = useState<{
|
|
algorithm: string;
|
|
windowHours: number;
|
|
seedLimit: number;
|
|
weights: {
|
|
engagement: number;
|
|
recency: number;
|
|
followBoost: number;
|
|
selfBoost: number;
|
|
};
|
|
} | null>(null);
|
|
|
|
const loadFeed = async (type: 'latest' | 'curated') => {
|
|
setLoading(true);
|
|
try {
|
|
const endpoint = type === 'curated' ? '/api/posts?type=curated' : '/api/posts?type=home';
|
|
const res = await fetch(endpoint);
|
|
const data = await res.json();
|
|
setPosts(data.posts || []);
|
|
setFeedMeta(data.meta || null);
|
|
} catch {
|
|
setPosts([]);
|
|
setFeedMeta(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadFeed(feedType);
|
|
}, [feedType]);
|
|
|
|
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any) => {
|
|
const res = await fetch('/api/posts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content, mediaIds, linkPreview }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (feedType === 'curated') {
|
|
loadFeed('curated');
|
|
} else {
|
|
setPosts([{ ...data.post, author: user }, ...posts]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleLike = async (postId: string) => {
|
|
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
|
|
};
|
|
|
|
const handleRepost = async (postId: string) => {
|
|
await fetch(`/api/posts/${postId}/repost`, { method: 'POST' });
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<header style={{
|
|
padding: '16px',
|
|
borderBottom: '1px solid var(--border)',
|
|
position: 'sticky',
|
|
top: 0,
|
|
background: 'var(--background)',
|
|
zIndex: 10,
|
|
backdropFilter: 'blur(12px)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1>
|
|
<div className="feed-toggle">
|
|
<button
|
|
className={`feed-toggle-btn ${feedType === 'latest' ? 'active' : ''}`}
|
|
onClick={() => setFeedType('latest')}
|
|
>
|
|
Latest
|
|
</button>
|
|
<button
|
|
className={`feed-toggle-btn ${feedType === 'curated' ? 'active' : ''}`}
|
|
onClick={() => setFeedType('curated')}
|
|
>
|
|
Curated
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{user && <Compose onPost={handlePost} />}
|
|
|
|
{!user && (
|
|
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
|
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
|
|
Join Synapsis to post and interact
|
|
</p>
|
|
<Link href="/login" className="btn btn-primary">
|
|
Login or Register
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{feedType === 'curated' && feedMeta && (
|
|
<div className="feed-meta card">
|
|
<div className="feed-meta-title">Curated feed</div>
|
|
<div className="feed-meta-body">
|
|
We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
|
|
</div>
|
|
<div className="feed-meta-weights">
|
|
Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}.
|
|
</div>
|
|
<div className="feed-meta-foot">
|
|
Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
|
Loading...
|
|
</div>
|
|
) : posts.length === 0 ? (
|
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
|
<p>No posts yet</p>
|
|
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
|
|
</div>
|
|
) : (
|
|
posts.map(post => (
|
|
<PostCard
|
|
key={post.id}
|
|
post={post}
|
|
onLike={handleLike}
|
|
onRepost={handleRepost}
|
|
/>
|
|
))
|
|
)}
|
|
</>
|
|
);
|
|
}
|