diff --git a/guild.d.ts b/guild.d.ts new file mode 100644 index 0000000000..58eafbad35 --- /dev/null +++ b/guild.d.ts @@ -0,0 +1,33 @@ +// 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 bf9f7fa1bf..e18764f217 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.1", "@phosphor-icons/react": "^2.1.7", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", @@ -24,8 +25,11 @@ "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", "@t3-oss/env-nextjs": "^0.11.1", "@tanstack/react-query": "^5.60.2", + "@tanstack/react-query-devtools": "^5.61.0", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/public/images/robot.svg b/public/images/robot.svg new file mode 100644 index 0000000000..9079bbab15 --- /dev/null +++ b/public/images/robot.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/app/explorer/atoms.ts b/src/app/explorer/atoms.ts new file mode 100644 index 0000000000..f354b9ac36 --- /dev/null +++ b/src/app/explorer/atoms.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; +import { ACTIVE_SECTION } from "./constants"; + +export const searchAtom = atom(undefined); +export const isNavStuckAtom = atom(false); +export const isSearchStuckAtom = atom(false); +export const activeSectionAtom = atom< + (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION] +>(ACTIVE_SECTION.yourGuilds); diff --git a/src/app/explorer/components/CreateGuildLink.tsx b/src/app/explorer/components/CreateGuildLink.tsx new file mode 100644 index 0000000000..f9d456ad00 --- /dev/null +++ b/src/app/explorer/components/CreateGuildLink.tsx @@ -0,0 +1,19 @@ +import { buttonVariants } from "@/components/ui/Button"; +import { Plus } from "@phosphor-icons/react/dist/ssr"; +import Link from "next/link"; + +export const CreateGuildLink = () => ( + + + Create guild + +); diff --git a/src/app/explorer/components/GuildCard.tsx b/src/app/explorer/components/GuildCard.tsx index c2fc6d246d..cef6659310 100644 --- a/src/app/explorer/components/GuildCard.tsx +++ b/src/app/explorer/components/GuildCard.tsx @@ -1,18 +1,35 @@ import { Badge } from "@/components/ui/Badge"; import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; import { ImageSquare, Users } from "@phosphor-icons/react/dist/ssr"; +import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; import Link from "next/link"; +import type { FunctionComponent } from "react"; -export const GuildCard = () => { +export const GuildCard: FunctionComponent<{ guild: Guild }> = ({ guild }) => { return ( - + -
- -
+ + + + + +

- Sample guild + {guild.name}

