feat(sprint7): Phase 3 — Forum MVP
- Flyway V15: forum_topics, forum_replies, forum_reactions, forum_reports tables - Enums: ForumTargetType, ReactionType, ReportStatus - Extended AuditEventType with FORUM_REPLY_CREATED, FORUM_REPORT_REVIEWED - Entities: ForumTopic, ForumReply, ForumReaction, ForumReport - Repositories: ForumTopicRepository, ForumReplyRepository, ForumReactionRepository, ForumReportRepository - ForumService: full CRUD, moderation (lock/pin/delete), 60-min edit window, toggle reactions, content reporting, notifications on new topics/replies - ForumController: admin + portal endpoints (topics, replies, reactions, reports, moderation) - Frontend: forum.ts service with React Query hooks (admin + portal) - Frontend: Admin forum page with topic list, moderation actions (lock/pin/delete) - Frontend: Portal forum page with topic list, reply thread, reactions, report - Navigation: added Forum with MessageSquare icon - i18n: forum.* keys in de.json and en.json
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
Plus,
|
||||
Trash2,
|
||||
Unlock,
|
||||
Flag,
|
||||
PinOff,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
useForumTopics,
|
||||
useCreateTopic,
|
||||
useLockTopic,
|
||||
useUnlockTopic,
|
||||
usePinTopic,
|
||||
useUnpinTopic,
|
||||
useDeleteTopic,
|
||||
useOpenReportCount,
|
||||
type ForumTopic,
|
||||
} from "@/services/forum"
|
||||
|
||||
export default function ForumPage() {
|
||||
const t = useTranslations("forum")
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
const { data: topicsData, isLoading } = useForumTopics()
|
||||
const { data: reportCount } = useOpenReportCount()
|
||||
const createTopic = useCreateTopic()
|
||||
const lockTopic = useLockTopic()
|
||||
const unlockTopic = useUnlockTopic()
|
||||
const pinTopic = usePinTopic()
|
||||
const unpinTopic = useUnpinTopic()
|
||||
const deleteTopic = useDeleteTopic()
|
||||
|
||||
const topics: ForumTopic[] = topicsData?.content ?? []
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim() || !content.trim()) return
|
||||
createTopic.mutate(
|
||||
{ title: title.trim(), content: content.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTitle("")
|
||||
setContent("")
|
||||
setShowCreate(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = (topicId: string) => {
|
||||
const reason = prompt(t("deleteReason"))
|
||||
if (reason !== null) {
|
||||
deleteTopic.mutate({ topicId, reason })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{reportCount?.count > 0 && (
|
||||
<a
|
||||
href="/forum/reports"
|
||||
className="bg-destructive/10 text-destructive inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
<Flag className="h-4 w-4" />
|
||||
{reportCount.count} {t("openReports")}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("newTopic")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Topic Form */}
|
||||
{showCreate && (
|
||||
<div className="bg-card rounded-lg border p-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("topicTitlePlaceholder")}
|
||||
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t("topicContentPlaceholder")}
|
||||
rows={4}
|
||||
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={createTopic.isPending}
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createTopic.isPending ? t("creating") : t("create")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="text-muted-foreground rounded-md px-4 py-2 text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topic List */}
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : topics.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t("noTopics")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topics.map((topic) => (
|
||||
<div
|
||||
key={topic.id}
|
||||
className="bg-card flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{topic.pinned && (
|
||||
<Pin className="text-primary h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{topic.locked && (
|
||||
<Lock className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<a
|
||||
href={`/forum/${topic.id}`}
|
||||
className="font-medium hover:underline truncate"
|
||||
>
|
||||
{topic.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{topic.replyCount} {t("replies")}
|
||||
</span>
|
||||
<span>
|
||||
{new Date(topic.createdAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
{topic.lastReplyAt && (
|
||||
<span>
|
||||
{t("lastReply")}:{" "}
|
||||
{new Date(topic.lastReplyAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moderation Actions */}
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
{topic.pinned ? (
|
||||
<button
|
||||
onClick={() => unpinTopic.mutate(topic.id)}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1.5"
|
||||
title={t("unpin")}
|
||||
>
|
||||
<PinOff className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => pinTopic.mutate(topic.id)}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1.5"
|
||||
title={t("pin")}
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{topic.locked ? (
|
||||
<button
|
||||
onClick={() => unlockTopic.mutate(topic.id)}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1.5"
|
||||
title={t("unlock")}
|
||||
>
|
||||
<Unlock className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => lockTopic.mutate(topic.id)}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1.5"
|
||||
title={t("lock")}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
className="text-muted-foreground hover:text-destructive rounded p-1.5"
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
Plus,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Flag,
|
||||
ArrowLeft,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
usePortalForumTopics,
|
||||
usePortalForumTopic,
|
||||
usePortalForumReplies,
|
||||
usePortalCreateTopic,
|
||||
usePortalCreateReply,
|
||||
usePortalToggleReaction,
|
||||
usePortalReportContent,
|
||||
type ForumTopic,
|
||||
type ForumReply,
|
||||
} from "@/services/forum"
|
||||
|
||||
export default function PortalForumPage() {
|
||||
const t = useTranslations("forum")
|
||||
const [selectedTopicId, setSelectedTopicId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [replyContent, setReplyContent] = useState("")
|
||||
|
||||
const { data: topicsData, isLoading } = usePortalForumTopics()
|
||||
const { data: topicDetail } = usePortalForumTopic(selectedTopicId ?? undefined)
|
||||
const { data: repliesData } = usePortalForumReplies(selectedTopicId ?? undefined)
|
||||
const createTopic = usePortalCreateTopic()
|
||||
const createReply = usePortalCreateReply(selectedTopicId ?? "")
|
||||
const toggleReaction = usePortalToggleReaction()
|
||||
const reportContent = usePortalReportContent()
|
||||
|
||||
const topics: ForumTopic[] = topicsData?.content ?? []
|
||||
const replies: ForumReply[] = repliesData?.content ?? []
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim() || !content.trim()) return
|
||||
createTopic.mutate(
|
||||
{ title: title.trim(), content: content.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTitle("")
|
||||
setContent("")
|
||||
setShowCreate(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleReply = () => {
|
||||
if (!replyContent.trim() || !selectedTopicId) return
|
||||
createReply.mutate(
|
||||
{ content: replyContent.trim() },
|
||||
{
|
||||
onSuccess: () => setReplyContent(""),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleReport = (targetType: "TOPIC" | "REPLY", targetId: string) => {
|
||||
const reason = prompt(t("reportReason"))
|
||||
if (reason) {
|
||||
reportContent.mutate({ targetType, targetId, reason })
|
||||
}
|
||||
}
|
||||
|
||||
// Topic Detail View
|
||||
if (selectedTopicId && topicDetail) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedTopicId(null)}
|
||||
className="text-muted-foreground inline-flex items-center gap-1 text-sm hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("backToTopics")}
|
||||
</button>
|
||||
|
||||
{/* Topic */}
|
||||
<div className="bg-card rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{topicDetail.pinned && <Pin className="text-primary h-4 w-4" />}
|
||||
{topicDetail.locked && <Lock className="text-muted-foreground h-4 w-4" />}
|
||||
<h2 className="text-lg font-bold">{topicDetail.title}</h2>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert mb-3"
|
||||
dangerouslySetInnerHTML={{ __html: topicDetail.content }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{new Date(topicDetail.createdAt).toLocaleDateString("de-DE")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleReaction.mutate({
|
||||
targetType: "TOPIC",
|
||||
targetId: topicDetail.id,
|
||||
reactionType: "THUMBS_UP",
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleReaction.mutate({
|
||||
targetType: "TOPIC",
|
||||
targetId: topicDetail.id,
|
||||
reactionType: "THUMBS_DOWN",
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReport("TOPIC", topicDetail.id)}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<Flag className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
<div className="space-y-2">
|
||||
{replies.map((reply) => (
|
||||
<div key={reply.id} className="bg-card rounded-lg border p-3">
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: reply.content }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{new Date(reply.createdAt).toLocaleDateString("de-DE")}</span>
|
||||
{reply.edited && <span className="italic">({t("edited")})</span>}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleReaction.mutate({
|
||||
targetType: "REPLY",
|
||||
targetId: reply.id,
|
||||
reactionType: "THUMBS_UP",
|
||||
})
|
||||
}
|
||||
className="rounded px-1.5 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleReaction.mutate({
|
||||
targetType: "REPLY",
|
||||
targetId: reply.id,
|
||||
reactionType: "THUMBS_DOWN",
|
||||
})
|
||||
}
|
||||
className="rounded px-1.5 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReport("REPLY", reply.id)}
|
||||
className="rounded px-1.5 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<Flag className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{!topicDetail.locked && (
|
||||
<div className="bg-card rounded-lg border p-3 space-y-2">
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder={t("replyPlaceholder")}
|
||||
rows={3}
|
||||
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleReply}
|
||||
disabled={createReply.isPending || !replyContent.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createReply.isPending ? t("sending") : t("reply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{topicDetail.locked && (
|
||||
<div className="text-muted-foreground text-center text-sm py-4">
|
||||
<Lock className="h-4 w-4 inline mr-1" />
|
||||
{t("topicLocked")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Topic List View
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("newTopic")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="bg-card rounded-lg border p-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("topicTitlePlaceholder")}
|
||||
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t("topicContentPlaceholder")}
|
||||
rows={4}
|
||||
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={createTopic.isPending}
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createTopic.isPending ? t("creating") : t("create")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="text-muted-foreground rounded-md px-4 py-2 text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : topics.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t("noTopics")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topics.map((topic) => (
|
||||
<button
|
||||
key={topic.id}
|
||||
onClick={() => setSelectedTopicId(topic.id)}
|
||||
className="bg-card w-full text-left rounded-lg border p-4 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{topic.pinned && <Pin className="text-primary h-4 w-4 shrink-0" />}
|
||||
{topic.locked && <Lock className="text-muted-foreground h-4 w-4 shrink-0" />}
|
||||
<span className="font-medium truncate">{topic.title}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{topic.replyCount} {t("replies")}
|
||||
</span>
|
||||
<span>
|
||||
{new Date(topic.createdAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/calendar",
|
||||
iconName: "Calendar",
|
||||
},
|
||||
{
|
||||
title: "Forum",
|
||||
href: "/forum",
|
||||
iconName: "MessageSquare",
|
||||
},
|
||||
{
|
||||
title: "Personal",
|
||||
href: "/settings/staff",
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type ForumTargetType = "TOPIC" | "REPLY"
|
||||
export type ReactionType = "THUMBS_UP" | "THUMBS_DOWN"
|
||||
export type ReportStatus = "OPEN" | "REVIEWED" | "DISMISSED"
|
||||
|
||||
export interface ForumTopic {
|
||||
id: string
|
||||
clubId: string
|
||||
title: string
|
||||
content: string
|
||||
authorId: string
|
||||
locked: boolean
|
||||
pinned: boolean
|
||||
replyCount: number
|
||||
lastReplyAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ForumReply {
|
||||
id: string
|
||||
topicId: string
|
||||
clubId: string
|
||||
content: string
|
||||
authorId: string
|
||||
edited: boolean
|
||||
editedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ForumReport {
|
||||
id: string
|
||||
clubId: string
|
||||
targetType: ForumTargetType
|
||||
targetId: string
|
||||
reporterId: string
|
||||
reason: string
|
||||
status: ReportStatus
|
||||
reviewedBy: string | null
|
||||
reviewedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateTopicRequest {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface CreateReplyRequest {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ReactionRequest {
|
||||
targetType: ForumTargetType
|
||||
targetId: string
|
||||
reactionType: ReactionType
|
||||
}
|
||||
|
||||
export interface ReportRequest {
|
||||
targetType: ForumTargetType
|
||||
targetId: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface PageResponse<T> {
|
||||
content: T[]
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
number: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// --- Admin Hooks ---
|
||||
|
||||
export function useForumTopics(page = 0, size = 20) {
|
||||
return useQuery({
|
||||
queryKey: ["forum-topics", page, size],
|
||||
queryFn: () =>
|
||||
apiClient<PageResponse<ForumTopic>>(
|
||||
`/forum/topics?page=${page}&size=${size}`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function useForumTopic(topicId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["forum-topic", topicId],
|
||||
queryFn: () => apiClient<ForumTopic>(`/forum/topics/${topicId}`),
|
||||
enabled: !!topicId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForumReplies(
|
||||
topicId: string | undefined,
|
||||
page = 0,
|
||||
size = 50
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["forum-replies", topicId, page, size],
|
||||
queryFn: () =>
|
||||
apiClient<PageResponse<ForumReply>>(
|
||||
`/forum/topics/${topicId}/replies?page=${page}&size=${size}`
|
||||
),
|
||||
enabled: !!topicId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForumReports(status: ReportStatus = "OPEN", page = 0) {
|
||||
return useQuery({
|
||||
queryKey: ["forum-reports", status, page],
|
||||
queryFn: () =>
|
||||
apiClient<PageResponse<ForumReport>>(
|
||||
`/forum/reports?status=${status}&page=${page}`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpenReportCount() {
|
||||
return useQuery({
|
||||
queryKey: ["forum-reports-count"],
|
||||
queryFn: () => apiClient<{ count: number }>("/forum/reports/count"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTopicRequest) =>
|
||||
apiClient<ForumTopic>("/forum/topics", { method: "POST", body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateReply(topicId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateReplyRequest) =>
|
||||
apiClient<ForumReply>(`/forum/topics/${topicId}/replies`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-replies", topicId] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topic", topicId] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useEditReply() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ replyId, content }: { replyId: string; content: string }) =>
|
||||
apiClient<ForumReply>(`/forum/replies/${replyId}`, {
|
||||
method: "PUT",
|
||||
body: { content },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-replies"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLockTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (topicId: string) =>
|
||||
apiClient<ForumTopic>(`/forum/topics/${topicId}/lock`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topic"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnlockTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (topicId: string) =>
|
||||
apiClient<ForumTopic>(`/forum/topics/${topicId}/unlock`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topic"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePinTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (topicId: string) =>
|
||||
apiClient<ForumTopic>(`/forum/topics/${topicId}/pin`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnpinTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (topicId: string) =>
|
||||
apiClient<ForumTopic>(`/forum/topics/${topicId}/unpin`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ topicId, reason }: { topicId: string; reason?: string }) =>
|
||||
apiClient<void>(
|
||||
`/forum/topics/${topicId}${reason ? `?reason=${encodeURIComponent(reason)}` : ""}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteReply() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (replyId: string) =>
|
||||
apiClient<void>(`/forum/replies/${replyId}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-replies"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useToggleReaction() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: ReactionRequest) =>
|
||||
apiClient<{ active: boolean; reactionType: string }>("/forum/reactions", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-topic"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-replies"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReportContent() {
|
||||
return useMutation({
|
||||
mutationFn: (data: ReportRequest) =>
|
||||
apiClient<{ status: string }>("/forum/reports", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useReviewReport() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
reportId,
|
||||
status,
|
||||
}: {
|
||||
reportId: string
|
||||
status: ReportStatus
|
||||
}) =>
|
||||
apiClient<ForumReport>(`/forum/reports/${reportId}/review`, {
|
||||
method: "POST",
|
||||
body: { status },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-reports"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["forum-reports-count"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Portal Hooks ---
|
||||
|
||||
export function usePortalForumTopics(page = 0, size = 20) {
|
||||
return useQuery({
|
||||
queryKey: ["portal-forum-topics", page, size],
|
||||
queryFn: () =>
|
||||
apiClient<PageResponse<ForumTopic>>(
|
||||
`/portal/forum/topics?page=${page}&size=${size}`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalForumTopic(topicId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["portal-forum-topic", topicId],
|
||||
queryFn: () => apiClient<ForumTopic>(`/portal/forum/topics/${topicId}`),
|
||||
enabled: !!topicId,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalForumReplies(topicId: string | undefined, page = 0) {
|
||||
return useQuery({
|
||||
queryKey: ["portal-forum-replies", topicId, page],
|
||||
queryFn: () =>
|
||||
apiClient<PageResponse<ForumReply>>(
|
||||
`/portal/forum/topics/${topicId}/replies?page=${page}`
|
||||
),
|
||||
enabled: !!topicId,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalCreateTopic() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTopicRequest) =>
|
||||
apiClient<ForumTopic>("/portal/forum/topics", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalCreateReply(topicId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateReplyRequest) =>
|
||||
apiClient<ForumReply>(`/portal/forum/topics/${topicId}/replies`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["portal-forum-replies", topicId],
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-forum-topics"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalToggleReaction() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: ReactionRequest) =>
|
||||
apiClient<{ active: boolean; reactionType: string }>(
|
||||
"/portal/forum/reactions",
|
||||
{ method: "POST", body: data }
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-forum-topic"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-forum-replies"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalReportContent() {
|
||||
return useMutation({
|
||||
mutationFn: (data: ReportRequest) =>
|
||||
apiClient<{ status: string }>("/portal/forum/reports", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user