> shadcn을 활용해서 네비게이션을 만들어보자.
import { Link } from "react-router";
import { Separator } from "~/common/components/ui/separator";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "./ui/navigation-menu";
import { cn } from "~/lib/utils";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import {
BarChart3Icon,
BellIcon,
LogOutIcon,
MessageCircleIcon,
SettingsIcon,
UserIcon,
} from "lucide-react";
const menus = [
{
name: "Products",
to: "/products",
items: [
{
name: "Leaderboards",
description: "See the top performers in your community",
to: "/products/leaderboards",
},
{
name: "Categories",
description: "See the top categories in your community",
to: "/products/categories",
},
{
name: "Search",
description: "Search for a product",
to: "/products/search",
},
{
name: "Submit a Product",
description: "Submit a product to our community",
to: "/products/submit",
},
{
name: "Promote",
description: "Promote a product to our community",
to: "/products/promote",
},
],
},
{
name: "Jobs",
to: "/jobs",
items: [
{
name: "Remote Jobs",
description: "Find a remote job in our community",
to: "/jobs?location=remote",
},
{
name: "Full-Time Jobs",
description: "Find a full-time job in our community",
to: "/jobs?type=full-time",
},
{
name: "Freelance Jobs",
description: "Find a freelance job in our community",
to: "/jobs?type=freelance",
},
{
name: "Internships",
description: "Find an internship in our community",
to: "/jobs?type=internship",
},
{
name: "Submit a Job",
description: "Submit a job to our community",
to: "/jobs/submit",
},
],
},
{
name: "Community",
to: "/community",
items: [
{
name: "All Posts",
description: "See all posts in our community",
to: "/community",
},
{
name: "Top Posts",
description: "See the top posts in our community",
to: "/community?sort=top",
},
{
name: "New Posts",
description: "See the new posts in our community",
to: "/community?sort=new",
},
{
name: "Create a Post",
description: "Create a post in our community",
to: "/community/create",
},
],
},
{
name: "IdeasGPT",
to: "/ideas",
},
{
name: "Teams",
to: "/teams",
items: [
{
name: "All Teams",
description: "See all teams in our community",
to: "/teams",
},
{
name: "Create a Team",
description: "Create a team in our community",
to: "/teams/create",
},
],
},
];
export default function Navigation({
isLoggedIn,
hasNotifications,
hasMessages,
}: {
isLoggedIn: boolean;
hasNotifications: boolean;
hasMessages: boolean;
}) {
return (
<nav className="flex px-20 h-16 items-center justify-between backdrop-blur fixed top-0 left-0 right-0 z-50 bg-background/50">
<div className="flex items-center">
<Link to="/" className="font-bold tracking-tighter text-lg">
wemake
</Link>
<Separator orientation="vertical" className="h-6 mx-4" />
<NavigationMenu>
<NavigationMenuList>
{menus.map((menu) => (
<NavigationMenuItem key={menu.name}>
{menu.items ? (
<>
<Link to={menu.to}>
<NavigationMenuTrigger>{menu.name}</NavigationMenuTrigger>
</Link>
<NavigationMenuContent>
<ul className="grid w-[600px] font-light gap-3 p-4 grid-cols-2">
{menu.items?.map((item) => (
<NavigationMenuItem
key={item.name}
className={cn([
"select-none rounded-md transition-colors focus:bg-accent hover:bg-accent",
(item.to === "/products/promote" ||
item.to === "/jobs/submit") &&
"col-span-2 bg-primary/10 hover:bg-primary/20 focus:bg-primary/20",
])}
>
<NavigationMenuLink>
<Link
className="p-3 space-y-1 block leading-none no-underline outline-none"
to={item.to}
>
<span className="text-sm font-medium leading-none">
{item.name}
</span>
<p className="text-sm leading-snug text-muted-foreground">
{item.description}
</p>
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
</ul>
</NavigationMenuContent>
</>
) : (
<Link className={navigationMenuTriggerStyle()} to={menu.to}>
{menu.name}
</Link>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
{isLoggedIn ? (
<div className="flex items-center gap-4">
<Button size="icon" variant="ghost" asChild className="relative">
<Link to="/my/notifications">
<BellIcon className="size-4" />
{hasNotifications && (
<div className="absolute top-1.5 right-1.5 size-2 bg-red-500 rounded-full" />
)}
</Link>
</Button>
<Button size="icon" variant="ghost" asChild className="relative">
<Link to="/my/messages">
<MessageCircleIcon className="size-4" />
{hasMessages && (
<div className="absolute top-1.5 right-1.5 size-2 bg-red-500 rounded-full" />
)}
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar>
<AvatarImage src="https://github.com/serranoarevalo.png" />
<AvatarFallback>N</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="flex flex-col">
<span className="font-medium">John Doe</span>
<span className="text-xs text-muted-foreground">@username</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my/dashboard">
<BarChart3Icon className="size-4 mr-2" />
Dashboard
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my/profile">
<UserIcon className="size-4 mr-2" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my/settings">
<SettingsIcon className="size-4 mr-2" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/logout">
<LogOutIcon className="size-4 mr-2" />
Logout
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="flex items-center gap-4">
<Button asChild variant="secondary">
<Link to="/auth/login">Login</Link>
</Button>
<Button asChild>
<Link to="/auth/join">Join</Link>
</Button>
</div>
)}
</nav>
);
}
- asChild는 부모 스타일을 자식에게 주지 않는 것이다. (부모 컴포넌트 때문에 가장자리에 네모모양 생기게 하는거 방지 할 수 있음)
- 현재 네비게이션부분에 ideasGPT 부분은 shadcn navigation 컴포넌트를 사용하지 않는데, 컴포넌트에 있는 디자인만 따올 수 있음. --> 요놈 navigationMenuTriggerStyle() 딱 버튼 모양같은것만 있고, 화살표 움직이는건 안보임
아래는 root.tsx파일인데, Navigation props에 인자값 추가해줬음
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import Navigation from "./common/components/navigation";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<>
<Navigation
isLoggedIn={true}
hasNotifications={true}
hasMessages={true}
/>
<Outlet />
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
> home-page 부분에 각종 컴포넌트 추상화 및 loader함수 활용
import { Link, type MetaFunction } from "react-router";
import { ProductCard } from "~/features/products/components/product-card";
import { Button } from "../components/ui/button";
import { PostCard } from "~/features/community/components/post-card";
import { IdeaCard } from "~/features/ideas/components/idea-card";
import { JobCard } from "~/features/jobs/components/job-card";
import { TeamCard } from "~/features/teams/components/team-card";
import type { Route } from "./+types/home-page";
export const meta: MetaFunction = () => {
return [
{ title: "Home | wemake" },
{ name: "description", content: "Welcome to wemake" },
];
};
export const loader = () => {
console.log("hello");
return {
hello: "world",
hello2: "lalalalll",
};
};
export default function HomePage({ loaderData }: Route.ComponentProps) {
return (
<div className="px-20 space-y-40">
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-5xl font-bold leading-tight tracking-tight">
Today's Products {JSON.stringify(loaderData)}
</h2>
<p className="text-xl font-light text-foreground">
The best products made by our community today.
</p>
<Button variant="link" asChild className="text-lg p-0">
<Link to="/products/leaderboards">Explore all products →</Link>
</Button>
</div>
{Array.from({ length: 11 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-5xl font-bold leading-tight tracking-tight">
Latest Discussions
</h2>
<p className="text-xl font-light text-foreground">
The latest discussions from our community.
</p>
<Button variant="link" asChild className="text-lg p-0">
<Link to="/community">Explore all discussions →</Link>
</Button>
</div>
{Array.from({ length: 11 }).map((_, index) => (
<PostCard
key={`postId-${index}`}
id={`postId-${index}`}
title="What is the best productivity tool?"
author="Nico"
authorAvatarUrl="https://github.com/apple.png"
category="Productivity"
postedAt="12 hours ago"
/>
))}
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-5xl font-bold leading-tight tracking-tight">
IdeasGPT
</h2>
<p className="text-xl font-light text-foreground">
Find ideas for your next project.
</p>
<Button variant="link" asChild className="text-lg p-0">
<Link to="/ideas">Explore all ideas →</Link>
</Button>
</div>
{Array.from({ length: 5 }).map((_, index) => (
<IdeaCard
key={`ideaId-${index}`}
id={`ideaId-${index}`}
title="A startup that creates an AI-powered generated personal trainer, delivering customized fitness recommendations and tracking of progress using a mobile app to track workouts and progress as well as a website to manage the business."
viewsCount={123}
postedAt="12 hours ago"
likesCount={12}
claimed={index % 2 === 0}
/>
))}
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<h2 className="text-5xl font-bold leading-tight tracking-tight">
Latest Jobs
</h2>
<p className="text-xl font-light text-foreground">
Find your dream job.
</p>
<Button variant="link" asChild className="text-lg p-0">
<Link to="/jobs">Explore all jobs →</Link>
</Button>
</div>
{Array.from({ length: 11 }).map((_, index) => (
<JobCard
key={`jobId-${index}`}
id={`jobId-${index}`}
company="Tesla"
companyLogoUrl="https://github.com/facebook.png"
companyHq="San Francisco, CA"
title="Software Engineer"
postedAt="12 hours ago"
type="Full-time"
positionLocation="Remote"
salary="$100,000 - $120,000"
/>
))}
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<h2 className="text-5xl font-bold leading-tight tracking-tight">
Find a team mate
</h2>
<p className="text-xl font-light text-foreground">
Join a team looking for a new member.
</p>
<Button variant="link" asChild className="text-lg p-0">
<Link to="/teams">Explore all teams →</Link>
</Button>
</div>
{Array.from({ length: 7 }).map((_, index) => (
<TeamCard
key={`teamId-${index}`}
id={`teamId-${index}`}
leaderUsername="lynn"
leaderAvatarUrl="https://github.com/inthetiger.png"
positions={[
"React Developer",
"Backend Developer",
"Product Manager",
]}
projectDescription="a new social media platform"
/>
))}
</div>
</div>
);
}
- 여러 컴포넌트들은 features/jobs/components/job-card.tsx 와 같이 features 폴더에 둔다. 그리고 네비게이션 products 부분에
있는 각 각의 page들은 url 주소가 products/~~ 이기 때문에 features/products/pages/xxx.tsx 이런식으로 세팅한다. (아래 파일트리 참고)
- loader 함수는 반드시 export를 해야하는 고유이름을 써야하며, 해당 loader값을 통해 db에서 값을 가져 올 수 있다. 해당 함수의 값은 ssr에 해당 한다. 그리고 아래와 같이 해당 loader 값을 사용하려면 인자값으로 loaderData로 불러와야한다. 그리고 해당 타입은 자동으로 ComponentProps로 가지고 온다. (route.ts 파일을 만들 때, 자동으로 생성됨)
> Loader의 활용
import { redirect } from "react-router";
export function loader() {
return redirect("/products/leaderboards");
}
기본 products-page는 화면을 보여줄게 없다. 그래서 redirect용으로 loader를 활용 할 수 있다. (loader는 ssr이기 때문에)
따라서 loader는 UI에 데이터를 넣기 위한 용으로 쓸 수도 있고, 리다이렉트용으로도 쓸 수 있다. 그리고 response값으로도 사용 할 수 있다.
>root.tsx 수정
<body>
<main className="px-20">{children}</main>
<ScrollRestoration />
<Scripts />
</body>
네비게이션과 동일한 px값을 주기 위해 위와 같이 수정
> hero 별도 컴포넌트 분리
app/common/components/hero.tsx 파일로 별도 컴포넌트로 만들었다. (여러 곳에 쓰이기 위함)
interface HeroProps {
title: string;
subtitle?: string;
className?: string;
}
export function Hero({ title, subtitle, className = "" }: HeroProps) {
return (
<div
className={`flex flex-col py-20 justify-center items-center rounded-md bg-gradient-to-t from-background to-primary/10 ${className}`}
>
<h1 className="text-5xl font-bold">{title}</h1>
{subtitle && (
<p className="text-2xl font-light text-foreground">{subtitle}</p>
)}
</div>
);
}
> leaderboard-page.tsx
import { Hero } from "~/common/components/hero";
import { Route } from "./+types/leaderboard-page";
import { Button } from "~/common/components/ui/button";
import { ProductCard } from "../components/product-card";
import { Link } from "react-router";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Leaderboards | wemake" },
{ name: "description", content: "Top products leaderboard" },
];
};
export default function LeaderboardPage() {
return (
<div className="space-y-20">
<Hero
title="Leaderboards"
subtitle="The most popular products on wemake"
/>
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-3xl font-bold leading-tight tracking-tight">
Daily Leaderboard
</h2>
<p className="text-xl font-light text-foreground">
The most popular products on wemake by day.
</p>
</div>
{Array.from({ length: 7 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
<Button variant="link" asChild className="text-lg self-center">
<Link to="/products/leaderboards/daily">
Explore all products →
</Link>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-3xl font-bold leading-tight tracking-tight">
Weekly Leaderboard
</h2>
<p className="text-xl font-light text-foreground">
The most popular products on wemake by week.
</p>
</div>
{Array.from({ length: 7 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
<Button variant="link" asChild className="text-lg self-center">
<Link to="/products/leaderboards/weekly">
Explore all products →
</Link>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-3xl font-bold leading-tight tracking-tight">
Monthly Leaderboard
</h2>
<p className="text-xl font-light text-foreground">
The most popular products on wemake by month.
</p>
</div>
{Array.from({ length: 7 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
<Button variant="link" asChild className="text-lg self-center">
<Link to="/products/leaderboards/monthly">
Explore all products →
</Link>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<h2 className="text-3xl font-bold leading-tight tracking-tight">
Yearly Leaderboard
</h2>
<p className="text-xl font-light text-foreground">
The most popular products on wemake by year.
</p>
</div>
{Array.from({ length: 7 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
<Button variant="link" asChild className="text-lg self-center">
<Link to="/products/leaderboards/yearly">
Explore all products →
</Link>
</Button>
</div>
</div>
);
}
일/월/년도 별 products 컴포넌트를 만들어주고, 각 각에 대해 Link도 만들어주었다.
> route.tsx 수정
route(
"/:period",
"features/products/pages/leaderboards-redirection-page.tsx"
),
리더보드에 쓰일 route 추가함
> leaderboards-redirection-page.tsx 생성
import { data, redirect } from "react-router";
import { Route } from "./+types/leaderboards-redirection-page";
import { DateTime } from "luxon";
export function loader({ params }: Route.LoaderArgs) {
const { period } = params;
let url: string;
const today = DateTime.now().setZone("Asia/Seoul");
if (period === "daily") {
url = `/products/leaderboards/daily/${today.year}/${today.month}/${today.day}`;
} else if (period === "weekly") {
url = `/products/leaderboards/weekly/${today.year}/${today.weekNumber}`;
} else if (period === "monthly") {
url = `/products/leaderboards/monthly/${today.year}/${today.month}`;
} else if (period === "yearly") {
url = `/products/leaderboards/yearly/${today.year}`;
} else {
return data(null, { status: 400 });
}
return redirect(url);
}
리더보드에서 각 product의 Link url은 daily, weekly, monthly, yearly로 되어 있고, 해당 text를 params값으로 받아와서,
현재 시각에 맞게 redirect 해준다. 이때 라이브러리는 luxon을 사용해준다. 아래와 같이 설치
npm install --save luxon
npm i --save-dev @types/luxon
> 데이터 검증
아래는 daily-leaderboard-page.tsx 파일이다. url 주소에 미래 주소를 넣거나, 유효하지 않은 data(숫자가 아닌 text 등)를 넣으면 zod를 통해 검증을 할 수 있다. (npm i zod 설치) 그리고 ErrorBoundary는 root.tsx에서 일괄적으로 할수도 있고, 아래와 같이 각 페이지 별로 커스터마이징도 가능하다.
import { DateTime } from "luxon";
import type { Route } from "./+types/daily-leaderboard-page";
import { data, isRouteErrorResponse } from "react-router";
import { z } from "zod";
const paramsSchema = z.object({
year: z.coerce.number(),
month: z.coerce.number(),
day: z.coerce.number(),
});
export const loader = ({ params }: Route.LoaderArgs) => {
const { success, data: parsedData } = paramsSchema.safeParse(params);
if (!success) {
throw data(
{
error_code: "invalid_params",
message: "Invalid params",
},
{ status: 400 }
);
}
const date = DateTime.fromObject(parsedData).setZone("Asia/Seoul");
console.log(date);
if (!date.isValid) {
throw data(
{
error_code: "invalid_date",
message: "Invalid date",
},
{
status: 400,
}
);
}
const today = DateTime.now().setZone("Asia/Seoul").startOf("day");
if (date > today) {
throw data(
{
error_code: "future_date",
message: "Future date",
},
{ status: 400 }
);
}
return {
date,
};
};
export default function DailyLeaderboardPage({
loaderData,
}: Route.ComponentProps) {
return <div className="container mx-auto px-4 py-8">효효</div>;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div>
{error.data.message} / {error.data.error_code}
</div>
);
}
if (error instanceof Error) {
return <div>{error.message}</div>;
}
return <div>Unknown error</div>;
}
> 시간 대 지역 고정
root.tsx 파일에 아래 추가 luxon의 setting default값 추가
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import Navigation from "./common/components/navigation";
import { Settings } from "luxon";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
Settings.defaultLocale = "ko";
Settings.defaultZone = "Asia/Seoul";
return (
<html lang="en" className="">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<main className="px-20">{children}</main>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<div className="py-28">
<Navigation
isLoggedIn={false}
hasNotifications={false}
hasMessages={false}
/>
<Outlet />
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
> 어제, 내일 날짜로 이동 버튼 추가 및 기타 수정
import { DateTime } from "luxon";
import type { Route } from "./+types/daily-leaderboard-page";
import { data, isRouteErrorResponse, Link } from "react-router";
import { z } from "zod";
import { Hero } from "~/common/components/hero";
import { ProductCard } from "../components/product-card";
import { Button } from "~/common/components/ui/button";
import ProductPagination from "~/common/components/product-pagination";
const paramsSchema = z.object({
year: z.coerce.number(),
month: z.coerce.number(),
day: z.coerce.number(),
});
export const loader = ({ params }: Route.LoaderArgs) => {
const { success, data: parsedData } = paramsSchema.safeParse(params);
if (!success) {
throw data(
{
error_code: "invalid_params",
message: "Invalid params",
},
{ status: 400 }
);
}
const date = DateTime.fromObject(parsedData).setZone("Asia/Seoul");
if (!date.isValid) {
throw data(
{
error_code: "invalid_date",
message: "Invalid date",
},
{
status: 400,
}
);
}
const today = DateTime.now().setZone("Asia/Seoul").startOf("day");
if (date > today) {
throw data(
{
error_code: "future_date",
message: "Future date",
},
{ status: 400 }
);
}
return {
...parsedData,
};
};
export default function DailyLeaderboardPage({
loaderData,
}: Route.ComponentProps) {
const urlDate = DateTime.fromObject({
year: loaderData.year,
month: loaderData.month,
day: loaderData.day,
});
const previousDay = urlDate.minus({ days: 1 });
const nextDay = urlDate.plus({ days: 1 });
const isToday = urlDate.equals(DateTime.now().startOf("day"));
return (
<div className="space-y-10">
<Hero
title={`The best products of ${urlDate.toLocaleString(
DateTime.DATE_MED
)}`}
/>
<div className="flex items-center justify-center gap-2">
<Button variant="secondary" asChild>
<Link
to={`/products/leaderboards/daily/${previousDay.year}/${previousDay.month}/${previousDay.day}`}
>
← {previousDay.toLocaleString(DateTime.DATE_SHORT)}
</Link>
</Button>
{!isToday ? (
<Button variant="secondary" asChild>
<Link
to={`/products/leaderboards/daily/${nextDay.year}/${nextDay.month}/${nextDay.day}`}
>
{nextDay.toLocaleString(DateTime.DATE_SHORT)} →
</Link>
</Button>
) : null}
</div>
<div className="space-y-5 w-full max-w-screen-md mx-auto">
{Array.from({ length: 11 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
</div>
<ProductPagination totalPages={10} />
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div>
{error.data.message} / {error.data.error_code}
</div>
);
}
if (error instanceof Error) {
return <div>{error.message}</div>;
}
return <div>Unknown error</div>;
}
> shadcn의 Pagination컴포넌트 추가 및 다뤄보기
shadcn의 pagination을 설치하고,
아래는 daily-leaderboard-page.tsx 하단에 쓰일 product-pagination.tsx 파일이다.
useSearchParams hook을 통해 params값을 가져올 수 있고, 해당 값들을 활용해서 pagination을 구성해줄 수 있다.
import { useSearchParams } from "react-router";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationEllipsis,
PaginationNext,
PaginationPrevious,
} from "./ui/pagination";
type ProductPaginationProps = {
totalPages: number;
};
export default function ProductPagination({
totalPages,
}: ProductPaginationProps) {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page")) ?? 1;
const onClick = (page: number) => {
searchParams.set("page", page.toString());
setSearchParams(searchParams);
};
return (
<div>
<Pagination>
<PaginationContent>
{page === 1 ? null : (
<>
<PaginationItem>
<PaginationPrevious
to={`?page=${page - 1}`}
onClick={(event) => {
event.preventDefault();
onClick(page - 1);
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
to={`?page=${page - 1}`}
onClick={(event) => {
event.preventDefault();
onClick(page - 1);
}}
>
{page - 1}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationLink
to={`?page=${page}`}
onClick={(event) => {
event.preventDefault();
onClick(page);
}}
isActive
>
{page}
</PaginationLink>
</PaginationItem>
{page === totalPages ? null : (
<>
<PaginationItem>
<PaginationLink
to={`?page=${page + 1}`}
onClick={(event) => {
event.preventDefault();
onClick(page + 1);
}}
>
{page + 1}
</PaginationLink>
</PaginationItem>
{page + 1 === totalPages ? null : (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
to={`?page=${page + 1}`}
onClick={(event) => {
event.preventDefault();
onClick(page + 1);
}}
/>
</PaginationItem>
</>
)}
</PaginationContent>
</Pagination>
</div>
);
}
**shadcn에서 설치한 기본 pagination은 <a> 인데, 우리가 원하는건 react router의 Link이기 때문에 변경해주어야 한다.
**daily-leaderboard-page.tsx와 동일하게 weekly, montly, yearly도 꾸며주자
> metaArgs
각 daily, weekly, monthly, yearly 페이지에 메타데이터를 아래와 같이 추가하자. (아래는 daily부분)
export const meta: Route.MetaFunction = ({ params }) => {
const date = DateTime.fromObject({
year: Number(params.year),
month: Number(params.month),
day: Number(params.day),
})
.setZone("Asia/Seoul")
.setLocale("ko");
return [
{
title: `The best products of ${date.toLocaleString(
DateTime.DATE_MED
)} | wemake`,
},
];
};
> SearchPage
import { z } from "zod";
import type { Route } from "./+types/search-page";
import { Hero } from "~/common/components/hero";
import { ProductCard } from "../components/product-card";
import ProductPagination from "~/common/components/product-pagination";
import { Form } from "react-router";
import { Input } from "~/common/components/ui/input";
import { Button } from "~/common/components/ui/button";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Search Products | wemake" },
{ name: "description", content: "Search for products" },
];
};
const paramsSchema = z.object({
query: z.string().optional().default(""),
page: z.coerce.number().optional().default(1),
});
export function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const { success, data: parsedData } = paramsSchema.safeParse(
Object.fromEntries(url.searchParams)
);
if (!success) {
throw new Error("Invalid params");
}
}
export default function SearchPage({ loaderData }: Route.ComponentProps) {
return (
<div className="space-y-10">
<Hero
title="Search"
subtitle="Search for products by title or description"
/>
<Form className="flex justify-center h-14 max-w-screen-sm items-center gap-2 mx-auto">
<Input
name="query"
placeholder="Search for products"
className="text-lg"
/>
<Button type="submit">Search</Button>
</Form>
<div className="space-y-5 w-full max-w-screen-md mx-auto">
{Array.from({ length: 11 }).map((_, index) => (
<ProductCard
key={`productId-${index}`}
id={`productId-${index}`}
name="Product Name"
description="Product Description"
commentsCount={12}
viewsCount={12}
votesCount={120}
/>
))}
</div>
<ProductPagination totalPages={10} />
</div>
);
}
zod를 통해 받아온 url값이 문제 없는지 검증할 수 있다. react router의 기능인 Form과 shadcn의 컴포넌트인 Input을 활용해서 search Input부분을 만들었다. 해당 Input부분에 값을 넣으면 해당 url로 이동한다.
> CategoryPage
npx shadcn@latest add label
npx shadcn@latest add textarea
npx shadcn@latest add select
> SubmitPage
--> 특이 내용 없이 코드보면 이해할 수 있으니 생략...
계속해서 추상화 작업하고, select에 들어가는 값은 db에서 가져올 예정
> Promote Product Page
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/promote-page";
import { Form } from "react-router";
import SelectPair from "~/common/components/select-pair";
import { Calendar } from "~/common/components/ui/calendar";
import { useState } from "react";
import { Label } from "~/common/components/ui/label";
import type { DateRange } from "react-day-picker";
import { DateTime } from "luxon";
import { Button } from "~/common/components/ui/button";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Promote Product | ProductHunt Clone" },
{ name: "description", content: "Promote your product" },
];
};
export default function PromotePage() {
const [promotionPeriod, setPromotionPeriod] = useState<
DateRange | undefined
>();
const totalDays =
promotionPeriod?.from && promotionPeriod.to
? DateTime.fromJSDate(promotionPeriod.to).diff(
DateTime.fromJSDate(promotionPeriod.from),
"days"
).days
: 0;
return (
<div>
<Hero
title="Promote Your Product"
subtitle="Boost your product's visibility."
/>
<Form className="max-w-sm mx-auto flex flex-col gap-10 items-center">
<SelectPair
label="Select a product"
description="Select the product you want to promote."
name="product"
placeholder="Select a product"
options={[
{
label: "AI Dark Mode Maker",
value: "ai-dark-mode-maker",
},
{
label: "AI Dark Mode Maker",
value: "ai-dark-mode-maker-1",
},
{
label: "AI Dark Mode Maker",
value: "ai-dark-mode-maker-2",
},
]}
/>
<div className="flex flex-col gap-2 items-center w-full">
<Label className="flex flex-col gap-1">
Select a range of dates for promotion{" "}
<small className="text-muted-foreground text-center ">
Minimum duration is 3 days.
</small>
</Label>
<Calendar
mode="range"
selected={promotionPeriod}
onSelect={setPromotionPeriod}
min={3}
disabled={{ before: new Date() }}
/>
</div>
<Button disabled={totalDays === 0}>
Go to checkout (${totalDays * 20})
</Button>
</Form>
</div>
);
}
shadcn의 Calendar를 활용해서, 날짜와 날짜 range를 걸고 totalDays를 구하였다. 그리고 Calendar에는 기능이 여러개 있으니 본 문서를 참고해보면 좋다. (달력 여러개 추가 가능)
> Product Overview Page
아래와 같이 레이아웃 페이지를 만들 수 있다. 아래는 route.tsx
import {
type RouteConfig,
index,
layout,
prefix,
route,
} from "@react-router/dev/routes";
export default [
index("common/pages/home-page.tsx"),
...prefix("products", [
index("features/products/pages/products-page.tsx"),
...prefix("leaderboards", [
index("features/products/pages/leaderboard-page.tsx"),
route(
"/yearly/:year",
"features/products/pages/yearly-leaderboard-page.tsx"
),
route(
"/monthly/:year/:month",
"features/products/pages/monthly-leaderboard-page.tsx"
),
route(
"/daily/:year/:month/:day",
"features/products/pages/daily-leaderboard-page.tsx"
),
route(
"/weekly/:year/:week",
"features/products/pages/weekly-leaderboard-page.tsx"
),
route(
"/:period",
"features/products/pages/leaderboards-redirection-page.tsx"
),
]),
...prefix("categories", [
index("features/products/pages/categories-page.tsx"),
route("/:category", "features/products/pages/category-page.tsx"),
]),
route("/search", "features/products/pages/search-page.tsx"),
route("/submit", "features/products/pages/submit-page.tsx"),
route("/promote", "features/products/pages/promote-page.tsx"),
...prefix("/:productId", [
index("features/products/pages/product-redirect-page.tsx"),
layout("features/products/layouts/product-overview-layout.tsx", [
route("/overview", "features/products/pages/product-overview-page.tsx"),
...prefix("/reviews", [
index("features/products/pages/product-reviews-page.tsx"),
route("/new", "features/products/pages/new-product-review-page.tsx"),
]),
]),
]),
]),
] satisfies RouteConfig;
아래는 product overview에 대한 layout 페이지인데, NavLink를 사용해서, 현재 url주소에 따라서 Link색을 변경할 수 있게 해준다.이때 Button을 사용할 수 없기 때문에, cn을 활용해주자. <outlet /> 부분에 각 페이지가 들어간다.
import { StarIcon } from "lucide-react";
import { ChevronUpIcon } from "lucide-react";
import { Link, NavLink, Outlet } from "react-router";
import { Button, buttonVariants } from "~/common/components/ui/button";
import { cn } from "~/lib/utils";
export default function ProductOverviewLayout() {
return (
<div className="space-y-10">
<div className="flex justify-between">
<div className="flex gap-10">
<div className="size-40 rounded-xl shadow-xl bg-primary/50"></div>
<div>
<h1 className="text-5xl font-bold">Product Name</h1>
<p className=" text-2xl font-light">Product description</p>
<div className="mt-5 flex items-center gap-2">
<div className="flex text-yellow-400">
{Array.from({ length: 5 }).map((_, i) => (
<StarIcon className="size-4" fill="currentColor" />
))}
</div>
<span className="text-muted-foreground ">100 reviews</span>
</div>
</div>
</div>
<div className="flex gap-5">
<Button
variant={"secondary"}
size="lg"
className="text-lg h-14 px-10"
>
Visit Website
</Button>
<Button size="lg" className="text-lg h-14 px-10">
<ChevronUpIcon className="size-4" />
Upvote (100)
</Button>
</div>
</div>
<div className="flex gap-2.5">
<NavLink
className={({ isActive }) =>
cn(
buttonVariants({ variant: "outline" }),
isActive && "bg-accent text-foreground "
)
}
to={`/products/1/overview`}
>
Overview
</NavLink>
<NavLink
className={({ isActive }) =>
cn(
buttonVariants({ variant: "outline" }),
isActive && "bg-accent text-foreground "
)
}
to={`/products/1/reviews`}
>
Reviews
</NavLink>
</div>
<div>
<Outlet />
</div>
</div>
);
}
위 내용에 맞게 overview page도 수정하였음.
> ReviewPage 추가
shadcn의 dialog를 이용할 것인데, 이부분에서 배울 점은 아래 create-review-dialog.tsx파일에서 마우스를 별 위에 hover시켰을 때 fill 되는 로직이다. 참고하자.
import { StarIcon } from "lucide-react";
import { useState } from "react";
import { Form } from "react-router";
import InputPair from "~/common/components/input-pair";
import { Button } from "~/common/components/ui/button";
import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "~/common/components/ui/dialog";
import { Label } from "~/common/components/ui/label";
export default function CreateReviewDialog() {
const [rating, setRating] = useState<number>(0);
const [hoveredStar, setHoveredStar] = useState<number>(0);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="text-2xl">
What do you think of this product?
</DialogTitle>
<DialogDescription>
Share your thoughts and experiences with this product.
</DialogDescription>
</DialogHeader>
<Form className="space-y-10">
<div>
<Label className="flex flex-col gap-1">
Rating
<small className="text-muted-foreground">
What would you rate this product?
</small>
</Label>
<div className="flex pr-2 mt-5">
{[1, 2, 3, 4, 5].map((star) => (
<label
key={star}
className="relative"
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
>
<StarIcon
className="size-5 text-yellow-400"
fill={
hoveredStar >= star || rating >= star
? "currentColor"
: "none"
}
/>
<input
type="radio"
value="star"
name="rating"
required
className="opacity-0 h-px w-px absolute"
onChange={() => setRating(star)}
/>
</label>
))}
</div>
</div>
<InputPair
textArea
required
label="Review"
description="Maximum 1000 characters"
placeholder="Tell us more about your experience with this product"
/>
<DialogFooter>
<Button type="submit">Submit review</Button>
</DialogFooter>
</Form>
</DialogContent>
);
}
> Ideas페이지 & Idea 상세 페이지 추가
특이사항 없는 내용은 별도로 적지 않겠다. 깃에 올라간 코드를 참고하자.
> JobsPage & JobPage추가
searchParams를 통해 3개의 버튼이 url에 동시에 반영되게 세팅했다. 아래코드 참고하자
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/jobs-page";
import { JobCard } from "../components/job-card";
import { Button } from "~/common/components/ui/button";
import { JOB_TYPES, LOCATION_TYPES, SALARY_RANGE } from "../constants";
import { Link, useSearchParams } from "react-router";
import { cn } from "~/lib/utils";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Jobs | wemake" },
{ name: "description", content: "Find your dream job at wemake" },
];
};
export default function JobsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const onFilterClick = (key: string, value: string) => {
searchParams.set(key, value);
setSearchParams(searchParams);
};
return (
<div className="space-y-20">
<Hero title="Jobs" subtitle="Companies looking for makers" />
<div className="grid grid-cols-6 gap-20 items-start">
<div className="grid grid-cols-3 col-span-4 gap-5">
{Array.from({ length: 11 }).map((_, index) => (
<JobCard
key={`jobId-${index}`}
id={`jobId-${index}`}
company="Tesla"
companyLogoUrl="https://github.com/facebook.png"
companyHq="San Francisco, CA"
title="Software Engineer"
postedAt="12 hours ago"
type="Full-time"
positionLocation="Remote"
salary="$100,000 - $120,000"
/>
))}
</div>
<div className="col-span-2 flex flex-col gap-10">
<div className="flex flex-col items-start gap-2.5">
<h4 className="text-sm text-muted-foreground font-bold">Type</h4>
<div className="flex flex-wrap gap-2">
{JOB_TYPES.map((type) => (
<Button
variant={"outline"}
onClick={() => onFilterClick("type", type.value)}
className={cn(
type.value === searchParams.get("type") ? "bg-accent" : ""
)}
>
{type.label}
</Button>
))}
</div>
</div>
<div className="flex flex-col items-start gap-2.5">
<h4 className="text-sm text-muted-foreground font-bold">
Location
</h4>
<div className="flex flex-wrap gap-2">
{LOCATION_TYPES.map((type) => (
<Button
variant={"outline"}
onClick={() => onFilterClick("location", type.value)}
className={cn(
type.value === searchParams.get("location")
? "bg-accent"
: ""
)}
>
{type.label}
</Button>
))}
</div>
</div>
<div className="flex flex-col items-start gap-2.5">
<h4 className="text-sm text-muted-foreground font-bold">
Salary Range
</h4>
<div className="flex flex-wrap gap-2">
{SALARY_RANGE.map((range) => (
<Button
variant={"outline"}
onClick={() => onFilterClick("salary", range)}
className={cn(
range === searchParams.get("salary") ? "bg-accent" : ""
)}
>
{range}
</Button>
))}
</div>
</div>
</div>
</div>
</div>
);
}
'코딩강의 > Maker 마스터클래스(노마드코더)' 카테고리의 다른 글
#6 Public Pages (0) | 2025.04.17 |
---|---|
#5 Data Loading Strategies (0) | 2025.04.17 |
#4 Supabase & Drizzle Database (3) | 2025.04.08 |
#2 Using CursorAI (0) | 2025.03.17 |
#1 Basics (0) | 2025.03.13 |