#9 Fetchers
> Fetcher란?
기존에 loader와 action을 사용하기 위해서는 url 주소에 의존해야 했다. 하지만 fetcher를 쓰면 화면 어디에서든지 자유롭게 loader와 action을 사용 할 수 있다. (notification이나, upvote에 쓰기 좋음)
> Uplovtes 버튼 작동
- routes.ts
route("/:postId/upvote", "features/community/pages/upvote-post-page.tsx"),
추가
- post-page.tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "~/common/components/ui/breadcrumb";
import type { Route } from "./+types/post-page";
import { Form, Link, useFetcher, useOutletContext } from "react-router";
import { ChevronUpIcon, DotIcon } from "lucide-react";
import { Button } from "~/common/components/ui/button";
import { Textarea } from "~/common/components/ui/textarea";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { Badge } from "~/common/components/ui/badge";
import { Reply } from "~/features/community/components/reply";
import { getPostById, getReplies } from "../queries";
import { createReply } from "../mutations";
import { DateTime } from "luxon";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { z } from "zod";
import { useEffect, useRef } from "react";
import { cn } from "~/lib/utils";
export const meta: Route.MetaFunction = ({ data }) => {
return [{ title: `${data.post.title} on ${data.post.topic_name} | wemake` }];
};
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { client, headers } = makeSSRClient(request);
const post = await getPostById(client, { postId: params.postId });
const replies = await getReplies(client, { postId: params.postId });
return { post, replies };
};
const formSchema = z.object({
reply: z.string().min(1),
topLevelId: z.coerce.number().optional(),
});
export const action = async ({ request, params }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const { success, error, data } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return {
formErrors: error.flatten().fieldErrors,
};
}
const { reply, topLevelId } = data;
await createReply(client, {
postId: params.postId,
reply,
userId,
topLevelId,
});
return {
ok: true,
};
};
export default function PostPage({
loaderData,
actionData,
}: Route.ComponentProps) {
const fetcher = useFetcher();
const { isLoggedIn, name, username, avatar } = useOutletContext<{
isLoggedIn: boolean;
name?: string;
username?: string;
avatar?: string;
}>();
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (actionData?.ok) {
formRef.current?.reset();
}
}, [actionData?.ok]);
return (
<div className="space-y-10">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/community">Community</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/community?topic=${loaderData.post.topic_slug}`}>
{loaderData.post.topic_name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/community/postId`}>{loaderData.post.title}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-6 gap-40 items-start">
<div className="col-span-4 space-y-10">
<div className="flex w-full items-start gap-10">
<fetcher.Form
method="post"
action={`/community/${loaderData.post.post_id}/upvote`}
>
<input
type="hidden"
name="postId"
value={loaderData.post.post_id}
/>
<Button
variant="outline"
className={cn(
"flex flex-col h-14",
loaderData.post.is_upvoted
? "border-primary text-primary"
: ""
)}
>
<ChevronUpIcon className="size-4 shrink-0" />
<span>{loaderData.post.upvotes}</span>
</Button>
</fetcher.Form>
<div className="space-y-20 w-full">
<div className="space-y-2">
<h2 className="text-3xl font-bold">{loaderData.post.title}</h2>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{loaderData.post.author_name}</span>
<DotIcon className="size-5" />
<span>
{DateTime.fromISO(loaderData.post.created_at, {
zone: "utc",
}).toRelative()}
</span>
<DotIcon className="size-5" />
<span>{loaderData.post.replies} replies</span>
</div>
<p className="text-muted-foreground w-3/4">
{loaderData.post.content}
</p>
</div>
{isLoggedIn ? (
<Form
ref={formRef}
className="flex items-start gap-5 w-3/4"
method="post"
>
<Avatar className="size-14">
<AvatarFallback>{name?.[0]}</AvatarFallback>
<AvatarImage src={avatar} />
</Avatar>
<div className="flex flex-col gap-5 items-end w-full">
<Textarea
name="reply"
placeholder="Write a reply"
className="w-full resize-none"
rows={5}
/>
<Button>Reply</Button>
</div>
</Form>
) : null}
<div className="space-y-10">
<h4 className="font-semibold">
{loaderData.post.replies} Replies
</h4>
<div className="flex flex-col gap-5">
{loaderData.replies.map((reply) => (
<Reply
name={reply.user.name}
username={reply.user.username}
avatarUrl={reply.user.avatar}
content={reply.reply}
timestamp={reply.created_at}
topLevel={true}
topLevelId={reply.post_reply_id}
replies={reply.post_replies}
/>
))}
</div>
</div>
</div>
</div>
</div>
<aside className="col-span-2 space-y-5 border rounded-lg p-6 shadow-sm">
<div className="flex gap-5">
<Avatar className="size-14">
<AvatarFallback>{loaderData.post.author_name[0]}</AvatarFallback>
{loaderData.post.author_avatar ? (
<AvatarImage src={loaderData.post.author_avatar} />
) : null}
</Avatar>
<div className="flex flex-col items-start">
<h4 className="text-lg font-medium">
{loaderData.post.author_name}
</h4>
<Badge variant="secondary" className="capitalize">
{loaderData.post.author_role}
</Badge>
</div>
</div>
<div className="gap-2 text-sm flex flex-col">
<span>
🎂 Joined{" "}
{DateTime.fromISO(loaderData.post.author_created_at, {
zone: "utc",
}).toRelative()}{" "}
ago
</span>
<span>🚀 Launched {loaderData.post.products} products</span>
</div>
<Button variant="outline" className="w-full">
Follow
</Button>
</aside>
</div>
</div>
);
}
useFetcher를 사용함
로직은 fetcher.form의 action값의 url이 작동되고 아래 upvote-post-page.tsx의 action이 실행됨
- upvote-post-page.tsx
import type { Route } from "./+types/upvote-post-page";
export const action = async ({ request, params }: Route.ActionArgs) => {
const formData = await request.formData();
const postId = formData.get("postId");
console.log(postId);
return {
ok: true,
};
};
위 fetcher.form의 name값을 가져올 수 있음.
fetcher.form 대신에 fetcher.submit을 사용하면 더 간단하게 구현 가능하다.
- post-card.tsx
import { Link, useFetcher } from "react-router";
import {
Card,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import {
Avatar,
AvatarImage,
AvatarFallback,
} from "~/common/components/ui/avatar";
import { Button } from "~/common/components/ui/button";
import { ChevronUpIcon, DotIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { DateTime } from "luxon";
interface PostCardProps {
id: number;
title: string;
author: string;
authorAvatarUrl: string | null;
category: string;
postedAt: string;
expanded?: boolean;
votesCount?: number;
isUpvoted?: boolean;
}
export function PostCard({
id,
title,
author,
authorAvatarUrl,
category,
postedAt,
expanded = false,
votesCount = 0,
isUpvoted = false,
}: PostCardProps) {
const fetcher = useFetcher();
const absorbClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
fetcher.submit(
{
postId: id,
},
{
method: "POST",
action: `/community/${id}/upvote`,
}
);
};
return (
<Link to={`/community/${id}`} className="block">
<Card
className={cn(
"bg-transparent hover:bg-card/50 transition-colors",
expanded ? "flex flex-row items-center justify-between" : ""
)}
>
<CardHeader className="flex flex-row items-center gap-2">
<Avatar className="size-14">
<AvatarFallback>{author[0]}</AvatarFallback>
{authorAvatarUrl && <AvatarImage src={authorAvatarUrl} />}
</Avatar>
<div className="space-y-2">
<CardTitle>{title}</CardTitle>
<div className="flex gap-2 text-sm leading-tight text-muted-foreground">
<span>
{author} on {category}
</span>
<DotIcon className="w-4 h-4" />
<span>{DateTime.fromISO(postedAt).toRelative()}</span>
</div>
</div>
</CardHeader>
{!expanded && (
<CardFooter className="flex justify-end">
<Button variant="link">Reply →</Button>
</CardFooter>
)}
{expanded && (
<CardFooter className="flex justify-end pb-0">
<Button
onClick={absorbClick}
variant="outline"
className={cn(
"flex flex-col h-14",
isUpvoted ? "border-primary text-primary" : ""
)}
>
<ChevronUpIcon className="size-4 shrink-0" />
<span>{votesCount}</span>
</Button>
</CardFooter>
)}
</Card>
</Link>
);
}
form&input 사용없이 바로 submit을 통해 action으로 값을 보내줄 수 있다. submit이 좋은 이유는, 사용이 간단한 이유도 있지만
다양하게 응용이 가능해서이다. 퀴즈앱을 만든다고 가정할 때, 이 방법대로라면 제출 버튼 없이 특정 시간 이후에 자동으로 submit이 되게끔 할 수 있다.
> optimistic UI
특정버튼 (upvote)을 눌렀을 때 로딩시간을 기다릴 필요 없이 바로 화면에 적용되고 나중에 데이터처리가 되는 방법이다. (사용자 만족)
- mutations.ts
export const toggleUpvote = async (
client: SupabaseClient<Database>,
{ postId, userId }: { postId: string; userId: string }
) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
const { count } = await client
.from("post_upvotes")
.select("*", { count: "exact", head: true })
.eq("post_id", postId)
.eq("profile_id", userId);
if (count === 0) {
await client.from("post_upvotes").insert({
post_id: Number(postId),
profile_id: userId,
});
} else {
await client
.from("post_upvotes")
.delete()
.eq("post_id", Number(postId))
.eq("profile_id", userId);
}
};
upvote토글 뮤테이션
- post-card.tsx
import { Link, useFetcher } from "react-router";
import {
Card,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import {
Avatar,
AvatarImage,
AvatarFallback,
} from "~/common/components/ui/avatar";
import { Button } from "~/common/components/ui/button";
import { ChevronUpIcon, DotIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { DateTime } from "luxon";
interface PostCardProps {
id: number;
title: string;
author: string;
authorAvatarUrl: string | null;
category: string;
postedAt: string;
expanded?: boolean;
votesCount?: number;
isUpvoted?: boolean;
}
export function PostCard({
id,
title,
author,
authorAvatarUrl,
category,
postedAt,
expanded = false,
votesCount = 0,
isUpvoted = false,
}: PostCardProps) {
const fetcher = useFetcher();
const optimisitcVotesCount =
fetcher.state === "idle"
? votesCount
: isUpvoted
? votesCount - 1
: votesCount + 1;
const optimisitcIsUpvoted = fetcher.state === "idle" ? isUpvoted : !isUpvoted;
const absorbClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
fetcher.submit(null, {
method: "POST",
action: `/community/${id}/upvote`,
});
};
return (
<Link to={`/community/${id}`} className="block">
<Card
className={cn(
"bg-transparent hover:bg-card/50 transition-colors",
expanded ? "flex flex-row items-center justify-between" : ""
)}
>
<CardHeader className="flex flex-row items-center gap-2">
<Avatar className="size-14">
<AvatarFallback>{author[0]}</AvatarFallback>
{authorAvatarUrl && <AvatarImage src={authorAvatarUrl} />}
</Avatar>
<div className="space-y-2">
<CardTitle>{title}</CardTitle>
<div className="flex gap-2 text-sm leading-tight text-muted-foreground">
<span>
{author} on {category}
</span>
<DotIcon className="w-4 h-4" />
<span>{DateTime.fromISO(postedAt).toRelative()}</span>
</div>
</div>
</CardHeader>
{!expanded && (
<CardFooter className="flex justify-end">
<Button variant="link">Reply →</Button>
</CardFooter>
)}
{expanded && (
<CardFooter className="flex justify-end pb-0">
<Button
onClick={absorbClick}
variant="outline"
className={cn(
"flex flex-col h-14",
optimisitcIsUpvoted ? "border-primary text-primary" : ""
)}
>
<ChevronUpIcon className="size-4 shrink-0" />
<span>{optimisitcVotesCount}</span>
</Button>
</CardFooter>
)}
</Card>
</Link>
);
}
fetcher의 idle상태를 확인 한후 idle상태가 아닐 때 바로 화면 UI에 값을 업데이트하는 로직이다. (숫자와 디자인 모두 적용)
- upvote-post-page.tsx
import { makeSSRClient } from "~/supa-client";
import type { Route } from "./+types/upvote-post-page";
import { getLoggedInUserId } from "~/features/users/queries";
import { toggleUpvote } from "../mutations";
export const action = async ({ request, params }: Route.ActionArgs) => {
if (request.method !== "POST") {
throw new Response("Method not allowed", { status: 405 });
}
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
await toggleUpvote(client, { postId: params.postId, userId });
return {
ok: true,
};
};
form을 받아오는 대신에 params 값에서 postId를 가져온다. 그리고 그 값을 위에서 만든 토글upvote의 인자값으로 넣어준다.
> notification 트리거 만들어주기
- notification-triggers.sql
CREATE FUNCTION public.notify_follow()
RETURNS TRIGGER
SECURITY DEFINER SET search_path = ''
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO public.notifications (type, source_id, target_id)
VALUES ('follow', NEW.follower_id, NEW.following_id);
RETURN NEW;
END;
$$;
CREATE TRIGGER notify_follow_trigger
AFTER INSERT ON public.follows
FOR EACH ROW
EXECUTE PROCEDURE public.notify_follow();
CREATE FUNCTION public.notify_review()
RETURNS TRIGGER
SECURITY DEFINER SET search_path = ''
LANGUAGE plpgsql
AS $$
DECLARE
product_owner uuid;
BEGIN
SELECT profile_id INTO product_owner FROM public.products WHERE product_id = NEW.product_id;
INSERT INTO public.notifications (type, source_id, target_id)
VALUES ('review', NEW.profile_id, product_owner);
RETURN NEW;
END;
$$;
CREATE TRIGGER notify_review_trigger
AFTER INSERT ON public.reviews
FOR EACH ROW
EXECUTE PROCEDURE public.notify_review();
CREATE FUNCTION public.notify_reply()
RETURNS TRIGGER
SECURITY DEFINER SET search_path = ''
LANGUAGE plpgsql
AS $$
DECLARE
post_owner uuid;
BEGIN
SELECT profile_id INTO post_owner FROM public.posts WHERE post_id = NEW.post_id;
INSERT INTO public.notifications (type, source_id, target_id)
VALUES ('reply', NEW.profile_id, post_owner);
RETURN NEW;
END;
$$;
CREATE TRIGGER notify_reply_trigger
AFTER INSERT ON public.post_replies
FOR EACH ROW
EXECUTE PROCEDURE public.notify_reply();
1) 팔로우시 2) 리뷰작성 시 3) 댓글 작성 시 트리거가 발동되서 해당 오너에게 알림이 가게끔 하려고 한다. 여기에서 직접적으로 해당 테이블에 값이 없는경우에는 변수 선언 (DECLARE 부분)을 해서 값을 넣을 수 있다.
> notification 페이지
- queries.ts
export const getNotifications = async (
client: SupabaseClient<Database>,
{ userId }: { userId: string }
) => {
const { data, error } = await client
.from("notifications")
.select(
`
notification_id,
type,
source:profiles!source_id(
profile_id,
name,
avatar
),
product:products!product_id(
product_id,
name
),
post:posts!post_id(
post_id,
title
),
seen,
created_at
`
)
.eq("target_id", userId);
if (error) {
throw error;
}
return data;
};
- notification-card.tsx
import {
Card,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { Button } from "~/common/components/ui/button";
import { EyeIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Link } from "react-router";
interface NotificationCardProps {
avatarUrl: string;
avatarFallback: string;
userName: string;
type: "follow" | "review" | "reply";
timestamp: string;
seen: boolean;
productName?: string;
payloadId?: number;
postTitle?: string;
}
export function NotificationCard({
type,
avatarUrl,
avatarFallback,
userName,
timestamp,
seen,
productName,
postTitle,
payloadId,
}: NotificationCardProps) {
const getMessage = (type: "follow" | "review" | "reply") => {
switch (type) {
case "follow":
return " followed you.";
case "review":
return " reviewed your product: ";
case "reply":
return " replied to your post: ";
}
};
return (
<Card className={cn("min-w-[450px]", seen ? "" : "bg-yellow-500/60")}>
<CardHeader className="flex flex-row gap-5 space-y-0 items-start">
<Avatar className="">
<AvatarImage src={avatarUrl} />
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg space-y-0 font-bold">
<span>{userName}</span>
<span>{getMessage(type)}</span>
{productName && (
<Button variant={"ghost"} asChild className="text-lg">
<Link to={`/products/${payloadId}`}>{productName}</Link>
</Button>
)}
{postTitle && (
<Button variant={"ghost"} asChild className="text-lg">
<Link to={`/community/${payloadId}`}>{postTitle}</Link>
</Button>
)}
</CardTitle>
<small className="text-muted-foreground text-sm">{timestamp}</small>
</div>
</CardHeader>
<CardFooter className="flex justify-end">
<Button variant="outline" size="icon">
<EyeIcon className="w-4 h-4" />
</Button>
</CardFooter>
</Card>
);
}
- notifications-page.tsx
import type { Route } from "./+types/notifications-page";
import { NotificationCard } from "../components/notification-card";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId, getNotifications } from "../queries";
import { DateTime } from "luxon";
export const meta: Route.MetaFunction = () => {
return [{ title: "Notifications | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const notifications = await getNotifications(client, { userId });
return { notifications };
};
export default function NotificationsPage({
loaderData,
}: Route.ComponentProps) {
return (
<div className="space-y-20">
<h1 className="text-4xl font-bold">Notifications</h1>
<div className="flex flex-col items-start gap-5">
{loaderData.notifications.map((notification) => (
<NotificationCard
key={notification.notification_id}
avatarUrl={notification.source?.avatar ?? ""}
avatarFallback={notification.source?.name?.[0] ?? ""}
userName={notification.source?.name ?? ""}
type={notification.type}
productName={notification.product?.name ?? ""}
postTitle={notification.post?.title ?? ""}
payloadId={
notification.product?.product_id ?? notification.post?.post_id
}
timestamp={DateTime.fromISO(notification.created_at).toRelative()!}
seen={notification.seen}
/>
))}
</div>
</div>
);
}
> notofication페이지 see처리하기
- notification-card.tsx
import {
Card,
CardFooter,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { Button } from "~/common/components/ui/button";
import { EyeIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Link, useFetcher } from "react-router";
interface NotificationCardProps {
avatarUrl: string;
avatarFallback: string;
userName: string;
type: "follow" | "review" | "reply";
timestamp: string;
seen: boolean;
productName?: string;
payloadId?: number;
postTitle?: string;
id: number;
}
export function NotificationCard({
type,
avatarUrl,
avatarFallback,
userName,
timestamp,
seen,
productName,
postTitle,
payloadId,
id,
}: NotificationCardProps) {
const getMessage = (type: "follow" | "review" | "reply") => {
switch (type) {
case "follow":
return " followed you.";
case "review":
return " reviewed your product: ";
case "reply":
return " replied to your post: ";
}
};
const fetcher = useFetcher();
const optimiscitSeen = fetcher.state === "idle" ? seen : true;
return (
<Card
className={cn("min-w-[450px]", optimiscitSeen ? "" : "bg-yellow-500/60")}
>
<CardHeader className="flex flex-row gap-5 space-y-0 items-start">
<Avatar className="">
<AvatarImage src={avatarUrl} />
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg space-y-0 font-bold">
<span>{userName}</span>
<span>{getMessage(type)}</span>
{productName && (
<Button variant={"ghost"} asChild className="text-lg">
<Link to={`/products/${payloadId}`}>{productName}</Link>
</Button>
)}
{postTitle && (
<Button variant={"ghost"} asChild className="text-lg">
<Link to={`/community/${payloadId}`}>{postTitle}</Link>
</Button>
)}
</CardTitle>
<small className="text-muted-foreground text-sm">{timestamp}</small>
</div>
</CardHeader>
<CardFooter className="flex justify-end">
{optimiscitSeen ? null : (
<fetcher.Form method="post" action={`/my/notifications/${id}/see`}>
<Button variant="outline" size="icon">
<EyeIcon className="w-4 h-4" />
</Button>
</fetcher.Form>
)}
</CardFooter>
</Card>
);
}
fetcher.form으로 post url 이동하기 및 optimistic UI 세팅
- see-notification-page.tsx
import { makeSSRClient } from "~/supa-client";
import { Route } from "./+types/see-notification-page";
import { getLoggedInUserId } from "../queries";
import { seeNotification } from "../mutations";
export const action = async ({ request, params }: Route.ActionArgs) => {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const { notificationId } = params;
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
await seeNotification(client, { userId, notificationId });
return {
ok: true,
};
};
- mutations.ts
export const seeNotification = async (
client: SupabaseClient<Database>,
{ userId, notificationId }: { userId: string; notificationId: string }
) => {
const { error } = await client
.from("notifications")
.update({ seen: true })
.eq("notification_id", notificationId)
.eq("target_id", userId);
if (error) {
throw error;
}
};
seen을 true로 업데이트함
- queries.ts
export const countNotifications = async (
client: SupabaseClient<Database>,
{ userId }: { userId: string }
) => {
const { count, error } = await client
.from("notifications")
.select("*", { count: "exact", head: true })
.eq("seen", false)
.eq("target_id", userId);
if (error) {
throw error;
}
return count ?? 0;
};
root에 사용함 (notification 부분 알림 표시)
- root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import stylesheet from "./app.css?url";
import Navigation from "./common/components/navigation";
import { Settings } from "luxon";
import { cn } from "./lib/utils";
import { makeSSRClient } from "./supa-client";
import { countNotifications, getUserById } from "./features/users/queries";
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",
},
{ rel: "stylesheet", href: stylesheet },
];
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>{children}</main>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const {
data: { user },
} = await client.auth.getUser();
if (user && user.id) {
const profile = await getUserById(client, { id: user.id });
const count = await countNotifications(client, { userId: user.id });
return { user, profile, notificationsCount: count };
}
return { user: null, profile: null, notificationsCount: 0 };
};
export default function App({ loaderData }: Route.ComponentProps) {
const { pathname } = useLocation();
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
const isLoggedIn = loaderData.user !== null;
return (
<div
className={cn({
"py-28 px-5 md:px-20": !pathname.includes("/auth/"),
"transition-opacity animate-pulse": isLoading,
})}
>
{pathname.includes("/auth") ? null : (
<Navigation
isLoggedIn={isLoggedIn}
username={loaderData.profile?.username}
avatar={loaderData.profile?.avatar}
name={loaderData.profile?.name}
hasNotifications={loaderData.notificationsCount > 0}
hasMessages={false}
/>
)}
<Outlet
context={{
isLoggedIn,
name: loaderData.profile?.name,
username: loaderData.profile?.username,
avatar: loaderData.profile?.avatar,
}}
/>
</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>
);
}