@@ -20,7 +37,7 @@ export const GuildCard = () => { {new Intl.NumberFormat("en", { notation: "compact", - }).format(125244)} + }).format(12345)} @@ -30,3 +47,20 @@ export const GuildCard = () => { ); }; + +export const GuildCardSkeleton = () => { + return ( +
+ + +
+ + +
+ + +
+
+
+ ); +}; diff --git a/src/app/explorer/components/HeaderBackground.tsx b/src/app/explorer/components/HeaderBackground.tsx new file mode 100644 index 0000000000..66544aba41 --- /dev/null +++ b/src/app/explorer/components/HeaderBackground.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { cn } from "@/lib/cssUtils"; +import { useAtomValue } from "jotai"; +import { isNavStuckAtom, isSearchStuckAtom } from "../atoms"; + +export const HeaderBackground = () => { + const isNavStuck = useAtomValue(isNavStuckAtom); + const isSearchStuck = useAtomValue(isSearchStuckAtom); + + return ( +
+ ); +}; diff --git a/src/app/explorer/components/InfiniteScrollGuilds.tsx b/src/app/explorer/components/InfiniteScrollGuilds.tsx new file mode 100644 index 0000000000..172d5e73fe --- /dev/null +++ b/src/app/explorer/components/InfiniteScrollGuilds.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useIntersection } from "foxact/use-intersection"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect } from "react"; +import { searchAtom } from "../atoms"; +import { PAGE_SIZE } from "../constants"; +import { getGuildSearch } from "../fetchers"; +import { GuildCard, GuildCardSkeleton } from "./GuildCard"; + +export const InfiniteScrollGuilds = () => { + const search = useAtomValue(searchAtom); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + queryKey: ["guilds", search || ""], + queryFn: getGuildSearch(search), + initialPageParam: 1, + staleTime: Number.POSITIVE_INFINITY, + enabled: search !== undefined, + getNextPageParam: (lastPage) => + lastPage.total / lastPage.pageSize <= lastPage.page + ? undefined + : lastPage.page + 1, + }); + + const [setIntersection, isIntersected, resetIsIntersected] = useIntersection({ + rootMargin: "700px", + }); + + useEffect(() => { + if (!isFetchingNextPage) { + resetIsIntersected(); + } + }, [resetIsIntersected, isFetchingNextPage]); + + useEffect(() => { + if (isFetchingNextPage) return; + if (isIntersected && hasNextPage) { + fetchNextPage(); + } + }, [isIntersected, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const guilds = data?.pages.flatMap((page) => page.items) || []; + + return ( +
+
+ {isLoading + ? Array.from({ length: PAGE_SIZE }, (_, i) => ( + + )) + : guilds.map((guild) => )} +
+
{ + setIntersection(element); + }, + [setIntersection], + )} + aria-hidden + /> + + {guilds.length === 0 && !isLoading && search ? ( +

+ `No results for "${search}"` +

+ ) : ( +

+ {isFetchingNextPage + ? "Loading more..." + : hasNextPage || "No More Data"} +

+ )} +
+ ); +}; diff --git a/src/app/explorer/components/StickyNavbar.tsx b/src/app/explorer/components/StickyNavbar.tsx new file mode 100644 index 0000000000..e794e5e14b --- /dev/null +++ b/src/app/explorer/components/StickyNavbar.tsx @@ -0,0 +1,79 @@ +"use client"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/ToggleGroup"; +import useIsStuck from "@/hooks/useIsStuck"; +import useScrollspy from "@/hooks/useScrollSpy"; +import { cn } from "@/lib/cssUtils"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { type PropsWithChildren, useEffect } from "react"; +import { activeSectionAtom, isNavStuckAtom, isSearchStuckAtom } from "../atoms"; +import { ACTIVE_SECTION } from "../constants"; + +const Nav = () => { + const isSearchStuck = useAtomValue(isSearchStuckAtom); + const [activeSection, setActiveSection] = useAtom(activeSectionAtom); + const spyActiveSection = useScrollspy(Object.values(ACTIVE_SECTION), 0); + useEffect(() => { + if (!spyActiveSection) return; + setActiveSection( + spyActiveSection as (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION], + ); + }, [spyActiveSection, setActiveSection]); + + return ( + + value && + setActiveSection( + value as (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION], + ) + } + value={activeSection} + > + + Your guilds + + + Explore guilds + + + ); +}; + +export const StickyNavbar = ({ children }: PropsWithChildren) => { + const setIsNavStuck = useSetAtom(isNavStuckAtom); + const isSearchStuck = useAtomValue(isSearchStuckAtom); + const { ref: navToggleRef } = useIsStuck(setIsNavStuck); + + return ( +
+
+
+
+ ); +}; diff --git a/src/app/explorer/components/StickySearch.tsx b/src/app/explorer/components/StickySearch.tsx new file mode 100644 index 0000000000..e67d2facc0 --- /dev/null +++ b/src/app/explorer/components/StickySearch.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Input } from "@/components/ui/Input"; +import useIsStuck from "@/hooks/useIsStuck"; +import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr"; +import { useDebouncedValue } from "foxact/use-debounced-value"; +import { useSetAtom } from "jotai"; +import { usePathname, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; +import { isSearchStuckAtom, searchAtom } from "../atoms"; + +const Search = () => { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const [value, setValue] = useState( + searchParams?.get("search")?.toString() || "", + ); + + const debouncedValue = useDebouncedValue(value, 200); + const setSearch = useSetAtom(searchAtom); + + useEffect(() => { + setSearch(debouncedValue); + }, [debouncedValue, setSearch]); + + useEffect(() => { + const newSearchParams = new URLSearchParams( + Object.entries({ search: value }).filter(([_, value]) => value), + ); + window.history.replaceState( + null, + "", + [pathname, newSearchParams.toString()].filter(Boolean).join("?"), + ); + }, [value, pathname]); + + return ( +
+ setValue(currentTarget.value)} + value={value} + /> +
+ +
+
+ ); +}; + +export const StickySearch = () => { + const setIsSearchStuck = useSetAtom(isSearchStuckAtom); + const { ref: searchRef } = useIsStuck(setIsSearchStuck); + + return ( +
+ + + +
+ ); +}; diff --git a/src/app/explorer/constants.ts b/src/app/explorer/constants.ts new file mode 100644 index 0000000000..0548d43ce2 --- /dev/null +++ b/src/app/explorer/constants.ts @@ -0,0 +1,5 @@ +export const PAGE_SIZE = 24; +export const ACTIVE_SECTION = { + yourGuilds: "your-guilds", + exploreGuilds: "explore-guilds", +} as const; diff --git a/src/app/explorer/fetchers.ts b/src/app/explorer/fetchers.ts new file mode 100644 index 0000000000..f2ce54db18 --- /dev/null +++ b/src/app/explorer/fetchers.ts @@ -0,0 +1,15 @@ +import { env } from "@/lib/env"; +import { PAGE_SIZE } from "./constants"; + +export const getGuildSearch = + (search = "") => + async ({ pageParam }: { pageParam: number }) => { + const res = await fetch( + `${env.NEXT_PUBLIC_API}/guild/search?page=${pageParam}&pageSize=${PAGE_SIZE}&search=${search}`, + ); + const json = await res.json(); + + if (json.error) throw new Error(json.error); + + return json as PaginatedResponse; + }; diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx index 83f7cd1db5..34803c5bed 100644 --- a/src/app/explorer/page.tsx +++ b/src/app/explorer/page.tsx @@ -1,11 +1,134 @@ -import { GuildCard } from "./components/GuildCard"; +import { AuthBoundary } from "@/components/AuthBoundary"; +import { SignInButton } from "@/components/SignInButton"; +import { env } from "@/lib/env"; +import { + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; +import { Suspense } from "react"; +import { CreateGuildLink } from "./components/CreateGuildLink"; +import { GuildCard, GuildCardSkeleton } from "./components/GuildCard"; +import { HeaderBackground } from "./components/HeaderBackground"; +import { InfiniteScrollGuilds } from "./components/InfiniteScrollGuilds"; +import { StickyNavbar } from "./components/StickyNavbar"; +import { StickySearch } from "./components/StickySearch"; +import { ACTIVE_SECTION } from "./constants"; +import { getGuildSearch } from "./fetchers"; + +const getAssociatedGuilds = async () => { + const request = `${env.NEXT_PUBLIC_API}/guild/search?page=1&pageSize=24&sortBy=name&reverse=false&search=`; + const guilds = (await ( + await fetch(request) + ).json()) as PaginatedResponse; + + return guilds; +}; + +export default async function Explorer() { + const queryClient = new QueryClient(); + await queryClient.prefetchInfiniteQuery({ + queryKey: ["guilds", ""], + initialPageParam: 1, + queryFn: getGuildSearch(""), + }); -const Explorer = () => { return ( -
- -
+ <> +
+
+

+ Guildhall +

+
+ + + + + + + + + + +

+ Explore verified guilds +

+ + + + + +
+ ); -}; +} + +async function YourGuildsSection() { + return ( +
+ +
+ Guild Robot -export default Explorer; +

+ Sign in to view your guilds or create new ones +

+
+ + +
+ } + > + }> + + + + + ); +} + +async function YourGuilds() { + const { items: myGuilds } = await getAssociatedGuilds(); + + return myGuilds && myGuilds.length > 0 ? ( +
+ {myGuilds.map((guild) => ( + + ))} +
+ ) : ( +
+ Guild Robot + +

+ You're not a member of any guilds yet. Explore and join some below, + or create your own! +

+ + +
+ ); +} + +function YourGuildsSkeleton() { + return ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index aa5172f928..2d18301eb7 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,8 +1,6 @@ -import { me } from "@/actions/me"; import { AuthBoundary } from "./AuthBoundary"; import { SignInButton } from "./SignInButton"; import { SignOutButton } from "./SignOutButton"; -import { Button } from "./ui/Button"; import { Card } from "./ui/Card"; export const Header = () => ( @@ -10,12 +8,10 @@ export const Header = () => ( {/* TODO: NavMenu component */} -
- -
- - }> - - + + }> + + + ); diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 33f57d07d9..6722b57a3a 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -2,6 +2,8 @@ import { wagmiConfig } from "@/config/wagmi"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { Provider as JotaiProvider } from "jotai"; import { ThemeProvider } from "next-themes"; import type { FunctionComponent, PropsWithChildren } from "react"; import { WagmiProvider } from "wagmi"; @@ -12,17 +14,20 @@ export const Providers: FunctionComponent = ({ children, }) => { return ( - - - - {children} - - - + + + + + {children} + + + + + ); }; diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 639ec1ef45..1731cddce8 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -1,23 +1,27 @@ "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"; -import { Card } from "./ui/Card"; -export const SignInButton = () => { +export const SignInButton = ({ + className, + ...props +}: ComponentProps) => { const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom); return ( - - - + ); }; diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx index 137724e1ca..da87504c97 100644 --- a/src/components/SignOutButton.tsx +++ b/src/components/SignOutButton.tsx @@ -10,6 +10,7 @@ export const SignOutButton = () => { return (