diff --git a/app/(dashboard)/posts/page.tsx b/app/(dashboard)/posts/page.tsx new file mode 100644 index 0000000..ca74488 --- /dev/null +++ b/app/(dashboard)/posts/page.tsx @@ -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 ( +
+
+
+

Posts

+

Manage all submissions to your board.

+
+
+ + {posts.length === 0 ? ( +
+ No posts yet. Share your board link to start collecting feedback. +
+ ) : ( +
+ + + + + + + + + + + {posts.map((p: (typeof posts)[number]) => ( + + + + + + + + ))} + +
TitleStatusVotesComments +
{p.title} + + + + {p._count.votes}{p._count.comments} + +
+
+ )} +
+ ); +} diff --git a/app/b/[slug]/loading.tsx b/app/b/[slug]/loading.tsx new file mode 100644 index 0000000..9b5576e --- /dev/null +++ b/app/b/[slug]/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/b/[slug]/page.tsx b/app/b/[slug]/page.tsx index 942b755..c8bdf13 100644 --- a/app/b/[slug]/page.tsx +++ b/app/b/[slug]/page.tsx @@ -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 (
-
-

{board.name}

- {board.description &&

{board.description}

} +
+
+

{board.name}

+ {board.description &&

{board.description}

} +
+
-
- No posts yet. The post list arrives in day 3. +
+
+ + {posts.length === 0 ? ( +
+ No posts match your filters yet. Be the first to suggest something. +
+ ) : ( +
+ {posts.map((post: PublicPost) => ( + + ))} +
+ )} + + {totalPages > 1 && ( +
+ +
+ )}
); } diff --git a/components/board/board-pagination.tsx b/components/board/board-pagination.tsx new file mode 100644 index 0000000..815693e --- /dev/null +++ b/components/board/board-pagination.tsx @@ -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 ( + + ); +} diff --git a/components/posts/admin-status-select.tsx b/components/posts/admin-status-select.tsx new file mode 100644 index 0000000..453b6f6 --- /dev/null +++ b/components/posts/admin-status-select.tsx @@ -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 ( + + + {children} + + + {STATUS_VALUES.map((s) => ( + handleSelect(s)}> + {STATUS_LABELS[s]} + + ))} + + + ); +} diff --git a/components/posts/create-post-dialog.tsx b/components/posts/create-post-dialog.tsx new file mode 100644 index 0000000..b7f0abc --- /dev/null +++ b/components/posts/create-post-dialog.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Plus } from "lucide-react"; +import { createPost } from "@/server/actions/posts"; + +type Props = { boardSlug: string; authenticated: boolean }; + +export function CreatePostDialog({ boardSlug, authenticated }: Props) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [errors, setErrors] = useState>({}); + + if (!authenticated) { + return ( + + ); + } + + function handleSubmit() { + setErrors({}); + startTransition(async () => { + const result = await createPost({ boardSlug, title, content }); + if (!result.ok) { + if (result.issues) setErrors(result.issues); + toast.error(result.error); + return; + } + toast.success("Post created"); + setTitle(""); + setContent(""); + setOpen(false); + router.refresh(); + }); + } + + return ( + + + + + + + Suggest an idea + Share what would make this product better. + + +
+
+ + setTitle(e.target.value)} + placeholder="Short, descriptive title" + maxLength={120} + /> + {errors["title"] &&

{errors["title"][0]}

} +
+ +
+ +