- 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 (
+
+ );
+}
diff --git a/components/posts/delete-post-button.tsx b/components/posts/delete-post-button.tsx
new file mode 100644
index 0000000..27e6348
--- /dev/null
+++ b/components/posts/delete-post-button.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useTransition } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Trash2 } from "lucide-react";
+import { deletePost } from "@/server/actions/posts";
+
+export function DeletePostButton({ postId }: { postId: string }) {
+ const [isPending, startTransition] = useTransition();
+
+ function handleClick() {
+ if (!confirm("Delete this post? This cannot be undone.")) return;
+ startTransition(async () => {
+ const result = await deletePost({ postId });
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success("Post deleted");
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/components/posts/post-card.tsx b/components/posts/post-card.tsx
new file mode 100644
index 0000000..8548f85
--- /dev/null
+++ b/components/posts/post-card.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { MessageSquare, ChevronUp } from "lucide-react";
+import { StatusBadge } from "./status-badge";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+import type { PublicPost } from "@/server/queries/posts";
+
+type Props = { post: PublicPost; slug: string };
+
+export function PostCard({ post, slug }: Props) {
+ return (
+
+
+
+ {post.voteCount}
+
+
+
+
+
+ {post.title}
+
+
+
+
{post.content}
+
+
+ {post.author && (
+
+
+
+
+ {(post.author.name ?? "?").slice(0, 1).toUpperCase()}
+
+
+
{post.author.name ?? "Anonymous"}
+
+ )}
+ {post.category && (
+
+ {post.category.name}
+
+ )}
+
+
+ {post.commentCount}
+
+
+
+
+ );
+}
diff --git a/components/posts/post-filters.tsx b/components/posts/post-filters.tsx
new file mode 100644
index 0000000..1611573
--- /dev/null
+++ b/components/posts/post-filters.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useTransition } from "react";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { STATUS_LABELS } from "@/lib/constants";
+
+const STATUS_VALUES = ["OPEN", "PLANNED", "IN_PROGRESS", "DONE", "REJECTED"] as const;
+
+export function PostFilters() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const params = useSearchParams();
+ const [isPending, startTransition] = useTransition();
+
+ const currentStatus = params.get("status") ?? "all";
+ const currentSort = params.get("sort") ?? "new";
+
+ function setParam(key: string, value: string | null) {
+ const next = new URLSearchParams(params.toString());
+ if (value === null || value === "all") next.delete(key);
+ else next.set(key, value);
+ next.delete("page"); // reset pagination on filter change
+ startTransition(() => {
+ router.push(`${pathname}?${next.toString()}`);
+ });
+ }
+
+ return (
+
+ setParam("status", v)}>
+
+ All
+ {STATUS_VALUES.map((s) => (
+
+ {STATUS_LABELS[s]}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/components/posts/status-badge.tsx b/components/posts/status-badge.tsx
new file mode 100644
index 0000000..e5b1bf6
--- /dev/null
+++ b/components/posts/status-badge.tsx
@@ -0,0 +1,12 @@
+import { Badge } from "@/components/ui/badge";
+import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
+import { cn } from "@/lib/utils";
+import type { PostStatusValue } from "@/lib/validators/posts";
+
+export function StatusBadge({ status }: { status: PostStatusValue }) {
+ return (
+
+ {STATUS_LABELS[status]}
+
+ );
+}
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..47ff2e2
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,112 @@
+"use client"
+
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..cacff11
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,49 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps
& { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..7caed7d
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,165 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..d6f8fce
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,192 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..d457090
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..8167d2e
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..95ed1c4
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/lib/constants.ts b/lib/constants.ts
new file mode 100644
index 0000000..b29c60d
--- /dev/null
+++ b/lib/constants.ts
@@ -0,0 +1,19 @@
+import type { PostStatusValue } from "@/lib/validators/posts";
+
+export const POSTS_PER_PAGE = 10;
+
+export const STATUS_LABELS: Record = {
+ OPEN: "Open",
+ PLANNED: "Planned",
+ IN_PROGRESS: "In progress",
+ DONE: "Done",
+ REJECTED: "Rejected",
+};
+
+export const STATUS_COLORS: Record = {
+ OPEN: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
+ PLANNED: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300",
+ IN_PROGRESS: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
+ DONE: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
+ REJECTED: "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300",
+};
diff --git a/lib/validators/posts.ts b/lib/validators/posts.ts
new file mode 100644
index 0000000..4bbf5bd
--- /dev/null
+++ b/lib/validators/posts.ts
@@ -0,0 +1,38 @@
+import { z } from "zod";
+
+export const PostStatusSchema = z.enum(["OPEN", "PLANNED", "IN_PROGRESS", "DONE", "REJECTED"]);
+
+export type PostStatusValue = z.infer;
+
+export const createPostSchema = z.object({
+ boardSlug: z.string().min(1),
+ title: z.string().trim().min(3, "Title must be at least 3 characters").max(120),
+ content: z.string().trim().min(10, "Content must be at least 10 characters").max(2000),
+ categoryId: z.string().cuid().optional().nullable(),
+});
+
+export const updatePostSchema = z.object({
+ postId: z.string().cuid(),
+ title: z.string().trim().min(3).max(120).optional(),
+ content: z.string().trim().min(10).max(2000).optional(),
+ categoryId: z.string().cuid().optional().nullable(),
+});
+
+export const changeStatusSchema = z.object({
+ postId: z.string().cuid(),
+ status: PostStatusSchema,
+});
+
+export const deletePostSchema = z.object({
+ postId: z.string().cuid(),
+});
+
+export const listPostsQuerySchema = z.object({
+ slug: z.string().min(1),
+ status: PostStatusSchema.optional(),
+ sort: z.enum(["new", "top"]).default("new"),
+ page: z.coerce.number().int().min(1).default(1),
+});
+
+export type CreatePostInput = z.infer;
+export type ListPostsQuery = z.infer;
diff --git a/package-lock.json b/package-lock.json
index d47c35d..80aeb58 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "feedbackflow",
- "version": "0.4.5",
+ "version": "0.4.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "feedbackflow",
- "version": "0.4.5",
+ "version": "0.4.16",
"hasInstallScript": true,
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
@@ -42,6 +42,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"prisma": "^7.8.0",
"tailwindcss": "^4",
+ "tsx": "^4.22.1",
"typescript": "^6.0.3"
},
"engines": {
@@ -768,6 +769,448 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -6391,6 +6834,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -7305,6 +7790,21 @@
"node": ">=14.14"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -12032,6 +12532,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
+ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
diff --git a/package.json b/package.json
index 71fceca..88ee056 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "feedbackflow",
- "version": "0.4.7",
+ "version": "0.5.0",
"private": true,
"engines": {
"node": ">=20.9.0"
@@ -12,6 +12,7 @@
"lint": "eslint",
"typecheck": "tsc --noEmit",
"db:migrate": "prisma migrate dev",
+ "db:seed": "tsx prisma/seed.ts",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
@@ -52,6 +53,10 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"prisma": "^7.8.0",
"tailwindcss": "^4",
+ "tsx": "^4.22.1",
"typescript": "^6.0.3"
+ },
+ "prisma": {
+ "seed": "tsx prisma/seed.ts"
}
}
diff --git a/prisma/seed.ts b/prisma/seed.ts
new file mode 100644
index 0000000..71389e5
--- /dev/null
+++ b/prisma/seed.ts
@@ -0,0 +1,73 @@
+import { PrismaPg } from "@prisma/adapter-pg";
+import { PrismaClient, PostStatus } from "@prisma/client";
+import "dotenv/config";
+
+const adapter = new PrismaPg({ connectionString: process.env["DATABASE_URL"] });
+const db = new PrismaClient({ adapter });
+
+async function main() {
+ const user = await db.user.upsert({
+ where: { email: "demo@feedbackflow.app" },
+ update: {},
+ create: {
+ email: "demo@feedbackflow.app",
+ name: "Demo User",
+ },
+ });
+
+ const board = await db.board.upsert({
+ where: { ownerId: user.id },
+ update: {},
+ create: {
+ slug: "demo",
+ name: "Demo board",
+ description: "Sample feedback board for FeedbackFlow.",
+ ownerId: user.id,
+ },
+ });
+
+ const categories = await Promise.all(
+ [
+ { name: "Feature", color: "#6366f1" },
+ { name: "Bug", color: "#ef4444" },
+ { name: "Improvement", color: "#10b981" },
+ ].map((c) =>
+ db.category.upsert({
+ where: { boardId_name: { boardId: board.id, name: c.name } },
+ update: {},
+ create: { ...c, boardId: board.id },
+ }),
+ ),
+ );
+
+ const samplePosts = [
+ { title: "Dark mode for the dashboard", status: PostStatus.PLANNED, cat: 0 },
+ { title: "Export feedback as CSV", status: PostStatus.OPEN, cat: 0 },
+ { title: "Voting button misaligned on mobile", status: PostStatus.IN_PROGRESS, cat: 1 },
+ { title: "Slack integration for new posts", status: PostStatus.OPEN, cat: 0 },
+ { title: "Faster page load on large boards", status: PostStatus.DONE, cat: 2 },
+ { title: "Spam from disposable emails", status: PostStatus.REJECTED, cat: 1 },
+ ];
+
+ for (const p of samplePosts) {
+ await db.post.create({
+ data: {
+ title: p.title,
+ content: `${p.title}. More details about why this matters and what it should do.`,
+ status: p.status,
+ boardId: board.id,
+ authorId: user.id,
+ categoryId: categories[p.cat]?.id,
+ },
+ });
+ }
+
+ console.warn(`Seeded board /b/${board.slug} with ${samplePosts.length} posts`);
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(() => db.$disconnect());
diff --git a/server/actions/posts.ts b/server/actions/posts.ts
new file mode 100644
index 0000000..4557dfc
--- /dev/null
+++ b/server/actions/posts.ts
@@ -0,0 +1,164 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import {
+ changeStatusSchema,
+ createPostSchema,
+ deletePostSchema,
+ updatePostSchema,
+ type CreatePostInput,
+} from "@/lib/validators/posts";
+import { z } from "zod";
+
+type ActionResult =
+ | { ok: true; data: T }
+ | { ok: false; error: string; issues?: Record };
+
+export async function createPost(
+ input: CreatePostInput,
+): Promise> {
+ const parsed = createPostSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ error: "Invalid input",
+ issues: parsed.error.flatten().fieldErrors,
+ };
+ }
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const board = await db.board.findUnique({
+ where: { slug: parsed.data.boardSlug },
+ select: { id: true, isPublic: true },
+ });
+ if (!board || !board.isPublic) return { ok: false, error: "Board not found" };
+
+ if (parsed.data.categoryId) {
+ const cat = await db.category.findFirst({
+ where: { id: parsed.data.categoryId, boardId: board.id },
+ select: { id: true },
+ });
+ if (!cat) return { ok: false, error: "Invalid category" };
+ }
+
+ const post = await db.post.create({
+ data: {
+ title: parsed.data.title,
+ content: parsed.data.content,
+ categoryId: parsed.data.categoryId ?? undefined,
+ boardId: board.id,
+ authorId: session.user.id,
+ },
+ select: { id: true },
+ });
+
+ revalidatePath(`/b/${parsed.data.boardSlug}`);
+ return { ok: true, data: { postId: post.id } };
+}
+
+export async function changeStatus(
+ input: z.infer,
+): Promise {
+ const parsed = changeStatusSchema.safeParse(input);
+ if (!parsed.success) return { ok: false, error: "Invalid input" };
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const post = await db.post.findUnique({
+ where: { id: parsed.data.postId },
+ select: { board: { select: { slug: true, ownerId: true } } },
+ });
+ if (!post) return { ok: false, error: "Post not found" };
+ if (post.board.ownerId !== session.user.id) {
+ return { ok: false, error: "Forbidden" };
+ }
+
+ await db.post.update({
+ where: { id: parsed.data.postId },
+ data: { status: parsed.data.status },
+ });
+
+ revalidatePath(`/b/${post.board.slug}`);
+ revalidatePath(`/posts`);
+ return { ok: true, data: undefined };
+}
+
+export async function updatePost(input: z.infer): Promise {
+ const parsed = updatePostSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ error: "Invalid input",
+ issues: parsed.error.flatten().fieldErrors,
+ };
+ }
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const post = await db.post.findUnique({
+ where: { id: parsed.data.postId },
+ select: {
+ authorId: true,
+ board: { select: { slug: true, ownerId: true, id: true } },
+ },
+ });
+ if (!post) return { ok: false, error: "Post not found" };
+
+ const isOwner = post.board.ownerId === session.user.id;
+ const isAuthor = post.authorId === session.user.id;
+ if (!isOwner && !isAuthor) return { ok: false, error: "Forbidden" };
+
+ if (parsed.data.categoryId) {
+ const cat = await db.category.findFirst({
+ where: { id: parsed.data.categoryId, boardId: post.board.id },
+ select: { id: true },
+ });
+ if (!cat) return { ok: false, error: "Invalid category" };
+ }
+
+ await db.post.update({
+ where: { id: parsed.data.postId },
+ data: {
+ title: parsed.data.title,
+ content: parsed.data.content,
+ categoryId: parsed.data.categoryId,
+ },
+ });
+
+ revalidatePath(`/b/${post.board.slug}`);
+ revalidatePath(`/posts`);
+ return { ok: true, data: undefined };
+}
+
+export async function deletePost(input: z.infer): Promise {
+ const parsed = deletePostSchema.safeParse(input);
+ if (!parsed.success) return { ok: false, error: "Invalid input" };
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const post = await db.post.findUnique({
+ where: { id: parsed.data.postId },
+ select: {
+ authorId: true,
+ board: { select: { slug: true, ownerId: true } },
+ },
+ });
+ if (!post) return { ok: false, error: "Post not found" };
+
+ const isOwner = post.board.ownerId === session.user.id;
+ const isAuthor = post.authorId === session.user.id;
+ if (!isOwner && !isAuthor) return { ok: false, error: "Forbidden" };
+
+ await db.post.delete({ where: { id: parsed.data.postId } });
+
+ revalidatePath(`/b/${post.board.slug}`);
+ revalidatePath(`/posts`);
+ return { ok: true, data: undefined };
+}
diff --git a/server/queries/boards.ts b/server/queries/boards.ts
new file mode 100644
index 0000000..7a95417
--- /dev/null
+++ b/server/queries/boards.ts
@@ -0,0 +1,20 @@
+import { db } from "@/lib/db";
+
+export async function getBoardBySlug(slug: string) {
+ return db.board.findUnique({
+ where: { slug },
+ select: {
+ id: true,
+ slug: true,
+ name: true,
+ description: true,
+ isPublic: true,
+ ownerId: true,
+ categories: { select: { id: true, name: true, color: true } },
+ },
+ });
+}
+
+export async function getBoardByOwner(ownerId: string) {
+ return db.board.findUnique({ where: { ownerId } });
+}
diff --git a/server/queries/posts.ts b/server/queries/posts.ts
new file mode 100644
index 0000000..fc8cb78
--- /dev/null
+++ b/server/queries/posts.ts
@@ -0,0 +1,96 @@
+import { db } from "@/lib/db";
+import { auth } from "@/auth";
+import { POSTS_PER_PAGE } from "@/lib/constants";
+import type { ListPostsQuery } from "@/lib/validators/posts";
+
+export type PublicPost = Awaited>["posts"][number];
+
+export async function listBoardPosts(query: ListPostsQuery) {
+ const session = await auth();
+ const userId = session?.user?.id;
+
+ const board = await db.board.findUnique({
+ where: { slug: query.slug },
+ select: { id: true, isPublic: true },
+ });
+
+ if (!board || !board.isPublic) {
+ return { posts: [], total: 0, board: null };
+ }
+
+ const where = {
+ boardId: board.id,
+ ...(query.status ? { status: query.status } : {}),
+ };
+
+ const [posts, total] = await Promise.all([
+ db.post.findMany({
+ where,
+ orderBy:
+ query.sort === "top"
+ ? [{ votes: { _count: "desc" } }, { createdAt: "desc" }]
+ : { createdAt: "desc" },
+ skip: (query.page - 1) * POSTS_PER_PAGE,
+ take: POSTS_PER_PAGE,
+ select: {
+ id: true,
+ title: true,
+ content: true,
+ status: true,
+ createdAt: true,
+ author: { select: { name: true, image: true } },
+ category: { select: { id: true, name: true, color: true } },
+ _count: { select: { votes: true, comments: true } },
+ votes: userId ? { where: { userId }, select: { id: true } } : false,
+ },
+ }),
+ db.post.count({ where }),
+ ]);
+
+ return {
+ board,
+ total,
+ posts: posts.map((p: (typeof posts)[number]) => ({
+ id: p.id,
+ title: p.title,
+ content: p.content,
+ status: p.status,
+ createdAt: p.createdAt,
+ author: p.author,
+ category: p.category,
+ voteCount: p._count.votes,
+ commentCount: p._count.comments,
+ hasVoted: userId ? Array.isArray(p.votes) && p.votes.length > 0 : false,
+ })),
+ };
+}
+
+export async function getPostById(postId: string) {
+ const session = await auth();
+ const userId = session?.user?.id;
+
+ const post = await db.post.findUnique({
+ where: { id: postId },
+ select: {
+ id: true,
+ title: true,
+ content: true,
+ status: true,
+ createdAt: true,
+ board: { select: { id: true, slug: true, name: true, ownerId: true, isPublic: true } },
+ author: { select: { id: true, name: true, image: true } },
+ category: { select: { id: true, name: true, color: true } },
+ _count: { select: { votes: true, comments: true } },
+ votes: userId ? { where: { userId }, select: { id: true } } : false,
+ },
+ });
+
+ if (!post) return null;
+
+ return {
+ ...post,
+ voteCount: post._count.votes,
+ commentCount: post._count.comments,
+ hasVoted: userId ? Array.isArray(post.votes) && post.votes.length > 0 : false,
+ };
+}