diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c542b8..5cc94dd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + + diff --git a/client/src/pages/Community.tsx b/client/src/pages/Community.tsx new file mode 100644 index 0000000..8830ffe --- /dev/null +++ b/client/src/pages/Community.tsx @@ -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(null); + + const slug = params?.slug || ''; + + const { data: outlet, isLoading: outletLoading } = useQuery({ + queryKey: ["/api/media-outlets", slug], + enabled: !!slug + }); + + const { data: posts = [], isLoading: postsLoading } = useQuery({ + 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 ( +
+
+
+

로딩 중...

+
+
+ ); + } + + if (!outlet) { + return
미디어 아울렛을 찾을 수 없습니다
; + } + + return ( +
+ {/* Header */} +
+
+
+
+ {outlet.imageUrl ? ( + {outlet.name} setEnlargedImage(outlet.imageUrl!)} + data-testid="image-outlet-header-profile" + /> + ) : ( +
+ + {outlet.name.charAt(0)} + +
+ )} +
setLocation(`/media/${slug}`)}> + SAPIENS +
+ + {outlet.name} + + +
+
+
+ +
+
setIsSearchModalOpen(true)} + data-testid="search-container" + > + + +
+ + {isAuthenticated && user && ( +
+ {(user.role === 'admin' || user.role === 'superadmin') && ( + + )} + {user.firstName || user.email} + +
+ )} +
+
+
+
+ + setIsSearchModalOpen(false)} + /> + + {enlargedImage && ( +
setEnlargedImage(null)} + > + Enlarged view +
+ )} + + {/* Community Content */} +
+ {/* Community Header */} +
+
+

+ {outlet.name} 커뮤니티 +

+

자유롭게 의견을 나눠보세요

+
+ + + + + + + + 새 글 작성 + +
{ + 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 + }); + }}> +
+
+ +
+
+