Skip to content
Open

Dev #27

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
5 changes: 4 additions & 1 deletion app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { GitPullRequestArrowIcon } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { logout } from "@/actions/auth";
import { DockNav } from "@/components/dock-nav";
import { Footer } from "@/components/footer";
import { Navbar } from "@/components/navbar";
import { Alert, AlertDescription } from "@/components/ui/alert";
Expand All @@ -11,9 +13,10 @@ export const metadata: Metadata = {

export default function Home() {
return (
<div className="min-h-screen flex flex-col justify-between">
<div className="min-h-screen flex flex-col justify-between pb-16">
<section>
<Navbar />
<DockNav logout={logout} />

<div className="space-y-12">
<Alert className="mt-4">
Expand Down
4 changes: 2 additions & 2 deletions app/forum/components/logout-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export function LogoutButton() {
className="grid place-items-center"
>
{pending ? (
<Loader2Icon className="size-5 animate-spin text-muted-foreground" />
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
) : (
<LogOutIcon className="size-5" />
<LogOutIcon className="size-4" />
)}
</button>
);
Expand Down
66 changes: 46 additions & 20 deletions app/forum/components/post-card.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { CalendarIcon } from "lucide-react";
import { CalendarIcon, Clock3Icon, MessageCircleIcon } from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { Post, Tag } from "@/db/schema";
import { formatDate, truncateContent } from "@/lib/utils";

Expand All @@ -11,36 +17,56 @@ type Props = {
};

export function PostCard({ post }: Props) {
const sanitizedContent = truncateContent(post.content).replace(/\s+/g, " ");
const wordCount = post.content.trim().split(/\s+/).length;
const readingTime = Math.max(1, Math.round(wordCount / 180));
const showGradient = sanitizedContent.length > 180;

return (
<Card className="min-h-[200px] w-full justify-between overflow-hidden">
<CardContent>
<CardTitle className="mb-2 leading-tight truncate">
<Card className="group relative flex h-full flex-col overflow-hidden border bg-card/80 shadow-sm transition hover:border-zinc-500/40 hover:shadow-md pb-0">
<CardHeader className="gap-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase text-primary/80">
<MessageCircleIcon className="h-3.5 w-3.5" />
Anonymous post
</div>
<CardTitle className="text-base font-semibold leading-snug text-foreground line-clamp-2">
{post.title}
</CardTitle>
<div className="min-w-0 break-words dark:prose-invert text-muted-foreground font-medium">
<Markdown remarkPlugins={[remarkGfm]}>
{truncateContent(post.content).replace(/\s+/g, " ")}
</Markdown>
</CardHeader>

<CardContent className="flex flex-1 flex-col gap-4">
<div className="relative">
<div className="min-w-0 rounded-2xl bg-muted/30 p-4 text-sm text-muted-foreground dark:prose-invert">
<Markdown remarkPlugins={[remarkGfm]}>{sanitizedContent}</Markdown>
</div>
{showGradient && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 rounded-b-2xl bg-gradient-to-t from-background via-background/30 to-transparent opacity-95 transition group-hover:opacity-60" />
)}
</div>

<div className="space-x-2 mt-4">
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Badge key={tag.id} variant="secondary">
<Badge
key={tag.id}
variant="secondary"
className="border border-transparent bg-secondary/70 text-secondary-foreground/80 transition group-hover:border-primary/30"
>
{tag.name}
</Badge>
))}
</div>
</CardContent>

<div className="space-y-6">
<div className="h-px bg-gradient-to-r from-transparent via-muted to-transparent" />
<CardFooter className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<CalendarIcon className="w-3.5 h-3.5" />
<span>Posted {formatDate(post.createdAt)}</span>
</div>
</CardFooter>
</div>
<CardFooter className="flex flex-wrap items-center gap-4 border-t bg-muted/30 px-6 py-6 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<CalendarIcon className="h-3.5 w-3.5 text-primary/70" />
<span>{formatDate(post.createdAt)}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock3Icon className="h-3.5 w-3.5 text-primary/70" />
<span>{readingTime} min read</span>
</div>
</CardFooter>
</Card>
);
}
32 changes: 2 additions & 30 deletions app/forum/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
import {
HomeIcon,
InfoIcon,
MessageCirclePlusIcon,
SquareCodeIcon,
} from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";

import { logout } from "@/actions/auth";
import { Button } from "@/components/ui/button";
import { DockNav } from "@/components/dock-nav";
import { getSession } from "@/lib/auth";
import { ForumNavbar } from "./components/forum-navbar";
import { LogoutButton } from "./components/logout-button";

