feat: private forum groups - Group/GroupMember models, group CRUD API, invite codes, private threads, groups UI pages
This commit is contained in:
+30
-1
@@ -35,6 +35,8 @@ model User {
|
|||||||
cashouts CashoutRequest[]
|
cashouts CashoutRequest[]
|
||||||
halalBookmarks HalalBookmark[]
|
halalBookmarks HalalBookmark[]
|
||||||
halalUsage HalalUsage?
|
halalUsage HalalUsage?
|
||||||
|
ownedGroups Group[] @relation("GroupOwner")
|
||||||
|
groupMemberships GroupMember[]
|
||||||
forumThreads ForumThread[]
|
forumThreads ForumThread[]
|
||||||
forumPosts ForumPost[]
|
forumPosts ForumPost[]
|
||||||
chatHistory ChatHistory[]
|
chatHistory ChatHistory[]
|
||||||
@@ -158,7 +160,34 @@ model ForumThread {
|
|||||||
shariahFlags String?
|
shariahFlags String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
posts ForumPost[]
|
posts ForumPost[]
|
||||||
|
group Group? @relation(fields: [groupId], references: [id])
|
||||||
|
groupId String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Group {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
ownerId String
|
||||||
|
owner User @relation("GroupOwner", fields: [ownerId], references: [id])
|
||||||
|
inviteCode String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
members GroupMember[]
|
||||||
|
threads ForumThread[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model GroupMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
groupId String
|
||||||
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
role String @default("member")
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([groupId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ForumPost {
|
model ForumPost {
|
||||||
|
|||||||
@@ -14,8 +14,28 @@ export async function GET(request: NextRequest) {
|
|||||||
where.categoryId = categoryId;
|
where.categoryId = categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For group-scoped visibility: if user is authenticated, find which group IDs
|
||||||
|
// they are a member of, so we can include public threads (groupId: null) plus
|
||||||
|
// private threads where they are a member.
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
let userGroupIds: string[] = [];
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const memberships = await prisma.groupMember.findMany({
|
||||||
|
where: { userId: auth.userId },
|
||||||
|
select: { groupId: true },
|
||||||
|
});
|
||||||
|
userGroupIds = memberships.map((m) => m.groupId);
|
||||||
|
}
|
||||||
|
|
||||||
const threads = await prisma.forumThread.findMany({
|
const threads = await prisma.forumThread.findMany({
|
||||||
where,
|
where: {
|
||||||
|
...where,
|
||||||
|
OR: [
|
||||||
|
{ groupId: null },
|
||||||
|
{ groupId: { in: userGroupIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
author: {
|
author: {
|
||||||
select: { id: true, name: true, isPremium: true, isPro: true },
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
@@ -23,6 +43,9 @@ export async function GET(request: NextRequest) {
|
|||||||
category: {
|
category: {
|
||||||
select: { id: true, name: true, icon: true },
|
select: { id: true, name: true, icon: true },
|
||||||
},
|
},
|
||||||
|
group: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { posts: true },
|
select: { posts: true },
|
||||||
},
|
},
|
||||||
@@ -65,7 +88,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, content, categoryId } = await request.json();
|
const { title, content, categoryId, groupId } = await request.json();
|
||||||
|
|
||||||
if (!title || !content || !categoryId) {
|
if (!title || !content || !categoryId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -86,13 +109,30 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If groupId is provided, verify user is a member of that group
|
||||||
|
if (groupId) {
|
||||||
|
const membership = await prisma.groupMember.findUnique({
|
||||||
|
where: {
|
||||||
|
groupId_userId: { groupId, userId: user.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You are not a member of this group" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const thread = await prisma.forumThread.create({
|
const thread = await prisma.forumThread.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
categoryId,
|
categoryId,
|
||||||
authorId: user.id,
|
authorId: user.id,
|
||||||
shariahStatus: "pending",
|
groupId: groupId || null,
|
||||||
|
shariahStatus: groupId ? "private" : "pending",
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
author: {
|
author: {
|
||||||
@@ -101,6 +141,9 @@ export async function POST(request: NextRequest) {
|
|||||||
category: {
|
category: {
|
||||||
select: { id: true, name: true, icon: true },
|
select: { id: true, name: true, icon: true },
|
||||||
},
|
},
|
||||||
|
group: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { posts: true },
|
select: { posts: true },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
// GET /api/groups/[id]/invite — get invite code (owner only)
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const group = await prisma.group.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerId: true,
|
||||||
|
inviteCode: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the owner can view the invite code
|
||||||
|
if (group.ownerId !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Only the group owner can view the invite code" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
groupId: group.id,
|
||||||
|
inviteCode: group.inviteCode,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("GET /api/groups/[id]/invite error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
// POST /api/groups/[id]/join — join by invite code
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { inviteCode } = await request.json();
|
||||||
|
|
||||||
|
if (!inviteCode || typeof inviteCode !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required field: inviteCode" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the group and verify invite code
|
||||||
|
const group = await prisma.group.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
where: { userId: auth.userId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.inviteCode !== inviteCode) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid invite code" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already a member
|
||||||
|
if (group.members.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You are already a member of this group" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user as member
|
||||||
|
const membership = await prisma.groupMember.create({
|
||||||
|
data: {
|
||||||
|
groupId: id,
|
||||||
|
userId: auth.userId,
|
||||||
|
role: "member",
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ membership }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("POST /api/groups/[id]/join error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
// DELETE /api/groups/[id]/members/[memberId] — remove member (owner only)
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, memberId } = await params;
|
||||||
|
|
||||||
|
// Find the group and verify the requester is the owner
|
||||||
|
const group = await prisma.group.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.ownerId !== auth.userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Only the group owner can remove members" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the membership to remove
|
||||||
|
const membership = await prisma.groupMember.findUnique({
|
||||||
|
where: { id: memberId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Member not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.groupId !== id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Member does not belong to this group" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot remove the owner
|
||||||
|
if (membership.role === "owner") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cannot remove the group owner" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.groupMember.delete({
|
||||||
|
where: { id: memberId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DELETE /api/groups/[id]/members/[memberId] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
// GET /api/groups/[id] — group details + member list
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const group = await prisma.group.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { joinedAt: "asc" },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { members: true, threads: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a member of this group
|
||||||
|
const isMember = group.members.some((m) => m.userId === auth.userId);
|
||||||
|
if (!isMember) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You are not a member of this group" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ group });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("GET /api/groups/[id] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
import { getUserTier, FORUM_LIMITS } from "@/lib/tiers";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
function generateInviteCode(): string {
|
||||||
|
return crypto.randomBytes(4).toString("hex").slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/groups — create a group (premium only)
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = getUserTier(user.isPremium, user.isPro);
|
||||||
|
const limits = FORUM_LIMITS[tier];
|
||||||
|
|
||||||
|
if (!limits.canPost) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Your tier does not support creating groups. Upgrade to Premium or Pro to create groups." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, description } = await request.json();
|
||||||
|
|
||||||
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required field: name" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteCode = generateInviteCode();
|
||||||
|
|
||||||
|
const group = await prisma.group.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
ownerId: user.id,
|
||||||
|
inviteCode,
|
||||||
|
members: {
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
role: "owner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { members: true, threads: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ group }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("POST /api/groups error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/groups — list groups where current user is a member
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAuth(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await prisma.group.findMany({
|
||||||
|
where: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: auth.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: { id: true, name: true, isPremium: true, isPro: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { members: true, threads: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ groups });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("GET /api/groups error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-2
@@ -16,6 +16,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
Users,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -34,6 +35,7 @@ interface Thread {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
groupId?: string | null;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
shariahStatus: "pending" | "approved" | "rejected";
|
shariahStatus: "pending" | "approved" | "rejected";
|
||||||
shariahFlags?: string;
|
shariahFlags?: string;
|
||||||
@@ -52,6 +54,20 @@ interface Thread {
|
|||||||
_count: {
|
_count: {
|
||||||
posts: number;
|
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 }> = {
|
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 [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [threads, setThreads] = useState<Thread[]>([]);
|
const [threads, setThreads] = useState<Thread[]>([]);
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [loadingData, setLoadingData] = useState(true);
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -79,6 +96,7 @@ export default function ForumPage() {
|
|||||||
title: "",
|
title: "",
|
||||||
content: "",
|
content: "",
|
||||||
categoryId: "",
|
categoryId: "",
|
||||||
|
groupId: "",
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
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 () => {
|
const fetchThreads = useCallback(async () => {
|
||||||
setLoadingData(true);
|
setLoadingData(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -112,7 +147,7 @@ export default function ForumPage() {
|
|||||||
if (activeCategory) params.set("categoryId", activeCategory);
|
if (activeCategory) params.set("categoryId", activeCategory);
|
||||||
if (searchQuery.trim()) params.set("search", searchQuery.trim());
|
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();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setThreads(data.threads);
|
setThreads(data.threads);
|
||||||
@@ -145,6 +180,12 @@ export default function ForumPage() {
|
|||||||
}
|
}
|
||||||
}, [token, fetchThreads]);
|
}, [token, fetchThreads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
fetchGroups();
|
||||||
|
}
|
||||||
|
}, [token, fetchGroups]);
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -169,13 +210,14 @@ export default function ForumPage() {
|
|||||||
title: createForm.title,
|
title: createForm.title,
|
||||||
content: createForm.content,
|
content: createForm.content,
|
||||||
categoryId: createForm.categoryId,
|
categoryId: createForm.categoryId,
|
||||||
|
...(createForm.groupId ? { groupId: createForm.groupId } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "" });
|
setCreateForm({ title: "", content: "", categoryId: categories[0]?.id || "", groupId: "" });
|
||||||
fetchThreads();
|
fetchThreads();
|
||||||
} else {
|
} else {
|
||||||
setCreateError(data.error || "Failed to create thread");
|
setCreateError(data.error || "Failed to create thread");
|
||||||
@@ -226,6 +268,13 @@ export default function ForumPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{canPost && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -233,6 +282,7 @@ export default function ForumPage() {
|
|||||||
title: "",
|
title: "",
|
||||||
content: "",
|
content: "",
|
||||||
categoryId: categories[0]?.id || "",
|
categoryId: categories[0]?.id || "",
|
||||||
|
groupId: "",
|
||||||
});
|
});
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
@@ -359,6 +409,7 @@ export default function ForumPage() {
|
|||||||
title: "",
|
title: "",
|
||||||
content: "",
|
content: "",
|
||||||
categoryId: categories[0]?.id || "",
|
categoryId: categories[0]?.id || "",
|
||||||
|
groupId: "",
|
||||||
});
|
});
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
@@ -389,6 +440,9 @@ export default function ForumPage() {
|
|||||||
{thread.pinned && (
|
{thread.pinned && (
|
||||||
<Pin size={12} className="text-[#D4AF37] shrink-0" />
|
<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">
|
<h3 className="text-sm font-semibold text-white truncate">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -419,6 +473,16 @@ export default function ForumPage() {
|
|||||||
|
|
||||||
<span className="text-gray-500">{thread.category.name}</span>
|
<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>
|
<span className="text-gray-700">·</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -495,6 +559,36 @@ export default function ForumPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||||
Title *
|
Title *
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useAuth } from "@/lib/AuthContext";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Crown,
|
||||||
|
Shield,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
UserMinus,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface GroupMember {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
role: "owner" | "admin" | "member";
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isPremium: boolean;
|
||||||
|
isPro: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inviteCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
ownerId: string;
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isPremium: boolean;
|
||||||
|
isPro: boolean;
|
||||||
|
};
|
||||||
|
members: GroupMember[];
|
||||||
|
_count: {
|
||||||
|
members: number;
|
||||||
|
threads: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_ICONS: Record<string, any> = {
|
||||||
|
owner: Crown,
|
||||||
|
admin: Shield,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
owner: "text-[#D4AF37]",
|
||||||
|
admin: "text-blue-400",
|
||||||
|
member: "text-gray-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GroupDetailPage() {
|
||||||
|
const { user, token, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const groupId = params.id as string;
|
||||||
|
|
||||||
|
const [group, setGroup] = useState<GroupDetail | null>(null);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Invite code
|
||||||
|
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||||
|
const [loadingInvite, setLoadingInvite] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Remove member
|
||||||
|
const [removingMember, setRemovingMember] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isOwner = group?.ownerId === user?.id;
|
||||||
|
|
||||||
|
const fetchGroup = useCallback(async () => {
|
||||||
|
setLoadingData(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/mobile/api/groups/${groupId}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setGroup(data);
|
||||||
|
} else {
|
||||||
|
setError(data.error || "Failed to load group");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
}, [token, groupId]);
|
||||||
|
|
||||||
|
const fetchInviteCode = useCallback(async () => {
|
||||||
|
setLoadingInvite(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/mobile/api/groups/${groupId}/invite`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setInviteCode(data.inviteCode);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoadingInvite(false);
|
||||||
|
}
|
||||||
|
}, [token, groupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !token) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [loading, token, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && groupId) {
|
||||||
|
fetchGroup();
|
||||||
|
}
|
||||||
|
}, [token, groupId, fetchGroup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOwner && groupId && token) {
|
||||||
|
fetchInviteCode();
|
||||||
|
}
|
||||||
|
}, [isOwner, groupId, token, fetchInviteCode]);
|
||||||
|
|
||||||
|
const handleCopyCode = async () => {
|
||||||
|
if (inviteCode) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteCode);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.value = inviteCode;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(input);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (memberId: string) => {
|
||||||
|
if (!confirm("Remove this member from the group?")) return;
|
||||||
|
setRemovingMember(memberId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/mobile/api/groups/${groupId}/members/${memberId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
fetchGroup();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setRemovingMember(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diffSec = Math.floor((now - then) / 1000);
|
||||||
|
if (diffSec < 60) return "just now";
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
|
||||||
|
{/* Sticky header */}
|
||||||
|
<div className="sticky top-0 z-20 bg-[#0a0a0f]/90 backdrop-blur-lg border-b border-gray-800/40">
|
||||||
|
<div className="flex items-center gap-3 px-4 h-12">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/groups")}
|
||||||
|
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-sm font-semibold text-white truncate">
|
||||||
|
{group ? group.name : "Group"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
|
||||||
|
<p className="text-sm text-gray-500">Loading group...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
|
||||||
|
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
|
||||||
|
<p className="text-sm text-red-300">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { fetchGroup(); }}
|
||||||
|
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !group ? (
|
||||||
|
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
|
||||||
|
<p className="text-sm text-gray-400">Group not found</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/groups")}
|
||||||
|
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||||
|
>
|
||||||
|
Back to groups
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Group info card */}
|
||||||
|
<div className="px-4 pt-4 pb-2">
|
||||||
|
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-2xl font-bold text-[#D4AF37]">
|
||||||
|
{group.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-lg font-bold text-white">{group.name}</h2>
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users size={11} />
|
||||||
|
<span>{group._count.members} members</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-700">·</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MessageCircle size={11} />
|
||||||
|
<span>{group._count.threads} threads</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-700">·</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Clock size={9} />
|
||||||
|
<span>{timeAgo(group.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner info */}
|
||||||
|
<div className="flex items-center gap-2 bg-[#0a0a0f] rounded-xl px-3.5 py-2.5">
|
||||||
|
<Crown size={14} className="text-[#D4AF37] shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500">Created by</span>
|
||||||
|
<span className={`text-xs font-semibold ${
|
||||||
|
group.owner.isPro
|
||||||
|
? "text-purple-400"
|
||||||
|
: group.owner.isPremium
|
||||||
|
? "text-[#D4AF37]"
|
||||||
|
: "text-gray-300"
|
||||||
|
}`}>
|
||||||
|
{group.owner.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite code section (owner only) */}
|
||||||
|
{isOwner && (
|
||||||
|
<div className="px-4 mb-3">
|
||||||
|
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-500">
|
||||||
|
Invite Code
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-amber-400/70 bg-amber-400/10 border border-amber-400/20 rounded-md px-1.5 py-0.5">
|
||||||
|
Share to invite members
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loadingInvite ? (
|
||||||
|
<div className="flex items-center justify-center py-3">
|
||||||
|
<Loader2 size={16} className="text-[#D4AF37] animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : inviteCode ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3">
|
||||||
|
<code className="text-sm font-mono text-[#D4AF37] tracking-wider select-all">
|
||||||
|
{inviteCode}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyCode}
|
||||||
|
className="w-11 h-11 rounded-xl bg-[#D4AF37]/15 border border-[#D4AF37]/30 flex items-center justify-center active:bg-[#D4AF37]/25 transition shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check size={18} className="text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={18} className="text-[#D4AF37]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Failed to load invite code.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Members section */}
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users size={14} className="text-gray-500" />
|
||||||
|
<span className="text-xs font-medium text-gray-500">
|
||||||
|
Members ({group.members.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.members.map((member) => {
|
||||||
|
const RoleIcon = ROLE_ICONS[member.role] || null;
|
||||||
|
const canRemove =
|
||||||
|
isOwner &&
|
||||||
|
member.role !== "owner" &&
|
||||||
|
member.userId !== user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-3.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-800 flex items-center justify-center text-xs font-bold text-gray-300 shrink-0">
|
||||||
|
{member.user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-semibold text-white truncate">
|
||||||
|
{member.user.name}
|
||||||
|
</span>
|
||||||
|
{member.user.isPro && (
|
||||||
|
<Crown size={11} className="text-purple-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{RoleIcon && (
|
||||||
|
<div className={`flex items-center gap-0.5 ${ROLE_COLORS[member.role]}`}>
|
||||||
|
<RoleIcon size={10} />
|
||||||
|
<span className="text-[10px] capitalize">{member.role}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{member.role === "member" && (
|
||||||
|
<span className="text-[10px] text-gray-600">Member</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveMember(member.id)}
|
||||||
|
disabled={removingMember === member.id}
|
||||||
|
className="w-9 h-9 rounded-full bg-red-900/15 border border-red-800/30 flex items-center justify-center active:bg-red-900/25 transition shrink-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{removingMember === member.id ? (
|
||||||
|
<Loader2 size={14} className="animate-spin text-red-400" />
|
||||||
|
) : (
|
||||||
|
<UserMinus size={14} className="text-red-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View threads button */}
|
||||||
|
<div className="px-4 mt-5">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/forum?groupId=${group.id}`)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 active:bg-[#D4AF37]/25 transition"
|
||||||
|
>
|
||||||
|
<MessageCircle size={16} className="text-[#D4AF37]" />
|
||||||
|
<span className="text-sm font-medium text-[#D4AF37]">
|
||||||
|
View Group Threads
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageCircle(props: any) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={props.size || 24}
|
||||||
|
height={props.size || 24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useAuth } from "@/lib/AuthContext";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Lock,
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Crown,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface GroupMember {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
role: "owner" | "admin" | "member";
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isPremium: boolean;
|
||||||
|
isPro: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inviteCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
ownerId: string;
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isPremium: boolean;
|
||||||
|
isPro: boolean;
|
||||||
|
};
|
||||||
|
members: GroupMember[];
|
||||||
|
_count: {
|
||||||
|
members: number;
|
||||||
|
threads: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupsPage() {
|
||||||
|
const { user, token, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create group modal
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [createName, setCreateName] = useState("");
|
||||||
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Join group modal
|
||||||
|
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||||
|
const [joinCode, setJoinCode] = useState("");
|
||||||
|
const [joining, setJoining] = useState(false);
|
||||||
|
const [joinError, setJoinError] = useState<string | null>(null);
|
||||||
|
const [joinSuccess, setJoinSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canCreateGroup = user?.isPremium || user?.isPro;
|
||||||
|
|
||||||
|
const fetchGroups = useCallback(async () => {
|
||||||
|
setLoadingData(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/mobile/api/groups", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setGroups(data.groups || []);
|
||||||
|
} else {
|
||||||
|
setError(data.error || "Failed to load groups");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoadingData(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !token) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [loading, token, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
fetchGroups();
|
||||||
|
}
|
||||||
|
}, [token, fetchGroups]);
|
||||||
|
|
||||||
|
const handleCreateGroup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreateError(null);
|
||||||
|
setCreating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/mobile/api/groups", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: createName.trim(),
|
||||||
|
description: createDescription.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setCreateName("");
|
||||||
|
setCreateDescription("");
|
||||||
|
fetchGroups();
|
||||||
|
} else {
|
||||||
|
setCreateError(data.error || "Failed to create group");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCreateError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinGroup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setJoinError(null);
|
||||||
|
setJoinSuccess(null);
|
||||||
|
setJoining(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/mobile/api/groups/join", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
inviteCode: joinCode.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setJoinSuccess("Joined group successfully!");
|
||||||
|
setJoinCode("");
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowJoinModal(false);
|
||||||
|
setJoinSuccess(null);
|
||||||
|
fetchGroups();
|
||||||
|
}, 1200);
|
||||||
|
} else {
|
||||||
|
setJoinError(data.error || "Failed to join group");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setJoinError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setJoining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diffSec = Math.floor((now - then) / 1000);
|
||||||
|
if (diffSec < 60) return "just now";
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[#0a0a0f] pb-28">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 pt-6 pb-4">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-[#D4AF37]/15 flex items-center justify-center">
|
||||||
|
<Users size={18} className="text-[#D4AF37]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-white">Groups</h1>
|
||||||
|
<p className="text-xs text-gray-500">Private forum groups</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setJoinCode("");
|
||||||
|
setJoinError(null);
|
||||||
|
setJoinSuccess(null);
|
||||||
|
setShowJoinModal(true);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-gray-300">Join</span>
|
||||||
|
</button>
|
||||||
|
{canCreateGroup && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCreateName("");
|
||||||
|
setCreateDescription("");
|
||||||
|
setCreateError(null);
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-3 py-3 active:bg-[#D4AF37]/25 transition"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="text-[#D4AF37]" />
|
||||||
|
<span className="text-xs font-medium text-[#D4AF37]">Create</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium gating banner for free users */}
|
||||||
|
{!canCreateGroup && (
|
||||||
|
<div className="mx-4 mb-3 bg-gradient-to-r from-amber-900/15 to-gray-900/50 border border-amber-800/20 rounded-xl px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock size={14} className="text-[#D4AF37] shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-amber-300/90">
|
||||||
|
<span className="font-semibold">Free</span> members can browse.{" "}
|
||||||
|
<span className="text-[#D4AF37] font-semibold">Premium</span> or{" "}
|
||||||
|
<span className="text-purple-400 font-semibold">Pro</span> required to create groups.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/profile")}
|
||||||
|
className="text-xs font-semibold text-[#D4AF37] bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-lg px-2.5 py-3 active:bg-[#D4AF37]/25 transition"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Groups list */}
|
||||||
|
<div className="px-4">
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<Loader2 size={32} className="text-[#D4AF37] animate-spin mb-3" />
|
||||||
|
<p className="text-sm text-gray-500">Loading groups...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
|
||||||
|
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
|
||||||
|
<p className="text-sm text-red-300">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchGroups}
|
||||||
|
className="mt-3 text-xs text-[#D4AF37] underline"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
|
||||||
|
<Users size={28} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-400 mb-1">
|
||||||
|
No groups yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600 text-center max-w-xs">
|
||||||
|
{canCreateGroup
|
||||||
|
? "Create a private group to discuss topics with select members."
|
||||||
|
: "Join a private group using an invite code."}
|
||||||
|
</p>
|
||||||
|
{canCreateGroup && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCreateName("");
|
||||||
|
setCreateDescription("");
|
||||||
|
setCreateError(null);
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
|
className="mt-4 flex items-center gap-2 bg-[#D4AF37]/15 border border-[#D4AF37]/30 rounded-xl px-4 py-3 active:bg-[#D4AF37]/25 transition"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="text-[#D4AF37]" />
|
||||||
|
<span className="text-sm font-medium text-[#D4AF37]">Create Group</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => router.push(`/groups/${group.id}`)}
|
||||||
|
className="w-full text-left bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.99] transition-transform"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Group avatar */}
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#D4AF37]/20 to-gray-800/60 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-base font-bold text-[#D4AF37]">
|
||||||
|
{group.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<h3 className="text-sm font-semibold text-white truncate">
|
||||||
|
{group.name}
|
||||||
|
</h3>
|
||||||
|
{group.ownerId === user.id && (
|
||||||
|
<Crown size={12} className="text-[#D4AF37] shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-xs text-gray-500 truncate mb-1">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users size={11} />
|
||||||
|
<span>{group._count.members} members</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-700">·</span>
|
||||||
|
<span>{group._count.threads} threads</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight size={16} className="text-gray-600 shrink-0" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create group modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users size={18} className="text-[#D4AF37]" />
|
||||||
|
<h2 className="text-base font-bold text-white">Create Group</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||||
|
>
|
||||||
|
<X size={16} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateGroup} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||||
|
Group Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createName}
|
||||||
|
onChange={(e) => setCreateName(e.target.value)}
|
||||||
|
placeholder="e.g., Book Club, Quran Study"
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={createDescription}
|
||||||
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
|
placeholder="What's this group about?"
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
|
||||||
|
<p className="text-xs text-red-300">{createError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="flex-1 py-3 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating}
|
||||||
|
className="flex-1 py-3 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{creating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create Group"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Join group modal */}
|
||||||
|
{showJoinModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!joining) setShowJoinModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full sm:max-w-md bg-[#111118] border border-gray-800/60 rounded-t-2xl sm:rounded-2xl p-5 animate-fade-in max-h-[90dvh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock size={18} className="text-[#D4AF37]" />
|
||||||
|
<h2 className="text-base font-bold text-white">Join Group</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!joining) setShowJoinModal(false);
|
||||||
|
}}
|
||||||
|
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:bg-gray-700/60 transition"
|
||||||
|
>
|
||||||
|
<X size={16} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleJoinGroup} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1.5">
|
||||||
|
Invite Code *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={joinCode}
|
||||||
|
onChange={(e) => setJoinCode(e.target.value)}
|
||||||
|
placeholder="Enter the invite code"
|
||||||
|
required
|
||||||
|
maxLength={50}
|
||||||
|
className="w-full bg-[#0a0a0f] border border-gray-800/60 rounded-xl px-3.5 py-3 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/40 transition"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-600 mt-1.5">
|
||||||
|
Ask the group owner for the invite code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{joinError && (
|
||||||
|
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
|
||||||
|
<p className="text-xs text-red-300">{joinError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{joinSuccess && (
|
||||||
|
<div className="bg-emerald-900/10 border border-emerald-800/30 rounded-xl p-3">
|
||||||
|
<p className="text-xs text-emerald-300">{joinSuccess}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!joining) setShowJoinModal(false);
|
||||||
|
}}
|
||||||
|
className="flex-1 py-3 rounded-xl text-sm font-medium text-gray-500 bg-gray-800/40 border border-gray-800/60 active:bg-gray-700/40 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={joining}
|
||||||
|
className="flex-1 py-3 rounded-xl text-sm font-semibold text-[#0a0a0f] bg-[#D4AF37] active:bg-[#c5a233] transition flex items-center justify-center gap-2 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{joining ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Joining...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Join Group"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user