- Remove unused onLogout prop from Chat component (was causing tsc error) - Add MentionAutocomplete component for post replies - Add Directory page - Add authorUrlKey field to SocialPost type - Channels/Thread: clickable author avatars with profile links - Add landing.html to public assets Co-Authored-By: Paperclip <noreply@paperclip.ing>
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '../lib/api';
|
|
import { Reactions } from '../components/Reactions';
|
|
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
|
import { Thread } from './Thread';
|
|
import type { SocialChannel, SocialPost } from '../types';
|
|
import type { FormEvent } from 'react';
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
if (seconds < 60) return 'just now';
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
function ChannelCard({
|
|
channel,
|
|
selected,
|
|
onClick,
|
|
}: {
|
|
channel: SocialChannel;
|
|
selected: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`w-full text-left px-4 py-3 border-b border-gray-200 hover:bg-gray-50 transition-colors ${
|
|
selected ? 'bg-blue-50 border-l-4 border-l-blue-600' : ''
|
|
}`}
|
|
>
|
|
<div className="font-medium text-gray-900">#{channel.slug}</div>
|
|
<div className="text-sm text-gray-500 mt-0.5">{channel.name}</div>
|
|
{channel.description && (
|
|
<div className="text-xs text-gray-400 mt-1 truncate">{channel.description}</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PostInChannel({ post, onOpenThread }: { post: SocialPost; onOpenThread: (postId: string) => void }) {
|
|
const profileUrl = post.authorUrlKey
|
|
? `/BARAAA/agents/${post.authorUrlKey}`
|
|
: `/BARAAA/agents/${post.authorName}`;
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
<div className="flex items-center gap-2">
|
|
<a
|
|
href={profileUrl}
|
|
className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-xs hover:bg-blue-200 transition-colors flex-shrink-0"
|
|
>
|
|
{post.authorName.slice(0, 2).toUpperCase()}
|
|
</a>
|
|
<a href={profileUrl} className="font-semibold text-gray-900 text-sm hover:text-blue-600 transition-colors">
|
|
{post.authorName}
|
|
</a>
|
|
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
|
|
</div>
|
|
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
|
{post.body}
|
|
</div>
|
|
<div className="mt-3 flex items-center gap-4">
|
|
<Reactions postId={post.id} />
|
|
{(post.replyCount ?? 0) > 0 && (
|
|
<button
|
|
onClick={() => onOpenThread(post.id)}
|
|
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
{post.replyCount} {post.replyCount === 1 ? 'reply' : 'replies'}
|
|
</button>
|
|
)}
|
|
{(post.replyCount ?? 0) === 0 && (
|
|
<button
|
|
onClick={() => onOpenThread(post.id)}
|
|
className="text-xs text-gray-500 hover:text-gray-700"
|
|
>
|
|
Reply
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChannelView({ channelId }: { channelId: string }) {
|
|
const [newPost, setNewPost] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['channel-posts', channelId],
|
|
queryFn: () => api.getSocialChannelPosts(channelId),
|
|
refetchInterval: 15000,
|
|
});
|
|
|
|
async function submitPost() {
|
|
if (!newPost.trim() || sending) return;
|
|
|
|
setSending(true);
|
|
try {
|
|
await api.createSocialPost(channelId, newPost);
|
|
setNewPost('');
|
|
queryClient.invalidateQueries({ queryKey: ['channel-posts', channelId] });
|
|
queryClient.invalidateQueries({ queryKey: ['social-feed'] });
|
|
} catch (err) {
|
|
console.error('Failed to post:', err);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
submitPost();
|
|
}
|
|
|
|
if (openThreadId) {
|
|
return <Thread postId={openThreadId} onBack={() => setOpenThreadId(null)} />;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <div className="flex items-center justify-center h-full text-gray-500">Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-4 border-b border-gray-200 bg-white">
|
|
<h3 className="text-lg font-semibold text-gray-900">#{data?.channel.slug}</h3>
|
|
<p className="text-sm text-gray-500">{data?.channel.name}</p>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
|
{data?.posts.length === 0 ? (
|
|
<div className="text-center text-gray-400 py-8">No posts in this channel yet.</div>
|
|
) : (
|
|
data?.posts.map((post) => <PostInChannel key={post.id} post={post} onOpenThread={setOpenThreadId} />)
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
|
|
<div className="flex gap-2">
|
|
<MentionAutocomplete
|
|
value={newPost}
|
|
onChange={setNewPost}
|
|
onSubmit={submitPost}
|
|
placeholder="Write a post... (use @ to mention agents)"
|
|
disabled={sending}
|
|
rows={2}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={sending || !newPost.trim()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm self-end"
|
|
>
|
|
Post
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Channels() {
|
|
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
|
|
|
const { data: channels, isLoading } = useQuery({
|
|
queryKey: ['social-channels'],
|
|
queryFn: api.getSocialChannels,
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-gray-500">
|
|
Loading channels...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
<aside className="w-56 border-r border-gray-200 bg-white overflow-y-auto flex-shrink-0">
|
|
<div className="p-4 border-b border-gray-200">
|
|
<h2 className="text-lg font-semibold text-gray-900">Channels</h2>
|
|
</div>
|
|
{channels?.map((channel) => (
|
|
<ChannelCard
|
|
key={channel.id}
|
|
channel={channel}
|
|
selected={selectedChannelId === channel.id}
|
|
onClick={() => setSelectedChannelId(channel.id)}
|
|
/>
|
|
))}
|
|
</aside>
|
|
|
|
<main className="flex-1">
|
|
{selectedChannelId ? (
|
|
<ChannelView channelId={selectedChannelId} />
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-gray-500">
|
|
Select a channel to view posts
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|