feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages

This commit is contained in:
root
2026-06-15 12:25:52 +02:00
parent 947fe3b66d
commit 64ae34e9c9
10 changed files with 1545 additions and 6 deletions
+96 -2
View File
@@ -16,6 +16,7 @@ import {
CheckCircle,
Loader2,
Lock,
Users,
MessageCircle,
FileText,
} from "lucide-react";
@@ -34,6 +35,7 @@ interface Thread {
title: string;
content: string;
categoryId: string;
groupId?: string | null;
pinned: boolean;
shariahStatus: "pending" | "approved" | "rejected";
shariahFlags?: string;
@@ -52,6 +54,20 @@ interface Thread {
_count: {
posts: number;
};
group?: {
id: string;
name: string;
} | null;
}
interface Group {
id: string;
name: string;
description?: string;
_count: {
members: number;
threads: number;
};
}
const SHARIAH_LABELS: Record<string, { label: string; color: string; icon: any }> = {
@@ -66,6 +82,7 @@ export default function ForumPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [threads, setThreads] = useState<Thread[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -79,6 +96,7 @@ export default function ForumPage() {
title: "",
content: "",
categoryId: "",
groupId: "",
});
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
@@ -104,6 +122,23 @@ export default function ForumPage() {
}
}, []);
const fetchGroups = useCallback(async () => {
if (!canPost) return;
try {
const res = await fetch("/mobile/api/groups", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await res.json();
if (res.ok) {
setGroups(data.groups || []);
}
} catch {
// ignore
}
}, [token, canPost]);
const fetchThreads = useCallback(async () => {
setLoadingData(true);
setError(null);
@@ -112,7 +147,7 @@ export default function ForumPage() {
if (activeCategory) params.set("categoryId", activeCategory);
if (searchQuery.trim()) params.set("search", searchQuery.trim());
const res = await fetch(`/api/forum/threads?${params.toString()}`);
const res = await fetch(`/mobile/api/forum/threads?${params.toString()}`);
const data = await res.json();
if (res.ok) {
setThreads(data.threads);
@@ -145,6 +180,12 @@ export default function ForumPage() {
}
}, [token, fetchThreads]);
useEffect(() => {
if (token) {
fetchGroups();
}
}, [token, fetchGroups]);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
@@ -169,13 +210,14 @@ export default function ForumPage() {
title: createForm.title,
content: createForm.content,
categoryId: createForm.categoryId,
...(createForm.groupId ? { groupId: createForm.groupId } : {}),
}),
});
const data = await res.json();
if (res.ok) {
setShowCreateModal(false);
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "" });
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "", groupId: "" });
fetchThreads();
} else {
setCreateError(data.error || "Failed to create thread");
@@ -226,6 +268,13 @@ export default function ForumPage() {
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => router.push("/groups")}
className="flex items-center gap-1.5 bg-gray-800/40 border border-gray-700/60 rounded-xl px-3 py-3 active:bg-gray-700/60 transition"
>
<Users size={14} className="text-gray-400" />
<span className="text-xs font-medium text-gray-300">Groups</span>
</button>
{canPost && (
<button
onClick={() => {
@@ -233,6 +282,7 @@ export default function ForumPage() {
title: "",
content: "",
categoryId: categories[0]?.id || "",
groupId: "",
});
setCreateError(null);
setShowCreateModal(true);
@@ -359,6 +409,7 @@ export default function ForumPage() {
title: "",
content: "",
categoryId: categories[0]?.id || "",
groupId: "",
});
setCreateError(null);
setShowCreateModal(true);
@@ -389,6 +440,9 @@ export default function ForumPage() {
{thread.pinned && (
<Pin size={12} className="text-[#D4AF37] shrink-0" />
)}
{thread.groupId && (
<Lock size={12} className="text-amber-500/70 shrink-0" />
)}
<h3 className="text-sm font-semibold text-white truncate">
{thread.title}
</h3>
@@ -419,6 +473,16 @@ export default function ForumPage() {
<span className="text-gray-500">{thread.category.name}</span>
{thread.group && (
<>
<span className="text-gray-700">·</span>
<div className="flex items-center gap-0.5 text-amber-500/70">
<Lock size={8} />
<span className="text-[10px]">{thread.group.name}</span>
</div>
</>
)}
<span className="text-gray-700">·</span>
<div className="flex items-center gap-1">
@@ -495,6 +559,36 @@ export default function ForumPage() {
</select>
</div>
{groups.length > 0 && canPost && (
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Group Post (optional)
</label>
<select
value={createForm.groupId}
onChange={(e) =>
setCreateForm((f) => ({ ...f, groupId: e.target.value }))
}
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white focus:outline-none focus:border-[#D4AF37]/40 transition appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
}}
>
<option value="">Public thread (visible to all)</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
🔒 {g.name} ({g._count.members} members)
</option>
))}
</select>
<p className="text-xs text-gray-600 mt-1">
Group posts are only visible to group members.
</p>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
Title *