Skip to content

Commit

Permalink
feat: add collections list
Browse files Browse the repository at this point in the history
  • Loading branch information
zobweyt committed Feb 3, 2025
1 parent a91b245 commit 5bb4e38
Show file tree
Hide file tree
Showing 19 changed files with 441 additions and 38 deletions.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"solid-snowfall": "0.3.2",
"solid-sonner": "^0.2.8",
"solid-transition-group": "^0.2.3",
"tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.17",
"vinxi": "^0.4.3",
"virtua": "^0.39.3"
Expand Down
1 change: 1 addition & 0 deletions apps/web/public/tgs/crossmark.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/public/tgs/search.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/public/tgs/spiderweb.json

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions apps/web/src/components/ErrorBoundaryFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Button, Placeholder, Stack } from "@quotepedia/solid";
import { useTranslator } from "~/lib/i18n";

export const ErrorBoundaryFallback = (err: any, reset: () => void) => {
const t = useTranslator();

return (
<Placeholder
heading={"An error occured:"}
description={
<code class="bg-bg-default ring-bg-secondary select-all rounded-lg p-1.5 ring-1">{err?.toString()}</code>
}
footer={
<Stack.Horizontal>
<Button
as={"a"}
rel="noopener noreferrer"
href={import.meta.env.APP_BUGS_URL}
target="_blank"
color="secondary"
spacing="lg"
leadingIcon="f7:flag"
>
{t("settings.about.feedback.heading")}
</Button>
<Button onClick={reset} spacing="lg" leadingIcon="f7:arrow-clockwise">
{t("continue")}
</Button>
</Stack.Horizontal>
}
/>
);
};
69 changes: 51 additions & 18 deletions apps/web/src/components/aside/Aside.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,77 @@
import { Avatar } from "@quotepedia/solid";
import { createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { getCurrentUser } from "~/lib/api/users/me";
import { Avatar, Button, Dropdown, Icon } from "@quotepedia/solid";
import { createAsync, createAsyncStore, useNavigate } from "@solidjs/router";
import { Show, Suspense } from "solid-js";
import { formatStorageObject } from "~/lib/api";
import { getRecentUserCollections } from "~/lib/api/collections";
import { getCurrentUser } from "~/lib/api/users/me";
import { useTranslator } from "~/lib/i18n";
import { CollectionSidebarItemList } from "../collections";
import { Sidebar } from "./sidebar";

export const Aside = () => {
const t = useTranslator();
const navigate = useNavigate();
const currentUser = createAsync(() => getCurrentUser());
const collections = createAsyncStore(() => getRecentUserCollections());

return (
<Sidebar>
<Sidebar.Group class="-mb-6 flex-row items-end overflow-y-hidden max-lg:hidden">
<Dropdown placement="bottom">
<Dropdown.Trigger as={Button} style="ghost" trailingIcon="f7:plus-circle" />
<Dropdown.Content>
<Dropdown.Item onSelect={() => navigate("/library/quotes/new")}>
<Icon icon="f7:square-pencil" class="size-6" />
<Dropdown.ItemLabel>{t("newQuote")}</Dropdown.ItemLabel>
</Dropdown.Item>
<Dropdown.Item onSelect={() => navigate("/library/collections/new")}>
<Icon icon="f7:folder-badge-plus" class="size-6" />
<Dropdown.ItemLabel>{t("newCollection")}</Dropdown.ItemLabel>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown>
</Sidebar.Group>

<Sidebar.Group class="max-lg:basis-2/3">
<Sidebar.GroupLabel>{t("quotepedia")}</Sidebar.GroupLabel>
<Sidebar.GroupLabel class="text-fg-body text-3xl font-bold">{t("quotepedia")}</Sidebar.GroupLabel>
<Sidebar.Item href="/" end>
<Sidebar.ItemIcon icon="ion:telescope" />
<Sidebar.ItemLabel>{t("routes.explore.title")}</Sidebar.ItemLabel>
</Sidebar.Item>
<Sidebar.Item href="/library">
<Sidebar.Item href="/library" end>
<Sidebar.ItemIcon icon="ion:library" />
<Sidebar.ItemLabel>{t("routes.library.title")}</Sidebar.ItemLabel>
</Sidebar.Item>
</Sidebar.Group>

<Sidebar.Group class="max-lg:hidden">
<Sidebar.GroupLabel>{t("components.aside.collections")}</Sidebar.GroupLabel>
</Sidebar.Group>
<Suspense>
<Show when={currentUser()}>
<Show when={collections()}>
{(collections) => (
<Sidebar.Group class="overflow-y-hidden max-lg:hidden">
<Sidebar.GroupLabel class="text-fg-body text-xl font-bold">
{t("components.aside.collections")}
</Sidebar.GroupLabel>
<CollectionSidebarItemList collections={collections()} />
</Sidebar.Group>
)}
</Show>
</Show>
</Suspense>

<Sidebar.Group class="max-lg:basis-1/3 lg:mt-auto">
<Sidebar.Item href="/settings" class="group">
<Show when={currentUser()} fallback={<Sidebar.ItemIcon icon="f7:gear" />}>
{(user) => (
<Sidebar.ItemIcon as={Avatar}>
<Show when={user().avatar_url} fallback={<Avatar.Img src={undefined} alt={user().email} />}>
{(avatar_url) => <Avatar.Img src={formatStorageObject(avatar_url())} alt={user().email} />}
</Show>
</Sidebar.ItemIcon>
)}
</Show>
<Suspense fallback={<Sidebar.ItemIcon icon="f7:gear" />}>
<Show when={currentUser()} fallback={<Sidebar.ItemIcon icon="f7:gear" />}>
{(user) => (
<Sidebar.ItemIcon as={Avatar}>
<Show when={user().avatar_url} fallback={<Avatar.Img src={undefined} alt={user().email} />}>
{(avatar_url) => <Avatar.Img src={formatStorageObject(avatar_url())} alt={user().email} />}
</Show>
</Sidebar.ItemIcon>
)}
</Show>
</Suspense>
<Sidebar.ItemLabel>{t("settings.title")}</Sidebar.ItemLabel>
</Sidebar.Item>
</Sidebar.Group>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/aside/sidebar/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const SidebarRoot: ParentComponent = (props) => {
<nav
class={cn(
"border-bg-secondary sticky z-10 flex transition-colors",
"lg:bg-bg-default lg:top-0 lg:h-dvh lg:min-w-80 lg:flex-col lg:gap-6 lg:border-r lg:px-4 lg:py-6",
"lg:bg-bg-default lg:top-0 lg:h-dvh lg:min-w-80 lg:flex-col lg:gap-6 lg:border-r lg:px-4 lg:pb-6 lg:pt-1",
"max-lg:bg-bg-default/75 max-lg:bottom-0 max-lg:w-full max-lg:justify-between max-lg:border-t max-lg:backdrop-blur-lg",
)}
{...props}
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/components/collections/CollectionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { A } from "@solidjs/router";
import { splitProps, type ComponentProps } from "solid-js";
import { tv } from "tailwind-variants";
import type { Collection } from "~/lib/api/collections";
import { usePlurarized } from "~/lib/i18n";
import EmojiImg from "../Emoji";

export const styles = tv({
slots: {
root: [
"group relative flex divide-x divide-bg-secondary rounded-xl !shadow-sm",
"ring-1 ring-bg-secondary transition-all duration-300",
"before:absolute before:inset-0 before:-z-10 before:-translate-y-1 before:scale-95 before:rounded-xl",
"before:bg-bg-default before:ring-1 before:ring-bg-secondary before:transition-transform before:duration-300",
"hover:divide-bg-tertiary hover:ring-bg-tertiary",
"before:hover:-translate-y-1 hover:before:scale-95 hover:before:ring-bg-tertiary active:opacity-50",
],
emoji: "ease-spring size-6 transition-transform duration-300 group-hover:scale-125",
thumb: "bg-bg-default flex items-center rounded-s-xl p-3",
hgroup: "bg-bg-body w-full grow space-y-1.5 rounded-e-xl p-4 ps-3",
h3: "text-nowrap font-medium leading-none",
p: "text-fg-muted text-nowrap text-sm leading-none",
},
variants: {
active: {
true: {
root: [
"divide-bg-tertiary opacity-75 outline outline-4",
"outline-ring-accent ring-bg-tertiary before:scale-95 before:ring-bg-tertiary",
],
emoji: "scale-125",
},
},
},
});

export type CollectionCardOptions = {
collection: Collection;
active?: boolean;
class?: string;
};

export type CollectionCardProps = CollectionCardOptions & Omit<ComponentProps<typeof A>, "href">;

export const CollectionCard = (props: CollectionCardProps) => {
const [variantProps, localProps, otherProps] = splitProps(props, ["active"], ["collection", "class"]);
return (
<A
href={`/library/collections/${props.collection.id}`}
class={styles().root({ ...variantProps, class: localProps.class })}
{...otherProps}
>
<span class={styles().thumb(variantProps)}>
<EmojiImg emoji={localProps.collection.emote} class={styles().emoji(variantProps)} />
</span>
<hgroup class={styles().hgroup(variantProps)}>
<h3 class={styles().h3(variantProps)}>{localProps.collection.name}</h3>
<p class={styles().p(variantProps)}>{usePlurarized("quotes", localProps.collection.quotes_count)}</p>
</hgroup>
</A>
);
};
25 changes: 25 additions & 0 deletions apps/web/src/components/collections/CollectionCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cn, Skeleton } from "@quotepedia/solid";
import { splitProps, type ComponentProps } from "solid-js";

export const CollectionCardSkeleton = (props: ComponentProps<"div">) => {
const [localProps, otherProps] = splitProps(props, ["class"]);
return (
<div
class={cn(
"divide-bg-secondary ring-bg-secondary relative flex min-w-56 divide-x rounded-xl ring-1",
"before:bg-bg-default before:ring-bg-secondary before:absolute before:inset-0",
"before:-z-10 before:-translate-y-1 before:scale-95 before:rounded-xl before:ring-1",
localProps.class,
)}
{...otherProps}
>
<div class="bg-bg-default flex items-center rounded-s-xl p-3">
<Skeleton opacity={75} class="size-6 rounded-full" />
</div>
<div class="bg-bg-body w-full grow space-y-1.5 overflow-hidden rounded-e-xl p-4 ps-3">
<Skeleton opacity={75} class="h-4 rounded-md" />
<Skeleton opacity={50} class="h-3.5 w-9/12 rounded-md" />
</div>
</div>
);
};
13 changes: 13 additions & 0 deletions apps/web/src/components/collections/CollectionSidebarItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { components } from "~/lib/api";
import { Sidebar } from "../aside/sidebar";
import EmojiImg from "../Emoji";

export const CollectionSidebarItem = (props: { collection: components["schemas"]["CollectionResponse"] }) => {
return (
<Sidebar.Item href={`/library/collections/${props.collection.id}`} class="group">
<Sidebar.ItemIcon as={EmojiImg} emoji={props.collection.emote} />
<Sidebar.ItemLabel>{props.collection.name}</Sidebar.ItemLabel>
<span class="text-fg-muted ms-auto text-base leading-none">{props.collection.quotes_count}</span>
</Sidebar.Item>
);
};
20 changes: 20 additions & 0 deletions apps/web/src/components/collections/CollectionSidebarItemList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { For } from "solid-js";
import { TransitionGroup } from "solid-transition-group";
import type { components } from "~/lib/api";
import { CollectionSidebarItem } from "./CollectionSidebarItem";

export const CollectionSidebarItemList = (props: { collections: components["schemas"]["CollectionResponse"][] }) => {
return (
<div class="relative flex grow flex-col space-y-1 overflow-y-scroll">
<TransitionGroup
moveClass="!transition-all !duration-500"
enterActiveClass="!transition-all !duration-500"
exitActiveClass="!transition-all !duration-500 !absolute"
enterClass="!opacity-0"
exitToClass="!opacity-0"
>
<For each={props.collections}>{(collection) => <CollectionSidebarItem collection={collection} />}</For>
</TransitionGroup>
</div>
);
};
126 changes: 126 additions & 0 deletions apps/web/src/components/collections/CollectionsInfinitePaginator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Lottie, Placeholder, Search } from "@quotepedia/solid";
import { Repeat } from "@solid-primitives/range";
import { useNavigate } from "@solidjs/router";
import { batch, createSelector, createSignal, Match, Show, Switch, type JSX } from "solid-js";
import { createList } from "solid-list";
import { WindowVirtualizer, type WindowVirtualizerHandle } from "virtua/solid";
import { CollectionCard, CollectionCardSkeleton } from "~/components/collections";
import { getCurrentUserCollections } from "~/lib/api/collections";
import { useTranslator } from "~/lib/i18n";
import { createInfinitePaginator } from "~/utils/pagination";
import { useSearchQuery } from "~/utils/router";

const SEARCH_WAIT = 300;
const SCROLL_TRIGGER_THRESHOLD = 1.0;
const SCROLL_TRIGGER_ROOT_MARGIN = "25%";
const COLLECTIONS_VIRTUALIZER_OVERSCAN = 5;
const COLLECTIONS_PER_PAGE = 20;

export const CollectionsInfinitePaginator = () => {
const t = useTranslator();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useSearchQuery();

const [virtualizer, setVirtualizer] = createSignal<WindowVirtualizerHandle>();

const [collections, setTriggerRef, paginator] = createInfinitePaginator(
(page) =>
getCurrentUserCollections({
q: searchQuery(),
limit: COLLECTIONS_PER_PAGE,
offset: COLLECTIONS_PER_PAGE * page,
}),
{
threshold: SCROLL_TRIGGER_THRESHOLD,
rootMargin: SCROLL_TRIGGER_ROOT_MARGIN,
},
);

const { active, setActive, onKeyDown } = createList({
loop: false,
items: collections,
onActiveChange: (collection) => {
if (collection) {
const index = collections().indexOf(collection);
virtualizer()?.scrollToIndex(index, { align: "center" });
}
},
});

const isActive = createSelector(active);

const onSearchBlur: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, FocusEvent> = () => {
setActive(null);
};

const onSearchFocus: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, FocusEvent> = () => {
setActive(null);
};

const onSearchChange = (value: string) => {
batch(() => {
setActive(null);
setSearchQuery(value);
paginator.reset();
});
};

const onSearchKeyDown: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, KeyboardEvent> = (event) => {
const collection = active();

if (event.key === "Enter" && collection !== null) {
return navigate(`/library/collections/${collection.id}`);
}

onKeyDown(event);
};

return (
<>
<Search
value={searchQuery()}
onBlur={onSearchBlur}
onFocus={onSearchFocus}
onChange={onSearchChange}
onKeyDown={onSearchKeyDown}
placeholder={t("search")}
wait={SEARCH_WAIT}
autofocus
/>

<Switch>
<Match when={collections().length > 0}>
<WindowVirtualizer ref={setVirtualizer} data={collections()} overscan={COLLECTIONS_VIRTUALIZER_OVERSCAN}>
{(collection) => <CollectionCard class="mt-4" collection={collection} active={isActive(collection)} />}
</WindowVirtualizer>

<Show when={!paginator.end()}>
<div ref={setTriggerRef} />
</Show>
</Match>

<Match when={paginator.loading() === true && collections().length <= 0}>
<Repeat times={COLLECTIONS_PER_PAGE}>
<CollectionCardSkeleton class="mt-4" />
</Repeat>
</Match>

<Match when={paginator.loading() === false && collections().length <= 0 && searchQuery() !== ""}>
<Placeholder
icon={<Lottie path="/tgs/search.json" class="size-24" />}
heading={t("noResults")}
description={t("noResultsFor", { value: searchQuery() })}
/>
</Match>

<Match when={paginator.loading() === false && collections().length <= 0 && searchQuery() === ""}>
<Placeholder
icon={<Lottie path="/tgs/spiderweb.json" class="size-24" />}
heading={t("empty")}
description={t("noCollections")}
/>
</Match>
</Switch>
</>
);
};
Loading

0 comments on commit 5bb4e38

Please sign in to comment.