Integrate PostComposer and community posts into Feed

cgen-c813287b31b3444fb531449411433b73
This commit is contained in:
Builder.io 2025-09-27 23:33:29 +00:00
parent 40a9e0ffd8
commit 3133bd2b5c

View file

@ -1,12 +1,16 @@
import Layout from "@/components/Layout"; import Layout from "@/components/Layout";
import Layout from "@/components/Layout";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import LoadingScreen from "@/components/LoadingScreen"; import LoadingScreen from "@/components/LoadingScreen";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { aethexSocialService } from "@/lib/aethex-social-service"; import { aethexSocialService } from "@/lib/aethex-social-service";
import { communityService, realtimeService } from "@/lib/supabase-service";
import PostComposer from "@/components/social/PostComposer";
import { useToast } from "@/hooks/use-toast";
import { import {
Heart, Heart,
MessageCircle, MessageCircle,
@ -29,44 +33,82 @@ interface FeedItem {
comments: number; comments: number;
} }
function parseContent(content: string): { text?: string; mediaUrl?: string | null; mediaType: "video" | "image" | "none" } {
try {
const obj = JSON.parse(content || "{}");
return {
text: obj.text || content,
mediaUrl: obj.mediaUrl || null,
mediaType: obj.mediaType || (obj.mediaUrl ? (/(mp4|webm|mov)$/i.test(obj.mediaUrl) ? "video" : "image") : "none"),
};
} catch {
return { text: content, mediaUrl: null, mediaType: "none" };
}
}
export default function Feed() { export default function Feed() {
const { user, profile, loading } = useAuth(); const { user, loading } = useAuth();
const navigate = useNavigate(); const { toast } = useToast();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [following, setFollowing] = useState<string[]>([]); const [following, setFollowing] = useState<string[]>([]);
const [items, setItems] = useState<FeedItem[]>([]); const [items, setItems] = useState<FeedItem[]>([]);
const [muted, setMuted] = useState(true); const [muted, setMuted] = useState(true);
useEffect(() => { useEffect(() => {
if (!loading && !user) return;
if (!user) return; if (!user) return;
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const recs = await aethexSocialService.listRecommended(user.id, 12); const posts = await communityService.getPosts(20);
const flw = await aethexSocialService.getFollowing(user.id); const flw = await aethexSocialService.getFollowing(user.id);
setFollowing(flw); setFollowing(flw);
const mapped: FeedItem[] = recs.map((r, idx) => ({ const mapped: FeedItem[] = posts.map((p: any) => {
id: r.id, const meta = parseContent(p.content);
authorId: r.id, const author = p.user_profiles || {};
authorName: r.full_name || r.username || "User", return {
authorAvatar: r.avatar_url, id: p.id,
caption: r.bio || "", authorId: p.author_id,
mediaUrl: r.banner_url || r.avatar_url || null, authorName: author.full_name || author.username || "User",
mediaType: r.banner_url?.match(/\.(mp4|webm|mov)(\?.*)?$/i) authorAvatar: author.avatar_url,
? "video" caption: meta.text,
: r.banner_url || r.avatar_url mediaUrl: meta.mediaUrl,
? "image" mediaType: meta.mediaType,
: "none", likes: p.likes_count ?? 0,
likes: Math.floor(Math.random() * 200) + 5, comments: p.comments_count ?? 0,
comments: Math.floor(Math.random() * 30), };
})); });
setItems(mapped); // If no posts yet, fall back to recommended people as placeholders
if (mapped.length === 0) {
const recs = await aethexSocialService.listRecommended(user.id, 12);
const placeholders: FeedItem[] = recs.map((r: any) => ({
id: r.id,
authorId: r.id,
authorName: r.full_name || r.username || "User",
authorAvatar: r.avatar_url,
caption: r.bio || "",
mediaUrl: r.banner_url || r.avatar_url || null,
mediaType: r.banner_url?.match(/\.(mp4|webm|mov)(\?.*)?$/i)
? "video"
: r.banner_url || r.avatar_url
? "image"
: "none",
likes: Math.floor(Math.random() * 200) + 5,
comments: Math.floor(Math.random() * 30),
}));
setItems(placeholders);
} else {
setItems(mapped);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
load(); load();
const sub = realtimeService.subscribeToCommunityPosts(() => load());
return () => {
try { sub.unsubscribe(); } catch {}
};
}, [user, loading]); }, [user, loading]);
const isFollowingAuthor = (id: string) => following.includes(id); const isFollowingAuthor = (id: string) => following.includes(id);
@ -81,128 +123,84 @@ export default function Feed() {
} }
}; };
const share = async (id: string) => {
const url = `${location.origin}/feed#post-${id}`;
try {
if ((navigator as any).share) {
await (navigator as any).share({ title: "AeThex", text: "Check this post", url });
} else {
await navigator.clipboard.writeText(url);
toast({ description: "Link copied" });
}
} catch {}
};
if (!user && !loading) return <Navigate to="/login" replace />; if (!user && !loading) return <Navigate to="/login" replace />;
if (loading || isLoading) { if (loading || isLoading) {
return ( return (
<LoadingScreen <LoadingScreen message="Loading your feed..." showProgress duration={1000} />
message="Loading your feed..."
showProgress
duration={1000}
/>
); );
} }
return ( return (
<Layout> <Layout>
<div className="min-h-screen bg-aethex-gradient"> <div className="min-h-screen bg-aethex-gradient">
<div className="h-[calc(100vh-64px)] overflow-y-auto snap-y snap-mandatory no-scrollbar"> <div className="max-w-2xl mx-auto p-4">
<PostComposer onPosted={() => setIsLoading(true)} />
</div>
<div className="h-[calc(100vh-64px-140px)] overflow-y-auto snap-y snap-mandatory no-scrollbar">
{items.length === 0 && ( {items.length === 0 && (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
No posts yet. Follow people to populate your feed. No posts yet. Share something to start the feed.
</div> </div>
)} )}
{items.map((item) => ( {items.map((item) => (
<section <section id={`post-${item.id}`} key={item.id} className="snap-start h-[calc(100vh-64px)] relative flex items-center justify-center">
key={item.id}
className="snap-start h-[calc(100vh-64px)] relative flex items-center justify-center"
>
<Card className="w-full h-full bg-black/60 border-border/30 overflow-hidden"> <Card className="w-full h-full bg-black/60 border-border/30 overflow-hidden">
<CardContent className="w-full h-full p-0 relative"> <CardContent className="w-full h-full p-0 relative">
{/* Media */}
{item.mediaType === "video" && item.mediaUrl ? ( {item.mediaType === "video" && item.mediaUrl ? (
<video <video src={item.mediaUrl} className="w-full h-full object-cover" autoPlay loop muted={muted} playsInline />
src={item.mediaUrl}
className="w-full h-full object-cover"
autoPlay
loop
muted={muted}
playsInline
/>
) : item.mediaType === "image" && item.mediaUrl ? ( ) : item.mediaType === "image" && item.mediaUrl ? (
<img <img src={item.mediaUrl} alt={item.caption || item.authorName} className="w-full h-full object-cover" />
src={item.mediaUrl}
alt={item.caption || item.authorName}
className="w-full h-full object-cover"
/>
) : ( ) : (
<div className="w-full h-full bg-gradient-to-br from-aethex-500/20 to-neon-blue/20" /> <div className="w-full h-full bg-gradient-to-br from-aethex-500/20 to-neon-blue/20" />
)} )}
{/* Overlay UI */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent" />
{/* Right rail actions */}
<div className="absolute right-4 bottom-24 flex flex-col items-center gap-4"> <div className="absolute right-4 bottom-24 flex flex-col items-center gap-4">
<Button <Button size="icon" variant="secondary" className="rounded-full bg-white/20 hover:bg-white/30" onClick={() => setMuted((m) => !m)}>
size="icon" {muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
variant="secondary"
className="rounded-full bg-white/20 hover:bg-white/30"
onClick={() => setMuted((m) => !m)}
>
{muted ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button> </Button>
<Button <Button size="icon" variant="secondary" className="rounded-full bg-white/20 hover:bg-white/30">
size="icon"
variant="secondary"
className="rounded-full bg-white/20 hover:bg-white/30"
>
<Heart className="h-5 w-5" /> <Heart className="h-5 w-5" />
</Button> </Button>
<Button <Button size="icon" variant="secondary" className="rounded-full bg-white/20 hover:bg-white/30">
size="icon"
variant="secondary"
className="rounded-full bg-white/20 hover:bg-white/30"
>
<MessageCircle className="h-5 w-5" /> <MessageCircle className="h-5 w-5" />
</Button> </Button>
<Button <Button size="icon" variant="secondary" className="rounded-full bg-white/20 hover:bg-white/30" onClick={() => share(item.id)}>
size="icon"
variant="secondary"
className="rounded-full bg-white/20 hover:bg-white/30"
>
<Share2 className="h-5 w-5" /> <Share2 className="h-5 w-5" />
</Button> </Button>
</div> </div>
{/* Bottom author bar */}
<div className="absolute left-0 right-0 bottom-0 p-4 flex items-center justify-between"> <div className="absolute left-0 right-0 bottom-0 p-4 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarImage src={item.authorAvatar || undefined} /> <AvatarImage src={item.authorAvatar || undefined} />
<AvatarFallback> <AvatarFallback>{item.authorName[0] || "U"}</AvatarFallback>
{item.authorName[0] || "U"}
</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<div className="font-semibold text-white"> <div className="font-semibold text-white">{item.authorName}</div>
{item.authorName}
</div>
{item.caption && ( {item.caption && (
<div className="text-xs text-white/80 max-w-[60vw] line-clamp-2"> <div className="text-xs text-white/80 max-w-[60vw] line-clamp-2">{item.caption}</div>
{item.caption}
</div>
)} )}
</div> </div>
</div> </div>
<Button <Button size="sm" variant={isFollowingAuthor(item.authorId) ? "outline" : "default"} onClick={() => toggleFollow(item.authorId)}>
size="sm"
variant={
isFollowingAuthor(item.authorId) ? "outline" : "default"
}
onClick={() => toggleFollow(item.authorId)}
>
{isFollowingAuthor(item.authorId) ? ( {isFollowingAuthor(item.authorId) ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><UserCheck className="h-4 w-4" /> Following</span>
<UserCheck className="h-4 w-4" /> Following
</span>
) : ( ) : (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><UserPlus className="h-4 w-4" /> Follow</span>
<UserPlus className="h-4 w-4" /> Follow
</span>
)} )}
</Button> </Button>
</div> </div>