diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 8550461..845685c 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,13 +1,124 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; import { auth } from "@/auth"; +import { getBoardByOwner } from "@/server/queries/boards"; +import { getBoardStats, type TopPost } from "@/server/queries/stats"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { StatusBadge } from "@/components/posts/status-badge"; +import { STATUS_LABELS } from "@/lib/constants"; +import { ExternalLink, MessageSquare, ThumbsUp, FileText } from "lucide-react"; +import type { PostStatusValue } from "@/lib/validators/posts"; export default async function DashboardPage() { const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const board = await getBoardByOwner(session.user.id); + if (!board) redirect("/login"); + + const stats = await getBoardStats(board.id); + return (
-

Overview

-

- Welcome back, {session?.user?.name ?? session?.user?.email}. -

+
+
+

Overview

+

Activity on your board over all time.

+
+ + View public board + +
+ +
+ } /> + } + /> + } + /> +
+ +
+ + + Posts by status + + + {(Object.keys(stats.byStatus) as PostStatusValue[]).map((s) => { + const count = stats.byStatus[s]; + const pct = stats.total === 0 ? 0 : (count / stats.total) * 100; + return ( +
+
+ {STATUS_LABELS[s]} + {count} +
+
+
+
+
+ ); + })} + + + + + + Top voted posts + + + {stats.topPosts.length === 0 ? ( +

No posts yet.

+ ) : ( +
    + {stats.topPosts.map((p: TopPost) => ( +
  • + + {p.title} + +
    + + + {p._count.votes} + +
    +
  • + ))} +
+ )} +
+
+
); } + +function StatCard({ label, value, icon }: { label: string; value: number; icon: React.ReactNode }) { + return ( + + +
+ {label} + {icon} +
+

{value}

+
+
+ ); +} diff --git a/app/b/[slug]/page.tsx b/app/b/[slug]/page.tsx index c8bdf13..798a378 100644 --- a/app/b/[slug]/page.tsx +++ b/app/b/[slug]/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { notFound } from "next/navigation"; import { auth } from "@/auth"; import { getBoardBySlug } from "@/server/queries/boards"; @@ -49,6 +50,12 @@ export default async function PublicBoardPage({ params, searchParams }: Props) {

{board.name}

{board.description &&

{board.description}

} + + View roadmap → +
@@ -64,7 +71,7 @@ export default async function PublicBoardPage({ params, searchParams }: Props) { ) : (
{posts.map((post: PublicPost) => ( - + ))}
)} diff --git a/app/b/[slug]/posts/[id]/page.tsx b/app/b/[slug]/posts/[id]/page.tsx new file mode 100644 index 0000000..3fc95bc --- /dev/null +++ b/app/b/[slug]/posts/[id]/page.tsx @@ -0,0 +1,101 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { auth } from "@/auth"; +import { getPostById } from "@/server/queries/posts"; +import { listPostComments, type PublicComment } from "@/server/queries/comments"; +import { StatusBadge } from "@/components/posts/status-badge"; +import { VoteButton } from "@/components/posts/vote-button"; +import { CommentForm } from "@/components/posts/comment-form"; +import { CommentItem } from "@/components/posts/comment-item"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +type Props = { params: Promise<{ slug: string; id: string }> }; + +export async function generateMetadata({ params }: Props) { + const { id } = await params; + const post = await getPostById(id); + if (!post) return { title: "Not found" }; + return { title: post.title, description: post.content.slice(0, 160) }; +} + +export default async function PostDetailPage({ params }: Props) { + const { slug, id } = await params; + const post = await getPostById(id); + if (!post || post.board.slug !== slug || !post.board.isPublic) notFound(); + + const session = await auth(); + const comments = await listPostComments(id); + const isBoardOwner = session?.user?.id === post.board.ownerId; + + return ( +
+ + Back to {post.board.name} + + +
+ + +
+
+

{post.title}

+ +
+ +
+ {post.author && ( + <> + + + + {(post.author.name ?? "?").slice(0, 1).toUpperCase()} + + + {post.author.name ?? "Anonymous"} + · + + )} + +
+ +

{post.content}

+
+
+ +
+

+ Comments ({comments.length}) +

+ + + +
+ {comments.length === 0 ? ( +

No comments yet.

+ ) : ( + comments.map((c: PublicComment) => ( + + )) + )} +
+
+
+ ); +} diff --git a/app/b/[slug]/roadmap/page.tsx b/app/b/[slug]/roadmap/page.tsx new file mode 100644 index 0000000..0704d9b --- /dev/null +++ b/app/b/[slug]/roadmap/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { getBoardBySlug } from "@/server/queries/boards"; +import { getBoardRoadmap, type RoadmapPost } from "@/server/queries/roadmap"; +import { STATUS_LABELS } from "@/lib/constants"; +import { ChevronUp, MessageSquare } from "lucide-react"; +import type { PostStatusValue } from "@/lib/validators/posts"; + +type Props = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Props) { + const { slug } = await params; + const board = await getBoardBySlug(slug); + if (!board) return { title: "Not found" }; + return { title: `Roadmap · ${board.name}` }; +} + +const COLUMNS: PostStatusValue[] = ["PLANNED", "IN_PROGRESS", "DONE"]; + +export default async function RoadmapPage({ params }: Props) { + const { slug } = await params; + const board = await getBoardBySlug(slug); + if (!board || !board.isPublic) notFound(); + + const roadmap = await getBoardRoadmap(board.id); + + return ( +
+
+
+ + {board.name} + + / + Roadmap +
+

Roadmap

+
+ +
+ {COLUMNS.map((status) => { + const items = roadmap[status as keyof typeof roadmap]; + return ( +
+
+

+ {STATUS_LABELS[status]} +

+ {items.length} +
+
+ {items.length === 0 ? ( +
+ Nothing here yet. +
+ ) : ( + items.map((p: RoadmapPost) => ( + +

{p.title}

+

{p.content}

+
+ + {p._count.votes} + + + {p._count.comments} + +
+ + )) + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/components/posts/comment-form.tsx b/components/posts/comment-form.tsx new file mode 100644 index 0000000..a225600 --- /dev/null +++ b/components/posts/comment-form.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { createComment } from "@/server/actions/comments"; + +type Props = { postId: string; authenticated: boolean; boardSlug: string }; + +export function CommentForm({ postId, authenticated, boardSlug }: Props) { + const router = useRouter(); + const [content, setContent] = useState(""); + const [isPending, startTransition] = useTransition(); + const textareaRef = useRef(null); + + if (!authenticated) { + return ( +
+ {" "} + to leave a comment. +
+ ); + } + + function handleSubmit() { + if (!content.trim()) return; + startTransition(async () => { + const result = await createComment({ postId, content }); + if (!result.ok) { + toast.error(result.error); + return; + } + setContent(""); + textareaRef.current?.focus(); + }); + } + + return ( +
+