> products 페이지의 db값을 가져와보자
- features/products/queries.ts
import { DateTime } from "luxon";
import client from "~/supa-client";
export const getProductsByDateRange = async ({
startDate,
endDate,
limit,
}: {
startDate: DateTime;
endDate: DateTime;
limit: number;
}) => {
const { data, error } = await client
.from("products")
.select(
`
product_id,
name,
description,
upvotes:stats->>upvotes,
views:stats->>views,
reviews:stats->>reviews
`
)
.order("stats->>upvotes", { ascending: false })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO())
.limit(limit);
if (error) throw error;
return data;
};
json 값은 ->>를 통해서 가져올 수 있다.
- home-page.tsx
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 { getProductsByDateRange } from "~/features/products/queries";
import { DateTime } from "luxon";
import type { Route } from "./+types/home-page";
export const meta: MetaFunction = () => {
return [
{ title: "Home | wemake" },
{ name: "description", content: "Welcome to wemake" },
];
};
export const loader = async () => {
const products = await getProductsByDateRange({
startDate: DateTime.now().startOf("day"),
endDate: DateTime.now().endOf("day"),
limit: 7,
});
return { products };
};
export default function HomePage({ loaderData }: Route.ComponentProps) {
return (
<div className="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
</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>
{loaderData.products.map((product, index) => (
<ProductCard
key={product.product_id}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
</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={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 prefetch="viewport" 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>
);
}
home-page 부분에 적용힘
- leaderboard-page.tsx
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/leaderboard-page";
import { Button } from "~/common/components/ui/button";
import { ProductCard } from "../components/product-card";
import { Link } from "react-router";
import { getProductsByDateRange } from "../queries";
import { DateTime } from "luxon";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Leaderboards | wemake" },
{ name: "description", content: "Top products leaderboard" },
];
};
export const loader = async () => {
const [dailyProducts, weeklyProducts, monthlyProducts, yearlyProducts] =
await Promise.all([
getProductsByDateRange({
startDate: DateTime.now().startOf("day"),
endDate: DateTime.now().endOf("day"),
limit: 7,
}),
getProductsByDateRange({
startDate: DateTime.now().startOf("week"),
endDate: DateTime.now().endOf("week"),
limit: 7,
}),
getProductsByDateRange({
startDate: DateTime.now().startOf("month"),
endDate: DateTime.now().endOf("month"),
limit: 7,
}),
getProductsByDateRange({
startDate: DateTime.now().startOf("year"),
endDate: DateTime.now().endOf("year"),
limit: 7,
}),
]);
return { dailyProducts, weeklyProducts, monthlyProducts, yearlyProducts };
};
export default function LeaderboardPage({ loaderData }: Route.ComponentProps) {
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>
{loaderData.dailyProducts.map((product) => (
<ProductCard
key={product.product_id.toString()}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
<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>
{loaderData.weeklyProducts.map((product) => (
<ProductCard
key={product.product_id.toString()}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
<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>
{loaderData.monthlyProducts.map((product) => (
<ProductCard
key={product.product_id.toString()}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
<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>
{loaderData.yearlyProducts.map((product) => (
<ProductCard
key={product.product_id.toString()}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
<Button variant="link" asChild className="text-lg self-center">
<Link to="/products/leaderboards/yearly">
Explore all products →
</Link>
</Button>
</div>
</div>
);
}
로더 db값들을 병렬로 호출하였음
- product-card.tsx
import { Link } from "react-router";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import { Button } from "~/common/components/ui/button";
import { ChevronUpIcon, EyeIcon, MessageCircleIcon } from "lucide-react";
interface ProductCardProps {
id: string;
name: string;
description: string;
reviewsCount: string;
viewsCount: string;
votesCount: string;
}
export function ProductCard({
id,
name,
description,
reviewsCount,
viewsCount,
votesCount,
}: ProductCardProps) {
return (
<Link to={`/products/${id}`} className="block">
<Card className="w-full flex items-center justify-between bg-transparent hover:bg-card/50">
<CardHeader>
<CardTitle className="text-2xl font-semibold leading-none tracking-tight">
{name}
</CardTitle>
<CardDescription className="text-muted-foreground">
{description}
</CardDescription>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-px text-xs text-muted-foreground">
<MessageCircleIcon className="w-4 h-4" />
<span>{reviewsCount}</span>
</div>
<div className="flex items-center gap-px text-xs text-muted-foreground">
<EyeIcon className="w-4 h-4" />
<span>{viewsCount}</span>
</div>
</div>
</CardHeader>
<CardFooter className="py-0">
<Button variant="outline" className="flex flex-col h-14">
<ChevronUpIcon className="size-4 shrink-0" />
<span>{votesCount}</span>
</Button>
</CardFooter>
</Card>
</Link>
);
}
> 각 products의 pagination 적용하기
- routes.ts
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"),
layout("features/products/layouts/leaderboard-layout.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-product-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"),
]),
]),
]),
]),
...prefix("/ideas", [
index("features/ideas/pages/ideas-page.tsx"),
route("/:ideaId", "features/ideas/pages/idea-page.tsx"),
]),
...prefix("/jobs", [
index("features/jobs/pages/jobs-page.tsx"),
route("/:jobId", "features/jobs/pages/job-page.tsx"),
route("/submit", "features/jobs/pages/submit-job-page.tsx"),
]),
...prefix("/auth", [
layout("features/auth/layouts/auth-layout.tsx", [
route("/login", "features/auth/pages/login-page.tsx"),
route("/join", "features/auth/pages/join-page.tsx"),
...prefix("/otp", [
route("/start", "features/auth/pages/otp-start-page.tsx"),
route("/complete", "features/auth/pages/otp-complete-page.tsx"),
]),
...prefix("/social/:provider", [
route("/start", "features/auth/pages/social-start-page.tsx"),
route("/complete", "features/auth/pages/social-complete-page.tsx"),
]),
]),
]),
...prefix("/community", [
index("features/community/pages/community-page.tsx"),
route("/:postId", "features/community/pages/post-page.tsx"),
route("/submit", "features/community/pages/submit-post-page.tsx"),
]),
...prefix("/teams", [
index("features/teams/pages/teams-page.tsx"),
route("/:teamId", "features/teams/pages/team-page.tsx"),
route("/create", "features/teams/pages/submit-team-page.tsx"),
]),
...prefix("/my", [
layout("features/users/layouts/dashboard-layout.tsx", [
...prefix("/dashboard", [
index("features/users/pages/dashboard-page.tsx"),
route("/ideas", "features/users/pages/dashboard-ideas-page.tsx"),
route(
"/products/:productId",
"features/users/pages/dashboard-product-page.tsx"
),
]),
]),
layout("features/users/layouts/messages-layout.tsx", [
...prefix("/messages", [
index("features/users/pages/messages-page.tsx"),
route("/:messageId", "features/users/pages/message-page.tsx"),
]),
]),
route("/profile", "features/users/pages/my-profile-page.tsx"),
route("/settings", "features/users/pages/settings-page.tsx"),
route("/notifications", "features/users/pages/notifications-page.tsx"),
]),
layout("features/users/layouts/profile-layout.tsx", [
...prefix("/users/:username", [
index("features/users/pages/profile-page.tsx"),
route("/products", "features/users/pages/profile-products-page.tsx"),
route("/posts", "features/users/pages/profile-posts-page.tsx"),
]),
]),
] satisfies RouteConfig;
- leaderboard 부분에 레이아웃을 적용했다.
- leaderboard-layout.tsx
import { Outlet, data } from "react-router";
import { z } from "zod";
import { Route } from "./+types/leaderboard-layout";
const searchParamsSchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
});
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const { success, data: parsedData } = searchParamsSchema.safeParse(
Object.fromEntries(url.searchParams)
);
if (!success) {
throw data(
{
error_code: "invalid_page",
message: "Invalid page",
},
{ status: 400 }
);
}
};
export default function LeaderboardLayout() {
return <Outlet />;
}
leaderboard 주소로 오는 searchParams를 검증하고자 zod를 사용한 레이아웃 페이지이다.
- queries.ts
import { DateTime } from "luxon";
import client from "~/supa-client";
import { PAGE_SIZE } from "./contants";
export const getProductsByDateRange = async ({
startDate,
endDate,
limit,
page = 1,
}: {
startDate: DateTime;
endDate: DateTime;
limit: number;
page?: number;
}) => {
const { data, error } = await client
.from("products")
.select(
`
product_id,
name,
description,
upvotes:stats->>upvotes,
views:stats->>views,
reviews:stats->>reviews
`
)
.order("stats->>upvotes", { ascending: false })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO())
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getProductPagesByDateRange = async ({
startDate,
endDate,
}: {
startDate: DateTime;
endDate: DateTime;
}) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO());
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
아래 함수인 getProductPageByDateRange함수를 먼저 설명하자면,
PAGE_SIZE는 1개 페이지 안에 몇개의 데이터를 화면에 보여주는 것이다. 예를 들어 2라고 가정해보자.
그럼 product의 수가 5개면 5/2, 즉 2.5가 되고 올림처리하게 되면 총 페이지 수는 3이 되는 방법이다.
다음 getProductsByDateRange는 페이지 수와 페이지 SIZE의 값을 이용하여 어떤 데이터를 리턴해줄지 정하는 로직이다.
range는 offset과 limit값을 넣어주면 된다. 예를 들어 page가 1이고, PAGE_SIZE가 2라면
1-1 * 2 = 0 (offset 값), 1*2-1 = 1 (limit값), 즉 0~1 (총 2개) 값을 보여줄 것이다. 이어서 page가 2라면
2-1 * 2 = 2 (offset 값), 2*2-1=3(limit값), 즉 2~3 (총2개) 값을 보여줄 것이다.
- daily-leaderboard-page.tsx
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";
import { getProductPagesByDateRange, getProductsByDateRange } from "../queries";
import { PAGE_SIZE } from "../contants";
const paramsSchema = z.object({
year: z.coerce.number(),
month: z.coerce.number(),
day: z.coerce.number(),
});
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`,
},
];
};
export const loader = async ({ params, request }: 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 }
);
}
const url = new URL(request.url);
const products = await getProductsByDateRange({
startDate: date.startOf("day"),
endDate: date.endOf("day"),
limit: PAGE_SIZE,
page: Number(url.searchParams.get("page") || 1),
});
const totalPages = await getProductPagesByDateRange({
startDate: date.startOf("day"),
endDate: date.endOf("day"),
});
return {
products,
totalPages,
...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">
{loaderData.products.map((product) => (
<ProductCard
key={product.product_id}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
</div>
<ProductPagination totalPages={loaderData.totalPages} />
</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>;
}
위 방법대로, weekly, monthly, yearly 페이지를 채워준다.
> community page 꾸미기
먼저 home-page부분을 업데이트해주자
- home-page.tsx
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 { getProductsByDateRange } from "~/features/products/queries";
import { DateTime } from "luxon";
import type { Route } from "./+types/home-page";
import { getPosts } from "~/features/community/queries";
export const meta: MetaFunction = () => {
return [
{ title: "Home | wemake" },
{ name: "description", content: "Welcome to wemake" },
];
};
export const loader = async () => {
const products = await getProductsByDateRange({
startDate: DateTime.now().startOf("day"),
endDate: DateTime.now().endOf("day"),
limit: 7,
});
const posts = await getPosts({
limit: 7,
sorting: "newest",
});
return { products, posts };
};
export default function HomePage({ loaderData }: Route.ComponentProps) {
return (
<div className="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
</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>
{loaderData.products.map((product, index) => (
<ProductCard
key={product.product_id}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
</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>
{loaderData.posts.map((post) => (
<PostCard
key={post.post_id}
id={post.post_id}
title={post.title}
author={post.author}
authorAvatarUrl={post.author_avatar}
category={post.topic}
postedAt={post.created_at}
votesCount={post.upvotes}
/>
))}
</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 prefetch="viewport" 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>
);
}
getPost부분 업데이트하였음
- queries.ts
// import db from "~/db";
// import { posts, postUpvotes, topics } from "./schema";
// import { asc, count, desc, eq } from "drizzle-orm";
// import { profiles } from "../users/schema";
import { DateTime } from "luxon";
import client from "~/supa-client";
// export const getTopics = async () => {
// const allTopics = await db
// .select({
// name: topics.name,
// slug: topics.slug,
// })
// .from(topics);
// return allTopics;
// };
// export const getPosts = async () => {
// const allPosts = await db
// .select({
// id: posts.post_id,
// title: posts.title,
// createdAt: posts.created_at,
// topic: topics.name,
// author: profiles.name,
// authorAvatarUrl: profiles.avatar,
// username: profiles.username,
// upvotes: count(postUpvotes.post_id),
// })
// .from(posts)
// .innerJoin(topics, eq(posts.topic_id, topics.topic_id))
// .innerJoin(profiles, eq(posts.profile_id, profiles.profile_id))
// .leftJoin(postUpvotes, eq(posts.post_id, postUpvotes.post_id))
// .groupBy(
// posts.post_id,
// profiles.name,
// profiles.avatar,
// profiles.username,
// topics.name
// )
// .orderBy(asc(posts.post_id));
// return allPosts;
// };
export const getTopics = async () => {
// await new Promise((resolve) => setTimeout(resolve, 4000));
const { data, error } = await client.from("topics").select("name, slug");
if (error) throw new Error(error.message);
return data;
};
export const getPosts = async ({
limit,
sorting,
period = "all",
keyword,
topic,
}: {
limit: number;
sorting: "newest" | "popular";
period?: "all" | "today" | "week" | "month" | "year";
keyword?: string;
topic?: string;
}) => {
const baseQuery = client
.from("community_post_list_view")
.select(`*`)
.limit(limit);
if (sorting === "newest") {
baseQuery.order("created_at", { ascending: false });
} else if (sorting === "popular") {
if (period === "all") {
baseQuery.order("upvotes", { ascending: false });
} else {
const today = DateTime.now();
if (period === "today") {
baseQuery.gte("created_at", today.startOf("day").toISO());
} else if (period === "week") {
baseQuery.gte("created_at", today.startOf("week").toISO());
} else if (period === "month") {
baseQuery.gte("created_at", today.startOf("month").toISO());
} else if (period === "year") {
baseQuery.gte("created_at", today.startOf("year").toISO());
}
baseQuery.order("upvotes", { ascending: false });
}
}
if (keyword) {
baseQuery.ilike("title", `%${keyword}%`);
}
if (topic) {
baseQuery.eq("topic_slug", topic);
}
const { data, error } = await baseQuery;
if (error) throw new Error(error.message);
return data;
};
sorting 값에 따라서, 값을 다르게 가져오기 위해, baseQuery문을 만들어 준다. 이 때 client에서는 await해서 바로 값을 가져 오지 않는다. 그리고 sorting값이 newest면 추가 필터 없이 생성된 값 역순으로 값을 가져 오면 되고, sorting값이 popular인 경우에는 추가로 period 값에 따라서 날짜기준으로 데이터를 가져온다. 그리고 form으로 작성한 keyword값과 클릭으로 생성한 topic값에 따라서 값을 가져올수도 있다.
- community-page.tsx
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/community-page";
import { data, Form, Link, useSearchParams } from "react-router";
import { Button } from "~/common/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/common/components/ui/dropdown-menu";
import { ChevronDownIcon } from "lucide-react";
import { PERIOD_OPTIONS, SORT_OPTIONS } from "../constants";
import { Input } from "~/common/components/ui/input";
import { PostCard } from "../components/post-card";
import { getPosts, getTopics } from "../queries";
import { z } from "zod";
// import { parseDomainOfCategoryAxis } from "recharts/types/util/ChartUtils";
export const meta: Route.MetaFunction = () => {
return [{ title: "Community | wemake" }];
};
const searchParamsSchema = z.object({
sorting: z.enum(["newest", "popular"]).optional().default("newest"),
period: z
.enum(["all", "today", "week", "month", "year"])
.optional()
.default("all"),
keyword: z.string().optional(),
topic: z.string().optional(),
});
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const { success, data: parsedData } = searchParamsSchema.safeParse(
Object.fromEntries(url.searchParams)
);
if (!success) {
throw data(
{
error_code: "invalid_search_params",
message: "Invalid search params",
},
{ status: 400 }
);
}
const [topics, posts] = await Promise.all([
getTopics(),
getPosts({
limit: 20,
sorting: parsedData.sorting,
period: parsedData.period,
keyword: parsedData.keyword,
topic: parsedData.topic,
}),
]);
return { topics, posts };
};
export default function CommunityPage({ loaderData }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams();
const sorting = searchParams.get("sorting") || "newest";
const period = searchParams.get("period") || "all";
return (
<div className="space-y-20">
<Hero
title="Community"
subtitle="Ask questions, share ideas, and connect with other developers"
/>
<div className="grid grid-cols-6 items-start gap-40">
<div className="col-span-4 space-y-10">
<div className="flex justify-between">
<div className="space-y-5 w-full">
<div className="flex items-center gap-5">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1">
<span className="text-sm capitalize">{sorting}</span>
<ChevronDownIcon className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{SORT_OPTIONS.map((option) => (
<DropdownMenuCheckboxItem
className="capitalize cursor-pointer"
key={option}
onCheckedChange={(checked: boolean) => {
if (checked) {
searchParams.set("sorting", option);
setSearchParams(searchParams);
}
}}
>
{option}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{sorting === "popular" && (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1">
<span className="text-sm capitalize">{period}</span>
<ChevronDownIcon className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{PERIOD_OPTIONS.map((option) => (
<DropdownMenuCheckboxItem
className="capitalize cursor-pointer"
key={option}
onCheckedChange={(checked: boolean) => {
if (checked) {
searchParams.set("period", option);
setSearchParams(searchParams);
}
}}
>
{option}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Form className="w-2/3">
<Input
type="text"
name="keyword"
placeholder="Search for discussions"
/>
</Form>
</div>
<Button asChild>
<Link to={`/community/submit`}>Create Discussion</Link>
</Button>
</div>
<div className="space-y-5">
{loaderData.posts.map((post) => (
<PostCard
key={post.post_id}
id={post.post_id}
title={post.title}
author={post.author}
authorAvatarUrl={post.author_avatar}
category={post.topic}
postedAt={post.created_at}
votesCount={post.upvotes}
expanded
/>
))}
</div>
</div>
<aside className="col-span-2 space-y-5">
<span className="text-sm font-bold text-muted-foreground uppercase">
Topics
</span>
<div className="flex flex-col gap-2 items-start">
{loaderData.topics.map((topic) => (
<Button
asChild
variant={"link"}
key={topic.slug}
className="pl-0"
>
<Link to={`/community?topic=${topic.slug}`}>{topic.name}</Link>
</Button>
))}
</div>
</aside>
</div>
</div>
);
}
searchParams부분 잘 확인해보자.
그리고 slug값을 가져와야 하기 때문에, 아래와 같이 view값 추가. (supabase에 view 업데이트 및 npm run db:typegen 실행)
CREATE OR REPLACE VIEW community_post_list_view AS
SELECT
posts.post_id,
posts.title,
posts.created_at,
topics.name AS topic,
profiles.name AS author,
profiles.avatar AS author_avatar,
profiles.username AS author_username,
posts.upvotes,
topics.slug AS topic_slug
FROM posts
INNER JOIN topics USING (topic_id)
INNER JOIN profiles USING (profile_id);
> gpt idea page 부분을 꾸며보자
- gtp_ideas_view.sql 파일
CREATE OR REPLACE VIEW gpt_ideas_view AS
SELECT
gpt_ideas.gpt_idea_id,
gpt_ideas.idea,
gpt_ideas.views,
CASE WHEN gpt_ideas.claimed_at IS NULL THEN FALSE ELSE TRUE END AS is_claimed,
COUNT(gpt_ideas_likes.gpt_idea_id) AS likes,
gpt_ideas.created_at
FROM public.gpt_ideas
LEFT JOIN public.gpt_ideas_likes USING (gpt_idea_id)
GROUP BY gpt_ideas.gpt_idea_id;
- fatures/ideas/queries.ts
import client from "~/supa-client";
export const getGptIdeas = async ({ limit }: { limit: number }) => {
const { data, error } = await client
.from("gpt_ideas_view")
.select("*")
.limit(limit);
if (error) {
throw error;
}
return data;
};
export const getGptIdea = async (ideaId: string) => {
const { data, error } = await client
.from("gpt_ideas_view")
.select("*")
.eq("gpt_idea_id", ideaId)
.single();
if (error) {
throw error;
}
return data;
};
위에서 생성한 gpt_ideas_view 에서 값을 가져옴
- home-page.tsx
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 { getProductsByDateRange } from "~/features/products/queries";
import { DateTime } from "luxon";
import type { Route } from "./+types/home-page";
import { getPosts } from "~/features/community/queries";
import { getGptIdeas } from "~/features/ideas/queries";
export const meta: MetaFunction = () => {
return [
{ title: "Home | wemake" },
{ name: "description", content: "Welcome to wemake" },
];
};
export const loader = async () => {
const products = await getProductsByDateRange({
startDate: DateTime.now().startOf("day"),
endDate: DateTime.now().endOf("day"),
limit: 7,
});
const posts = await getPosts({
limit: 7,
sorting: "newest",
});
const ideas = await getGptIdeas({ limit: 7 });
return { products, posts, ideas };
};
export default function HomePage({ loaderData }: Route.ComponentProps) {
return (
<div className="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
</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>
{loaderData.products.map((product, index) => (
<ProductCard
key={product.product_id}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
</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>
{loaderData.posts.map((post) => (
<PostCard
key={post.post_id}
id={post.post_id}
title={post.title}
author={post.author}
authorAvatarUrl={post.author_avatar}
category={post.topic}
postedAt={post.created_at}
votesCount={post.upvotes}
/>
))}
</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>
{loaderData.ideas.map((idea) => (
<IdeaCard
key={idea.gpt_idea_id}
id={idea.gpt_idea_id}
title={idea.idea}
viewsCount={idea.views}
postedAt={idea.created_at}
likesCount={idea.likes}
claimed={idea.is_claimed}
/>
))}
</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 prefetch="viewport" 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>
);
}
gpt idea 부분 업데이트
- supa-client.ts
import { createClient } from "@supabase/supabase-js";
import type { MergeDeep, SetNonNullable, SetFieldType } from "type-fest";
import type { Database as SupabaseDatabase } from "database.types";
type Database = MergeDeep<
SupabaseDatabase,
{
public: {
Views: {
community_post_list_view: {
Row: SetFieldType<
SetNonNullable<
SupabaseDatabase["public"]["Views"]["community_post_list_view"]["Row"]
>,
"author_avatar",
string | null
>;
};
gpt_ideas_view: {
Row: SetNonNullable<
SupabaseDatabase["public"]["Views"]["gpt_ideas_view"]["Row"]
>;
};
};
};
}
>;
const client = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
export default client;
새롭게 생성한 view에 대해서 notNull 타입 지정
-idea-card.tsx
import { Link } from "react-router";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import { Button } from "~/common/components/ui/button";
import { DotIcon, EyeIcon, HeartIcon, LockIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { DateTime } from "luxon";
interface IdeaCardProps {
id: number;
title: string;
viewsCount: number;
postedAt: string;
likesCount: number;
claimed?: boolean;
}
export function IdeaCard({
id,
title,
viewsCount,
postedAt,
likesCount,
claimed,
}: IdeaCardProps) {
return (
<Card className="bg-transparent hover:bg-card/50 transition-colors">
<CardHeader>
<Link to={`/ideas/${id}`}>
<CardTitle className="text-xl">
<span
className={cn(
claimed
? "bg-muted-foreground selection:bg-muted-foreground text-muted-foreground"
: ""
)}
>
{title}
</span>
</CardTitle>
</Link>
</CardHeader>
<CardContent className="flex items-center text-sm">
<div className="flex items-center gap-1">
<EyeIcon className="w-4 h-4" />
<span>{viewsCount}</span>
</div>
<DotIcon className="w-4 h-4" />
<span>{DateTime.fromISO(postedAt).toRelative()}</span>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button variant="outline">
<HeartIcon className="w-4 h-4" />
<span>{likesCount}</span>
</Button>
{!claimed ? (
<Button asChild>
<Link to={`/ideas/${id}/claim`}>Claim idea now →</Link>
</Button>
) : (
<Button variant="outline" disabled className="cursor-not-allowed">
<LockIcon className="size-4" />
Claimed
</Button>
)}
</CardFooter>
</Card>
);
}
- ideas-page.tsx
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/ideas-page";
import { IdeaCard } from "../components/idea-card";
import { getGptIdeas } from "../queries";
export const meta: Route.MetaFunction = () => {
return [
{ title: "IdeasGPT | wemake" },
{ name: "description", content: "Find ideas for your next project" },
];
};
export const loader = async () => {
const ideas = await getGptIdeas({ limit: 20 });
return { ideas };
};
export default function IdeasPage({ loaderData }: Route.ComponentProps) {
return (
<div className="space-y-20">
<Hero title="IdeasGPT" subtitle="Find ideas for your next project" />
<div className="grid grid-cols-4 gap-4">
{loaderData.ideas.map((idea) => (
<IdeaCard
key={idea.gpt_idea_id}
id={idea.gpt_idea_id}
title={idea.idea}
viewsCount={idea.views}
postedAt={idea.created_at}
likesCount={idea.likes}
claimed={idea.is_claimed}
/>
))}
</div>
</div>
);
}
- idea-page.tsx
import { DotIcon, HeartIcon } from "lucide-react";
import { EyeIcon } from "lucide-react";
import { Hero } from "~/common/components/hero";
import { Button } from "~/common/components/ui/button";
import type { Route } from "./+types/idea-page";
import { getGptIdea } from "../queries";
import { DateTime } from "luxon";
export const meta = ({
data: {
idea: { gpt_idea_id, idea },
},
}: Route.MetaArgs) => {
return [
{ title: `Idea #${gpt_idea_id}: ${idea} | wemake` },
{ name: "description", content: "Find ideas for your next project" },
];
};
export const loader = async ({ params }: Route.LoaderArgs) => {
const idea = await getGptIdea(params.ideaId);
return { idea };
};
export default function IdeaPage({ loaderData }: Route.ComponentProps) {
return (
<div className="">
<Hero title={`Idea #${loaderData.idea.gpt_idea_id}`} />
<div className="max-w-screen-sm mx-auto flex flex-col items-center gap-10">
<p className="italic text-center">"{loaderData.idea.idea}"</p>
<div className="flex items-center text-sm">
<div className="flex items-center gap-1">
<EyeIcon className="w-4 h-4" />
<span>{loaderData.idea.views}</span>
</div>
<DotIcon className="w-4 h-4" />
<span>
{DateTime.fromISO(loaderData.idea.created_at).toRelative()}
</span>
<DotIcon className="w-4 h-4" />
<Button variant="outline">
<HeartIcon className="w-4 h-4" />
<span>{loaderData.idea.likes}</span>
</Button>
</div>
<Button size="lg">Claim idea now →</Button>
</div>
</div>
);
}
메타데이터에 이미 불러온 db값을 넣을 수 있다. (db 2번 호출 안해도 됨)
> Jobs 페이지를 꾸며보자
- jobs/queries.ts
import client from "~/supa-client";
export const getJobs = async ({
limit,
location,
type,
salary,
}: {
limit: number;
location?: string;
type?: string;
salary?: string;
}) => {
const baseQuery = client
.from("jobs")
.select(
`
job_id,
position,
overview,
company_name,
company_logo,
company_location,
job_type,
location,
salary_range,
created_at
`
)
.limit(limit);
if (location) {
baseQuery.eq("location", location);
}
if (type) {
baseQuery.eq("job_type", type);
}
if (salary) {
baseQuery.eq("salary_range", salary);
}
const { data, error } = await baseQuery;
if (error) {
throw error;
}
return data;
};
- home-page.tsx
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 { getProductsByDateRange } from "~/features/products/queries";
import { DateTime } from "luxon";
import type { Route } from "./+types/home-page";
import { getPosts } from "~/features/community/queries";
import { getGptIdeas } from "~/features/ideas/queries";
import { getJobs } from "~/features/jobs/queries";
export const meta: MetaFunction = () => {
return [
{ title: "Home | wemake" },
{ name: "description", content: "Welcome to wemake" },
];
};
export const loader = async () => {
const products = await getProductsByDateRange({
startDate: DateTime.now().startOf("day"),
endDate: DateTime.now().endOf("day"),
limit: 7,
});
const posts = await getPosts({
limit: 7,
sorting: "newest",
});
const ideas = await getGptIdeas({ limit: 7 });
const jobs = await getJobs({ limit: 11 });
return { products, posts, ideas, jobs };
};
export default function HomePage({ loaderData }: Route.ComponentProps) {
return (
<div className="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
</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>
{loaderData.products.map((product, index) => (
<ProductCard
key={product.product_id}
id={product.product_id.toString()}
name={product.name}
description={product.description}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
</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>
{loaderData.posts.map((post) => (
<PostCard
key={post.post_id}
id={post.post_id}
title={post.title}
author={post.author}
authorAvatarUrl={post.author_avatar}
category={post.topic}
postedAt={post.created_at}
votesCount={post.upvotes}
/>
))}
</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>
{loaderData.ideas.map((idea) => (
<IdeaCard
key={idea.gpt_idea_id}
id={idea.gpt_idea_id}
title={idea.idea}
viewsCount={idea.views}
postedAt={idea.created_at}
likesCount={idea.likes}
claimed={idea.is_claimed}
/>
))}
</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>
{loaderData.jobs.map((job) => (
<JobCard
key={job.job_id}
id={job.job_id}
company={job.company_name}
companyLogoUrl={job.company_logo}
companyHq={job.company_location}
title={job.position}
postedAt={job.created_at}
type={job.job_type}
positionLocation={job.location}
salary={job.salary_range}
/>
))}
</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 prefetch="viewport" 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>
);
}
- job-card.tsx
import { Link } from "react-router";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import { Button } from "~/common/components/ui/button";
import { Badge } from "~/common/components/ui/badge";
import { DateTime } from "luxon";
interface JobCardProps {
id: number;
company: string;
companyLogoUrl: string;
companyHq: string;
title: string;
postedAt: string;
type: string;
positionLocation: string;
salary: string;
}
export function JobCard({
id,
company,
companyLogoUrl,
companyHq,
title,
postedAt,
type,
positionLocation,
salary,
}: JobCardProps) {
return (
<Link to={`/jobs/${id}`}>
<Card className="bg-transparent transition-colors hover:bg-card/50">
<CardHeader>
<div className="flex items-center gap-4 mb-4">
<img
src={companyLogoUrl}
alt={`${company} Logo`}
className="size-10 rounded-full"
/>
<div className="space-x-2">
<span className="text-accent-foreground">{company}</span>
<span className="text-xs text-muted-foreground">
{DateTime.fromISO(postedAt).toRelative()}
</span>
</div>
</div>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="outline" className="capitalize">
{type}
</Badge>
<Badge variant="outline" className="capitalize">
{positionLocation}
</Badge>
</CardContent>
<CardFooter className="flex justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium text-muted-foreground">
{salary}
</span>
<span className="text-sm font-medium text-muted-foreground">
{companyHq}
</span>
</div>
<Button variant="secondary" size="sm">
Apply now
</Button>
</CardFooter>
</Card>
</Link>
);
}
- jobs-page.tsx
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 { data, Link, useSearchParams } from "react-router";
import { cn } from "~/lib/utils";
import { getJobs } from "../queries";
import { z } from "zod";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Jobs | wemake" },
{ name: "description", content: "Find your dream job at wemake" },
];
};
const searchParamsSchema = z.object({
type: z
.enum(JOB_TYPES.map((type) => type.value) as [string, ...string[]])
.optional(),
location: z
.enum(LOCATION_TYPES.map((type) => type.value) as [string, ...string[]])
.optional(),
salary: z.enum(SALARY_RANGE).optional(),
});
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const { success, data: parsedData } = searchParamsSchema.safeParse(
Object.fromEntries(url.searchParams)
);
if (!success) {
throw data(
{
error_code: "invalid_search_params",
message: "Invalid search params",
},
{ status: 400 }
);
}
const jobs = await getJobs({
limit: 40,
location: parsedData.location,
type: parsedData.type,
salary: parsedData.salary,
});
return { jobs };
};
export default function JobsPage({ loaderData }: Route.ComponentProps) {
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-1 xl:grid-cols-6 gap-20 items-start">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:col-span-4 gap-5">
{loaderData.jobs.map((job) => (
<JobCard
key={job.job_id}
id={job.job_id}
company={job.company_name}
companyLogoUrl={job.company_logo}
companyHq={job.company_location}
title={job.position}
postedAt={job.created_at}
type={job.job_type}
positionLocation={job.location}
salary={job.salary_range}
/>
))}
</div>
<div className="xl:col-span-2 sticky top-20 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>
);
}
> team page를 꾸며보자
- queries.ts
import client from "~/supa-client";
export const getTeams = async ({ limit }: { limit: number }) => {
const { data, error } = await client
.from("teams")
.select(
`
team_id,
roles,
product_description,
team_leader:profiles!inner(
username,
avatar
)
`
)
.limit(limit);
if (error) {
throw error;
}
return data;
};
이번에는 view가 아닌 바로 supabase client를 사용함
- team-card및 teams-page 업데이트 (생략)
> category 화면을 꾸며보자
- queries.ts
import { DateTime } from "luxon";
import client from "~/supa-client";
import { PAGE_SIZE } from "./contants";
const productListSelect = `
product_id,
name,
description,
upvotes:stats->>upvotes,
views:stats->>views,
reviews:stats->>reviews
`;
export const getProductsByDateRange = async ({
startDate,
endDate,
limit,
page = 1,
}: {
startDate: DateTime;
endDate: DateTime;
limit: number;
page?: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.order("stats->>upvotes", { ascending: false })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO())
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getProductPagesByDateRange = async ({
startDate,
endDate,
}: {
startDate: DateTime;
endDate: DateTime;
}) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO());
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
export const getCategories = async () => {
const { data, error } = await client
.from("categories")
.select("category_id, name, description");
if (error) throw error;
return data;
};
export const getCategory = async (categoryId: number) => {
const { data, error } = await client
.from("categories")
.select("category_id, name, description")
.eq("category_id", categoryId)
.single();
if (error) throw error;
return data;
};
export const getProductsByCategory = async ({
categoryId,
page,
}: {
categoryId: number;
page: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.eq("category_id", categoryId)
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getCategoryPages = async (categoryId: number) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.eq("category_id", categoryId);
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
추가적으로 categories-page, category-page 등 업데이트함
> Product search 페이지를 꾸며보자
- queries.ts
import { DateTime } from "luxon";
import client from "~/supa-client";
import { PAGE_SIZE } from "./contants";
const productListSelect = `
product_id,
name,
tagline,
upvotes:stats->>upvotes,
views:stats->>views,
reviews:stats->>reviews
`;
export const getProductsByDateRange = async ({
startDate,
endDate,
limit,
page = 1,
}: {
startDate: DateTime;
endDate: DateTime;
limit: number;
page?: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.order("stats->>upvotes", { ascending: false })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO())
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getProductPagesByDateRange = async ({
startDate,
endDate,
}: {
startDate: DateTime;
endDate: DateTime;
}) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO());
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
export const getCategories = async () => {
const { data, error } = await client
.from("categories")
.select("category_id, name, description");
if (error) throw error;
return data;
};
export const getCategory = async (categoryId: number) => {
const { data, error } = await client
.from("categories")
.select("category_id, name, description")
.eq("category_id", categoryId)
.single();
if (error) throw error;
return data;
};
export const getProductsByCategory = async ({
categoryId,
page,
}: {
categoryId: number;
page: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.eq("category_id", categoryId)
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getCategoryPages = async (categoryId: number) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.eq("category_id", categoryId);
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
export const getProductsBySearch = async ({
query,
page,
}: {
query: string;
page: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.or(`name.ilike.%${query}%, tagline.ilike.%${query}%`)
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getPagesBySearch = async ({ query }: { query: string }) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.or(`name.ilike.%${query}%, tagline.ilike.%${query}%`);
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
product search 부분을 보면 query문에 대해서, or을 통해 name이나 tagline을 조회 할 수 있다.
- search-page.tsx
import { z } from "zod";
import { 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";
import { getProductsBySearch, getPagesBySearch } from "../queries";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Search Products | wemake" },
{ name: "description", content: "Search for products" },
];
};
const searchParams = z.object({
query: z.string().optional().default(""),
page: z.coerce.number().optional().default(1),
});
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const { success, data: parsedData } = searchParams.safeParse(
Object.fromEntries(url.searchParams)
);
if (!success) {
throw new Error("Invalid params");
}
if (parsedData.query === "") {
return { products: [], totalPages: 1 };
}
const products = await getProductsBySearch({
query: parsedData.query,
page: parsedData.page,
});
const totalPages = await getPagesBySearch({ query: parsedData.query });
return { products, totalPages };
}
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">
{loaderData.products.map((product) => (
<ProductCard
key={product.product_id}
id={product.product_id}
name={product.name}
description={product.tagline}
reviewsCount={product.reviews}
viewsCount={product.views}
votesCount={product.upvotes}
/>
))}
</div>
<ProductPagination totalPages={loaderData.totalPages} />
</div>
);
}
> PRODUCT 상세 페이지를 꾸며보자
- view 파일생성
CREATE OR REPLACE VIEW product_overview_view AS
SELECT
product_id,
name,
tagline,
description,
how_it_works,
icon,
url,
stats->>'upvotes' AS upvotes,
stats->>'views' AS views,
stats->>'reviews' AS reviews,
AVG(product_reviews.rating) AS average_rating
FROM public.products
LEFT JOIN public.reviews AS product_reviews USING (product_id)
GROUP BY product_id;
- queries.ts
import { DateTime } from "luxon";
import client from "~/supa-client";
import { PAGE_SIZE } from "./contants";
const productListSelect = `
product_id,
name,
tagline,
upvotes:stats->>upvotes,
views:stats->>views,
reviews:stats->>reviews
`;
export const getProductsByDateRange = async ({
startDate,
endDate,
limit,
page = 1,
}: {
startDate: DateTime;
endDate: DateTime;
limit: number;
page?: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.order("stats->>upvotes", { ascending: false })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO())
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getProductPagesByDateRange = async ({
startDate,
endDate,
}: {
startDate: DateTime;
endDate: DateTime;
}) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.gte("created_at", startDate.toISO())
.lte("created_at", endDate.toISO());
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
export const getCategories = async () => {
const { data, error } = await client
.from("categories")
.select("category_id, name, description");
if (error) throw error;
return data;
};
export const getCategory = async (categoryId: number) => {
const { data, error } = await client
.from("categories")
.select("category_id, name, description")
.eq("category_id", categoryId)
.single();
if (error) throw error;
return data;
};
export const getProductsByCategory = async ({
categoryId,
page,
}: {
categoryId: number;
page: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.eq("category_id", categoryId)
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getCategoryPages = async (categoryId: number) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.eq("category_id", categoryId);
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
export const getProductsBySearch = async ({
query,
page,
}: {
query: string;
page: number;
}) => {
const { data, error } = await client
.from("products")
.select(productListSelect)
.or(`name.ilike.%${query}%, tagline.ilike.%${query}%`)
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
if (error) throw error;
return data;
};
export const getPagesBySearch = async ({ query }: { query: string }) => {
const { count, error } = await client
.from("products")
.select(`product_id`, { count: "exact", head: true })
.or(`name.ilike.%${query}%, tagline.ilike.%${query}%`);
if (error) throw error;
if (!count) return 1;
return Math.ceil(count / PAGE_SIZE);
};
export const getProductById = async (productId: string) => {
const { data, error } = await client
.from("product_overview_view")
.select("*")
.eq("product_id", productId)
.single();
if (error) throw error;
return data;
};
getProductId로 데이터 받아오기
- product-overview-layout.tsx
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";
import type { Route } from "./+types/product-overview-layout";
import { getProductById } from "../queries";
export function meta({ data }: Route.MetaArgs) {
return [
{ title: `${data.product.name} Overview | wemake` },
{ name: "description", content: "View product details and information" },
];
}
export const loader = async ({
params,
}: Route.LoaderArgs & { params: { productId: string } }) => {
const product = await getProductById(params.productId);
return { product };
};
export default function ProductOverviewLayout({
loaderData,
}: Route.ComponentProps) {
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">
<img
src={loaderData.product.icon}
alt={loaderData.product.name}
className="size-full object-cover"
/>
</div>
<div>
<h1 className="text-5xl font-bold">{loaderData.product.name}</h1>
<p className=" text-2xl font-light">{loaderData.product.tagline}</p>
<div className="mt-5 flex items-center gap-2">
<div className="flex text-yellow-400">
{Array.from({ length: 5 }).map((_, i) => (
<StarIcon
key={i}
className="size-4"
fill={
i < Math.floor(loaderData.product.average_rating)
? "currentColor"
: "none"
}
/>
))}
</div>
<span className="text-muted-foreground ">
{loaderData.product.reviews} 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 ({loaderData.product.upvotes})
</Button>
</div>
</div>
<div className="flex gap-2.5">
<NavLink
end
className={({ isActive }) =>
cn(
buttonVariants({ variant: "outline" }),
isActive && "bg-accent text-foreground "
)
}
to={`/products/${loaderData.product.product_id}/overview`}
>
Overview
</NavLink>
<NavLink
className={({ isActive }) =>
cn(
buttonVariants({ variant: "outline" }),
isActive && "bg-accent text-foreground "
)
}
to={`/products/${loaderData.product.product_id}/reviews`}
>
Reviews
</NavLink>
</div>
<div>
<Outlet
context={{
product_id: loaderData.product.product_id,
description: loaderData.product.description,
how_it_works: loaderData.product.how_it_works,
}}
/>
</div>
</div>
);
}
레이아웃 파일에서 loader부분 타입지정은 이부분만 params에 대해 타입을 지정해준다. (버그라고 한다)
자식 페이지 (Outlet)에게 데이터를 넘겨줄때는 context를 쓰면 된다.
- product-overview-page.tsx
import { useOutletContext } from "react-router";
import type { Route } from "./+types/product-overview-page";
export default function ProductOverviewPage() {
const { description, how_it_works } = useOutletContext<{
description: string;
how_it_works: string;
}>();
return (
<div className="space-y-10">
<div className="space-y-1">
<h3 className="text-lg font-bold">What is this product?</h3>
<p className="text-muted-foreground">{description}</p>
</div>
<div className="space-y-1">
<h3 className="text-lg font-bold">How does it work?</h3>
<p className="text-muted-foreground">{how_it_works}</p>
</div>
</div>
);
}
레이아웃에서 받아온 값을 쓰려면 useOutletContext 훅을 쓰면 된다.
- queries.ts
export const getReviews = async (productId: string) => {
const { data, error } = await client
.from("reviews")
.select(
`
review_id,
rating,
review,
created_at,
user:profiles!inner(
name,username,avatar
)
`
)
.eq("product_id", productId);
if (error) throw error;
return data;
};
이거 외에 review관련된거 업데이트 및 수정 (생략)
> 기타 POST PAGE들 및 REPLY 부분 추가 (생략)
- 로직들이 비슷하기 때문에 굳이 안쓰고 생략 하는 것임
> Profile 페이지 꾸미기
- schema.ts
export const products = pgTable(
"products",
{
product_id: bigint({ mode: "number" })
.primaryKey()
.generatedAlwaysAsIdentity(),
name: text().notNull(),
tagline: text().notNull(),
description: text().notNull(),
how_it_works: text().notNull(),
icon: text().notNull(),
url: text().notNull(),
stats: jsonb().notNull().default({ views: 0, reviews: 0, upvotes: 0 }),
profile_id: uuid().notNull(),
category_id: bigint({ mode: "number" }).references(
() => categories.category_id,
{ onDelete: "set null" }
),
created_at: timestamp().notNull().defaultNow(),
updated_at: timestamp().notNull().defaultNow(),
},
(table) => [
foreignKey({
columns: [table.profile_id],
foreignColumns: [profiles.profile_id],
name: "products_to_profiles",
}).onDelete("cascade"),
]
);
fk를 연결 할 때, reference를 해도 되지만, 그 방법 외에 위 profile_id를 보면 reference()를 안쓰고, 밑에 foreignkey 부분을 사용함으로써 name을 직접 지정 할 수 있다. (근데 굳이 이렇게 할 필요는 없을듯?)
- queries.ts
import client from "~/supa-client";
import { productListSelect } from "../products/queries";
export const getUserProfile = async (username: string) => {
const { data, error } = await client
.from("profiles")
.select(
`
profile_id,
name,
username,
avatar,
role,
headline,
bio
`
)
.eq("username", username)
.single();
if (error) {
throw error;
}
return data;
};
export const getUserProducts = async (username: string) => {
const { data, error } = await client
.from("products")
.select(
`
${productListSelect},
profiles!products_to_profiles!inner (
profile_id
)
`
)
.eq("profiles.username", username);
if (error) {
throw error;
}
return data;
};
export const getUserPosts = async (username: string) => {
const { data, error } = await client
.from("posts")
.select("*")
.eq("username", username);
if (error) {
throw error;
}
return data;
};
products 테이블에는 profile_id를 받아오려면 upvote에서 받아오거나, products에서 받아올 수 있는데, 이거를 직접 입력해주어야 한다. 위에서 새로 만든 products_to_profiles로 지정해주었다.
나머지 layout이나 page 부분은 생략
> RPCs (Supabase에서 RPC는 Remote Procedure Call의 약자야. 쉽게 말하면, 데이터베이스 안에 저장된 함수(Stored Procedure)를 호출해서 실행하는 방식). 보통 sql function은 복잡한 sql 구문을 사용 해야 할 때 쓰인다고 함.
RPC를 활용해서, 특정 페이지를 누군가 방문 했을 때 db 테이블에 그 기록이 남게 해보자. (조회수, 프로필 방문 횟수 같은 것들을 확인하거나, event를 추가해서, 특정 유저에게 notification 줄 수 있을 것 같다.)
- features/analytics/schema.ts
import { jsonb, pgEnum, pgTable, timestamp, uuid } from "drizzle-orm/pg-core";
export const eventType = pgEnum("event_type", [
"product_view",
"product_visit",
"profile_view",
]);
export const events = pgTable("events", {
event_id: uuid("event_id").primaryKey().defaultRandom(),
event_type: eventType("event_type"),
event_data: jsonb("event_data"),
created_at: timestamp("created_at").defaultNow(),
});
events 테이블 생성 및 supabase db에 반영
- track_event.sql
create or replace function track_event(
event_type event_type,
event_data jsonb
) returns void as $$
begin
insert into events (event_type, event_data) values (event_type, event_data);
end;
$$ language plpgsql;
supabase function에 추가. 해당 함수를 밑에 RPC로 호출할 것이다.
- product-overview-layout.tsx
<Link to={`/products/${loaderData.product.product_id}/visit`}>
Visit Website
</Link>
특정 상품 웹페이지 방문 링크는 위와 같고, 해당 url로 가게 되면, 먼저 아래 페이지를 먼저 거쳐야 한다.
- product-visit-page.tsx
import client from "~/supa-client";
import { Route } from "./+types/product-visit-page";
import { redirect } from "react-router";
export const loader = async ({ params }: Route.LoaderArgs) => {
const { error, data } = await client
.from("products")
.select("url")
.eq("product_id", params.productId)
.single();
if (data) {
await client.rpc("track_event", {
event_type: "product_visit",
event_data: {
product_id: params.productId,
},
});
return redirect(data.url);
}
};
해당 페이지를 먼저 방문 후, RPC로 업데이트 한 후, 원래 가야할 url 주소로 리다이렉트 해주는 구조다. 그럼 아래와 같이 테이블에 기록이 남는다. (event_type : product_visit) 이와 같은 방법으로, profile방문이나, product overview 페이지 방문시에 테이블을 업데이트해줄 수 있다. (여기에는 생략)
'코딩강의 > Maker 마스터클래스(노마드코더)' 카테고리의 다른 글
#8 Private Pages (0) | 2025.05.02 |
---|---|
#7 Authentication (0) | 2025.04.28 |
#5 Data Loading Strategies (0) | 2025.04.17 |
#4 Supabase & Drizzle Database (3) | 2025.04.08 |
#3 UI with Cursor & Shadcn (0) | 2025.03.18 |