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:
@ -14,6 +14,8 @@ import Auctions from "@/pages/Auctions";
|
|||||||
import AuctionGuide from "@/pages/AuctionGuide";
|
import AuctionGuide from "@/pages/AuctionGuide";
|
||||||
import MediaOutletAuction from "@/pages/MediaOutletAuction";
|
import MediaOutletAuction from "@/pages/MediaOutletAuction";
|
||||||
import Report from "@/pages/Report";
|
import Report from "@/pages/Report";
|
||||||
|
import Community from "@/pages/Community";
|
||||||
|
import CommunityPost from "@/pages/CommunityPost";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
@ -24,6 +26,8 @@ function Router() {
|
|||||||
<Route path="/" component={isAuthenticated ? Home : Landing} />
|
<Route path="/" component={isAuthenticated ? Home : Landing} />
|
||||||
<Route path="/media/:slug/report" component={Report} />
|
<Route path="/media/:slug/report" component={Report} />
|
||||||
<Route path="/media/:slug/auction" component={MediaOutletAuction} />
|
<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="/media/:slug" component={MediaOutlet} />
|
||||||
<Route path="/articles/:slug" component={Article} />
|
<Route path="/articles/:slug" component={Article} />
|
||||||
<Route path="/auctions" component={Auctions} />
|
<Route path="/auctions" component={Auctions} />
|
||||||
|
|||||||
439
client/src/pages/Community.tsx
Normal file
439
client/src/pages/Community.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
client/src/pages/CommunityPost.tsx
Normal file
293
client/src/pages/CommunityPost.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -262,6 +262,15 @@ export default function MediaOutlet() {
|
|||||||
Auctions
|
Auctions
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLocation(`/media/${outlet.slug}/community`)}
|
||||||
|
data-testid="button-community"
|
||||||
|
>
|
||||||
|
Community
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
149
server/routes.ts
149
server/routes.ts
@ -2,7 +2,7 @@ import type { Express } from "express";
|
|||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { setupAuth, isAuthenticated } from "./simpleAuth";
|
import { setupAuth, isAuthenticated } from "./simpleAuth";
|
||||||
import { insertArticleSchema, insertMediaOutletRequestSchema, insertBidSchema, insertCommentSchema, insertPredictionBetSchema } from "@shared/schema";
|
import { insertArticleSchema, insertMediaOutletRequestSchema, insertBidSchema, insertCommentSchema, insertPredictionBetSchema, insertCommunityPostSchema, insertCommunityReplySchema } from "@shared/schema";
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
// Auth middleware
|
// Auth middleware
|
||||||
@ -392,6 +392,153 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Community routes
|
||||||
|
app.get('/api/media-outlets/:slug/community', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const outlet = await storage.getMediaOutletBySlug(req.params.slug);
|
||||||
|
if (!outlet) {
|
||||||
|
return res.status(404).json({ message: "Media outlet not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sort = req.query.sort as string || 'latest';
|
||||||
|
const posts = await storage.getCommunityPostsByOutlet(outlet.id, sort);
|
||||||
|
res.json(posts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching community posts:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch community posts" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/community/posts/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await storage.getCommunityPostById(req.params.id);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ message: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await storage.incrementPostViews(req.params.id);
|
||||||
|
|
||||||
|
res.json(post);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching community post:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch community post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/media-outlets/:slug/community', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const outlet = await storage.getMediaOutletBySlug(req.params.slug);
|
||||||
|
if (!outlet) {
|
||||||
|
return res.status(404).json({ message: "Media outlet not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user.claims?.sub || req.user.id;
|
||||||
|
const postData = insertCommunityPostSchema.parse({
|
||||||
|
...req.body,
|
||||||
|
mediaOutletId: outlet.id,
|
||||||
|
authorId: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await storage.createCommunityPost(postData);
|
||||||
|
res.status(201).json(post);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating community post:", error);
|
||||||
|
res.status(500).json({ message: "Failed to create community post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/community/posts/:id', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.claims?.sub || req.user.id;
|
||||||
|
const post = await storage.getCommunityPostById(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ message: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.authorId !== userId) {
|
||||||
|
return res.status(403).json({ message: "Not authorized to edit this post" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateCommunityPost(req.params.id, req.body);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating community post:", error);
|
||||||
|
res.status(500).json({ message: "Failed to update community post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/community/posts/:id', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.claims?.sub || req.user.id;
|
||||||
|
const user = await storage.getUser(userId);
|
||||||
|
const post = await storage.getCommunityPostById(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ message: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.authorId !== userId && user?.role !== 'admin' && user?.role !== 'superadmin') {
|
||||||
|
return res.status(403).json({ message: "Not authorized to delete this post" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteCommunityPost(req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting community post:", error);
|
||||||
|
res.status(500).json({ message: "Failed to delete community post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/community/posts/:id/like', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const post = await storage.getCommunityPostById(req.params.id);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ message: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.incrementPostLikes(req.params.id);
|
||||||
|
const updated = await storage.getCommunityPostById(req.params.id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error liking post:", error);
|
||||||
|
res.status(500).json({ message: "Failed to like post" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/community/posts/:id/replies', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const replies = await storage.getRepliesByPost(req.params.id);
|
||||||
|
res.json(replies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching replies:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch replies" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/community/posts/:id/replies', isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const post = await storage.getCommunityPostById(req.params.id);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ message: "Post not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user.claims?.sub || req.user.id;
|
||||||
|
const replyData = insertCommunityReplySchema.parse({
|
||||||
|
...req.body,
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const reply = await storage.createCommunityReply(replyData);
|
||||||
|
res.status(201).json(reply);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating reply:", error);
|
||||||
|
res.status(500).json({ message: "Failed to create reply" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import {
|
|||||||
mediaOutletRequests,
|
mediaOutletRequests,
|
||||||
comments,
|
comments,
|
||||||
predictionBets,
|
predictionBets,
|
||||||
|
communityPosts,
|
||||||
|
communityReplies,
|
||||||
type User,
|
type User,
|
||||||
type UpsertUser,
|
type UpsertUser,
|
||||||
type MediaOutlet,
|
type MediaOutlet,
|
||||||
@ -26,6 +28,10 @@ import {
|
|||||||
type InsertComment,
|
type InsertComment,
|
||||||
type PredictionBet,
|
type PredictionBet,
|
||||||
type InsertPredictionBet,
|
type InsertPredictionBet,
|
||||||
|
type CommunityPost,
|
||||||
|
type InsertCommunityPost,
|
||||||
|
type CommunityReply,
|
||||||
|
type InsertCommunityReply,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, desc, asc, and, ilike, sql } from "drizzle-orm";
|
import { eq, desc, asc, and, ilike, sql } from "drizzle-orm";
|
||||||
@ -77,6 +83,18 @@ export interface IStorage {
|
|||||||
getCommentsByArticle(articleId: string): Promise<Comment[]>;
|
getCommentsByArticle(articleId: string): Promise<Comment[]>;
|
||||||
createComment(comment: InsertComment): Promise<Comment>;
|
createComment(comment: InsertComment): Promise<Comment>;
|
||||||
|
|
||||||
|
// Community operations
|
||||||
|
getCommunityPostsByOutlet(mediaOutletId: string, sort?: string): Promise<CommunityPost[]>;
|
||||||
|
getCommunityPostById(id: string): Promise<CommunityPost | undefined>;
|
||||||
|
createCommunityPost(post: InsertCommunityPost): Promise<CommunityPost>;
|
||||||
|
updateCommunityPost(id: string, post: Partial<InsertCommunityPost>): Promise<CommunityPost>;
|
||||||
|
deleteCommunityPost(id: string): Promise<void>;
|
||||||
|
incrementPostViews(id: string): Promise<void>;
|
||||||
|
incrementPostLikes(id: string): Promise<void>;
|
||||||
|
|
||||||
|
getRepliesByPost(postId: string): Promise<CommunityReply[]>;
|
||||||
|
createCommunityReply(reply: InsertCommunityReply): Promise<CommunityReply>;
|
||||||
|
|
||||||
// Analytics operations
|
// Analytics operations
|
||||||
getAnalytics(): Promise<{
|
getAnalytics(): Promise<{
|
||||||
totalArticles: number;
|
totalArticles: number;
|
||||||
@ -368,6 +386,81 @@ export class DatabaseStorage implements IStorage {
|
|||||||
.where(eq(predictionBets.userId, userId))
|
.where(eq(predictionBets.userId, userId))
|
||||||
.orderBy(desc(predictionBets.createdAt));
|
.orderBy(desc(predictionBets.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community operations
|
||||||
|
async getCommunityPostsByOutlet(mediaOutletId: string, sort: string = 'latest'): Promise<CommunityPost[]> {
|
||||||
|
const query = db
|
||||||
|
.select()
|
||||||
|
.from(communityPosts)
|
||||||
|
.where(eq(communityPosts.mediaOutletId, mediaOutletId));
|
||||||
|
|
||||||
|
if (sort === 'views') {
|
||||||
|
return await query.orderBy(desc(communityPosts.isPinned), desc(communityPosts.viewCount), desc(communityPosts.createdAt));
|
||||||
|
} else if (sort === 'likes') {
|
||||||
|
return await query.orderBy(desc(communityPosts.isPinned), desc(communityPosts.likeCount), desc(communityPosts.createdAt));
|
||||||
|
} else if (sort === 'replies') {
|
||||||
|
return await query.orderBy(desc(communityPosts.isPinned), desc(communityPosts.replyCount), desc(communityPosts.createdAt));
|
||||||
|
} else {
|
||||||
|
return await query.orderBy(desc(communityPosts.isPinned), desc(communityPosts.createdAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommunityPostById(id: string): Promise<CommunityPost | undefined> {
|
||||||
|
const [post] = await db.select().from(communityPosts).where(eq(communityPosts.id, id));
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommunityPost(post: InsertCommunityPost): Promise<CommunityPost> {
|
||||||
|
const [newPost] = await db.insert(communityPosts).values(post).returning();
|
||||||
|
return newPost;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCommunityPost(id: string, post: Partial<InsertCommunityPost>): Promise<CommunityPost> {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(communityPosts)
|
||||||
|
.set({ ...post, updatedAt: new Date() })
|
||||||
|
.where(eq(communityPosts.id, id))
|
||||||
|
.returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCommunityPost(id: string): Promise<void> {
|
||||||
|
await db.delete(communityPosts).where(eq(communityPosts.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementPostViews(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(communityPosts)
|
||||||
|
.set({ viewCount: sql`view_count + 1` })
|
||||||
|
.where(eq(communityPosts.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementPostLikes(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(communityPosts)
|
||||||
|
.set({ likeCount: sql`like_count + 1` })
|
||||||
|
.where(eq(communityPosts.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepliesByPost(postId: string): Promise<CommunityReply[]> {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(communityReplies)
|
||||||
|
.where(eq(communityReplies.postId, postId))
|
||||||
|
.orderBy(asc(communityReplies.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCommunityReply(reply: InsertCommunityReply): Promise<CommunityReply> {
|
||||||
|
const [newReply] = await db.insert(communityReplies).values(reply).returning();
|
||||||
|
|
||||||
|
// Increment reply count on the post
|
||||||
|
await db
|
||||||
|
.update(communityPosts)
|
||||||
|
.set({ replyCount: sql`reply_count + 1` })
|
||||||
|
.where(eq(communityPosts.id, reply.postId));
|
||||||
|
|
||||||
|
return newReply;
|
||||||
|
}
|
||||||
|
|
||||||
// Analytics operations
|
// Analytics operations
|
||||||
async getAnalytics(): Promise<{
|
async getAnalytics(): Promise<{
|
||||||
|
|||||||
Reference in New Issue
Block a user