Skip to content

Commit

Permalink
add explorer (#1555)
Browse files Browse the repository at this point in the history
* chore: add explorer scaffolding

* feat: add infinite scroll

* feat: trigger fetch using observer

* feat: add search

* feat: sync search using atoms

* fix: prevent rerender by router

* fix: prevent render loop

* chore: separate useEffect calls

* feat: add ssr using tanstack query

* refactor: move out consts and fetchers

* chore: align to review suggestions

* chore: dont revalidate search

* chore: replace guild card skeleton

* refactor: move out guild card

* chore: move v2 search

* chore: add header prereqs

* chore: remove infinite scroll button

* chore: add Toggle and ToggleGroup

* chore: hook sticky header together

* feat: add sign in button to explorer

* fix: adjust navigation alignment

* fix: make dialog overlay properly

* fix: make call to action responsive

* chore: resolve merge conflict

* fix(getGuildSearch): throw on error

* fix(InfiniteScrollGuilds): properly update the page param

* fix(GuildCard): don't show legacy guild logos

* fix(StickyNavbar): use native scroll

* fix(InfiniteScrollGuilds): revert latest change

* fix(StickyNavbar): remove console.log

* fix: rename `_components` to `components`

* fix(Toggle): colors and variants

* fix: sign in buttom styling

* final cleanup

---------

Co-authored-by: BrickheadJohnny <[email protected]>
  • Loading branch information
dominik-stumpf and BrickheadJohnny authored Nov 22, 2024
1 parent 198f502 commit 107df80
Show file tree
Hide file tree
Showing 26 changed files with 839 additions and 49 deletions.
33 changes: 33 additions & 0 deletions guild.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
settings: Record<string, string>;
searchTags: string[];
categoryTags: string[];
socialLinks: Record<string, string>;
owner: string;
};

type PaginatedResponse<Item extends any> = {
page: number;
pageSize: number;
sortBy: string;
reverse: boolean;
searchQuery: string;
query: string;
items: Item[];
total: number;
};
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions public/images/robot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/app/explorer/atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { atom } from "jotai";
import { ACTIVE_SECTION } from "./constants";

export const searchAtom = atom<string | undefined>(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);
19 changes: 19 additions & 0 deletions src/app/explorer/components/CreateGuildLink.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Link
href="/create-guild"
aria-label="Create guild"
prefetch={false}
className={buttonVariants({
variant: "ghost",
size: "sm",
className: "min-h-11 w-11 gap-1.5 px-0 sm:min-h-0 sm:w-auto sm:px-3",
})}
>
<Plus />
<span className="hidden sm:inline-block">Create guild</span>
</Link>
);
48 changes: 41 additions & 7 deletions src/app/explorer/components/GuildCard.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
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 (
<Link href="#" className="rounded-2xl outline-none focus-visible:ring-4">
<Link
href={`/${guild.urlName}`}
className="relative rounded-2xl outline-none focus-visible:ring-4"
>
<Card className="relative grid grid-cols-[theme(space.12)_1fr] grid-rows-2 items-center gap-x-4 gap-y-0.5 overflow-hidden rounded-2xl px-6 py-7 shadow-md before:absolute before:inset-0 before:bg-black/5 before:opacity-0 before:transition-opacity before:duration-200 before:content-[''] hover:before:opacity-55 active:before:opacity-85 dark:before:bg-white/5">
<div className="row-span-2 grid size-12 place-items-center rounded-full bg-image text-white">
<ImageSquare weight="duotone" className="size-6" />
</div>
<Avatar className="row-span-2 grid size-12 place-items-center overflow-hidden rounded-full bg-image text-white">
<AvatarImage
src={
!!guild.imageUrl && !guild.imageUrl.startsWith("/guildLogos")
? guild.imageUrl
: undefined
}
className="size-full"
alt="guild avatar"
/>
<AvatarFallback>
<ImageSquare weight="duotone" className="size-6" />
</AvatarFallback>
</Avatar>

<h3 className="line-clamp-1 font-black font-display text-lg">
Sample guild
{guild.name}
</h3>
<div className="flex flex-wrap gap-1">
<Badge>
<Users className="size-4" />
<span>
{new Intl.NumberFormat("en", {
notation: "compact",
}).format(125244)}
}).format(12345)}
</span>
</Badge>

