Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/(dashboard)/posts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getBoardByOwner } from "@/server/queries/boards";
import { StatusBadge } from "@/components/posts/status-badge";
import { AdminStatusSelect } from "@/components/posts/admin-status-select";
import { DeletePostButton } from "@/components/posts/delete-post-button";

export default async function AdminPostsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");

const board = await getBoardByOwner(session.user.id);
if (!board) redirect("/login");

const posts = await db.post.findMany({
where: { boardId: board.id },
orderBy: { createdAt: "desc" },
select: {
id: true,
title: true,
status: true,
createdAt: true,
_count: { select: { votes: true, comments: true } },
},
});

return (
<div>
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Posts</h1>
<p className="text-muted-foreground text-sm">Manage all submissions to your board.</p>
</div>
</header>

{posts.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-12 text-center text-sm">
No posts yet. Share your board link to start collecting feedback.
</div>
) : (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Votes</th>
<th className="px-4 py-3">Comments</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y">
{posts.map((p: (typeof posts)[number]) => (
<tr key={p.id}>
<td className="px-4 py-3 font-medium">{p.title}</td>
<td className="px-4 py-3">
<AdminStatusSelect postId={p.id} current={p.status}>
<StatusBadge status={p.status} />
</AdminStatusSelect>
</td>
<td className="px-4 py-3 tabular-nums">{p._count.votes}</td>
<td className="px-4 py-3 tabular-nums">{p._count.comments}</td>
<td className="px-4 py-3 text-right">
<DeletePostButton postId={p.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
18 changes: 18 additions & 0 deletions app/b/[slug]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<div className="mb-8 border-b pb-6">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="mt-2 h-4 w-2/3" />
</div>
<Skeleton className="h-10 w-full" />
<div className="mt-6 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</main>
);
}
71 changes: 60 additions & 11 deletions app/b/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,79 @@
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { getBoardBySlug } from "@/server/queries/boards";
import { listBoardPosts, type PublicPost } from "@/server/queries/posts";
import { listPostsQuerySchema } from "@/lib/validators/posts";
import { POSTS_PER_PAGE } from "@/lib/constants";
import { PostCard } from "@/components/posts/post-card";
import { PostFilters } from "@/components/posts/post-filters";
import { CreatePostDialog } from "@/components/posts/create-post-dialog";
import { BoardPagination } from "@/components/board/board-pagination";

type Props = { params: Promise<{ slug: string }> };
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ status?: string; sort?: string; page?: string }>;
};

export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const board = await db.board.findUnique({ where: { slug } });
const board = await getBoardBySlug(slug);
if (!board) return { title: "Not found" };
return { title: board.name, description: board.description ?? undefined };
return {
title: board.name,
description: board.description ?? `Feedback board for ${board.name}`,
};
}

export default async function PublicBoardPage({ params }: Props) {
export default async function PublicBoardPage({ params, searchParams }: Props) {
const { slug } = await params;
const board = await db.board.findUnique({ where: { slug } });
const sp = await searchParams;

const board = await getBoardBySlug(slug);
if (!board || !board.isPublic) notFound();

const parsed = listPostsQuerySchema.safeParse({
slug,
status: sp.status,
sort: sp.sort,
page: sp.page,
});
if (!parsed.success) notFound();

const session = await auth();
const { posts, total } = await listBoardPosts(parsed.data);
const totalPages = Math.max(1, Math.ceil(total / POSTS_PER_PAGE));

return (
<main className="mx-auto max-w-3xl px-6 py-12">
<header className="mb-8 border-b pb-6">
<h1 className="text-3xl font-bold">{board.name}</h1>
{board.description && <p className="text-muted-foreground mt-2">{board.description}</p>}
<header className="mb-8 flex items-start justify-between gap-4 border-b pb-6">
<div>
<h1 className="text-3xl font-bold">{board.name}</h1>
{board.description && <p className="text-muted-foreground mt-2">{board.description}</p>}
</div>
<CreatePostDialog boardSlug={slug} authenticated={!!session?.user} />
</header>

<div className="text-muted-foreground rounded-md border border-dashed p-12 text-center text-sm">
No posts yet. The post list arrives in day 3.
<div className="mb-6">
<PostFilters />
</div>

{posts.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-12 text-center text-sm">
No posts match your filters yet. Be the first to suggest something.
</div>
) : (
<div className="space-y-3">
{posts.map((post: PublicPost) => (
<PostCard key={post.id} post={post} slug={slug} />
))}
</div>
)}

{totalPages > 1 && (
<div className="mt-8">
<BoardPagination currentPage={parsed.data.page} totalPages={totalPages} />
</div>
)}
</main>
);
}
50 changes: 50 additions & 0 deletions components/board/board-pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";

type Props = { currentPage: number; totalPages: number };

export function BoardPagination({ currentPage, totalPages }: Props) {
const pathname = usePathname();
const params = useSearchParams();

function buildHref(page: number) {
const next = new URLSearchParams(params.toString());
if (page === 1) next.delete("page");
else next.set("page", String(page));
const qs = next.toString();
return qs ? `${pathname}?${qs}` : pathname;
}

const prevDisabled = currentPage <= 1;
const nextDisabled = currentPage >= totalPages;

return (
<nav className="flex items-center justify-between text-sm">
<Button asChild variant="outline" size="sm" disabled={prevDisabled}>
<Link
href={buildHref(currentPage - 1)}
aria-disabled={prevDisabled}
className={prevDisabled ? "pointer-events-none opacity-50" : undefined}
>
<ChevronLeft className="mr-1 h-4 w-4" /> Previous
</Link>
</Button>
<span className="text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button asChild variant="outline" size="sm" disabled={nextDisabled}>
<Link
href={buildHref(currentPage + 1)}
aria-disabled={nextDisabled}
className={nextDisabled ? "pointer-events-none opacity-50" : undefined}
>
Next <ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</nav>
);
}
55 changes: 55 additions & 0 deletions components/posts/admin-status-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import { useTransition } from "react";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { changeStatus } from "@/server/actions/posts";
import { STATUS_LABELS } from "@/lib/constants";
import type { PostStatusValue } from "@/lib/validators/posts";

type Props = {
postId: string;
current: PostStatusValue;
children: React.ReactNode;
};

const STATUS_VALUES: PostStatusValue[] = ["OPEN", "PLANNED", "IN_PROGRESS", "DONE", "REJECTED"];

export function AdminStatusSelect({ postId, current, children }: Props) {
const [isPending, startTransition] = useTransition();

function handleSelect(status: PostStatusValue) {
if (status === current) return;
startTransition(async () => {
const result = await changeStatus({ postId, status });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success(`Status changed to ${STATUS_LABELS[status]}`);
});
}

return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={isPending}
className="focus:ring-ring cursor-pointer rounded-md outline-none focus:ring-2"
>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{STATUS_VALUES.map((s) => (
<DropdownMenuItem key={s} onSelect={() => handleSelect(s)}>
{STATUS_LABELS[s]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
Loading
Loading