Add community features to media outlets and improve UI

Implement community section for each media outlet, including post creation, viewing, and replies, along with navigation and backend API routes.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9a264234-c5d7-4dcc-adf3-a954b149b30d
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/9a264234-c5d7-4dcc-adf3-a954b149b30d/AX0T336
This commit is contained in:
kimjaehyeon0101
2025-10-14 09:37:47 +00:00
parent 2ea8ecb0ff
commit 4f0b4c7e1d
6 changed files with 986 additions and 1 deletions

View File

@ -14,6 +14,8 @@ import Auctions from "@/pages/Auctions";
import AuctionGuide from "@/pages/AuctionGuide";
import MediaOutletAuction from "@/pages/MediaOutletAuction";
import Report from "@/pages/Report";
import Community from "@/pages/Community";
import CommunityPost from "@/pages/CommunityPost";
import NotFound from "@/pages/not-found";
function Router() {
@ -24,6 +26,8 @@ function Router() {
<Route path="/" component={isAuthenticated ? Home : Landing} />
<Route path="/media/:slug/report" component={Report} />
<Route path="/media/:slug/auction" component={MediaOutletAuction} />
<Route path="/media/:slug/community/:postId" component={CommunityPost} />
<Route path="/media/:slug/community" component={Community} />
<Route path="/media/:slug" component={MediaOutlet} />
<Route path="/articles/:slug" component={Article} />
<Route path="/auctions" component={Auctions} />

View File

@ -0,0 +1,439 @@
import { useRoute, useLocation, Link } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Eye, MessageCircle, ThumbsUp, Pin, Search, Info, Settings, User, LogOut } from "lucide-react";
import type { MediaOutlet, CommunityPost } from "@shared/schema";
import { queryClient, apiRequest } from "@/lib/queryClient";
import SearchModal from "@/components/SearchModal";
export default function Community() {
const [, params] = useRoute("/media/:slug/community");
const [, setLocation] = useLocation();
const { user, isAuthenticated } = useAuth();
const [isNewPostOpen, setIsNewPostOpen] = useState(false);
const [sortBy, setSortBy] = useState("latest");
const [searchTerm, setSearchTerm] = useState("");
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [enlargedImage, setEnlargedImage] = useState<string | null>(null);
const slug = params?.slug || '';
const { data: outlet, isLoading: outletLoading } = useQuery<MediaOutlet>({
queryKey: ["/api/media-outlets", slug],
enabled: !!slug
});
const { data: posts = [], isLoading: postsLoading } = useQuery<CommunityPost[]>({
queryKey: [`/api/media-outlets/${slug}/community?sort=${sortBy}`],
enabled: !!slug && !!outlet
});
const createPostMutation = useMutation({
mutationFn: async (data: { title: string; content: string; imageUrl?: string }) => {
return await apiRequest(`/api/media-outlets/${slug}/community`, "POST", data);
},
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0]?.toString().startsWith(`/api/media-outlets/${slug}/community`)
});
setIsNewPostOpen(false);
}
});
const handleLogout = async () => {
try {
const response = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
if (response.ok) {
window.location.href = "/";
}
} catch (error) {
console.error("Logout failed:", error);
}
};
const handleAdminPage = () => {
if (user?.role === "admin" || user?.role === "superadmin") {
setLocation("/admin");
}
};
const formatTimeAgo = (createdAt: Date | string | null) => {
if (!createdAt) return "";
const now = new Date();
const postDate = new Date(createdAt);
const diffInMs = now.getTime() - postDate.getTime();
const diffInMinutes = Math.floor(diffInMs / 60000);
if (diffInMinutes < 1) return "방금";
if (diffInMinutes < 60) return `${diffInMinutes}분 전`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}시간 전`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) return `${diffInDays}일 전`;
return postDate.toLocaleDateString('ko-KR');
};
const filteredPosts = posts.filter(post =>
searchTerm === "" ||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.content.toLowerCase().includes(searchTerm.toLowerCase())
);
const pinnedPosts = filteredPosts.filter(post => post.isPinned || post.isNotice);
const regularPosts = filteredPosts.filter(post => !post.isPinned && !post.isNotice);
if (outletLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600"> ...</p>
</div>
</div>
);
}
if (!outlet) {
return <div> </div>;
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{outlet.imageUrl ? (
<img
src={outlet.imageUrl}
alt={outlet.name}
className="w-10 h-10 rounded-full object-cover cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all"
onClick={() => setEnlargedImage(outlet.imageUrl!)}
data-testid="image-outlet-header-profile"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-bold text-sm">
{outlet.name.charAt(0)}
</span>
</div>
)}
<div className="flex flex-col cursor-pointer hover:opacity-80 transition-opacity mt-1" onClick={() => setLocation(`/media/${slug}`)}>
<img
src="/attached_assets/logo_black_1759162717640.png"
alt="SAPIENS"
className="h-2.5 w-auto max-w-[70px] mb-0.5"
data-testid="logo-sapiens"
/>
<div className="flex items-center space-x-1.5">
<span className="text-lg font-bold text-gray-900" data-testid="text-outlet-name-header">
{outlet.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setLocation(`/media/${outlet.slug}/report`);
}}
className="inline-flex items-center"
aria-label="View reports"
>
<Info
className="h-4 w-4 text-gray-500 cursor-pointer hover:text-gray-700"
data-testid="icon-info-header"
/>
</button>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div
className="relative cursor-pointer"
onClick={() => setIsSearchModalOpen(true)}
data-testid="search-container"
>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="text"
placeholder="Search the website"
className="w-80 pl-10 bg-gray-50 border-gray-200 cursor-pointer"
readOnly
data-testid="input-search"
/>
</div>
{isAuthenticated && user && (
<div className="flex items-center space-x-2">
{(user.role === 'admin' || user.role === 'superadmin') && (
<Button
variant="ghost"
size="sm"
onClick={handleAdminPage}
data-testid="button-admin"
>
<Settings className="h-4 w-4" />
</Button>
)}
<span className="text-sm text-gray-700" data-testid="text-user-name">{user.firstName || user.email}</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
data-testid="button-logout"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
</div>
</header>
<SearchModal
isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
/>
{enlargedImage && (
<div
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4"
onClick={() => setEnlargedImage(null)}
>
<img
src={enlargedImage}
alt="Enlarged view"
className="max-w-full max-h-full object-contain"
/>
</div>
)}
{/* Community Content */}
<main className="max-w-7xl mx-auto px-6 py-8">
{/* Community Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900" data-testid="text-community-title">
{outlet.name}
</h1>
<p className="text-sm text-gray-600 mt-1"> </p>
</div>
<Dialog open={isNewPostOpen} onOpenChange={setIsNewPostOpen}>
<DialogTrigger asChild>
<Button data-testid="button-new-post">
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl" data-testid="dialog-new-post">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPostMutation.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
imageUrl: formData.get('imageUrl') as string || undefined
});
}}>
<div className="space-y-4">
<div>
<Input
name="title"
placeholder="제목을 입력하세요"
required
data-testid="input-post-title"
/>
</div>
<div>
<Textarea
name="content"
placeholder="내용을 입력하세요"
rows={10}
required
data-testid="textarea-post-content"
/>
</div>
<div>
<Input
name="imageUrl"
placeholder="이미지 URL (선택사항)"
data-testid="input-post-image-url"
/>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => setIsNewPostOpen(false)} data-testid="button-cancel-post">
</Button>
<Button type="submit" disabled={createPostMutation.isPending} data-testid="button-submit-post">
{createPostMutation.isPending ? '작성 중...' : '작성하기'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
{/* Controls */}
<div className="mb-4 flex items-center justify-between bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center space-x-4">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-32" data-testid="select-sort">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest"></SelectItem>
<SelectItem value="views"></SelectItem>
<SelectItem value="likes"></SelectItem>
<SelectItem value="replies"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="검색"
className="w-64 pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
data-testid="input-search-posts"
/>
</div>
</div>
{/* Posts List */}
{postsLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600"> ...</p>
</div>
) : (
<div className="space-y-1">
{/* Pinned/Notice Posts */}
{pinnedPosts.map((post) => (
<Link key={post.id} href={`/media/${slug}/community/${post.id}`}>
<Card
className="p-4 hover:bg-gray-50 transition-colors cursor-pointer border-l-4 border-l-red-500"
data-testid={`card-post-${post.id}`}
>
<div className="flex items-center space-x-4">
{post.imageUrl && (
<img
src={post.imageUrl}
alt={post.title}
className="w-16 h-16 object-cover rounded flex-shrink-0"
data-testid={`image-post-${post.id}`}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
{post.isNotice && (
<Badge variant="destructive" className="text-xs" data-testid={`badge-notice-${post.id}`}>
</Badge>
)}
{post.isPinned && !post.isNotice && (
<Badge variant="secondary" className="text-xs" data-testid={`badge-pin-${post.id}`}>
<Pin className="h-3 w-3 mr-1" />
</Badge>
)}
</div>
<h3 className="font-medium text-gray-900 truncate" data-testid={`text-post-title-${post.id}`}>
{post.title}
</h3>
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
<span data-testid={`text-post-time-${post.id}`}>{formatTimeAgo(post.createdAt)}</span>
<div className="flex items-center space-x-1" data-testid={`stat-views-${post.id}`}>
<Eye className="h-3 w-3" />
<span>{post.viewCount}</span>
</div>
<div className="flex items-center space-x-1" data-testid={`stat-replies-${post.id}`}>
<MessageCircle className="h-3 w-3" />
<span>{post.replyCount}</span>
</div>
<div className="flex items-center space-x-1" data-testid={`stat-likes-${post.id}`}>
<ThumbsUp className="h-3 w-3" />
<span>{post.likeCount}</span>
</div>
</div>
</div>
</div>
</Card>
</Link>
))}
{/* Regular Posts */}
{regularPosts.map((post) => (
<Link key={post.id} href={`/media/${slug}/community/${post.id}`}>
<Card
className="p-4 hover:bg-gray-50 transition-colors cursor-pointer"
data-testid={`card-post-${post.id}`}
>
<div className="flex items-center space-x-4">
{post.imageUrl && (
<img
src={post.imageUrl}
alt={post.title}
className="w-16 h-16 object-cover rounded flex-shrink-0"
data-testid={`image-post-${post.id}`}
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate" data-testid={`text-post-title-${post.id}`}>
{post.title}
</h3>
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
<span data-testid={`text-post-time-${post.id}`}>{formatTimeAgo(post.createdAt)}</span>
<div className="flex items-center space-x-1" data-testid={`stat-views-${post.id}`}>
<Eye className="h-3 w-3" />
<span>{post.viewCount}</span>
</div>
<div className="flex items-center space-x-1" data-testid={`stat-replies-${post.id}`}>
<MessageCircle className="h-3 w-3" />
<span>{post.replyCount}</span>
</div>
<div className="flex items-center space-x-1" data-testid={`stat-likes-${post.id}`}>
<ThumbsUp className="h-3 w-3" />
<span>{post.likeCount}</span>
</div>
</div>
</div>
</div>
</Card>
</Link>
))}
{filteredPosts.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500"> .</p>
</div>
)}
</div>
)}
</main>
</div>
);
}

View File

@ -0,0 +1,293 @@
import { useRoute, useLocation, Link } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ThumbsUp, MessageCircle, Eye, ArrowLeft, Trash } from "lucide-react";
import type { MediaOutlet, CommunityPost, CommunityReply } from "@shared/schema";
import { queryClient, apiRequest } from "@/lib/queryClient";
export default function CommunityPostPage() {
const [, params] = useRoute("/media/:slug/community/:postId");
const [, setLocation] = useLocation();
const { user, isAuthenticated } = useAuth();
const [replyContent, setReplyContent] = useState("");
const slug = params?.slug || '';
const postId = params?.postId || '';
const { data: outlet } = useQuery<MediaOutlet>({
queryKey: ["/api/media-outlets", slug],
enabled: !!slug
});
const { data: post, isLoading: postLoading } = useQuery<CommunityPost>({
queryKey: [`/api/community/posts/${postId}`],
enabled: !!postId
});
const { data: replies = [], isLoading: repliesLoading } = useQuery<CommunityReply[]>({
queryKey: [`/api/community/posts/${postId}/replies`],
enabled: !!postId
});
const likePostMutation = useMutation({
mutationFn: async () => {
return await apiRequest(`/api/community/posts/${postId}/like`, "POST", {});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/community/posts/${postId}`] });
}
});
const createReplyMutation = useMutation({
mutationFn: async (content: string) => {
return await apiRequest(`/api/community/posts/${postId}/replies`, "POST", { content });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/community/posts/${postId}/replies`] });
queryClient.invalidateQueries({ queryKey: [`/api/community/posts/${postId}`] });
setReplyContent("");
}
});
const deletePostMutation = useMutation({
mutationFn: async () => {
return await apiRequest(`/api/community/posts/${postId}`, "DELETE", {});
},
onSuccess: () => {
setLocation(`/media/${slug}/community`);
}
});
const formatTimeAgo = (createdAt: Date | string | null) => {
if (!createdAt) return "";
const now = new Date();
const postDate = new Date(createdAt);
const diffInMs = now.getTime() - postDate.getTime();
const diffInMinutes = Math.floor(diffInMs / 60000);
if (diffInMinutes < 1) return "방금";
if (diffInMinutes < 60) return `${diffInMinutes}분 전`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}시간 전`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) return `${diffInDays}일 전`;
return postDate.toLocaleDateString('ko-KR');
};
const canDeletePost = () => {
if (!user || !post) return false;
return post.authorId === user.id || user.role === 'admin' || user.role === 'superadmin';
};
if (postLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600"> ...</p>
</div>
</div>
);
}
if (!post) {
return <div> </div>;
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={() => setLocation(`/media/${slug}/community`)}
data-testid="button-back"
>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
{outlet && (
<Link href={`/media/${slug}`}>
<div className="flex items-center space-x-2 cursor-pointer hover:opacity-80">
{outlet.imageUrl && (
<img
src={outlet.imageUrl}
alt={outlet.name}
className="w-8 h-8 rounded-full object-cover"
/>
)}
<span className="font-semibold text-gray-900">{outlet.name}</span>
</div>
</Link>
)}
</div>
</div>
</header>
{/* Post Content */}
<main className="max-w-4xl mx-auto px-6 py-8">
<Card className="mb-6">
<CardContent className="p-8">
{/* Post Header */}
<div className="mb-6">
<div className="flex items-center space-x-2 mb-3">
{post.isNotice && (
<Badge variant="destructive" className="text-xs" data-testid="badge-notice">
</Badge>
)}
{post.isPinned && !post.isNotice && (
<Badge variant="secondary" className="text-xs" data-testid="badge-pin">
</Badge>
)}
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3" data-testid="text-post-title">
{post.title}
</h1>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-4">
<span data-testid="text-post-time">{formatTimeAgo(post.createdAt)}</span>
<div className="flex items-center space-x-1" data-testid="stat-views">
<Eye className="h-4 w-4" />
<span>{post.viewCount}</span>
</div>
<div className="flex items-center space-x-1" data-testid="stat-replies">
<MessageCircle className="h-4 w-4" />
<span>{post.replyCount}</span>
</div>
<div className="flex items-center space-x-1" data-testid="stat-likes">
<ThumbsUp className="h-4 w-4" />
<span>{post.likeCount}</span>
</div>
</div>
{canDeletePost() && (
<Button
variant="ghost"
size="sm"
onClick={() => deletePostMutation.mutate()}
disabled={deletePostMutation.isPending}
data-testid="button-delete-post"
>
<Trash className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</div>
{/* Post Image */}
{post.imageUrl && (
<div className="mb-6">
<img
src={post.imageUrl}
alt={post.title}
className="w-full max-h-96 object-contain rounded-lg"
data-testid="image-post"
/>
</div>
)}
{/* Post Content */}
<div className="prose max-w-none mb-6">
<p className="text-gray-800 whitespace-pre-wrap" data-testid="text-post-content">
{post.content}
</p>
</div>
{/* Like Button */}
<div className="flex justify-center pt-4 border-t border-gray-200">
<Button
variant="outline"
onClick={() => likePostMutation.mutate()}
disabled={!isAuthenticated || likePostMutation.isPending}
data-testid="button-like"
>
<ThumbsUp className="h-4 w-4 mr-2" />
({post.likeCount})
</Button>
</div>
</CardContent>
</Card>
{/* Replies Section */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-4" data-testid="text-replies-title">
{post.replyCount}
</h2>
{/* Reply Form */}
{isAuthenticated ? (
<Card className="mb-6">
<CardContent className="p-4">
<Textarea
placeholder="댓글을 입력하세요"
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
rows={3}
className="mb-3"
data-testid="textarea-reply"
/>
<div className="flex justify-end">
<Button
onClick={() => createReplyMutation.mutate(replyContent)}
disabled={!replyContent.trim() || createReplyMutation.isPending}
data-testid="button-submit-reply"
>
{createReplyMutation.isPending ? '작성 중...' : '댓글 작성'}
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="mb-6">
<CardContent className="p-4 text-center text-gray-500">
.
</CardContent>
</Card>
)}
{/* Replies List */}
{repliesLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<p className="text-gray-600 text-sm"> ...</p>
</div>
) : replies.length > 0 ? (
<div className="space-y-3">
{replies.map((reply) => (
<Card key={reply.id}>
<CardContent className="p-4">
<p className="text-gray-800 whitespace-pre-wrap" data-testid={`text-reply-content-${reply.id}`}>
{reply.content}
</p>
<p className="text-xs text-gray-500 mt-2" data-testid={`text-reply-time-${reply.id}`}>
{formatTimeAgo(reply.createdAt)}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500"> !</p>
</div>
)}
</div>
</main>
</div>
);
}

View File

@ -262,6 +262,15 @@ export default function MediaOutlet() {
Auctions
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLocation(`/media/${outlet.slug}/community`)}
data-testid="button-community"
>
Community
</Button>
<Button
variant="ghost"
size="sm"