Expand All @@ -30,3 +47,20 @@ export const GuildCard = () => {
</Link>
);
};

export const GuildCardSkeleton = () => {
return (
<div className="grid grid-cols-[theme(space.14)_1fr] items-center gap-4 rounded-2xl bg-card px-5 py-6 shadow-md">
<Skeleton className="size-14 rounded-full" />

<div className="grid gap-1.5">
<Skeleton className="h-6 w-3/4" />

<div className="flex flex-wrap gap-1">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
</div>
</div>
);
};
23 changes: 23 additions & 0 deletions src/app/explorer/components/HeaderBackground.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"fixed inset-x-0 top-0 z-10 h-0 bg-card shadow-md transition-all duration-200 dark:bg-background",
{
"h-16": isNavStuck,
"h-[calc(theme(space.36)+theme(space.2))] bg-gradient-to-b from-card to-background sm:h-[calc(theme(space.28)-theme(space.2))] dark:from-background dark:to-card-secondary/50":
isSearchStuck,
},
)}
/>
);
};
78 changes: 78 additions & 0 deletions src/app/explorer/components/InfiniteScrollGuilds.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="grid gap-2">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{isLoading
? Array.from({ length: PAGE_SIZE }, (_, i) => (
<GuildCardSkeleton key={i} />
))
: guilds.map((guild) => <GuildCard key={guild.id} guild={guild} />)}
</div>
<div
ref={useCallback(
(element: HTMLDivElement | null) => {
setIntersection(element);
},
[setIntersection],
)}
aria-hidden
/>

{guilds.length === 0 && !isLoading && search ? (
<p className="mt-6 text-center text-foreground-secondary">
`No results for "${search}"`
</p>
) : (
<p className="mt-6 text-center text-foreground-secondary">
{isFetchingNextPage
? "Loading more..."
: hasNextPage || "No More Data"}
</p>
)}
</section>
);
};
79 changes: 79 additions & 0 deletions src/app/explorer/components/StickyNavbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToggleGroup
type="single"
className="gap-2"
size={isSearchStuck ? "sm" : "lg"}
variant="secondary"
onValueChange={(value) =>
value &&
setActiveSection(
value as (typeof ACTIVE_SECTION)[keyof typeof ACTIVE_SECTION],
)
}
value={activeSection}
>
<ToggleGroupItem
value={ACTIVE_SECTION.yourGuilds}
className={cn("rounded-xl transition-all", {
"rounded-lg": isSearchStuck,
})}
asChild
>
<a href={`#${ACTIVE_SECTION.yourGuilds}`}>Your guilds</a>
</ToggleGroupItem>
<ToggleGroupItem
value={ACTIVE_SECTION.exploreGuilds}
className={cn("rounded-xl transition-all", {
"rounded-lg": isSearchStuck,
})}
asChild
>
<a href={`#${ACTIVE_SECTION.exploreGuilds}`}>Explore guilds</a>
</ToggleGroupItem>
</ToggleGroup>
);
};

export const StickyNavbar = ({ children }: PropsWithChildren) => {
const setIsNavStuck = useSetAtom(isNavStuckAtom);
const isSearchStuck = useAtomValue(isSearchStuckAtom);
const { ref: navToggleRef } = useIsStuck(setIsNavStuck);

return (
<div
className={cn(
"sticky top-0 z-10 flex h-16 w-full items-center transition-all",
{
"h-12": isSearchStuck,
},
)}
ref={navToggleRef}
>
<div className="relative flex w-full items-center justify-between">
<Nav />
{children}
</div>
</div>
);
};
Loading

0 comments on commit 107df80

Please sign in to comment.