Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion core/app/c/[communitySlug]/ContentLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,19 @@ export const ContentLayout = ({
left,
right,
children,
className,
}: {
title: ReactNode;
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
className?: string;
}) => {
return (
<div className="absolute inset-0 w-full">
<div className="flex h-full flex-col">
<Heading title={title} left={left} right={right} />
<div className="h-full flex-1 overflow-auto">{children}</div>
<div className={`h-full flex-1 overflow-auto ${className || ""}`}>{children}</div>
</div>
</div>
);
Expand Down
157 changes: 113 additions & 44 deletions core/app/c/[communitySlug]/pubs/PubList.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Suspense } from "react";

import type { ProcessedPub } from "contracts";
import type { CommunitiesId, UsersId } from "db/public";
import { Skeleton } from "ui/skeleton";
import { cn } from "utils";

import { searchParamsCache } from "~/app/components/DataTable/PubsDataTable/validations";
import { FooterPagination } from "~/app/components/Pagination";
import { PubCard } from "~/app/components/PubCard";
import { getStageActions } from "~/lib/db/queries";
import { getPubsCount, getPubsWithRelatedValues } from "~/lib/server";
import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug";
import { getStages } from "~/lib/server/stages";
import { PubSelector } from "./PubSelector";
import { PubSearch } from "./PubSearchInput";
import { PubsSelectedProvider } from "./PubsSelectedContext";
import { PubsSelectedCounter } from "./PubsSelectedCounter";

Expand All @@ -27,56 +27,46 @@ type PaginatedPubListProps = {
userId: UsersId;
};

