#8 Private Pages
> 프라이빗 페이지들을 꾸며보자. (로그인 된 유저들만 접속 가능한 페이지들)
먼저 커뮤니티 게시글 작성 하는 페이지
- mutations.ts
import { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "~/supa-client";
export const createPost = async (
client: SupabaseClient<Database>,
{
title,
category,
content,
userId,
}: {
title: string;
category: string;
content: string;
userId: string;
}
) => {
const { data: categoryData, error: categoryError } = await client
.from("topics")
.select("topic_id")
.eq("slug", category)
.single();
if (categoryError) {
throw categoryError;
}
const { data, error } = await client
.from("posts")
.insert({
title,
content,
profile_id: userId,
topic_id: categoryData.topic_id,
})
.select()
.single();
if (error) {
throw error;
}
return data;
};
mutations 파일을 별도로 하나 추가함
- users/queries.ts
export const getLoggedInUserId = async (client: SupabaseClient<Database>) => {
const { data, error } = await client.auth.getUser();
if (error || data.user === null) {
throw redirect("/auth/login");
}
return data.user.id;
};
userId를 얻는 쿼리문 작성, 만약에 없는 유저면 로그인 페이지로 리디렉트 처리해줌
- community/submit-post-page.tsx
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/submit-post-page";
import { Form, redirect } from "react-router";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { Button } from "~/common/components/ui/button";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { getTopics } from "../queries";
import { z } from "zod";
import { createPost } from "../mutations";
export const meta: Route.MetaFunction = () => {
return [{ title: "Submit Post | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
await getLoggedInUserId(client);
const topics = await getTopics(client);
return { topics };
};
const formSchema = z.object({
title: z.string().min(1).max(40),
category: z.string().min(1).max(100),
content: z.string().min(1).max(1000),
});
export const action = async ({ request }: 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 {
fieldErrors: error.flatten().fieldErrors,
};
}
const { title, category, content } = data;
const { post_id } = await createPost(client, {
title,
category,
content,
userId,
});
return redirect(`/community/${post_id}`);
};
export default function SubmitPostPage({
loaderData,
actionData,
}: Route.ComponentProps) {
return (
<div className="space-y-20">
<Hero
title="Create Discussion"
subtitle="Ask questions, share ideas, and connect with other developers"
/>
<Form
className="flex flex-col gap-10 max-w-screen-md mx-auto"
method="post"
>
<InputPair
label="Title"
name="title"
id="title"
description="(40 characters or less)"
required
placeholder="i.e What is the best productivity tool?"
/>
{actionData && "fieldErrors" in actionData && (
<div className="text-red-500">
{actionData.fieldErrors.title?.join(", ")}
</div>
)}
<SelectPair
required
name="category"
label="Category"
description="Select the category that best fits your discussion"
placeholder="i.e Productivity"
options={loaderData.topics.map((topic) => ({
label: topic.name,
value: topic.slug,
}))}
/>
{actionData && "fieldErrors" in actionData && (
<div className="text-red-500">
{actionData.fieldErrors.category?.join(", ")}
</div>
)}
<InputPair
label="Content"
name="content"
id="content"
description="(1000 characters or less)"
required
placeholder="i.e I'm looking for a tool that can help me manage my time and tasks. What are the best tools out there?"
textArea
/>
{actionData && "fieldErrors" in actionData && (
<div className="text-red-500">
{actionData.fieldErrors.content?.join(", ")}
</div>
)}
<Button className="mx-auto">Create Discussion</Button>
</Form>
</div>
);
}
로더부분에서 로그인 여부 검증함
- post-page.tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "~/common/components/ui/breadcrumb";
import type { Route } from "./+types/post-page";
import { Form, Link } 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 { DateTime } from "luxon";
import { makeSSRClient } from "~/supa-client";
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 };
};
export default function PostPage({ loaderData }: Route.ComponentProps) {
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">
<Button variant="outline" className="flex flex-col h-14">
<ChevronUpIcon className="size-4 shrink-0" />
<span>{loaderData.post.upvotes}</span>
</Button>
<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({ unit: "hours" })}
</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>
<Form className="flex items-start gap-5 w-3/4">
<Avatar className="size-14">
<AvatarFallback>N</AvatarFallback>
<AvatarImage src="https://github.com/serranoarevalo.png" />
</Avatar>
<div className="flex flex-col gap-5 items-end w-full">
<Textarea
placeholder="Write a reply"
className="w-full resize-none"
rows={5}
/>
<Button>Reply</Button>
</div>
</Form>
<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
username={reply.user.name}
avatarUrl={reply.user.avatar}
content={reply.reply}
timestamp={reply.created_at}
topLevel={true}
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({ unit: "hours" })}{" "}
ago
</span>
<span>🚀 Launched {loaderData.post.products} products</span>
</div>
<Button variant="outline" className="w-full">
Follow
</Button>
</aside>
</div>
</div>
);
}
> jobs 생성 부분
- mutations.ts
import { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { Database } from "~/supa-client";
import { formSchema } from "./pages/submit-job-page";
export const createJob = async (
client: SupabaseClient<Database>,
data: z.infer<typeof formSchema>
) => {
const { data: jobData, error } = await client
.from("jobs")
.insert({
position: data.position,
overview: data.overview,
responsibilities: data.responsibilities,
qualifications: data.qualifications,
benefits: data.benefits,
skills: data.skills,
company_name: data.companyName,
company_logo: data.companyLogoUrl,
company_location: data.companyLocation,
apply_url: data.applyUrl,
job_type: data.jobType as
| "full-time"
| "part-time"
| "freelance"
| "internship",
location: data.jobLocation as "remote" | "in-person" | "hybrid",
salary_range: data.salaryRange,
})
.select()
.single();
if (error) {
throw error;
}
return jobData;
};
data의 타입을 zod 스키마에서 지정한걸로 대체할 수 있다
- submit-job-page.tsx
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/submit-job-page";
import { Form, redirect } from "react-router";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { JOB_TYPES, LOCATION_TYPES, SALARY_RANGE } from "../constants";
import { Button } from "~/common/components/ui/button";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { z } from "zod";
import { createJob } from "../mutations";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Post a Job | wemake" },
{
name: "description",
content: "Reach out to the best developers in the world",
},
];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
await getLoggedInUserId(client);
};
export const formSchema = z.object({
position: z.string().max(40),
overview: z.string().max(400),
responsibilities: z.string().max(400),
qualifications: z.string().max(400),
benefits: z.string().max(400),
skills: z.string().max(400),
companyName: z.string().max(40),
companyLogoUrl: z.string().max(40),
companyLocation: z.string().max(40),
applyUrl: z.string().max(40),
jobType: z.enum(JOB_TYPES.map((type) => type.value) as [string, ...string[]]),
jobLocation: z.enum(
LOCATION_TYPES.map((location) => location.value) as [string, ...string[]]
),
salaryRange: z.enum(SALARY_RANGE),
});
export const action = async ({ request }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
await getLoggedInUserId(client);
const formData = await request.formData();
const { success, data, error } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return {
fieldErrors: error.flatten().fieldErrors,
};
}
const { job_id } = await createJob(client, data);
return redirect(`/jobs/${job_id}`);
};
export default function SubmitJobPage({ actionData }: Route.ComponentProps) {
return (
<div>
<Hero
title="Post a Job"
subtitle="Reach out to the best developers in the world"
/>
<Form
className="max-w-screen-2xl flex flex-col items-center gap-10 mx-auto"
method="post"
>
<div className="grid grid-cols-3 w-full gap-10">
<InputPair
label="Position"
description="(40 characters max)"
name="position"
maxLength={40}
type="text"
id="position"
required
defaultValue="Senior React Developer"
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.position}</p>
)}
<InputPair
id="overview"
label="Overview"
description="(400 characters max)"
name="overview"
maxLength={400}
type="text"
required
defaultValue="We are looking for a Senior React Developer"
textArea
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.overview}</p>
)}
<InputPair
id="responsibilities"
label="Responsibilities"
description="(400 characters max, comma separated)"
name="responsibilities"
maxLength={400}
type="text"
required
defaultValue="Implement new features, Maintain code quality, etc."
textArea
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">
{actionData.fieldErrors.responsibilities}
</p>
)}
<InputPair
id="qualifications"
label="Qualifications"
description="(400 characters max, comma separated)"
name="qualifications"
maxLength={400}
type="text"
required
defaultValue="3+ years of experience, Strong TypeScript skills, etc."
textArea
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">
{actionData.fieldErrors.qualifications}
</p>
)}
<InputPair
id="benefits"
label="Benefits"
description="(400 characters max, comma separated)"
name="benefits"
maxLength={400}
type="text"
required
defaultValue="Flexible working hours, Health insurance, etc."
textArea
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.benefits}</p>
)}
<InputPair
id="skills"
label="Skills"
description="(400 characters max, comma separated)"
name="skills"
maxLength={400}
type="text"
required
defaultValue="React, TypeScript, etc."
textArea
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.skills}</p>
)}
<InputPair
id="companyName"
label="Company Name"
description="(40 characters max)"
name="companyName"
maxLength={40}
type="text"
required
defaultValue="wemake"
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.companyName}</p>
)}
<InputPair
id="companyLogoUrl"
label="Company Logo URL"
description="(40 characters max)"
name="companyLogoUrl"
type="url"
required
defaultValue="https://wemake.services/logo.png"
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">
{actionData.fieldErrors.companyLogoUrl}
</p>
)}
<InputPair
id="companyLocation"
label="Company Location"
description="(40 characters max)"
name="companyLocation"
maxLength={40}
type="text"
required
defaultValue="Remote, New York, etc."
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">
{actionData.fieldErrors.companyLocation}
</p>
)}
<InputPair
id="applyUrl"
label="Apply URL"
description="(40 characters max)"
name="applyUrl"
maxLength={40}
type="url"
required
defaultValue="https://wemake.services/apply"
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.applyUrl}</p>
)}
<SelectPair
label="Job Type"
description="Select the type of job"
name="jobType"
required
placeholder="Select the type of job"
options={JOB_TYPES.map((type) => ({
label: type.label,
value: type.value,
}))}
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.jobType}</p>
)}
<SelectPair
label="Job Location"
description="Select the location of the job"
name="jobLocation"
required
placeholder="Select the location of the job"
options={LOCATION_TYPES.map((location) => ({
label: location.label,
value: location.value,
}))}
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.jobLocation}</p>
)}
<SelectPair
label="Salary Range"
description="Select the salary range of the job"
name="salaryRange"
required
placeholder="Select the salary range of the job"
options={SALARY_RANGE.map((salary) => ({
label: salary,
value: salary,
}))}
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.salaryRange}</p>
)}
</div>
<Button type="submit" className="w-full max-w-sm" size="lg">
Post job for $100
</Button>
</Form>
</div>
);
}
> teams 생성 부분
- mutations.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { Database } from "~/supa-client";
import { formSchema } from "./pages/submit-team-page";
export const createTeam = async (
client: SupabaseClient<Database>,
userId: string,
team: z.infer<typeof formSchema>
) => {
const { data, error } = await client
.from("teams")
.insert({
team_leader_id: userId,
team_size: team.size,
product_name: team.name,
product_stage: team.stage as "idea" | "prototype" | "mvp" | "product",
product_description: team.description,
roles: team.roles,
equity_split: team.equity,
})
.select("team_id")
.single();
if (error) {
throw error;
}
return data;
};
- submit-team-page.tsx
import { Hero } from "~/common/components/hero";
import { Route } from "./+types/submit-team-page";
import { Form, redirect } from "react-router";
import { Button } from "~/common/components/ui/button";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { PRODUCT_STAGES } from "../constants";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { z } from "zod";
import { createTeam } from "../mutations";
export const meta: Route.MetaFunction = () => [
{ title: "Create Team | wemake" },
];
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
await getLoggedInUserId(client);
};
export const formSchema = z.object({
name: z.string().min(1).max(20),
stage: z.string(),
size: z.coerce.number().min(1).max(100),
equity: z.coerce.number().min(1).max(100),
roles: z.string(),
description: z.string().min(1).max(200),
});
export const action = async ({ request }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const { success, data, error } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return { fieldErrors: error.flatten().fieldErrors };
}
const { team_id } = await createTeam(client, userId, {
...data,
});
return redirect(`/teams/${team_id}`);
};
export default function SubmitTeamPage({ actionData }: Route.ComponentProps) {
return (
<div className="space-y-20">
<Hero title="Create Team" subtitle="Create a team to find a team mate." />
<Form
className="max-w-screen-2xl flex flex-col items-center gap-10 mx-auto"
method="post"
>
<div className="grid grid-cols-3 w-full gap-10">
<InputPair
label="What is the name of your product?"
description="(20 characters max)"
placeholder="i.e Doggy Social"
name="name"
maxLength={20}
type="text"
id="name"
required
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.name}</p>
)}
<SelectPair
label="What is the stage of your product?"
description="Select the stage of your product"
name="stage"
required
placeholder="Select the stage of your product"
options={PRODUCT_STAGES}
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.stage}</p>
)}
<InputPair
label="What is the size of your team?"
description="(1-100)"
name="size"
max={100}
min={1}
type="number"
id="size"
required
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.size}</p>
)}
<InputPair
label="How much equity are you willing to give?"
description="(each)"
name="equity"
max={100}
min={1}
type="number"
id="equity"
required
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.equity}</p>
)}
<InputPair
label="What roles are you looking for?"
placeholder="React Developer, Backend Developer, Product Manager"
description="(comma separated)"
name="roles"
type="text"
id="roles"
required
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.roles}</p>
)}
<InputPair
label="What is the description of your product?"
description="(200 characters max)"
placeholder="i.e We are building a new social media platform for dogs to connect with each other"
name="description"
maxLength={200}
type="text"
id="description"
required
textArea
/>
{actionData && "fieldErrors" in actionData && (
<p className="text-red-500">{actionData.fieldErrors.description}</p>
)}
</div>
<Button type="submit" className="w-full max-w-sm" size="lg">
Create team
</Button>
</Form>
</div>
);
}
> Post reply 부분을 만들어 보자
- root.tsx
<Outlet
context={{
isLoggedIn,
name: loaderData.profile?.name,
username: loaderData.profile?.username,
avatar: loaderData.profile?.avatar,
}}
/>
Outlet에 로그인 정보와 프로필 정보를 넘겨줌 (reply form 부분에 쓰기 위해 / 중복 호출 방지)
- mutations.ts
export const createReply = async (
client: SupabaseClient<Database>,
{ postId, reply, userId }: { postId: string; reply: string; userId: string }
) => {
const { error } = await client
.from("post_replies")
.insert({ post_id: Number(postId), reply, profile_id: userId });
if (error) {
throw error;
}
};
reply 생성 뮤테이션
- post-page.tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "~/common/components/ui/breadcrumb";
import type { Route } from "./+types/post-page";
import { Form, Link, 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";
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),
});
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 } = data;
await createReply(client, {
postId: params.postId,
reply,
userId,
});
return {
ok: true,
};
};
export default function PostPage({
loaderData,
actionData,
}: Route.ComponentProps) {
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">
<Button variant="outline" className="flex flex-col h-14">
<ChevronUpIcon className="size-4 shrink-0" />
<span>{loaderData.post.upvotes}</span>
</Button>
<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
username={reply.user.name}
avatarUrl={reply.user.avatar}
content={reply.reply}
timestamp={reply.created_at}
topLevel={true}
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>
);
}
> 대댓글을 만들어보자
- mutations.ts
export const createReply = async (
client: SupabaseClient<Database>,
{
postId,
reply,
userId,
topLevelId,
}: { postId: string; reply: string; userId: string; topLevelId?: number }
) => {
const { error } = await client.from("post_replies").insert({
...(topLevelId ? { parent_id: topLevelId } : { post_id: Number(postId) }),
reply,
profile_id: userId,
});
if (error) {
throw error;
}
};
topLevelId는 1번 댓글을 뜻한다. topLevelId여부에 따라 댓글 or 대댓글이 결정된다.
- post-page.tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "~/common/components/ui/breadcrumb";
import type { Route } from "./+types/post-page";
import { Form, Link, 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";
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 { 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">
<Button variant="outline" className="flex flex-col h-14">
<ChevronUpIcon className="size-4 shrink-0" />
<span>{loaderData.post.upvotes}</span>
</Button>
<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>
);
}
- reply.tsx
import { Form, Link, useActionData, useOutletContext } from "react-router";
import { DotIcon, MessageCircleIcon } from "lucide-react";
import { Button } from "~/common/components/ui/button";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { useEffect, useState } from "react";
import { Textarea } from "~/common/components/ui/textarea";
import { DateTime } from "luxon";
import { action } from "../pages/post-page";
interface ReplyProps {
name: string;
username: string;
avatarUrl: string | null;
content: string;
timestamp: string;
topLevel: boolean;
topLevelId: number;
replies?: {
post_reply_id: number;
reply: string;
created_at: string;
user: {
name: string;
avatar: string | null;
username: string;
};
}[];
}
export function Reply({
name,
username,
avatarUrl,
content,
timestamp,
topLevel,
topLevelId,
replies,
}: ReplyProps) {
const actionData = useActionData<typeof action>();
const [replying, setReplying] = useState(false);
const toggleReplying = () => setReplying((prev) => !prev);
const {
isLoggedIn,
name: loggedInName,
avatar,
} = useOutletContext<{
isLoggedIn: boolean;
name: string;
avatar: string;
}>();
useEffect(() => {
if (actionData?.ok) {
setReplying(false);
}
}, [actionData]);
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-start gap-5 w-2/3">
<Avatar className="size-14">
<AvatarFallback>{name[0]}</AvatarFallback>
{avatarUrl ? <AvatarImage src={avatarUrl} /> : null}
</Avatar>
<div className="flex flex-col gap-2 items-start w-full">
<div className="flex gap-2 items-center">
<Link to={`/users/${username}`}>
<h4 className="font-medium">{name}</h4>
</Link>
<DotIcon className="size-5" />
<span className="text-xs text-muted-foreground">
{DateTime.fromISO(timestamp).toRelative()}
</span>
</div>
<p className="text-muted-foreground">{content}</p>
{isLoggedIn ? (
<Button
variant="ghost"
className="self-end"
onClick={toggleReplying}
>
<MessageCircleIcon className="size-4" />
Reply
</Button>
) : null}
</div>
</div>
{replying && (
<Form className="flex items-start gap-5 w-3/4" method="post">
<input type="hidden" name="topLevelId" value={topLevelId} />
<Avatar className="size-14">
<AvatarFallback>{loggedInName[0]}</AvatarFallback>
<AvatarImage src={avatar} />
</Avatar>
<div className="flex flex-col gap-5 items-end w-full">
<Textarea
autoFocus
name="reply"
placeholder="Write a reply"
className="w-full resize-none"
defaultValue={`@${username} `}
rows={5}
/>
<Button>Reply</Button>
</div>
</Form>
)}
{topLevel && replies && (
<div className="pl-20 w-full">
{replies.map((reply) => (
<Reply
name={reply.user.name}
username={reply.user.username}
avatarUrl={reply.user.avatar}
content={reply.reply}
timestamp={reply.created_at}
topLevel={false}
topLevelId={topLevelId}
/>
))}
</div>
)}
</div>
);
}
해당 컴포넌트에는 action이 없기 때문에, form을 닫을 수 없다. 이 때 useActionData를 사용하게 되면, 가장 최근에 POST된 action 값을 가져 올 수 있는데, 이를 활용해서 form을 닫아 줄 수 있다. 그리고 대댓글 부분에는 자동적으로 기본디폴트값은 @username으로 해놨다.
> clamed idea 부분
- mutations.ts
import { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "~/supa-client";
export const claimIdea = async (
client: SupabaseClient<Database>,
{ ideaId, userId }: { ideaId: string; userId: string }
) => {
const { error } = await client
.from("gpt_ideas")
.update({ claimed_by: userId, claimed_at: new Date().toISOString() })
.eq("gpt_idea_id", ideaId);
if (error) {
throw error;
}
};
아이디어 claim 하는 뮤테이션
- 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;
owner?: boolean;
viewsCount?: number;
postedAt?: string;
likesCount?: number;
claimed?: boolean;
}
export function IdeaCard({
id,
title,
owner,
viewsCount,
postedAt,
likesCount,
claimed,
}: IdeaCardProps) {
return (
<Card className="bg-transparent hover:bg-card/50 transition-colors">
<CardHeader>
<Link to={claimed || owner ? "" : `/ideas/${id}`}>
<CardTitle className="text-xl">
<span
className={cn(
claimed
? "bg-muted-foreground break-all selection:bg-muted-foreground text-muted-foreground"
: ""
)}
>
{title}
</span>
</CardTitle>
</Link>
</CardHeader>
{owner ? null : (
<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" />
{postedAt ? (
<span>{DateTime.fromISO(postedAt).toRelative()}</span>
) : null}
</CardContent>
)}
<CardFooter className="flex justify-end gap-2">
{!claimed && !owner ? (
<>
<Button variant="outline">
<HeartIcon className="w-4 h-4" />
<span>{likesCount}</span>
</Button>
<Button asChild>
<Link to={`/ideas/${id}`}>Claim idea now →</Link>
</Button>
</>
) : (
<Button variant="outline" disabled className="cursor-not-allowed">
<LockIcon className="size-4" />
Claimed
</Button>
)}
</CardFooter>
</Card>
);
}
- 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 { Route } from "./+types/idea-page";
import { getGptIdea } from "../queries";
import { DateTime } from "luxon";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { Form, redirect } from "react-router";
import { claimIdea } from "../mutations";
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 ({ request, params }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const idea = await getGptIdea(client, { ideaId: params.ideaId });
if (idea.is_claimed) {
throw redirect(`/ideas`);
}
return { idea };
};
export const action = async ({ request, params }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const idea = await getGptIdea(client, { ideaId: params.ideaId });
if (idea.is_claimed) {
return { ok: false };
}
await claimIdea(client, { ideaId: params.ideaId, userId });
return redirect(`/my/dashboard/ideas`);
};
export default function IdeaPage({ loaderData }: Route.ComponentProps) {
return (
<div>
<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>
{loaderData.idea.is_claimed ? null : (
<Form method="post">
<Button size="lg">Claim idea</Button>
</Form>
)}
</div>
</div>
);
}
- dashboard-ideas-page.tsx
import { IdeaCard } from "~/features/ideas/components/idea-card";
import { Route } from "./+types/dashboard-ideas-page";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "../queries";
import { getClaimedIdeas } from "~/features/ideas/queries";
export const meta: Route.MetaFunction = () => {
return [{ title: "My Ideas | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const ideas = await getClaimedIdeas(client, { userId });
return { ideas };
};
export default function DashboardIdeasPage({
loaderData,
}: Route.ComponentProps) {
return (
<div className="space-y-5 h-full">
<h1 className="text-2xl font-semibold mb-6">Claimed Ideas</h1>
<div className="grid grid-cols-4 gap-6">
{loaderData.ideas.map((idea) => (
<IdeaCard
key={idea.gpt_idea_id}
id={idea.gpt_idea_id}
title={idea.idea}
owner={true}
/>
))}
</div>
</div>
);
}
- queries.ts
export const getClaimedIdeas = async (
client: SupabaseClient<Database>,
{ userId }: { userId: string }
) => {
const { data, error } = await client
.from("gpt_ideas")
.select("gpt_idea_id, claimed_at, idea")
.eq("claimed_by", userId);
if (error) {
throw error;
}
return data;
};
> product에 review 부분을 해보자
-mutations.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "~/supa-client";
export const createProductReview = async (
client: SupabaseClient<Database>,
{
productId,
review,
rating,
userId,
}: { productId: string; review: string; rating: number; userId: string }
) => {
const { error } = await client.from("reviews").insert({
product_id: +productId,
review,
rating,
profile_id: userId,
});
if (error) {
throw error;
}
};
+ 는 Number와 같은 역할
- product-reviews-page.tsx
import { Button } from "~/common/components/ui/button";
import { ReviewCard } from "../components/review-card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/common/components/ui/dialog";
import CreateReviewDialog from "../components/create-review-dialog";
import { useOutletContext } from "react-router";
import type { Route } from "./+types/product-reviews-page";
import { getReviews } from "../queries";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { z } from "zod";
import { createProductReview } from "../mutations";
import { useEffect, useState } from "react";
export function meta() {
return [
{ title: "Product Reviews | wemake" },
{ name: "description", content: "Read and write product reviews" },
];
}
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { client, headers } = makeSSRClient(request);
const reviews = await getReviews(client, {
productId: params.productId,
});
return { reviews };
};
const formSchema = z.object({
review: z.string().min(1),
rating: z.coerce.number().min(1).max(5),
});
export const action = async ({ request, params }: Route.ActionArgs) => {
const { client, headers } = 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,
};
}
await createProductReview(client, {
productId: params.productId,
review: data.review,
rating: data.rating,
userId,
});
return {
ok: true,
};
};
export default function ProductReviewsPage({
loaderData,
actionData,
}: Route.ComponentProps) {
const { review_count } = useOutletContext<{
review_count: string;
}>();
const [open, setOpen] = useState(false);
useEffect(() => {
if (actionData?.ok) {
setOpen(false);
}
}, [actionData]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<div className="space-y-10 max-w-xl">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">
{review_count} {review_count === "1" ? "Review" : "Reviews"}
</h2>
<DialogTrigger asChild>
<Button variant={"secondary"}>Write a review</Button>
</DialogTrigger>
</div>
<div className="space-y-20">
{loaderData.reviews.map((review) => (
<ReviewCard
key={review.review_id}
username={review.user!.name}
handle={review.user!.username}
avatarUrl={review.user!.avatar}
rating={review.rating}
content={review.review}
postedAt={review.created_at}
/>
))}
</div>
</div>
<CreateReviewDialog />
</Dialog>
);
}
- create-review-dialog.tsx
import { StarIcon } from "lucide-react";
import { useState } from "react";
import { Form, useActionData } from "react-router";
import InputPair from "~/common/components/input-pair";
import { Button } from "~/common/components/ui/button";
import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "~/common/components/ui/dialog";
import { Label } from "~/common/components/ui/label";
import type { action } from "../pages/product-reviews-page";
export default function CreateReviewDialog() {
const [rating, setRating] = useState<number>(0);
const [hoveredStar, setHoveredStar] = useState<number>(0);
const actionData = useActionData<typeof action>();
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="text-2xl">
What do you think of this product?
</DialogTitle>
<DialogDescription>
Share your thoughts and experiences with this product.
</DialogDescription>
</DialogHeader>
<Form className="space-y-10" method="post">
<div>
<Label className="flex flex-col gap-1">
Rating
<small className="text-muted-foreground">
What would you rate this product?
</small>
</Label>
<div className="flex gap-2 mt-5">
{[1, 2, 3, 4, 5].map((star) => (
<label
key={star}
className="relative cursor-pointer"
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
>
<StarIcon
className="size-5 text-yellow-400"
fill={
hoveredStar >= star || rating >= star
? "currentColor"
: "none"
}
/>
<input
type="radio"
value={star}
name="rating"
required
className="opacity-0 h-px w-px absolute"
onChange={() => setRating(star)}
/>
</label>
))}
</div>
{actionData?.formErrors?.rating && (
<p className="text-red-500">
{actionData.formErrors.rating.join(", ")}
</p>
)}
</div>
<InputPair
textArea
required
name="review"
label="Review"
description="Maximum 1000 characters"
placeholder="Tell us more about your experience with this product"
/>
{actionData?.formErrors?.review && (
<p className="text-red-500">
{actionData.formErrors.review.join(", ")}
</p>
)}
<DialogFooter>
<Button>Submit review</Button>
</DialogFooter>
</Form>
</DialogContent>
);
}
이 부분도 역시 action 부분을 다른 페이지에서 쓰던 것을 사용함
> 대쉬보드 부분 해보자
- functions/get_dashboard_stats.sql
CREATE OR REPLACE FUNCTION get_dashboard_stats(user_id uuid)
RETURNS TABLE (
views bigint,
month text
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) AS views,
to_char(events.created_at, 'YYYY-MM') AS month
FROM public.events
JOIN public.profiles ON profiles.profile_id = user_id
WHERE event_data ->> 'username' = profiles.username
GROUP BY month;
END;
$$ LANGUAGE plpgsql;
유저 id에 따른 views와 month 추출하는 함수 선언 + npm run db:typegen 실행
이는 그래프 data를 위한 것임
- queries.ts
export const getProductsByUserId = async (
client: SupabaseClient<Database>,
{ userId }: { userId: string }
) => {
const { data, error } = await client
.from("products")
.select(`name, product_id`)
.eq("profile_id", userId);
if (error) {
throw error;
}
return data;
};
대쉬보드에 내가 보유한 product 나열을 위한 쿼리값
- dashboard-layout.tsx
import { HomeIcon, PackageIcon, RocketIcon, SparklesIcon } from "lucide-react";
import { Link, Outlet, useLocation } from "react-router";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "~/common/components/ui/sidebar";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId, getProductsByUserId } from "../queries";
import { Route } from "./+types/dashboard-layout";
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const products = await getProductsByUserId(client, { userId });
return {
userId,
products,
};
};
export default function DashboardLayout({ loaderData }: Route.ComponentProps) {
const location = useLocation();
return (
<SidebarProvider className="flex min-h-full">
<Sidebar className="pt-16" variant="floating">
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname === "/my/dashboard"}
>
<Link to="/my/dashboard">
<HomeIcon className="size-4" />
<span>Home</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={location.pathname === "/my/dashboard/ideas"}
>
<Link to="/my/dashboard/ideas">
<SparklesIcon className="size-4" />
<span>Ideas</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Product Analytics</SidebarGroupLabel>
<SidebarMenu>
{loaderData.products.map((product) => (
<SidebarMenuItem key={product.product_id}>
<SidebarMenuButton asChild>
<Link to={`/my/dashboard/products/${product.product_id}`}>
<RocketIcon className="size-4" />
<span>{product.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<div className="w-full h-full">
<Outlet />
</div>
</SidebarProvider>
);
}
- dashboard-page.tsx
import { Line } from "recharts";
import { ChartConfig, ChartTooltipContent } from "~/common/components/ui/chart";
import { ChartTooltip } from "~/common/components/ui/chart";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import { Route } from "./+types/dashboard-page";
import { ChartContainer } from "~/common/components/ui/chart";
import { CartesianGrid, LineChart, XAxis } from "recharts";
import { getLoggedInUserId } from "../queries";
import { makeSSRClient } from "~/supa-client";
export const meta: Route.MetaFunction = () => {
return [{ title: "Dashboard | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const { data, error } = await client.rpc("get_dashboard_stats", {
user_id: userId,
});
if (error) {
throw error;
}
return {
chartData: data,
};
};
const chartConfig = {
views: {
label: "👁️",
color: "hsl(var(--primary))",
},
} satisfies ChartConfig;
export default function DashboardPage({ loaderData }: Route.ComponentProps) {
return (
<div className="space-y-5">
<h1 className="text-2xl font-semibold mb-6">Dashboard</h1>
<Card className="w-1/2">
<CardHeader>
<CardTitle>Profile views</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={loaderData.chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
padding={{ left: 15, right: 15 }}
/>
<Line
dataKey="views"
type="natural"
stroke="var(--color-views)"
strokeWidth={2}
dot={false}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
</div>
);
}
> 대쉬보드 product 부분
- functions/get_product_stats.sql
CREATE OR REPLACE FUNCTION get_product_stats(product_id text)
RETURNS TABLE (
product_views bigint,
product_visits bigint,
month text
) AS $$
BEGIN
RETURN QUERY
SELECT
SUM(CASE WHEN event_type = 'product_view' THEN 1 ELSE 0 END) AS product_views,
SUM(CASE WHEN event_type = 'product_visit' THEN 1 ELSE 0 END) AS product_visit,
to_char(events.created_at, 'YYYY-MM') AS month
FROM public.events
WHERE event_data ->> 'product_id' = product_id
GROUP BY month;
END;
$$ LANGUAGE plpgsql;
product 그래프를용 값을 위한 함수
- dashboard-layout.tsx
<SidebarMenuButton
asChild
isActive={
location.pathname ===
`/my/dashboard/products/${product.product_id}`
}
>
url 주소에 따라서, 해당 버튼 부분 활성화 시킬 수 있음
- dashboard-product-page.tsx
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import type { Route } from "./+types/dashboard-product-page";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "~/common/components/ui/chart";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "../queries";
import { redirect } from "react-router";
export const meta: Route.MetaFunction = () => {
return [{ title: "Product Dashboard | wemake" }];
};
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const { error } = await client
.from("products")
.select("product_id")
.eq("profile_id", userId)
.eq("product_id", params.productId)
.single();
if (error) {
throw redirect("/my/dashboard/products");
}
const { data, error: rcpError } = await client.rpc("get_product_stats", {
product_id: params.productId,
});
if (rcpError) {
throw error;
}
return {
chartData: data,
};
};
const chartConfig = {
views: {
label: "Page Views",
color: "var(--primary)",
},
visitors: {
label: "Visitors",
color: "var(--chart-3)",
},
} satisfies ChartConfig;
export default function DashboardProductPage({
loaderData,
}: Route.ComponentProps) {
return (
<div className="space-y-5">
<h1 className="text-2xl font-semibold mb-6">Analytics</h1>
<Card className="w-1/2">
<CardHeader>
<CardTitle>Performance</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={loaderData.chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
padding={{ left: 15, right: 15 }}
/>
<Area
dataKey="product_views"
type="natural"
stroke="var(--color-views)"
fill="var(--color-views)"
strokeWidth={2}
dot={false}
/>
<Area
dataKey="product_visits"
type="natural"
stroke="var(--color-visitors)"
fill="var(--color-visitors)"
strokeWidth={2}
dot={false}
/>
<ChartTooltip
cursor={false}
wrapperStyle={{ minWidth: "200px" }}
content={<ChartTooltipContent indicator="dot" />}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
);
}
> 내 프로필 수정을 해보자
- users/mutations.ts
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "~/supa-client";
export const updateUser = async (
client: SupabaseClient<Database>,
{
id,
name,
role,
headline,
bio,
}: {
id: string;
name: string;
role: "developer" | "designer" | "marketer" | "founder" | "product-manager";
headline: string;
bio: string;
}
) => {
const { error } = await client
.from("profiles")
.update({ name, role, headline, bio })
.eq("profile_id", id);
if (error) {
throw error;
}
};
- setting-page.tsx
import { Form } from "react-router";
import type { Route } from "./+types/settings-page";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { useState } from "react";
import { Label } from "~/common/components/ui/label";
import { Input } from "~/common/components/ui/input";
import { Button } from "~/common/components/ui/button";
import { getLoggedInUserId, getUserById } from "../queries";
import { makeSSRClient } from "~/supa-client";
import { z } from "zod";
import { updateUser } from "../mutations";
import {
Alert,
AlertDescription,
AlertTitle,
} from "~/common/components/ui/alert";
export const meta: Route.MetaFunction = () => {
return [{ title: "Settings | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const user = await getUserById(client, { id: userId });
return { user };
};
const formSchema = z.object({
name: z.string().min(3),
role: z.string(),
headline: z.string().optional().default(""),
bio: z.string().optional().default(""),
});
export const action = async ({ request }: 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 { name, role, headline, bio } = data;
await updateUser(client, {
id: userId,
name,
role: role as
| "developer"
| "designer"
| "marketer"
| "founder"
| "product-manager",
headline,
bio,
});
return {
ok: true,
};
};
export default function SettingsPage({
loaderData,
actionData,
}: Route.ComponentProps) {
const [avatar, setAvatar] = useState<string | null>(null);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const file = event.target.files[0];
setAvatar(URL.createObjectURL(file));
}
};
return (
<div className="space-y-20">
<div className="grid grid-cols-6 gap-40">
<div className="col-span-4 flex flex-col gap-10">
{actionData?.ok ? (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>
Your profile has been updated.
</AlertDescription>
</Alert>
) : null}
<h2 className="text-2xl font-semibold">Edit profile</h2>
<Form className="flex flex-col w-1/2 gap-5" method="post">
<InputPair
label="Name"
description="Your public name"
required
id="name"
defaultValue={loaderData.user.name}
name="name"
placeholder="John Doe"
/>
{actionData?.formErrors?.name ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors.name.join(", ")}
</AlertDescription>
</Alert>
) : null}
<SelectPair
label="Role"
defaultValue={loaderData.user.role}
description="What role do you do identify the most with"
name="role"
placeholder="Select a role"
options={[
{ label: "Developer", value: "developer" },
{ label: "Designer", value: "designer" },
{ label: "Product Manager", value: "product-manager" },
{ label: "Founder", value: "founder" },
{ label: "Marketer", value: "marketer" },
]}
/>
{actionData?.formErrors?.role ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors.role.join(", ")}
</AlertDescription>
</Alert>
) : null}
<InputPair
label="Headline"
description="An introduction to your profile."
required
defaultValue={loaderData.user.headline ?? ""}
id="headline"
name="headline"
placeholder="John Doe"
textArea
/>
{actionData?.formErrors?.headline ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors.headline.join(", ")}
</AlertDescription>
</Alert>
) : null}
<InputPair
label="Bio"
description="Your public bio. It will be displayed on your profile page."
required
id="bio"
defaultValue={loaderData.user.bio ?? ""}
name="bio"
placeholder="John Doe"
textArea
/>
{actionData?.formErrors?.bio ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors.bio.join(", ")}
</AlertDescription>
</Alert>
) : null}
<Button className="w-full">Update profile</Button>
</Form>
</div>
<aside className="col-span-2 p-6 rounded-lg border shadow-md">
<Label className="flex flex-col gap-1">
Avatar
<small className="text-muted-foreground">
This is your public avatar.
</small>
</Label>
<div className="space-y-5">
<div className="size-40 rounded-full shadow-xl overflow-hidden ">
{avatar ? (
<img src={avatar} className="object-cover w-full h-full" />
) : null}
</div>
<Input
type="file"
className="w-1/2"
onChange={onChange}
required
name="icon"
/>
<div className="flex flex-col text-xs">
<span className=" text-muted-foreground">
Recommended size: 128x128px
</span>
<span className=" text-muted-foreground">
Allowed formats: PNG, JPEG
</span>
<span className=" text-muted-foreground">Max file size: 1MB</span>
</div>
<Button className="w-full">Update avatar</Button>
</div>
</aside>
</div>
</div>
);
}
- profile-layout.tsx
import { Form, Link, NavLink, Outlet, useOutletContext } from "react-router";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { Badge } from "~/common/components/ui/badge";
import { Button, buttonVariants } from "~/common/components/ui/button";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogContent,
DialogTrigger,
DialogTitle,
} from "~/common/components/ui/dialog";
import { Textarea } from "~/common/components/ui/textarea";
import { cn } from "~/lib/utils";
import type { Route } from "./+types/profile-layout";
import { getUserProfile } from "../queries";
import { makeSSRClient } from "~/supa-client";
export const meta: Route.MetaFunction = ({ data }) => {
return [{ title: `${data.user.name} | wemake` }];
};
export const loader = async ({
request,
params,
}: Route.LoaderArgs & { params: { username: string } }) => {
const { client } = makeSSRClient(request);
const user = await getUserProfile(client, {
username: params.username,
});
return { user };
};
export default function ProfileLayout({
loaderData,
params,
}: Route.ComponentProps & { params: { username: string } }) {
const { isLoggedIn, username } = useOutletContext<{
isLoggedIn: boolean;
username?: string;
}>();
return (
<div className="space-y-10">
<div className="flex items-center gap-4">
<Avatar className="size-40">
{loaderData.user.avatar ? (
<AvatarImage src={loaderData.user.avatar} />
) : (
<AvatarFallback className="text-2xl">
{loaderData.user.name[0]}
</AvatarFallback>
)}
</Avatar>
<div className="space-y-5">
<div className="flex gap-2">
<h1 className="text-2xl font-semibold">{loaderData.user.name}</h1>
{isLoggedIn && username === params.username ? (
<Button variant="outline" asChild>
<Link to="/my/settings">Edit profile</Link>
</Button>
) : null}
{isLoggedIn && username !== params.username ? (
<>
<Button variant="secondary">Follow</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary">Message</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Message</DialogTitle>
</DialogHeader>
<DialogDescription className="space-y-4">
<span className="text-sm text-muted-foreground">
Send a message to John Doe
</span>
<Form className="space-y-4">
<Textarea
placeholder="Message"
className="resize-none"
rows={4}
/>
<Button type="submit">Send</Button>
</Form>
</DialogDescription>
</DialogContent>
</Dialog>
</>
) : null}
</div>
<div className="flex gap-2 items-center">
<span className="text-sm text-muted-foreground">
@{loaderData.user.username}
</span>
<Badge variant={"secondary"} className="capitalize">
{loaderData.user.role}
</Badge>
<Badge variant={"secondary"}>100 followers</Badge>
<Badge variant={"secondary"}>100 following</Badge>
</div>
</div>
</div>
<div className="flex gap-5">
{[
{ label: "About", to: `/users/${loaderData.user.username}` },
{
label: "Products",
to: `/users/${loaderData.user.username}/products`,
},
{ label: "Posts", to: `/users/${loaderData.user.username}/posts` },
].map((item) => (
<NavLink
end
key={item.label}
className={({ isActive }) =>
cn(
buttonVariants({ variant: "outline" }),
isActive && "bg-accent text-foreground "
)
}
to={item.to}
>
{item.label}
</NavLink>
))}
</div>
<div className="max-w-screen-md">
<Outlet
context={{
headline: loaderData.user.headline,
bio: loaderData.user.bio,
}}
/>
</div>
</div>
);
}
> 내 프로필 아바타를 업로드 해보자
- users/mutations.ts
export const updateUserAvatar = async (
client: SupabaseClient<Database>,
{
id,
avatarUrl,
}: {
id: string;
avatarUrl: string;
}
) => {
const { error } = await client
.from("profiles")
.update({ avatar: avatarUrl })
.eq("profile_id", id);
if (error) {
throw error;
}
};
- supabase에서 storage -> bucket 생성
- settings-page.tsx
import { Form } from "react-router";
import type { Route } from "./+types/settings-page";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { useState } from "react";
import { Label } from "~/common/components/ui/label";
import { Input } from "~/common/components/ui/input";
import { Button } from "~/common/components/ui/button";
import { getLoggedInUserId, getUserById } from "../queries";
import { makeSSRClient } from "~/supa-client";
import { z } from "zod";
import { updateUser, updateUserAvatar } from "../mutations";
import {
Alert,
AlertDescription,
AlertTitle,
} from "~/common/components/ui/alert";
export const meta: Route.MetaFunction = () => {
return [{ title: "Settings | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const user = await getUserById(client, { id: userId });
return { user };
};
const formSchema = z.object({
name: z.string().min(3),
role: z.string(),
headline: z.string().optional().default(""),
bio: z.string().optional().default(""),
});
export const action = async ({ request }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const avatar = formData.get("avatar");
if (avatar && avatar instanceof File) {
if (avatar.size <= 2097152 && avatar.type.startsWith("image/")) {
const { data, error } = await client.storage
.from("avatars")
.upload(userId, avatar, {
contentType: avatar.type,
upsert: true,
});
if (error) {
console.log(error);
return { formErrors: { avatar: ["Failed to upload avatar"] } };
}
const {
data: { publicUrl },
} = await client.storage.from("avatars").getPublicUrl(data.path);
await updateUserAvatar(client, {
id: userId,
avatarUrl: publicUrl,
});
} else {
return { formErrors: { avatar: ["Invalid file size or type"] } };
}
} else {
const { success, error, data } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return { formErrors: error.flatten().fieldErrors };
}
const { name, role, headline, bio } = data;
await updateUser(client, {
id: userId,
name,
role: role as
| "developer"
| "designer"
| "marketer"
| "founder"
| "product-manager",
headline,
bio,
});
return {
ok: true,
};
}
};
export default function SettingsPage({
loaderData,
actionData,
}: Route.ComponentProps) {
const [avatar, setAvatar] = useState<string | null>(null);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const file = event.target.files[0];
setAvatar(URL.createObjectURL(file));
}
};
return (
<div className="space-y-20">
<div className="grid grid-cols-6 gap-40">
<div className="col-span-4 flex flex-col gap-10">
{actionData?.ok ? (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>
Your profile has been updated.
</AlertDescription>
</Alert>
) : null}
<h2 className="text-2xl font-semibold">Edit profile</h2>
<Form className="flex flex-col w-1/2 gap-5" method="post">
<InputPair
label="Name"
description="Your public name"
required
id="name"
defaultValue={loaderData.user.name}
name="name"
placeholder="John Doe"
/>
{actionData?.formErrors && "name" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.name?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<SelectPair
label="Role"
defaultValue={loaderData.user.role}
description="What role do you do identify the most with"
name="role"
placeholder="Select a role"
options={[
{ label: "Developer", value: "developer" },
{ label: "Designer", value: "designer" },
{ label: "Product Manager", value: "product-manager" },
{ label: "Founder", value: "founder" },
{ label: "Marketer", value: "marketer" },
]}
/>
{actionData?.formErrors && "role" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.role?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<InputPair
label="Headline"
description="An introduction to your profile."
required
defaultValue={loaderData.user.headline ?? ""}
id="headline"
name="headline"
placeholder="John Doe"
textArea
/>
{actionData?.formErrors && "headline" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.headline?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<InputPair
label="Bio"
description="Your public bio. It will be displayed on your profile page."
required
id="bio"
defaultValue={loaderData.user.bio ?? ""}
name="bio"
placeholder="John Doe"
textArea
/>
{actionData?.formErrors && "bio" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.bio?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<Button className="w-full">Update profile</Button>
</Form>
</div>
<Form
className="col-span-2 p-6 rounded-lg border shadow-md"
method="post"
encType="multipart/form-data"
>
<Label className="flex flex-col gap-1">
Avatar
<small className="text-muted-foreground">
This is your public avatar.
</small>
</Label>
<div className="space-y-5">
<div className="size-40 rounded-full shadow-xl overflow-hidden ">
{avatar ? (
<img src={avatar} className="object-cover w-full h-full" />
) : null}
</div>
<Input
type="file"
className="w-1/2"
onChange={onChange}
required
name="avatar"
/>
{actionData?.formErrors && "avatar" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors.avatar.join(", ")}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col text-xs">
<span className=" text-muted-foreground">
Recommended size: 128x128px
</span>
<span className=" text-muted-foreground">
Allowed formats: PNG, JPEG
</span>
<span className=" text-muted-foreground">Max file size: 1MB</span>
</div>
<Button className="w-full">Update avatar</Button>
</div>
</Form>
</div>
</div>
);
}
파일 업로드를 시도 하면 아래와 같은 에러가 나온다. 이 문제를 다음 내용에서 해결해보자
{
statusCode: '403',
error: 'Unauthorized',
message: 'new row violates row-level security policy'
}
row-level security는 말 그대로 low level에서 보안설정하는 방법이다. supabase에서 storage -> policies에서 이를 설정해 줄 수 있다.
expression 내용의 의미는, bucket id는 avatars 명이어야 하고, 폴더 명은 유저 id와 같다는 것이다.
- settings-page.tsx
import { Form } from "react-router";
import type { Route } from "./+types/settings-page";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { useState } from "react";
import { Label } from "~/common/components/ui/label";
import { Input } from "~/common/components/ui/input";
import { Button } from "~/common/components/ui/button";
import { getLoggedInUserId, getUserById } from "../queries";
import { makeSSRClient } from "~/supa-client";
import { z } from "zod";
import { updateUser, updateUserAvatar } from "../mutations";
import {
Alert,
AlertDescription,
AlertTitle,
} from "~/common/components/ui/alert";
export const meta: Route.MetaFunction = () => {
return [{ title: "Settings | wemake" }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const user = await getUserById(client, { id: userId });
return { user };
};
const formSchema = z.object({
name: z.string().min(3),
role: z.string(),
headline: z.string().optional().default(""),
bio: z.string().optional().default(""),
});
export const action = async ({ request }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const avatar = formData.get("avatar");
if (avatar && avatar instanceof File) {
if (avatar.size <= 2097152 && avatar.type.startsWith("image/")) {
const { data, error } = await client.storage
.from("avatars")
.upload(`${userId}/${Date.now()}`, avatar, {
contentType: avatar.type,
upsert: false,
});
if (error) {
console.log(error);
return { formErrors: { avatar: ["Failed to upload avatar"] } };
}
const {
data: { publicUrl },
} = await client.storage.from("avatars").getPublicUrl(data.path);
await updateUserAvatar(client, {
id: userId,
avatarUrl: publicUrl,
});
} else {
return { formErrors: { avatar: ["Invalid file size or type"] } };
}
} else {
const { success, error, data } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return { formErrors: error.flatten().fieldErrors };
}
const { name, role, headline, bio } = data;
await updateUser(client, {
id: userId,
name,
role: role as
| "developer"
| "designer"
| "marketer"
| "founder"
| "product-manager",
headline,
bio,
});
return {
ok: true,
};
}
};
export default function SettingsPage({
loaderData,
actionData,
}: Route.ComponentProps) {
const [avatar, setAvatar] = useState<string | null>(loaderData.user.avatar);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const file = event.target.files[0];
setAvatar(URL.createObjectURL(file));
}
};
return (
<div className="space-y-20">
<div className="grid grid-cols-6 gap-40">
<div className="col-span-4 flex flex-col gap-10">
{actionData?.ok ? (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>
Your profile has been updated.
</AlertDescription>
</Alert>
) : null}
<h2 className="text-2xl font-semibold">Edit profile</h2>
<Form className="flex flex-col w-1/2 gap-5" method="post">
<InputPair
label="Name"
description="Your public name"
required
id="name"
defaultValue={loaderData.user.name}
name="name"
placeholder="John Doe"
/>
{actionData?.formErrors && "name" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.name?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<SelectPair
label="Role"
defaultValue={loaderData.user.role}
description="What role do you do identify the most with"
name="role"
placeholder="Select a role"
options={[
{ label: "Developer", value: "developer" },
{ label: "Designer", value: "designer" },
{ label: "Product Manager", value: "product-manager" },
{ label: "Founder", value: "founder" },
{ label: "Marketer", value: "marketer" },
]}
/>
{actionData?.formErrors && "role" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.role?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<InputPair
label="Headline"
description="An introduction to your profile."
required
defaultValue={loaderData.user.headline ?? ""}
id="headline"
name="headline"
placeholder="John Doe"
textArea
/>
{actionData?.formErrors && "headline" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.headline?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<InputPair
label="Bio"
description="Your public bio. It will be displayed on your profile page."
required
id="bio"
defaultValue={loaderData.user.bio ?? ""}
name="bio"
placeholder="John Doe"
textArea
/>
{actionData?.formErrors && "bio" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors?.bio?.join(", ")}
</AlertDescription>
</Alert>
) : null}
<Button className="w-full">Update profile</Button>
</Form>
</div>
<Form
className="col-span-2 p-6 rounded-lg border shadow-md"
method="post"
encType="multipart/form-data"
>
<Label className="flex flex-col gap-1">
Avatar
<small className="text-muted-foreground">
This is your public avatar.
</small>
</Label>
<div className="space-y-5">
<div className="size-40 rounded-full shadow-xl overflow-hidden ">
{avatar ? (
<img src={avatar} className="object-cover w-full h-full" />
) : null}
</div>
<Input
type="file"
className="w-1/2"
onChange={onChange}
required
name="avatar"
/>
{actionData?.formErrors && "avatar" in actionData?.formErrors ? (
<Alert>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{actionData.formErrors.avatar.join(", ")}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col text-xs">
<span className=" text-muted-foreground">
Recommended size: 128x128px
</span>
<span className=" text-muted-foreground">
Allowed formats: PNG, JPEG
</span>
<span className=" text-muted-foreground">Max file size: 1MB</span>
</div>
<Button className="w-full">Update avatar</Button>
</div>
</Form>
</div>
</div>
);
}
- 유저명/시간 이렇게 파일트리를 만든 이유는, 같은 url주소면 업데이트 되더라도 캐시때문에 웹 클라이언트에서 업데이트가 된걸로 보이지 않는다. 따라서 업데이트 되었을 때 url주소가 바뀌게 하기 위해서 파일 저장을 유저명/시간 이렇게 되도록 했다.
> Products submit 페이지 만들어보자
- products/mutations.ts
export const createProduct = async (
client: SupabaseClient<Database>,
{
name,
tagline,
description,
howItWorks,
url,
iconUrl,
categoryId,
userId,
}: {
name: string;
tagline: string;
description: string;
howItWorks: string;
url: string;
iconUrl: string;
categoryId: number;
userId: string;
}
) => {
const { data, error } = await client
.from("products")
.insert({
name,
tagline,
description,
how_it_works: howItWorks,
url,
icon: iconUrl,
category_id: categoryId,
profile_id: userId,
})
.select("product_id")
.single();
if (error) throw error;
return data.product_id;
};
- submit-product-page.tsx
import { Hero } from "~/common/components/hero";
import type { Route } from "./+types/submit-product-page";
import { Form, redirect } from "react-router";
import InputPair from "~/common/components/input-pair";
import SelectPair from "~/common/components/select-pair";
import { Input } from "~/common/components/ui/input";
import { Label } from "~/common/components/ui/label";
import { useState } from "react";
import { Button } from "~/common/components/ui/button";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId } from "~/features/users/queries";
import { z } from "zod";
import { getCategories } from "../queries";
import { createProduct } from "../mutations";
export const meta: Route.MetaFunction = () => {
return [
{ title: "Submit Product | wemake" },
{ name: "description", content: "Submit your product" },
];
};
const formSchema = z.object({
name: z.string().min(1),
tagline: z.string().min(1),
url: z.string().min(1),
description: z.string().min(1),
howItWorks: z.string().min(1),
category: z.coerce.number(),
icon: z.instanceof(File).refine((file) => {
return file.size <= 2097152 && file.type.startsWith("image/");
}),
});
export const action = async ({ request }: Route.ActionArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const { data, success, error } = formSchema.safeParse(
Object.fromEntries(formData)
);
if (!success) {
return { formErrors: error.flatten().fieldErrors };
}
const { icon, ...rest } = data;
const { data: uploadData, error: uploadError } = await client.storage
.from("icons")
.upload(`${userId}/${Date.now()}`, icon, {
contentType: icon.type,
upsert: false,
});
if (uploadError) {
return { formErrors: { icon: ["Failed to upload icon"] } };
}
const {
data: { publicUrl },
} = await client.storage.from("icons").getPublicUrl(uploadData.path);
const productId = await createProduct(client, {
name: rest.name,
tagline: rest.tagline,
description: rest.description,
howItWorks: rest.howItWorks,
url: rest.url,
iconUrl: publicUrl,
categoryId: rest.category,
userId,
});
return redirect(`/products/${productId}`);
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const categories = await getCategories(client);
return { categories };
};
export default function SubmitPage({
loaderData,
actionData,
}: Route.ComponentProps) {
const [icon, setIcon] = useState<string | null>(null);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const file = event.target.files[0];
setIcon(URL.createObjectURL(file));
}
};
return (
<div>
<Hero
title="Submit Your Product"
subtitle="Share your product with the world"
/>
<Form
method="post"
encType="multipart/form-data"
className="grid grid-cols-2 gap-10 max-w-screen-lg mx-auto"
>
<div className="space-y-5">
<InputPair
label="Name"
description="This is the name of your product"
id="name"
name="name"
type="text"
required
placeholder="Name of your product"
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.name && (
<p className="text-red-500">{actionData.formErrors.name}</p>
)}
<InputPair
label="Tagline"
description="60 characters or less"
id="tagline"
name="tagline"
required
type="text"
placeholder="A concise description of your product"
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.tagline && (
<p className="text-red-500">{actionData.formErrors.tagline}</p>
)}
<InputPair
label="URL"
description="The URL of your product"
id="url"
name="url"
required
type="url"
placeholder="https://example.com"
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.url && (
<p className="text-red-500">{actionData.formErrors.url}</p>
)}
<InputPair
textArea
label="Description"
description="A detailed description of your product"
id="description"
name="description"
required
type="text"
placeholder="A detailed description of your product"
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.description && (
<p className="text-red-500">
{actionData.formErrors.description}
</p>
)}
<InputPair
textArea
label="How it works"
description="A detailed description of how your product howItWorks"
id="howItWorks"
name="howItWorks"
required
type="text"
placeholder="A detailed description of how your product works"
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.howItWorks && (
<p className="text-red-500">{actionData.formErrors.howItWorks}</p>
)}
<SelectPair
label="Category"
description="The category of your product"
name="category"
required
placeholder="Select a category"
options={loaderData.categories.map((category) => ({
label: category.name,
value: category.category_id.toString(),
}))}
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.category && (
<p className="text-red-500">{actionData.formErrors.category}</p>
)}
<Button type="submit" className="w-full" size="lg">
Submit
</Button>
</div>
<div className="flex flex-col space-y-2">
<div className="size-40 rounded-xl shadow-xl overflow-hidden ">
{icon ? (
<img src={icon} className="object-cover w-full h-full" />
) : null}
</div>
<Label className="flex flex-col gap-1">
Icon
<small className="text-muted-foreground">
This is the icon of your product.
</small>
</Label>
<Input
type="file"
className="w-1/2"
onChange={onChange}
required
name="icon"
/>
{actionData &&
"formErrors" in actionData &&
actionData?.formErrors?.icon && (
<p className="text-red-500">{actionData.formErrors.icon}</p>
)}
<div className="flex flex-col text-xs">
<span className=" text-muted-foreground">
Recommended size: 128x128px
</span>
<span className=" text-muted-foreground">
Allowed formats: PNG, JPEG
</span>
<span className=" text-muted-foreground">Max file size: 1MB</span>
</div>
</div>
</Form>
</div>
);
}
> upvotes 부분을 해보자
upvote 부분은 로그인한 유저가 upvote를 눌렀는지 여부에 따라 화면 표시를 다르게 해주어야 한다. 이를 구현하려면 supabase sql 구문에 로그인한 유저의 id를 인식하여 구분하는 방법을 써야 한다.
- community-post-list-view.sql
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,
(SELECT EXISTS (SELECT 1 FROM public.post_upvotes WHERE post_upvotes.post_id = posts.post_id AND post_upvotes.profile_id = auth.uid())) AS is_upvoted
FROM posts
INNER JOIN topics USING (topic_id)
INNER JOIN profiles USING (profile_id);
마지막 서브쿼리 부분이 로그인한 유저가 해당 post의 upvote를 눌렀는지 true/false를 알려주는 부분이다
- community-post-detail.sql
CREATE OR REPLACE VIEW community_post_detail AS
SELECT
posts.post_id,
posts.title,
posts.content,
posts.upvotes,
posts.created_at,
topics.topic_id,
topics.name as topic_name,
topics.slug as topic_slug,
COUNT(post_replies.post_reply_id) as replies,
profiles.name as author_name,
profiles.avatar as author_avatar,
profiles.role as author_role,
profiles.created_at as author_created_at,
(SELECT COUNT(*) FROM products WHERE products.profile_id = profiles.profile_id) as products,
(SELECT EXISTS (SELECT 1 FROM public.post_upvotes WHERE post_upvotes.post_id = posts.post_id AND post_upvotes.profile_id = auth.uid())) AS is_upvoted
FROM posts
INNER JOIN topics USING (topic_id)
LEFT JOIN post_replies USING (post_id)
INNER JOIN profiles ON (profiles.profile_id = posts.profile_id)
GROUP BY posts.post_id, topics.topic_id, topics.name, topics.slug, profiles.name, profiles.avatar, profiles.role, profiles.created_at, profiles.profile_id;
같은 로직으로 위 sql에도 마지막 서브쿼리에 넣어 주었다.
- community-page.tsx
<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}
isUpvoted={post.is_upvoted}
expanded
/>
isUpvoted 추가
- post-card.tsx
import { Link } 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) {
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
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>
);
}
- post-page.tsx
여기에도 추가