diff --git a/src/actions/communities.ts b/src/actions/communities.ts new file mode 100644 index 0000000..020ca97 --- /dev/null +++ b/src/actions/communities.ts @@ -0,0 +1,390 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { Prisma, InviteStatus, NotificationType } from "@/generated/prisma/client"; +import { AddRuleSchema, CreateCommunitySchema, InviteUserSchema } from "@/lib/validations/community"; + +type CommunityActionResult = { + error?: string; + success?: string; +}; + +// ─── Story 1: Create Community ─────────────────────────────────────────────── + +export async function createCommunityAction( + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in before creating a community." }; + } + + const validated = CreateCommunitySchema.safeParse({ + name: formData.get("name"), + description: formData.get("description"), + isNsfw: formData.get("isNsfw"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Community could not be created." }; + } + + const { name, description, isNsfw } = validated.data; + const userId = session.user.id; + + try { + await prisma.$transaction(async (tx) => { + const community = await tx.community.create({ + data: { name, description, isNsfw, ownerId: userId }, + select: { id: true }, + }); + + await tx.communityMember.create({ + data: { userId, communityId: community.id }, + }); + + await tx.communityModerator.create({ + data: { + userId, + communityId: community.id, + canManageSettings: true, + canManagePosts: true, + canRestrictUsers: true, + }, + }); + }); + + revalidatePath("/communities"); + return { success: name }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { error: "This community name is already taken." }; + } + console.error("createCommunityAction failed", error); + return { error: "Something went wrong while creating the community." }; + } +} + +// ─── Story 3: Join Community ───────────────────────────────────────────────── + +export async function joinCommunityAction( + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to join a community." }; + } + + try { + await prisma.communityMember.create({ + data: { userId: session.user.id, communityId }, + }); + + revalidatePath(`/communities/${communityName}`); + return { success: "You have joined the community." }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { error: "You are already a member of this community." }; + } + console.error("joinCommunityAction failed", error); + return { error: "Something went wrong while joining the community." }; + } +} + +// ─── Story 3: Leave Community ──────────────────────────────────────────────── + +export async function leaveCommunityAction( + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to leave a community." }; + } + + const community = await prisma.community.findUnique({ + where: { id: communityId }, + select: { ownerId: true }, + }); + + if (!community) { + return { error: "Community not found." }; + } + + if (community.ownerId === session.user.id) { + return { + error: "You cannot leave a community you own. Transfer ownership or delete it first.", + }; + } + + try { + await prisma.communityMember.delete({ + where: { + userId_communityId: { userId: session.user.id, communityId }, + }, + }); + + revalidatePath(`/communities/${communityName}`); + return { success: "You have left the community." }; + } catch (error) { + console.error("leaveCommunityAction failed", error); + return { error: "Something went wrong while leaving the community." }; + } +} + +// ─── Story 2: Add Community Rule ───────────────────────────────────────────── + +export async function addRuleAction( + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to manage community rules." }; + } + + const validated = AddRuleSchema.safeParse({ + communityId: formData.get("communityId"), + communityName: formData.get("communityName"), + title: formData.get("title"), + description: formData.get("description"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Rule could not be added." }; + } + + const { communityId, communityName, title, description } = validated.data; + + const mod = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId: session.user.id, communityId } }, + select: { canManageSettings: true }, + }); + + if (!mod?.canManageSettings) { + return { error: "Unauthorized: you do not have permission to manage rules." }; + } + + const lastRule = await prisma.communityRule.findFirst({ + where: { communityId }, + orderBy: { displayOrder: "desc" }, + select: { displayOrder: true }, + }); + + const nextOrder = (lastRule?.displayOrder ?? -1) + 1; + + try { + await prisma.communityRule.create({ + data: { communityId, title, description, displayOrder: nextOrder }, + }); + + revalidatePath(`/communities/${communityName}/settings`); + revalidatePath(`/communities/${communityName}`); + return { success: "Rule added." }; + } catch (error) { + console.error("addRuleAction failed", error); + return { error: "Something went wrong while adding the rule." }; + } +} + +// ─── Story 2: Reorder Rules ─────────────────────────────────────────────────── + +export async function reorderRulesAction( + orderedIds: string[], + communityId: string, + communityName: string +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to manage community rules." }; + } + + const mod = await prisma.communityModerator.findUnique({ + where: { userId_communityId: { userId: session.user.id, communityId } }, + select: { canManageSettings: true }, + }); + + if (!mod?.canManageSettings) { + return { error: "Unauthorized: you do not have permission to manage rules." }; + } + + try { + await prisma.$transaction( + orderedIds.map((id, index) => + prisma.communityRule.updateMany({ + where: { id, communityId }, + data: { displayOrder: index }, + }) + ) + ); + + revalidatePath(`/communities/${communityName}/settings`); + revalidatePath(`/communities/${communityName}`); + return { success: "Rules reordered." }; + } catch (error) { + console.error("reorderRulesAction failed", error); + return { error: "Something went wrong while reordering rules." }; + } +} + +// ─── Story 4: Send Community Invite ────────────────────────────────────────── + +export async function sendInviteAction( + formData: FormData +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to invite users." }; + } + + const validated = InviteUserSchema.safeParse({ + communityId: formData.get("communityId"), + communityName: formData.get("communityName"), + inviteeUsername: formData.get("inviteeUsername"), + }); + + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Invite could not be sent." }; + } + + const { communityId, communityName, inviteeUsername } = validated.data; + const inviterId = session.user.id; + + const invitee = await prisma.user.findUnique({ + where: { username: inviteeUsername }, + select: { id: true }, + }); + + if (!invitee) { + return { error: "User not found." }; + } + + if (invitee.id === inviterId) { + return { error: "You cannot invite yourself." }; + } + + const isMember = await prisma.communityMember.findUnique({ + where: { userId_communityId: { userId: inviterId, communityId } }, + select: { userId: true }, + }); + + if (!isMember) { + return { error: "You must be a member to invite others." }; + } + + const alreadyMember = await prisma.communityMember.findUnique({ + where: { userId_communityId: { userId: invitee.id, communityId } }, + select: { userId: true }, + }); + + if (alreadyMember) { + return { error: "This user is already a member." }; + } + + const pendingInvite = await prisma.communityInvite.findFirst({ + where: { communityId, inviteeId: invitee.id, status: InviteStatus.PENDING }, + select: { id: true }, + }); + + if (pendingInvite) { + return { error: "An invite is already pending for this user." }; + } + + try { + await prisma.$transaction(async (tx) => { + const invite = await tx.communityInvite.create({ + data: { + communityId, + inviterId, + inviteeId: invitee.id, + status: InviteStatus.PENDING, + }, + select: { id: true }, + }); + + await tx.notification.create({ + data: { + userId: invitee.id, + actorId: inviterId, + type: NotificationType.COMMUNITY_INVITE, + inviteId: invite.id, + }, + }); + }); + + revalidatePath(`/communities/${communityName}`); + return { success: `Invite sent to ${inviteeUsername}.` }; + } catch (error) { + console.error("sendInviteAction failed", error); + return { error: "Something went wrong while sending the invite." }; + } +} + +// ─── Story 4: Respond to Invite ─────────────────────────────────────────────── + +export async function respondToInviteAction( + inviteId: string, + accept: boolean +): Promise { + const session = await auth(); + if (!session?.user?.id) { + return { error: "Please log in to respond to invites." }; + } + + const invite = await prisma.communityInvite.findUnique({ + where: { id: inviteId }, + select: { inviteeId: true, communityId: true, status: true }, + }); + + if (!invite) { + return { error: "Invite not found." }; + } + + if (invite.inviteeId !== session.user.id) { + return { error: "Unauthorized." }; + } + + if (invite.status !== InviteStatus.PENDING) { + return { error: "This invite has already been responded to." }; + } + + try { + if (accept) { + await prisma.$transaction([ + prisma.communityInvite.update({ + where: { id: inviteId }, + data: { status: InviteStatus.ACCEPTED }, + }), + prisma.communityMember.upsert({ + where: { + userId_communityId: { + userId: session.user.id, + communityId: invite.communityId, + }, + }, + update: {}, + create: { userId: session.user.id, communityId: invite.communityId }, + }), + ]); + } else { + await prisma.communityInvite.update({ + where: { id: inviteId }, + data: { status: InviteStatus.REJECTED }, + }); + } + + revalidatePath("/communities"); + return { success: accept ? "You joined the community." : "Invite declined." }; + } catch (error) { + console.error("respondToInviteAction failed", error); + return { error: "Something went wrong while responding to the invite." }; + } +} diff --git a/src/app/(main)/communities/[name]/page.tsx b/src/app/(main)/communities/[name]/page.tsx new file mode 100644 index 0000000..8bcaa77 --- /dev/null +++ b/src/app/(main)/communities/[name]/page.tsx @@ -0,0 +1,138 @@ +import { notFound } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { PostStatus } from "@/generated/prisma/client"; +import { CommunityPageClient } from "@/components/communities/community-page-client"; + +export const dynamic = "force-dynamic"; + +type Params = Promise<{ name: string }>; +type SearchParams = Promise<{ sort?: string }>; + +function getPostOrderBy(sort?: string) { + if (sort === "top") { + return [{ isPinned: "desc" as const }, { upvotes: "desc" as const }]; + } + if (sort === "controversial") { + return [{ isPinned: "desc" as const }, { downvotes: "desc" as const }]; + } + return [{ isPinned: "desc" as const }, { createdAt: "desc" as const }]; +} + +export default async function CommunityPage({ + params, + searchParams, +}: { + params: Params; + searchParams: SearchParams; +}) { + const { name } = await params; + const { sort } = await searchParams; + const currentSort = ["top", "controversial"].includes(sort ?? "") ? sort! : "new"; + + const community = await prisma.community.findUnique({ + where: { name }, + include: { + rules: { orderBy: { displayOrder: "asc" } }, + _count: { select: { members: true } }, + }, + }); + + if (!community) notFound(); + + const session = await auth(); + + let isMember = false; + let canManageSettings = false; + + if (session?.user?.id) { + const [membership, modRecord] = await Promise.all([ + prisma.communityMember.findUnique({ + where: { + userId_communityId: { + userId: session.user.id, + communityId: community.id, + }, + }, + select: { userId: true }, + }), + prisma.communityModerator.findUnique({ + where: { + userId_communityId: { + userId: session.user.id, + communityId: community.id, + }, + }, + select: { canManageSettings: true }, + }), + ]); + + isMember = !!membership; + canManageSettings = modRecord?.canManageSettings ?? false; + } + + const [posts, moderators] = await Promise.all([ + prisma.post.findMany({ + where: { + communityId: community.id, + status: PostStatus.PUBLISHED, + isDeleted: false, + }, + orderBy: getPostOrderBy(currentSort), + include: { + user: { select: { username: true, name: true } }, + flair: { select: { name: true, colorHex: true } }, + _count: { select: { comments: true } }, + }, + take: 25, + }), + prisma.communityModerator.findMany({ + where: { communityId: community.id }, + include: { + user: { select: { id: true, username: true, name: true, image: true } }, + }, + }), + ]); + + return ( + ({ + id: r.id, + title: r.title, + description: r.description, + displayOrder: r.displayOrder, + })), + createdAt: community.createdAt.toISOString(), + }} + posts={posts.map((p) => ({ + id: p.id, + title: p.title, + body: p.body, + isPinned: p.isPinned, + upvotes: p.upvotes, + downvotes: p.downvotes, + commentCount: p._count.comments, + createdAt: p.createdAt.toISOString(), + authorHandle: p.user.username || p.user.name || "anonymous", + flair: p.flair + ? { name: p.flair.name, colorHex: p.flair.colorHex } + : null, + }))} + moderators={moderators.map((m) => ({ + userId: m.userId, + handle: m.user.username || m.user.name || "moderator", + image: m.user.image, + }))} + isMember={isMember} + canManageSettings={canManageSettings} + currentSort={currentSort} + /> + ); +} diff --git a/src/app/(main)/communities/[name]/settings/page.tsx b/src/app/(main)/communities/[name]/settings/page.tsx new file mode 100644 index 0000000..1021965 --- /dev/null +++ b/src/app/(main)/communities/[name]/settings/page.tsx @@ -0,0 +1,51 @@ +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { RulesSettingsClient } from "@/components/communities/rules-settings-client"; + +export const dynamic = "force-dynamic"; + +type Params = Promise<{ name: string }>; + +export default async function CommunitySettingsPage({ + params, +}: { + params: Params; +}) { + const { name } = await params; + + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const community = await prisma.community.findUnique({ + where: { name }, + select: { id: true, name: true }, + }); + + if (!community) notFound(); + + const mod = await prisma.communityModerator.findUnique({ + where: { + userId_communityId: { + userId: session.user.id, + communityId: community.id, + }, + }, + select: { canManageSettings: true }, + }); + + if (!mod?.canManageSettings) redirect(`/communities/${name}`); + + const rules = await prisma.communityRule.findMany({ + where: { communityId: community.id }, + orderBy: { displayOrder: "asc" }, + select: { id: true, title: true, description: true, displayOrder: true }, + }); + + return ( + + ); +} diff --git a/src/app/(main)/communities/page.tsx b/src/app/(main)/communities/page.tsx new file mode 100644 index 0000000..344f01c --- /dev/null +++ b/src/app/(main)/communities/page.tsx @@ -0,0 +1,26 @@ +import { prisma } from "@/lib/prisma"; +import { CommunitiesPageClient } from "@/components/communities/communities-page-client"; + +export const dynamic = "force-dynamic"; + +export default async function CommunitiesPage() { + const communities = await prisma.community.findMany({ + where: { isUserProfile: false }, + include: { + _count: { select: { members: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + const serialized = communities.map((c) => ({ + id: c.id, + name: c.name, + description: c.description, + isNsfw: c.isNsfw, + memberCount: c._count.members, + createdAt: c.createdAt.toISOString(), + })); + + return ; +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..177674f --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -0,0 +1,28 @@ +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { AppShell } from "@/components/app-shell"; + +export default async function MainLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + + let userCommunities: { name: string }[] = []; + + if (session?.user?.id) { + const memberships = await prisma.communityMember.findMany({ + where: { userId: session.user.id }, + include: { community: { select: { name: true } } }, + orderBy: { joinedAt: "desc" }, + take: 15, + }); + + userCommunities = memberships.map((m) => ({ name: m.community.name })); + } + + return ( + {children} + ); +} diff --git a/src/app/page.tsx b/src/app/(main)/page.tsx similarity index 82% rename from src/app/page.tsx rename to src/app/(main)/page.tsx index b25d4d8..648be8f 100644 --- a/src/app/page.tsx +++ b/src/app/(main)/page.tsx @@ -2,6 +2,8 @@ import { PostStatus } from "@/generated/prisma/client"; import { HomePageClient } from "@/components/home-page-client"; import { prisma } from "@/lib/prisma"; +export const dynamic = "force-dynamic"; + export default async function HomePage() { const posts = await prisma.post.findMany({ where: { @@ -40,8 +42,10 @@ export default async function HomePage() { downvotes: post.downvotes, commentCount: post._count.comments, communityName: post.community.name, - authorName: post.user.name || post.user.username || post.user.email || "Anonymous", - authorHandle: post.user.username || post.user.email?.split("@")[0] || "anonymous", + authorName: + post.user.name || post.user.username || post.user.email || "Anonymous", + authorHandle: + post.user.username || post.user.email?.split("@")[0] || "anonymous", authorId: post.userId, })); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx new file mode 100644 index 0000000..01f19f5 --- /dev/null +++ b/src/components/app-shell.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { signOut, useSession } from "next-auth/react"; +import { + ArrowLeft, + Bell, + Hash, + Home, + Mail, + Menu, + Search, + User, + Users, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ModeToggle } from "@/components/ui/mode-toggle"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +const NAV_LINKS = [ + { href: "/", icon: Home, label: "Home" }, + { href: "/explore", icon: Hash, label: "Explore" }, + { href: "/notifications", icon: Bell, label: "Notifications" }, + { href: "/messages", icon: Mail, label: "Messages" }, + { href: "/communities", icon: Users, label: "Communities" }, +]; + +function SidebarContent({ + userCommunities, +}: { + userCommunities: { name: string }[]; +}) { + const pathname = usePathname(); + + return ( + + ); +} + +type AppShellProps = { + userCommunities: { name: string }[]; + children: React.ReactNode; +}; + +export function AppShell({ userCommunities, children }: AppShellProps) { + const { data: session, status } = useSession(); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isMobileSearchActive, setIsMobileSearchActive] = useState(false); + + return ( +
+ {/* ── Header ── */} + {isMobileSearchActive ? ( +
+ + +
+ ) : ( +
+ {/* Left */} +
+ + + {/* Mobile sheet */} + + + + + + + ArelSocial + + + + + + + + + ArelSocial + +
+ + {/* Center — search (desktop) */} +
+
+ + +
+
+ + {/* Right — actions */} +
+ + + + + {status === "loading" ? ( +
+ ) : session?.user ? ( + + + + + +
+

+ {session.user.username || session.user.name || "User"} +

+

+ {session.user.email} +

+
+ + signOut()} + > + Log out + +
+
+ ) : ( + + + + )} +
+
+ )} + + {/* ── Body ── */} +
+ {/* Desktop sidebar */} + + + {/* Page content */} +
+ {children} +
+
+
+ ); +} diff --git a/src/components/communities/communities-page-client.tsx b/src/components/communities/communities-page-client.tsx new file mode 100644 index 0000000..77c3b1e --- /dev/null +++ b/src/components/communities/communities-page-client.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Users, Plus, ShieldAlert } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { CreateCommunityForm } from "@/components/communities/create-community-form"; + +type CommunityItem = { + id: string; + name: string; + description: string | null; + isNsfw: boolean; + memberCount: number; + createdAt: string; +}; + +type CommunitiesPageClientProps = { + communities: CommunityItem[]; +}; + +export function CommunitiesPageClient({ communities }: CommunitiesPageClientProps) { + const { data: session } = useSession(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + + return ( +
+ {/* Header row */} +
+
+

Communities

+

+ Discover spaces to discuss what you care about. +

+
+ {session?.user && ( + + + + + + + Create a Community + +
+ setIsSheetOpen(false)} /> +
+
+
+ )} +
+ + {/* List */} + {communities.length === 0 ? ( +
+ +

No communities yet. Be the first to create one!

+
+ ) : ( +
+ {communities.map((community) => ( + + + + + c/{community.name} + {community.isNsfw && ( + + NSFW + + )} + + + + {community.description && ( +

+ {community.description} +

+ )} +

+ {community.memberCount.toLocaleString()}{" "} + {community.memberCount === 1 ? "member" : "members"} +

+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/src/components/communities/community-page-client.tsx b/src/components/communities/community-page-client.tsx new file mode 100644 index 0000000..40f875b --- /dev/null +++ b/src/components/communities/community-page-client.tsx @@ -0,0 +1,485 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { useRouter, usePathname } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { + ChevronUp, + ChevronDown, + MessageSquare, + Pin, + Settings, + ShieldAlert, + Users, + Flame, + TrendingUp, + Clock, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { InviteUserForm } from "@/components/communities/invite-user-form"; +import { joinCommunityAction, leaveCommunityAction } from "@/actions/communities"; +import { cn } from "@/lib/utils"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Rule = { + id: string; + title: string; + description: string | null; + displayOrder: number; +}; + +type CommunityPost = { + id: string; + title: string; + body: string | null; + isPinned: boolean; + upvotes: number; + downvotes: number; + commentCount: number; + createdAt: string; + authorHandle: string; + flair: { name: string; colorHex: string | null } | null; +}; + +type Moderator = { + userId: string; + handle: string; + image: string | null; +}; + +type CommunityPageClientProps = { + community: { + id: string; + name: string; + description: string | null; + isNsfw: boolean; + ownerId: string; + memberCount: number; + rules: Rule[]; + createdAt: string; + }; + posts: CommunityPost[]; + moderators: Moderator[]; + isMember: boolean; + canManageSettings: boolean; + currentSort: string; +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatRelativeDate(dateString: string) { + const date = new Date(dateString); + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + return new Intl.DateTimeFormat("en", { + day: "numeric", + month: "short", + year: "numeric", + }).format(date); +} + +// ─── Sort tabs ──────────────────────────────────────────────────────────────── + +const SORT_OPTIONS = [ + { key: "new", label: "New", icon: Clock }, + { key: "top", label: "Top", icon: TrendingUp }, + { key: "controversial", label: "Controversial", icon: Flame }, +] as const; + +function SortTabs({ + currentSort, + communityName, +}: { + currentSort: string; + communityName: string; +}) { + const router = useRouter(); + const pathname = usePathname(); + + return ( +
+ {SORT_OPTIONS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ ); +} + +// ─── Post card ──────────────────────────────────────────────────────────────── + +function CommunityPostCard({ post }: { post: CommunityPost }) { + const score = post.upvotes - post.downvotes; + + return ( +
+ {/* Vote column */} +
+ + 0 + ? "text-primary" + : score < 0 + ? "text-destructive" + : "text-muted-foreground" + )} + > + {score} + + +
+ + {/* Content */} +
+ {/* Pinned badge */} + {post.isPinned && ( +
+ + Pinned +
+ )} + + {/* Meta row */} +
+ u/{post.authorHandle} + · + {formatRelativeDate(post.createdAt)} + {post.flair && ( + <> + · + + {post.flair.name} + + + )} +
+ + {/* Title */} +

+ {post.title} +

+ + {/* Body preview */} + {post.body && ( +

+ {post.body} +

+ )} + + {/* Footer */} +
+ + + {post.commentCount} {post.commentCount === 1 ? "comment" : "comments"} + +
+
+
+ ); +} + +// ─── Right info panel ───────────────────────────────────────────────────────── + +function InfoPanel({ + community, + moderators, + canManageSettings, +}: { + community: CommunityPageClientProps["community"]; + moderators: Moderator[]; + canManageSettings: boolean; +}) { + const createdAt = new Intl.DateTimeFormat("en", { + month: "long", + year: "numeric", + }).format(new Date(community.createdAt)); + + return ( +
+ {/* About card */} +
+
+

About c/{community.name}

+
+
+ {community.description && ( +

+ {community.description} +

+ )} +
+ + + + {community.memberCount.toLocaleString()} + {" "} + {community.memberCount === 1 ? "member" : "members"} + +
+

Created {createdAt}

+ {canManageSettings && ( + + )} +
+
+ + {/* Rules card */} + {community.rules.length > 0 && ( +
+
+

Community Rules

+
+
+ {community.rules.map((rule, index) => ( +
+

+ {index + 1}. {rule.title} +

+ {rule.description && ( +

+ {rule.description} +

+ )} +
+ ))} +
+
+ )} + + {/* Moderators card */} + {moderators.length > 0 && ( +
+
+

Moderators

+
+
+ {moderators.map((mod) => ( +
+
+ {mod.image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + mod.handle[0]?.toUpperCase() ?? "M" + )} +
+ + u/{mod.handle} + +
+ ))} +
+
+ )} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function CommunityPageClient({ + community, + posts, + moderators, + isMember, + canManageSettings, + currentSort, +}: CommunityPageClientProps) { + const { data: session } = useSession(); + const [memberState, setMemberState] = useState(isMember); + const [actionMessage, setActionMessage] = useState(null); + const [isPending, startTransition] = useTransition(); + + const isOwner = session?.user?.id === community.ownerId; + + function handleJoinLeave() { + const joining = !memberState; + setMemberState(joining); + setActionMessage(null); + + startTransition(async () => { + const result = joining + ? await joinCommunityAction(community.id, community.name) + : await leaveCommunityAction(community.id, community.name); + + if (result.error) { + setMemberState(!joining); + setActionMessage(result.error); + } + }); + } + + const pinnedPosts = posts.filter((p) => p.isPinned); + const regularPosts = posts.filter((p) => !p.isPinned); + + return ( +
+ {/* ── Community banner + header ── */} +
+ +
+
+
+ {/* Community identity */} +
+
+

+ c/{community.name} +

+ {community.isNsfw && ( + + + NSFW + + )} +
+
+ + + {community.memberCount.toLocaleString()}{" "} + {community.memberCount === 1 ? "member" : "members"} + +
+ {community.description && ( +

+ {community.description} +

+ )} + {actionMessage && ( +

{actionMessage}

+ )} +
+ + {/* Join / Leave */} + {session?.user ? ( + !isOwner && ( + + ) + ) : ( + + )} +
+
+
+ + {/* ── Main content ── */} +
+
+ {/* ── Feed column ── */} +
+ {/* Sort tabs */} + + + {/* Pinned posts */} + {pinnedPosts.map((post) => ( + + ))} + + {/* Regular posts */} + {regularPosts.length > 0 ? ( + regularPosts.map((post) => ( + + )) + ) : pinnedPosts.length === 0 ? ( +
+ +

No posts yet — be the first to share something.

+
+ ) : null} + + {/* Invite form — members only */} + {session?.user && memberState && ( + + )} +
+ + {/* ── Info panel ── */} + + + {/* Mobile info panel (below feed) */} +
+ +
+
+
+
+ ); +} diff --git a/src/components/communities/create-community-form.tsx b/src/components/communities/create-community-form.tsx new file mode 100644 index 0000000..1427ae6 --- /dev/null +++ b/src/components/communities/create-community-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { createCommunityAction } from "@/actions/communities"; + +export function CreateCommunityForm({ onSuccess }: { onSuccess?: () => void }) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isNsfw, setIsNsfw] = useState(false); + const [statusMessage, setStatusMessage] = useState<{ type: "error" | "success"; text: string } | null>(null); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setStatusMessage(null); + + const formData = new FormData(); + formData.set("name", name); + formData.set("description", description); + if (isNsfw) formData.set("isNsfw", "on"); + + startTransition(async () => { + const result = await createCommunityAction(formData); + if (result.error) { + setStatusMessage({ type: "error", text: result.error }); + return; + } + // success contains the community name + onSuccess?.(); + router.push(`/communities/${result.success}`); + }); + } + + return ( +
+
+
+ + {name.length}/21 +
+ setName(e.target.value.slice(0, 21))} + placeholder="community_name" + disabled={isPending} + required + /> +

Letters, numbers, and underscores only.

+
+ +
+
+ + {description.length}/500 +
+