Skip to content

Commit

Permalink
feat: simple create guild page
Browse files Browse the repository at this point in the history
  • Loading branch information
BrickheadJohnny committed Nov 22, 2024
1 parent aad6a98 commit efde5c6
Show file tree
Hide file tree
Showing 17 changed files with 368 additions and 42 deletions.
33 changes: 0 additions & 33 deletions guild.d.ts

This file was deleted.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@
"next": "15.0.3",
"next-themes": "^0.4.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-canvas-confetti": "^2.0.7",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-hook-form": "^7.53.2",
"slugify": "^1.6.6",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
Expand Down
68 changes: 68 additions & 0 deletions src/app/create-guild/components/CreateGuildButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"use client";

import { useConfetti } from "@/components/ConfettiProvider";
import { Button } from "@/components/ui/Button";
import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants";
import { env } from "@/lib/env";
import { fetcher } from "@/lib/fetcher";
import { getCookie } from "@/lib/getCookie";
import type { CreateGuildForm, Guild } from "@/lib/schemas/guild";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useFormContext } from "react-hook-form";
import slugify from "slugify";

const CreateGuildButton = () => {
const { handleSubmit } = useFormContext<CreateGuildForm>();

const confetti = useConfetti();

const router = useRouter();

const { mutate: onSubmit, isPending } = useMutation({
mutationFn: async (data: CreateGuildForm) => {
const token = getCookie(GUILD_AUTH_COOKIE_NAME);

if (!token) throw new Error("Unauthorized"); // TODO: custom errors?

const guild = {
...data,
contact: undefined,
// TODO: I think we should do it on the backend
urlName: slugify(data.name, {
replacement: "-",
lower: true,
strict: true,
}),
};

return fetcher<Guild>(`${env.NEXT_PUBLIC_API}/guild`, {
method: "POST",
headers: {
"X-Auth-Token": token,
},
body: JSON.stringify(guild),
});
},
onError: (error) => console.error(error),
onSuccess: (res) => {
confetti.current();
router.push(`/${res.urlName}`);
console.log(res);
},
});

return (
<Button
colorScheme="success"
size="xl"
isLoading={isPending}
loadingText="Creating guild"
onClick={handleSubmit((data) => onSubmit(data))}
>
Create guild
</Button>
);
};

export { CreateGuildButton };
51 changes: 51 additions & 0 deletions src/app/create-guild/components/CreateGuildForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import { useFormContext } from "react-hook-form";

import {
FormControl,
FormErrorMessage,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/Form";
import { Input } from "@/components/ui/Input";
import type { CreateGuildForm as CreateGuildFormType } from "@/lib/schemas/guild";

export const CreateGuildForm = () => {
const { control } = useFormContext<CreateGuildFormType>();

return (
<>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Guild name</FormLabel>
<FormControl>
<Input size="lg" {...field} />
</FormControl>

<FormErrorMessage />
</FormItem>
)}
/>

<FormField
control={control}
name="contact"
render={({ field }) => (
<FormItem>
<FormLabel>E-mail address</FormLabel>
<FormControl>
<Input size="lg" {...field} />
</FormControl>

<FormErrorMessage />
</FormItem>
)}
/>
</>
);
};
25 changes: 25 additions & 0 deletions src/app/create-guild/components/CreateGuildFormProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { type CreateGuildForm, GuildSchema } from "@/lib/schemas/guild";
import { zodResolver } from "@hookform/resolvers/zod";
import type { PropsWithChildren } from "react";
import { FormProvider, useForm } from "react-hook-form";

const defaultValues = {
name: "",
imageUrl: "",
urlName: "test",
contact: "",
} satisfies CreateGuildForm;

const CreateGuildFormProvider = ({ children }: PropsWithChildren) => {
const methods = useForm<CreateGuildForm>({
mode: "all",
resolver: zodResolver(GuildSchema),
defaultValues,
});

return <FormProvider {...methods}>{children}</FormProvider>;
};

export { CreateGuildFormProvider };
38 changes: 38 additions & 0 deletions src/app/create-guild/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AuthBoundary } from "@/components/AuthBoundary";
import { ConfettiProvider } from "@/components/ConfettiProvider";
import { SignInButton } from "@/components/SignInButton";
import { Card } from "@/components/ui/Card";
import { CreateGuildButton } from "./components/CreateGuildButton";
import { CreateGuildForm } from "./components/CreateGuildForm";
import { CreateGuildFormProvider } from "./components/CreateGuildFormProvider";

export const metadata = {
title: "Begin your guild",
};

const CreateGuild = () => (
<main className="container mx-auto grid max-w-lg gap-8 px-4 py-8">
{/* TODO: make a common layout component & use it here too */}
<CreateGuildFormProvider>
<ConfettiProvider>
<Card className="flex flex-col px-5 py-6 shadow-lg md:px-6">
<h2 className="mb-7 text-center font-display font-extrabold text-2xl">
Begin your guild
</h2>

{/* TODO: <CreateGuildImageUploader /> */}

<div className="mb-8 flex flex-col gap-4">
<CreateGuildForm />
</div>

<AuthBoundary fallback={<SignInButton size="xl" />}>
<CreateGuildButton />
</AuthBoundary>
</Card>
</ConfettiProvider>
</CreateGuildFormProvider>
</main>
);

