feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages
This commit is contained in:
+96
-2
@@ -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 *
|
||||
|
||||
Reference in New Issue
Block a user