export const metadata: Metadata = {
title: "Umedu — Private Forum",
Expand All @@ -37,26 +28,7 @@ export default async function ForumLayout({
<ForumNavbar forumId={session.forumId} />

{children}

<section className="rounded-t-4xl bg-secondary w-full p-4 max-w-xl mx-auto flex items-center justify-evenly fixed bottom-0 right-0 left-0">
<Link href="/">
<HomeIcon className="size-5" />
</Link>
<Link href="/about">
<InfoIcon className="size-5" />
</Link>
<Button asChild size="icon">
<Link href="/forum/submit">
<MessageCirclePlusIcon className="size-6" />
</Link>
</Button>
<Link href="https://github.com/joshxfi/umedu" target="_blank">
<SquareCodeIcon className="size-5" />
</Link>
<form action={logout}>
<LogoutButton />
</form>
</section>
<DockNav logout={logout} />
</section>
);
}
174 changes: 124 additions & 50 deletions app/forum/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,23 @@
import { useThrottledCallback } from "@tanstack/react-pacer/throttler";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import { AlertCircleIcon, MessageCircleDashedIcon } from "lucide-react";
import {
AlertCircleIcon,
MessageCircleDashedIcon,
MessageCirclePlusIcon,
ShieldCheckIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import DecryptedText from "@/components/DecryptedText";
import { HoverPrefetchLink } from "@/components/hover-prefetch-link";
import ShinyText from "@/components/ShinyText";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { ProgressiveBlur } from "@/components/ui/progressive-blur";
import { Separator } from "@/components/ui/separator";
import type { Post, Tag } from "@/db/schema";
import { PostCard } from "./components/post-card";
import { PostCardSkeleton } from "./components/post-card-skeleton";
Expand Down Expand Up @@ -108,63 +121,124 @@ export default function FeedPage() {

return (
<div className="w-full overflow-auto">
<ProgressiveBlur position="bottom" height="20%" className="fixed" />
<div>
<div className="relative overflow-hidden rounded-t-3xl border-b-0 border bg-linear-to-br from-primary/5 via-background to-transparent p-6 shadow-sm dark:from-zinc-900 dark:via-zinc-900/70 dark:to-background">
<div className="space-y-3">
<Badge variant="outline">
<ShinyText text="📚 Private forum" duration={3} />
</Badge>
<div className="space-y-1">
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
usls.edu.ph
</h1>
<p className="text-muted-foreground max-w-2xl">
Your campus peers are reading in real time. Share honest wins,
worries, or random shower thoughts.
</p>
</div>
</div>
</div>

<Card className="relative overflow-hidden border-dashed bg-card/80 shadow-none rounded-t-none">
<CardHeader className="gap-2">
<CardTitle className="text-2xl">
Share it anonymously in seconds.
</CardTitle>
<ShinyText
text="Real students. Instant support. Zero judgment."
className="text-base font-medium"
/>
</CardHeader>

<CardFooter className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground flex items-center gap-2">
<ShieldCheckIcon className="size-4 text-emerald-500" />
<DecryptedText
text="Identity protection is always on."
animateOn="both"
className="cursor-crosshair"
/>
</p>
<Button asChild className="w-full sm:w-auto">
<Link href="/forum/submit">
Craft a post
<MessageCirclePlusIcon className="size-4" />
</Link>
</Button>
</CardFooter>
</Card>
</div>

{allPosts.length === 0 && !isFetching && (
<Alert>
<MessageCircleDashedIcon />
<AlertTitle>No posts yet</AlertTitle>
<AlertDescription>
Start the conversation by creating a new post!
<AlertTitle>No posts just yet</AlertTitle>
<AlertDescription className="gap-2">
<p>
Your note could be the first spark - drop a question or story.
</p>
<Button asChild size="sm">
<Link href="/forum/submit">Start the conversation</Link>
</Button>
</AlertDescription>
</Alert>
)}

<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{items.map((virtualRow) => {
const isLoaderRow = virtualRow.index > allPosts.length - 1;
const post = allPosts[virtualRow.index];

if (!isLoaderRow && !post) return null;

return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? (
<PostCardSkeleton />
<Separator className="my-8" />

<section id="latest-discussions" aria-live="polite" className="space-y-4">
<p className="text-sm uppercase tracking-wide text-muted-foreground">
Latest discussions
</p>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{items.map((virtualRow) => {
const isLoaderRow = virtualRow.index > allPosts.length - 1;
const post = allPosts[virtualRow.index];

if (!isLoaderRow && !post) return null;

return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? (
<PostCardSkeleton />
) : (
<div className="text-center mt-4 text-muted-foreground">
Nothing more to load
</div>
)
) : (
<div className="text-center mt-4 text-muted-foreground">
Nothing more to load
</div>
)
) : (
<HoverPrefetchLink
href={`/posts/${post.id}`}
key={post.id}
className="block mb-3"
>
<PostCard key={post.id} post={post} />
</HoverPrefetchLink>
)}
</div>
);
})}
</div>
<HoverPrefetchLink
href={`/posts/${post.id}`}
key={post.id}
className="block mb-3"
>
<PostCard key={post.id} post={post} />
</HoverPrefetchLink>
)}
</div>
);
})}
</div>
</section>
</div>
);
}
Loading