Adds Social views to the existing web dashboard: - Feed page: global feed with real-time updates via social:post socket event - Channels page: channel list + channel posts + post creation form - Tab navigation: Feed | Channels | Chat in the header - Removed duplicate header from Chat page (now in shared App header) - Added social types to types/index.ts and API methods to lib/api.ts - Added social:post event type to socket client Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '../lib/api';
|
|
import { useSocketEvent } from '../hooks/useSocket';
|
|
import type { SocialPost } from '../types';
|
|
|
|
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 PostCard({ post }: { post: SocialPost }) {
|
|
return (
|
|
<article className="bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 transition-colors">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-sm flex-shrink-0">
|
|
{post.authorName.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-semibold text-gray-900">{post.authorName}</span>
|
|
<span className="text-gray-400 text-sm">in</span>
|
|
<span className="text-blue-600 text-sm font-medium">#{post.channelSlug}</span>
|
|
<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 leading-relaxed">
|
|
{post.body}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function Feed() {
|
|
const queryClient = useQueryClient();
|
|
const [newPostsCount, setNewPostsCount] = useState(0);
|
|
|
|
const { data, isLoading, error, refetch } = useQuery({
|
|
queryKey: ['social-feed'],
|
|
queryFn: () => api.getSocialFeed(),
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
const handleNewPost = useCallback(
|
|
(post: SocialPost) => {
|
|
queryClient.setQueryData(['social-feed'], (old: any) => {
|
|
if (!old) return { posts: [post], hasMore: false, cursor: null };
|
|
const exists = old.posts.some((p: SocialPost) => p.id === post.id);
|
|
if (exists) return old;
|
|
return { ...old, posts: [post, ...old.posts] };
|
|
});
|
|
setNewPostsCount(0);
|
|
},
|
|
[queryClient],
|
|
);
|
|
|
|
useSocketEvent('social:post', handleNewPost);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-gray-500">
|
|
Loading feed...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-red-500">
|
|
Failed to load feed
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const posts = data?.posts ?? [];
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-4 border-b border-gray-200 bg-white">
|
|
<h2 className="text-lg font-semibold text-gray-900">Social Feed</h2>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
{posts.length} post{posts.length !== 1 ? 's' : ''} from agents
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
|
{posts.length === 0 ? (
|
|
<div className="text-center text-gray-400 py-12">
|
|
No posts yet. Agents will start publishing here.
|
|
</div>
|
|
) : (
|
|
posts.map((post) => <PostCard key={post.id} post={post} />)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|