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
119 changes: 115 additions & 4 deletions app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1 className="text-2xl font-semibold">Overview</h1>
<p className="text-muted-foreground mt-2 text-sm">
Welcome back, {session?.user?.name ?? session?.user?.email}.
</p>
<header className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Overview</h1>
<p className="text-muted-foreground text-sm">Activity on your board over all time.</p>
</div>
<Link
href={`/b/${board.slug}`}
target="_blank"
className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm"
>
View public board <ExternalLink className="h-3.5 w-3.5" />
</Link>
</header>

<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="Total posts" value={stats.total} icon={<FileText className="h-4 w-4" />} />
<StatCard
label="Total votes"
value={stats.totalVotes}
icon={<ThumbsUp className="h-4 w-4" />}
/>
<StatCard
label="Total comments"
value={stats.totalComments}
icon={<MessageSquare className="h-4 w-4" />}
/>
</div>

<div className="mt-8 grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Posts by status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(Object.keys(stats.byStatus) as PostStatusValue[]).map((s) => {
const count = stats.byStatus[s];
const pct = stats.total === 0 ? 0 : (count / stats.total) * 100;
return (
<div key={s} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{STATUS_LABELS[s]}</span>
<span className="tabular-nums">{count}</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle className="text-base">Top voted posts</CardTitle>
</CardHeader>
<CardContent>
{stats.topPosts.length === 0 ? (
<p className="text-muted-foreground text-sm">No posts yet.</p>
) : (
<ul className="space-y-3">
{stats.topPosts.map((p: TopPost) => (
<li key={p.id} className="flex items-center justify-between gap-3">
<Link
href={`/b/${board.slug}/posts/${p.id}`}
className="min-w-0 flex-1 truncate text-sm hover:underline"
>
{p.title}
</Link>
<div className="flex items-center gap-2">
<StatusBadge status={p.status} />
<span className="w-8 text-right text-sm font-medium tabular-nums">
{p._count.votes}
</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
);
}

function StatCard({ label, value, icon }: { label: string; value: number; icon: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-muted-foreground flex items-center justify-between text-sm">
<span>{label}</span>
{icon}
</div>
<p className="mt-2 text-3xl font-semibold tabular-nums">{value}</p>
</CardContent>
</Card>
);
}
9 changes: 8 additions & 1 deletion app/b/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { getBoardBySlug } from "@/server/queries/boards";
Expand Down Expand Up @@ -49,6 +50,12 @@ export default async function PublicBoardPage({ params, searchParams }: Props) {
<div>
<h1 className="text-3xl font-bold">{board.name}</h1>
{board.description && <p className="text-muted-foreground mt-2">{board.description}</p>}
<Link
href={`/b/${slug}/roadmap`}
className="text-muted-foreground hover:text-foreground mt-2 inline-block text-sm underline-offset-4 hover:underline"
>
View roadmap →
</Link>
</div>
<CreatePostDialog boardSlug={slug} authenticated={!!session?.user} />
</header>
Expand All @@ -64,7 +71,7 @@ export default async function PublicBoardPage({ params, searchParams }: Props) {
) : (
<div className="space-y-3">
{posts.map((post: PublicPost) => (
<PostCard key={post.id} post={post} slug={slug} />
<PostCard key={post.id} post={post} slug={slug} authenticated={!!session?.user} />
))}
</div>
)}
Expand Down
101 changes: 101 additions & 0 deletions app/b/[slug]/posts/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href={`/b/${slug}`}
className="text-muted-foreground hover:text-foreground mb-6 inline-flex items-center gap-1 text-sm"
>
<ArrowLeft className="h-4 w-4" /> Back to {post.board.name}
</Link>

<article className="flex gap-4">
<VoteButton
postId={post.id}
voteCount={post.voteCount}
hasVoted={post.hasVoted}
authenticated={!!session?.user}
boardSlug={slug}
size="md"
/>

<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<h1 className="text-2xl font-bold">{post.title}</h1>
<StatusBadge status={post.status} />
</div>

<div className="text-muted-foreground mt-3 flex items-center gap-2 text-xs">
{post.author && (
<>
<Avatar className="h-5 w-5">
<AvatarImage src={post.author.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{(post.author.name ?? "?").slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{post.author.name ?? "Anonymous"}</span>
<span>·</span>
</>
)}
<time dateTime={post.createdAt.toISOString()}>
{post.createdAt.toLocaleDateString()}
</time>
</div>

<p className="mt-4 text-base whitespace-pre-wrap">{post.content}</p>
</div>
</article>

<section className="mt-10">
<h2 className="text-muted-foreground mb-4 text-sm font-semibold tracking-wide uppercase">
Comments ({comments.length})
</h2>

<CommentForm postId={post.id} authenticated={!!session?.user} boardSlug={slug} />

<div className="mt-6 divide-y">
{comments.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">No comments yet.</p>
) : (
comments.map((c: PublicComment) => (
<CommentItem
key={c.id}
comment={c}
canDelete={session?.user?.id === c.authorId || isBoardOwner}
/>
))
)}
</div>
</section>
</main>
);
}
83 changes: 83 additions & 0 deletions app/b/[slug]/roadmap/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto max-w-6xl px-6 py-12">
<header className="mb-8 border-b pb-6">
<div className="text-muted-foreground flex items-center gap-3 text-sm">
<Link href={`/b/${slug}`} className="hover:text-foreground">
{board.name}
</Link>
<span>/</span>
<span>Roadmap</span>
</div>
<h1 className="mt-2 text-3xl font-bold">Roadmap</h1>
</header>

<div className="grid gap-6 md:grid-cols-3">
{COLUMNS.map((status) => {
const items = roadmap[status as keyof typeof roadmap];
return (
<div key={status}>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-muted-foreground text-sm font-semibold tracking-wide uppercase">
{STATUS_LABELS[status]}
</h2>
<span className="text-muted-foreground text-xs tabular-nums">{items.length}</span>
</div>
<div className="space-y-2">
{items.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-xs">
Nothing here yet.
</div>
) : (
items.map((p: RoadmapPost) => (
<Link
key={p.id}
href={`/b/${slug}/posts/${p.id}`}
className="bg-card hover:border-foreground/20 block rounded-md border p-3 transition"
>
<p className="leading-snug font-medium">{p.title}</p>
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{p.content}</p>
<div className="text-muted-foreground mt-2 flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<ChevronUp className="h-3 w-3" /> {p._count.votes}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" /> {p._count.comments}
</span>
</div>
</Link>
))
)}
</div>
</div>
);
})}
</div>
</main>
);
}
Loading
Loading