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:
@@ -808,5 +808,33 @@
|
||||
"WORKSHOP": "Workshop",
|
||||
"OTHER": "Sonstiges"
|
||||
}
|
||||
},
|
||||
"forum": {
|
||||
"title": "Forum",
|
||||
"description": "Vereinsinternes Diskussionsforum",
|
||||
"newTopic": "Neues Thema",
|
||||
"topicTitlePlaceholder": "Titel des Themas...",
|
||||
"topicContentPlaceholder": "Beschreibe dein Thema...",
|
||||
"creating": "Wird erstellt...",
|
||||
"create": "Erstellen",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Wird geladen...",
|
||||
"noTopics": "Noch keine Themen vorhanden. Erstelle das erste!",
|
||||
"replies": "Antworten",
|
||||
"lastReply": "Letzte Antwort",
|
||||
"openReports": "offene Meldungen",
|
||||
"pin": "Anheften",
|
||||
"unpin": "Lösen",
|
||||
"lock": "Sperren",
|
||||
"unlock": "Entsperren",
|
||||
"delete": "Löschen",
|
||||
"deleteReason": "Grund für die Löschung (optional):",
|
||||
"replyPlaceholder": "Schreibe eine Antwort...",
|
||||
"sending": "Wird gesendet...",
|
||||
"reply": "Antworten",
|
||||
"edited": "bearbeitet",
|
||||
"topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.",
|
||||
"reportReason": "Grund der Meldung:",
|
||||
"backToTopics": "Zurück zur Übersicht"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,21 +610,76 @@
|
||||
"onboarding": "Personal onboarding"
|
||||
},
|
||||
"comparison": {
|
||||
"compMembers": { "label": "Members", "starter": "Up to 30", "pro": "Up to 100", "enterprise": "Unlimited" },
|
||||
"compDistributions": { "label": "Distribution tracking", "starter": "✓", "pro": "✓", "enterprise": "✓" },
|
||||
"compReports": { "label": "Reports (PDF/CSV)", "starter": "Standard", "pro": "Advanced", "enterprise": "Custom" },
|
||||
"compGrow": { "label": "Grow calendar", "starter": "—", "pro": "✓", "enterprise": "✓" },
|
||||
"compStaff": { "label": "Staff management", "starter": "—", "pro": "✓", "enterprise": "✓" },
|
||||
"compApi": { "label": "API access", "starter": "—", "pro": "✓", "enterprise": "✓" },
|
||||
"compMultiClub": { "label": "Multi-club", "starter": "—", "pro": "—", "enterprise": "✓" },
|
||||
"compSupport": { "label": "Support", "starter": "Email", "pro": "Priority", "enterprise": "Dedicated" }
|
||||
"compMembers": {
|
||||
"label": "Members",
|
||||
"starter": "Up to 30",
|
||||
"pro": "Up to 100",
|
||||
"enterprise": "Unlimited"
|
||||
},
|
||||
"compDistributions": {
|
||||
"label": "Distribution tracking",
|
||||
"starter": "✓",
|
||||
"pro": "✓",
|
||||
"enterprise": "✓"
|
||||
},
|
||||
"compReports": {
|
||||
"label": "Reports (PDF/CSV)",
|
||||
"starter": "Standard",
|
||||
"pro": "Advanced",
|
||||
"enterprise": "Custom"
|
||||
},
|
||||
"compGrow": {
|
||||
"label": "Grow calendar",
|
||||
"starter": "—",
|
||||
"pro": "✓",
|
||||
"enterprise": "✓"
|
||||
},
|
||||
"compStaff": {
|
||||
"label": "Staff management",
|
||||
"starter": "—",
|
||||
"pro": "✓",
|
||||
"enterprise": "✓"
|
||||
},
|
||||
"compApi": {
|
||||
"label": "API access",
|
||||
"starter": "—",
|
||||
"pro": "✓",
|
||||
"enterprise": "✓"
|
||||
},
|
||||
"compMultiClub": {
|
||||
"label": "Multi-club",
|
||||
"starter": "—",
|
||||
"pro": "—",
|
||||
"enterprise": "✓"
|
||||
},
|
||||
"compSupport": {
|
||||
"label": "Support",
|
||||
"starter": "Email",
|
||||
"pro": "Priority",
|
||||
"enterprise": "Dedicated"
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"trial": { "question": "How does the free trial work?", "answer": "You can test CannaManage free for 3 months with no commitment. All features of your chosen plan are available immediately. After the trial, you decide whether to continue." },
|
||||
"payment": { "question": "Which payment methods are accepted?", "answer": "We accept SEPA direct debit, credit card (Visa, Mastercard) and PayPal. Billing is monthly through our payment partner Stripe." },
|
||||
"cancel": { "question": "Can I cancel anytime?", "answer": "Yes, you can cancel your subscription at any time at the end of the current billing period. There is no minimum contract period." },
|
||||
"data": { "question": "What happens to my data after cancellation?", "answer": "After cancellation, you have 30 days to export your data. After that, all personal data is deleted in accordance with GDPR. Data subject to retention requirements remains stored in compliance with the law." },
|
||||
"migration": { "question": "Can I switch plans later?", "answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period." }
|
||||
"trial": {
|
||||
"question": "How does the free trial work?",
|
||||
"answer": "You can test CannaManage free for 3 months with no commitment. All features of your chosen plan are available immediately. After the trial, you decide whether to continue."
|
||||
},
|
||||
"payment": {
|
||||
"question": "Which payment methods are accepted?",
|
||||
"answer": "We accept SEPA direct debit, credit card (Visa, Mastercard) and PayPal. Billing is monthly through our payment partner Stripe."
|
||||
},
|
||||
"cancel": {
|
||||
"question": "Can I cancel anytime?",
|
||||
"answer": "Yes, you can cancel your subscription at any time at the end of the current billing period. There is no minimum contract period."
|
||||
},
|
||||
"data": {
|
||||
"question": "What happens to my data after cancellation?",
|
||||
"answer": "After cancellation, you have 30 days to export your data. After that, all personal data is deleted in accordance with GDPR. Data subject to retention requirements remains stored in compliance with the law."
|
||||
},
|
||||
"migration": {
|
||||
"question": "Can I switch plans later?",
|
||||
"answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period."
|
||||
}
|
||||
}
|
||||
},
|
||||
"impressum": {
|
||||
@@ -753,5 +808,33 @@
|
||||
"WORKSHOP": "Workshop",
|
||||
"OTHER": "Other"
|
||||
}
|
||||
},
|
||||
"forum": {
|
||||
"title": "Forum",
|
||||
"description": "Club-internal discussion forum",
|
||||
"newTopic": "New Topic",
|
||||
"topicTitlePlaceholder": "Topic title...",
|
||||
"topicContentPlaceholder": "Describe your topic...",
|
||||
"creating": "Creating...",
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading...",
|
||||
"noTopics": "No topics yet. Create the first one!",
|
||||
"replies": "Replies",
|
||||
"lastReply": "Last reply",
|
||||
"openReports": "open reports",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
"delete": "Delete",
|
||||
"deleteReason": "Reason for deletion (optional):",
|
||||
"replyPlaceholder": "Write a reply...",
|
||||
"sending": "Sending...",
|
||||
"reply": "Reply",
|
||||
"edited": "edited",
|
||||
"topicLocked": "This topic is locked. New replies are not possible.",
|
||||
"reportReason": "Reason for report:",
|
||||
"backToTopics": "Back to overview"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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