diff --git a/guild.d.ts b/guild.d.ts deleted file mode 100644 index 58eafbad35..0000000000 --- a/guild.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -// dumping here types util it comes down properly - -export {} - -declare global { - type Guild = { - name: string; - id: string; - urlName: string; - createdAt: number; - updatedAt: number; - description: string; - imageUrl: string; - backgroundImageUrl: string; - visibility: Record; - settings: Record; - searchTags: string[]; - categoryTags: string[]; - socialLinks: Record; - owner: string; - }; - - type PaginatedResponse = { - page: number; - pageSize: number; - sortBy: string; - reverse: boolean; - searchQuery: string; - query: string; - items: Item[]; - total: number; - }; -} diff --git a/package.json b/package.json index aca2d8e80c..d0c0d4cef5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/getPinataKey.ts b/src/actions/getPinataKey.ts new file mode 100644 index 0000000000..6479d9e379 --- /dev/null +++ b/src/actions/getPinataKey.ts @@ -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; +}; diff --git a/src/app/[guild]/page.tsx b/src/app/[guild]/page.tsx new file mode 100644 index 0000000000..b31d944b7e --- /dev/null +++ b/src/app/[guild]/page.tsx @@ -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 { + const urlName = (await params).guild; + + return { + title: urlName, + }; +} + +const GuildPage = async ({ params }: Props) => { + const urlName = (await params).guild; + + return ( +
+

Guild page

+

{`URL name: ${urlName}`}

+
+ ); +}; + +export default GuildPage; diff --git a/src/app/create-guild/components/CreateGuildButton.tsx b/src/app/create-guild/components/CreateGuildButton.tsx new file mode 100644 index 0000000000..741ca7725f --- /dev/null +++ b/src/app/create-guild/components/CreateGuildButton.tsx @@ -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(); + + 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(`${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: , + }); + console.error(error); + }, + onSuccess: (res) => { + confetti.current(); + toast("Guild successfully created", { + description: "You're being redirected to its page", + icon: , + }); + router.push(`/${res.urlName}`); + }, + }); + + return ( + + ); +}; + +export { CreateGuildButton }; diff --git a/src/app/create-guild/components/CreateGuildForm.tsx b/src/app/create-guild/components/CreateGuildForm.tsx new file mode 100644 index 0000000000..c3d1aeeb4b --- /dev/null +++ b/src/app/create-guild/components/CreateGuildForm.tsx @@ -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(); + + return ( + <> +
+ + setValue("imageUrl", imageUrl, { + shouldDirty: true, + }) + } + className="size-32" + /> +
+ + ( + + Guild name + + + + + + + )} + /> + + ( + + E-mail address + + + + + + + )} + /> + + ); +}; diff --git a/src/app/create-guild/components/CreateGuildFormProvider.tsx b/src/app/create-guild/components/CreateGuildFormProvider.tsx new file mode 100644 index 0000000000..b1cc73d122 --- /dev/null +++ b/src/app/create-guild/components/CreateGuildFormProvider.tsx @@ -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({ + mode: "onTouched", + resolver: zodResolver(CreateGuildSchema), + defaultValues, + }); + + return {children}; +}; + +export { CreateGuildFormProvider }; diff --git a/src/app/create-guild/page.tsx b/src/app/create-guild/page.tsx new file mode 100644 index 0000000000..345d643884 --- /dev/null +++ b/src/app/create-guild/page.tsx @@ -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 = () => ( +
+
`, + )}")`, + }} + /> + + + + +

+ Begin your guild +

+ +
+ +
+ + } + > + + +
+
+
+
+); + +export default CreateGuild; diff --git a/src/app/explorer/components/GuildCard.tsx b/src/app/explorer/components/GuildCard.tsx index cef6659310..7c9ba80f0c 100644 --- a/src/app/explorer/components/GuildCard.tsx +++ b/src/app/explorer/components/GuildCard.tsx @@ -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"; diff --git a/src/app/explorer/fetchers.ts b/src/app/explorer/fetchers.ts index f2ce54db18..7bfc490ea4 100644 --- a/src/app/explorer/fetchers.ts +++ b/src/app/explorer/fetchers.ts @@ -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 = diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx index 34803c5bed..5c7f511e53 100644 --- a/src/app/explorer/page.tsx +++ b/src/app/explorer/page.tsx @@ -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, diff --git a/src/components/ConfettiProvider.tsx b/src/components/ConfettiProvider.tsx new file mode 100644 index 0000000000..1878f5f2e3 --- /dev/null +++ b/src/components/ConfettiProvider.tsx @@ -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>( + {} as MutableRefObject, +); + +export const useConfetti = () => useContext(ConfettiContext); + +type ConfettiPlayer = () => void; + +export const ConfettiProvider = ({ children }: PropsWithChildren) => { + const confettiRef = useRef(() => { + return; + }); + + const onInitHandler = ({ + confetti, + }: { confetti: TCanvasConfettiInstance }) => { + const confettiClosure: ConfettiPlayer = () => { + doubleConfetti(confetti); + }; + confettiRef.current = confettiClosure; + }; + + return ( + + {children} + + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2d18301eb7..b6beb9de72 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,7 +6,7 @@ import { Card } from "./ui/Card"; export const Header = () => (
{/* TODO: NavMenu component */} - + }> diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx new file mode 100644 index 0000000000..115cd7eb11 --- /dev/null +++ b/src/components/ImageUploader.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { getPinataKey } from "@/actions/getPinataKey"; +import { pinata } from "@/config/pinata.client"; +import { cn } from "@/lib/cssUtils"; +import { + CircleNotch, + UploadSimple, + XCircle, +} from "@phosphor-icons/react/dist/ssr"; +import { useMutation } from "@tanstack/react-query"; +import { type InputHTMLAttributes, useCallback, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button, type ButtonProps } from "./ui/Button"; + +type Props = Omit & { + maxSizeMB?: number; + onSuccess?: (imageUrl: string) => void; + onError?: (error: string) => void; + onFileInputChange?: InputHTMLAttributes["onChange"]; +}; + +const mbToBytes = (mb: number) => mb * 10 ** 6; + +export const ImageUploader = ({ + className, + maxSizeMB = 5, + onSuccess, + onError, + onFileInputChange, + ...props +}: Props) => { + const fileInputRef = useRef(null); + + const [uploadedImage, setUploadedImage] = useState(""); + + const { mutate: upload, isPending } = useMutation({ + mutationFn: async (file: File) => { + const pinataJWT = await getPinataKey(); + const upload = await pinata.upload.file(file).key(pinataJWT.JWT); + const url = await pinata.gateways.convert(upload.IpfsHash); + return url; + }, + onSuccess: (imageUrl: string) => { + setUploadedImage(imageUrl); + if (typeof onSuccess === "function") { + onSuccess(imageUrl); + } + }, + onError: (error) => { + toast("Upload error", { + description: error.message, + icon: , + }); + + if (typeof onError === "function") { + onError(error.message); + } + }, + }); + + const validateFiles = useCallback( + (files: FileList | null) => { + if (!files) return; + + const [file] = Array.from(files); + + if (file.size > mbToBytes(maxSizeMB)) { + if (typeof onError === "function") + onError(`Max file size is ${maxSizeMB}MB`); + return; + } + + upload(file); + }, + [maxSizeMB, onError, upload], + ); + + return ( + + ); +}; diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 1731cddce8..9715059b4b 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -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) => { +export const SignInButton = (props: ComponentProps) => { const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom); return (