const PaginatedPubListInner = async (props: PaginatedPubListProps) => {
const search = searchParamsCache.parse(props.searchParams);
const [count, pubs, stages, actions] = await Promise.all([
getPubsCount({ communityId: props.communityId }),
getPubsWithRelatedValues(
type PubListProcessedPub = ProcessedPub<{
withPubType: true;
withRelatedPubs: false;
withStage: true;
withRelatedCounts: true;
}>;

const PaginatedPubListInner = async (
props: PaginatedPubListProps & {
communitySlug: string;
pubsPromise: Promise<PubListProcessedPub[]>;
}
) => {
const [pubs, stages] = await Promise.all([
props.pubsPromise,
getStages(
{ communityId: props.communityId, userId: props.userId },
{
limit: search.perPage,
offset: (search.page - 1) * search.perPage,
orderBy: "updatedAt",
withPubType: true,
withRelatedPubs: false,
withStage: true,
withValues: false,
withRelatedCounts: true,
}
),
getStages({ communityId: props.communityId, userId: props.userId }).execute(),
getStageActions({ communityId: props.communityId }).execute(),
{ withActionInstances: "full" }
).execute(),
]);

const totalPages = Math.ceil(count / search.perPage);

const communitySlug = await getCommunitySlug();
const basePath = props.basePath ?? `/c/${communitySlug}/pubs`;

return (
<div className={cn("flex flex-col gap-8")}>
<PubsSelectedProvider pubIds={[]}>
<PubsSelectedProvider pubIds={[]}>
<div className="mr-auto flex flex-col gap-3 md:max-w-screen-lg">
{pubs.map((pub) => {
const stageForPub = stages.find((stage) => stage.id === pub.stage?.id);

return (
<PubCard
key={pub.id}
pub={pub}
communitySlug={communitySlug}
stages={stages}
actionInstances={actions}
communitySlug={props.communitySlug}
moveFrom={stageForPub?.moveConstraintSources}
moveTo={stageForPub?.moveConstraints}
actionInstances={stageForPub?.actionInstances}
/>
);
})}
<FooterPagination
basePath={basePath}
searchParams={props.searchParams}
page={search.page}
totalPages={totalPages}
>
<PubsSelectedCounter pageSize={search.perPage} />
</FooterPagination>
</PubsSelectedProvider>
</div>
</div>
</PubsSelectedProvider>
);
};

Expand All @@ -87,7 +77,7 @@ export const PubListSkeleton = ({
amount?: number;
className?: string;
}) => (
<div className={cn(["flex flex-col gap-8", className])}>
<div className={cn(["flex flex-col gap-3", className])}>
{Array.from({ length: amount }).map((_, index) => (
<Skeleton key={index} className="flex h-[90px] w-full flex-col gap-2 px-4 py-3">
<Skeleton className="mt-3 h-6 w-24 space-y-1.5" />
Expand All @@ -97,10 +87,89 @@ export const PubListSkeleton = ({
</div>
);

const PubListFooterPagination = async (props: {
basePath: string;
searchParams: Record<string, unknown>;
page: number;
communityId: CommunitiesId;
children: React.ReactNode;
pubsPromise: Promise<ProcessedPub[]>;
}) => {
const perPage = searchParamsCache.get("perPage");
const isQuery = !!searchParamsCache.get("query");

const count = await (isQuery
? props.pubsPromise.then((pubs) => pubs.length)
: getPubsCount({ communityId: props.communityId }));

const paginationProps = isQuery
? {
mode: "cursor" as const,
hasNextPage: count > perPage,
}
: {
mode: "total" as const,
totalPages: Math.ceil((count ?? 0) / perPage),
};

return (
<FooterPagination {...props} {...paginationProps} className="z-20">
{props.children}
</FooterPagination>
);
};

export const PaginatedPubList: React.FC<PaginatedPubListProps> = async (props) => {
const search = searchParamsCache.parse(props.searchParams);

const communitySlug = await getCommunitySlug();

const basePath = props.basePath ?? `/c/${communitySlug}/pubs`;

// we do one more than the total amount of pubs to know if there is a next page
const limit = search.query ? search.perPage + 1 : search.perPage;

const pubsPromise = getPubsWithRelatedValues(
{ communityId: props.communityId, userId: props.userId },
{
limit,
offset: (search.page - 1) * search.perPage,
orderBy: "updatedAt",
withPubType: true,
withRelatedPubs: false,
withStage: true,
withValues: false,
withRelatedCounts: true,
search: search.query,
}
);

return (
<Suspense fallback={<PubListSkeleton />}>
<PaginatedPubListInner {...props} />
</Suspense>
<div className="relative flex h-full flex-col">
<div
className={cn("mb-4 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4 pb-16")}
>
<PubSearch>
<Suspense fallback={<PubListSkeleton />}>
<PaginatedPubListInner
{...props}
communitySlug={communitySlug}
pubsPromise={pubsPromise}
/>
</Suspense>
</PubSearch>
</div>
<Suspense fallback={null}>
<PubListFooterPagination
basePath={basePath}
searchParams={props.searchParams}
page={search.page}
communityId={props.communityId}
pubsPromise={pubsPromise}
>
<PubsSelectedCounter pageSize={search.perPage} />
</PubListFooterPagination>
</Suspense>
</div>
);
};
122 changes: 122 additions & 0 deletions core/app/c/[communitySlug]/pubs/PubSearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import React, { useDeferredValue, useEffect, useRef, useState } from "react";
import { Search, X } from "lucide-react";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useDebouncedCallback } from "use-debounce";

import { KeyboardShortcutPriority, useKeyboardShortcut, usePlatformModifierKey } from "ui/hooks";
import { Input } from "ui/input";
import { cn } from "utils";

type PubSearchProps = React.PropsWithChildren<{}>;

const DEBOUNCE_TIME = 300;

export const PubSearch = (props: PubSearchProps) => {
const [query, setQuery] = useQueryStates(
{
query: parseAsString.withDefault(""),
page: parseAsInteger.withDefault(1),
},
{
shallow: false,
}
);

// local input state for immediate UI responsiveness + sync with URL
// otherwise, when navigating back/forward or refreshing, the input will be empty
const [inputValue, setInputValue] = useState(query.query);

// deferred query to keep track of server updates
// without this, we can't only check if inputValue !== query.query,
// which only tells us that the debounce has happened
const deferredQuery = useDeferredValue(query.query);
Comment on lines +31 to +34
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kind of mysterious to me, but this allows me to keep the body of the search at half-opacity until the content has fully refreshed. this could also be done by using useTransition.


const inputRef = useRef<HTMLInputElement>(null);

useKeyboardShortcut(
"Mod+k",
() => {
inputRef.current?.focus();
inputRef.current?.select();
},
{
priority: KeyboardShortcutPriority.MEDIUM,
}
);

// sync input with URL when navigating back/forward
useEffect(() => {
if (query.query === inputValue) {
return;
}
setInputValue(query.query);
}, [query.query]);

const { symbol, platform } = usePlatformModifierKey();

const debouncedSetQuery = useDebouncedCallback((value: string) => {
if (value.length >= 2 || value.length === 0) {
setQuery({ query: value, page: 1 }); // reset to page 1 on new search
}
}, DEBOUNCE_TIME);

const handleClearInput = () => {
setInputValue("");
setQuery({ query: "", page: 1 });
};

// determine if content is stale, in order to provide a visual feedback to the user
const isStale = inputValue !== deferredQuery;

return (
<div className="flex flex-col gap-4">
<div className="sticky top-0 z-20 flex max-w-md items-center gap-x-2">
<Search
className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500"
size={16}
/>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
debouncedSetQuery(e.target.value);
}}
placeholder="Search updates as you type..."
className={cn("bg-white pl-8 tracking-wide shadow-none", inputValue && "pr-8")}
/>
<span className="absolute right-2 top-1/2 hidden -translate-y-1/2 items-center gap-x-2 font-mono text-xs text-gray-500 opacity-50 md:flex">
{inputValue && (
<button
onClick={handleClearInput}
className="rounded-full p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 md:right-16"
type="button"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
<span
className={cn(
"flex w-10 items-center justify-center gap-x-1 transition-opacity duration-200",
{
// hide until hydrated, otherwise you see flash of `Ctrl` -> `Cmd` on mac
"opacity-0": platform === "unknown",
}
)}
>
<span className={cn({ "mt-0.5 text-lg": platform === "mac" })}>
{symbol}
</span>{" "}
K
</span>
</span>
</div>
<div className={cn(isStale && "opacity-50 transition-opacity duration-200")}>
{props.children}
</div>
</div>
);
};
1 change: 1 addition & 0 deletions core/app/c/[communitySlug]/pubs/PubSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const PubSelector = ({ pubId, className }: { pubId: PubsId; className?: s

return (
<Checkbox
aria-label="Select pub"
checked={isSelected(pubId)}
onCheckedChange={() => {
toggle(pubId);
Expand Down
Loading
Loading