Skip to content

Commit

Permalink
feat: create guild page (#1556)
Browse files Browse the repository at this point in the history
* feat: simple guild page

* feat: simple create guild page

* fix: better schema names

* fix(CreateGuildButton): stringify body only once

* fix: use `onTouched` form mode

* feat: implement image uploader, improve fetcher and error messages

* feat: add background image

* feat: add success and error toasts

* feat(ImageUploader): `onFileInputChange` prop

* fix: add `aria-required` attributes

* refactor: simplify the image uploader (#1561)

* fix: address typo in request pathname

* feat: add option for toast instead of form message

* refactor: simplify the image uploader

---------

Co-authored-by: BrickheadJohnny <[email protected]>

---------

Co-authored-by: Dominik Stumpf <[email protected]>
  • Loading branch information
BrickheadJohnny and dominik-stumpf authored Nov 25, 2024
1 parent b05474d commit b3cfc89
Show file tree
Hide file tree
Showing 26 changed files with 589 additions and 44 deletions.
33 changes: 0 additions & 33 deletions guild.d.ts

This file was deleted.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@
"foxact": "^0.2.41",
"jotai": "^2.10.2",
"jwt-decode": "^4.0.0",
"mini-svg-data-uri": "^1.4.4",
"next": "15.0.3",
"next-themes": "^0.4.3",
"pinata-web3": "^0.5.2",
"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",
"react-markdown": "^9.0.1",
"rehype-external-links": "^3.0.0",
"rehype-slug": "^6.0.0",
Expand Down
19 changes: 19 additions & 0 deletions src/actions/getPinataKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use server";

import { pinata } from "@/config/pinata.server";

export const getPinataKey = async () => {
const uuid = crypto.randomUUID();
const keyData = await pinata.keys.create({
keyName: uuid.toString(),
permissions: {
endpoints: {
pinning: {
pinFileToIPFS: true,
},
},
},
maxUses: 1,
});
return keyData;
};
27 changes: 27 additions & 0 deletions src/app/[guild]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Metadata } from "next";

type Props = {
params: Promise<{ guild: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const urlName = (await params).guild;

return {
title: urlName,
};
}

const GuildPage = async ({ params }: Props) => {
const urlName = (await params).guild;

return (
<div className="grid gap-4">
<p>Guild page</p>
<p>{`URL name: ${urlName}`}</p>
</div>
);
};

export default GuildPage;
80 changes: 80 additions & 0 deletions src/app/create-guild/components/CreateGuildButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"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 { CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useFormContext } from "react-hook-form";
import slugify from "slugify";
import { toast } from "sonner";

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,
"Content-Type": "application/json",
},
body: JSON.stringify(guild),
});
},
onError: (error) => {
// TODO: parse the error and display it in a user-friendly way
toast("An error occurred", {
icon: <XCircle weight="fill" className="text-icon-error" />,
});
console.error(error);
},
onSuccess: (res) => {
confetti.current();
toast("Guild successfully created", {
description: "You're being redirected to its page",
icon: <CheckCircle weight="fill" className="text-icon-success" />,
});
router.push(`/${res.urlName}`);
},
});

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

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

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

import { ImageUploader } from "@/components/ImageUploader";
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, setValue } = useFormContext<CreateGuildFormType>();

return (
<>
<div className="mx-auto size-32 rounded-full bg-input-background">
<ImageUploader
onSuccess={(imageUrl) =>
setValue("imageUrl", imageUrl, {
shouldDirty: true,
})
}
className="size-32"
/>
</div>

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

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

<FormField
control={control}
name="contact"
render={({ field }) => (
<FormItem>
<FormLabel aria-required>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, CreateGuildSchema } 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: "onTouched",
resolver: zodResolver(CreateGuildSchema),
defaultValues,
});

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

export { CreateGuildFormProvider };
47 changes: 47 additions & 0 deletions src/app/create-guild/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { AuthBoundary } from "@/components/AuthBoundary";
import { ConfettiProvider } from "@/components/ConfettiProvider";
import { SignInButton } from "@/components/SignInButton";
import { Card } from "@/components/ui/Card";
import svgToTinyDataUri from "mini-svg-data-uri";
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-16">
<div
className="-z-10 absolute inset-0 opacity-40 dark:opacity-60"
style={{
background: `radial-gradient(ellipse at center, transparent -250%, var(--background) 80%), url("${svgToTinyDataUri(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="30" height="30" fill="none" stroke="#666"><path d="M0 .5H31.5V32"/></svg>`,
)}")`,
}}
/>

<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>

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

<AuthBoundary
fallback={<SignInButton size="xl" colorScheme="primary" />}
>
<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
Loading

0 comments on commit b3cfc89

Please sign in to comment.