export default CreateGuild;
1 change: 1 addition & 0 deletions src/app/explorer/components/GuildCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { Skeleton } from "@/components/ui/Skeleton";
import type { Guild } from "@/lib/schemas/guild";
import { ImageSquare, Users } from "@phosphor-icons/react/dist/ssr";
import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar";
import Link from "next/link";
Expand Down
2 changes: 2 additions & 0 deletions src/app/explorer/fetchers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { env } from "@/lib/env";
import type { Guild } from "@/lib/schemas/guild";
import type { PaginatedResponse } from "@/lib/types";
import { PAGE_SIZE } from "./constants";

export const getGuildSearch =
Expand Down
2 changes: 2 additions & 0 deletions src/app/explorer/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AuthBoundary } from "@/components/AuthBoundary";
import { SignInButton } from "@/components/SignInButton";
import { env } from "@/lib/env";
import type { Guild } from "@/lib/schemas/guild";
import type { PaginatedResponse } from "@/lib/types";
import {
HydrationBoundary,
QueryClient,
Expand Down
99 changes: 99 additions & 0 deletions src/components/ConfettiProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"use client";

import {
type MutableRefObject,
type PropsWithChildren,
createContext,
useContext,
useRef,
} from "react";
import ReactCanvasConfetti from "react-canvas-confetti/dist";
import type { TCanvasConfettiInstance } from "react-canvas-confetti/dist/types";

const doubleConfetti = (confetti: TCanvasConfettiInstance) => {
const count = 200;
const defaultsPerBarrage: confetti.Options[] = [
{
origin: { x: -0.05 },
angle: 50,
},
{
origin: { x: 1.05 },
angle: 130,
},
] as const;

const fire = (particleRatio: number, opts: confetti.Options) => {
confetti({
...opts,
particleCount: Math.floor(count * particleRatio),
});
};

for (const defaults of defaultsPerBarrage) {
fire(0.25, {
spread: 26,
startVelocity: 55,
...defaults,
});
fire(0.2, {
spread: 60,
...defaults,
});
fire(0.35, {
spread: 100,
decay: 0.91,
scalar: 0.8,
...defaults,
});
fire(0.1, {
spread: 120,
startVelocity: 25,
decay: 0.92,
scalar: 1.2,
...defaults,
});
fire(0.1, {
spread: 120,
startVelocity: 45,
...defaults,
});
}
};

const ConfettiContext = createContext<MutableRefObject<ConfettiPlayer>>(
{} as MutableRefObject<ConfettiPlayer>,
);

export const useConfetti = () => useContext(ConfettiContext);

type ConfettiPlayer = () => void;

export const ConfettiProvider = ({ children }: PropsWithChildren) => {
const confettiRef = useRef<ConfettiPlayer>(() => {
return;
});

const onInitHandler = ({
confetti,
}: { confetti: TCanvasConfettiInstance }) => {
const confettiClosure: ConfettiPlayer = () => {
doubleConfetti(confetti);
};
confettiRef.current = confettiClosure;
};

return (
<ConfettiContext.Provider value={confettiRef}>
{children}
<ReactCanvasConfetti
onInit={onInitHandler}
globalOptions={{
disableForReducedMotion: true,
useWorker: true,
resize: true,
}}
/>
</ConfettiContext.Provider>
);
};
2 changes: 1 addition & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Card } from "./ui/Card";
export const Header = () => (
<header className="flex h-14 items-center justify-between gap-4 p-2">
{/* TODO: NavMenu component */}
<Card className="h-10 w-24 rounded-xl" />
<Card className="h-11 w-24 rounded-xl" />

<Card className="rounded-xl">
<AuthBoundary fallback={<SignInButton variant="ghost" />}>
Expand Down
8 changes: 1 addition & 7 deletions src/components/SignInButton.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
"use client";

import { signInDialogOpenAtom } from "@/config/atoms";
import { cn } from "@/lib/cssUtils";
import { SignIn } from "@phosphor-icons/react/dist/ssr";
import { useSetAtom } from "jotai";
import type { ComponentProps } from "react";
import { Button } from "./ui/Button";

export const SignInButton = ({
className,
...props
}: ComponentProps<typeof Button>) => {
export const SignInButton = (props: ComponentProps<typeof Button>) => {
const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom);

return (
<Button
className={cn("h-10", className)}
variant="solid"
{...props}
leftIcon={<SignIn weight="bold" />}
onClick={() => setSignInDialogOpen(true)}
Expand Down
1 change: 0 additions & 1 deletion src/components/SignOutButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export const SignOutButton = () => {
const pathname = usePathname();
return (
<Button
className="h-10"
variant="ghost"
leftIcon={<SignOut weight="bold" />}
onClick={() => signOut(pathname)}
Expand Down
Loading

0 comments on commit efde5c6

Please sign in to comment.