Skip to content

Commit

Permalink
feat: improve search bar with debounce
Browse files Browse the repository at this point in the history
  • Loading branch information
dotslashf committed Aug 18, 2024
1 parent 987da3e commit f07dbdd
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 48 deletions.
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@trpc/client": "^11.0.0-rc.477",
"@trpc/react-query": "^11.0.0-rc.446",
"@trpc/server": "^11.0.0-rc.477",
"@uidotdev/usehooks": "^2.4.1",
"boring-avatars": "^1.10.2",
"chalk": "^5.3.0",
"class-variance-authority": "^0.7.0",
Expand Down
16 changes: 15 additions & 1 deletion src/app/_components/ListCopyPastaPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@
import { useSearchParams } from "next/navigation";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
import SearchBar from "../../components/SearchBar";
import { ArrowDown, LoaderCircle, Skull } from "lucide-react";
import { Suspense } from "react";
import ListTags from "~/components/ListTags";
import { sendGAEvent } from "@next/third-parties/google";
import ListTagsSkeleton from "~/components/ListTagsSkeleton";
import CopyPastaCardMinimal from "~/components/CopyPastaCardMinimal";
import dynamic from "next/dynamic";
import { Skeleton } from "~/components/ui/skeleton";

const SearchBar = dynamic(() => import("../../components/SearchBar"), {
ssr: false,
loading() {
return (
<div className="col-span-2 flex space-x-2">
<Skeleton className="h-10 flex-1" />
<Skeleton className="h-10 w-10" />
<Skeleton className="h-10 w-10" />
</div>
);
},
});

export function ListCopyPasta() {
const searchParams = useSearchParams();
Expand Down
169 changes: 123 additions & 46 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,151 @@
import { Input } from "~/components/ui/input";
import { Button, buttonVariants } from "~/components/ui/button";
import { PlusIcon, Search } from "lucide-react";
import { LoaderCircle, PlusIcon, Search } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { cn } from "~/lib/utils";
import { cn, trimContent } from "~/lib/utils";
import { sendGAEvent } from "@next/third-parties/google";
import { api } from "~/trpc/react";
import { type CopyPasta } from "@prisma/client";
import { useMediaQuery, useDebounce } from "@uidotdev/usehooks";

export default function SearchBar() {
const [query, setQuery] = useState<string>("");
const router = useRouter();
const searchParams = useSearchParams();
const [results, setResults] = useState<CopyPasta[]>([]);
const [isSearching, setIsSearching] = useState(false);
const debouncedSearchTerm = useDebounce(query, 500);
const [isSearchOpen, setIsSearchOpen] = useState(false);

const handleSearch = () => {
const searchMutation = api.copyPasta.search.useMutation();
const isSmallDevice = useMediaQuery("only screen and (max-width : 768px)");

useEffect(() => {
const searchQuery = async () => {
setIsSearching(true);
let queryResult: CopyPasta[] = [];
if (query) {
const data = await searchMutation.mutateAsync({ query });
queryResult = data;
}

setIsSearching(false);
setResults(queryResult);
setIsSearchOpen(true);
};

void searchQuery();
}, [debouncedSearchTerm]);

const handleSubmit = () => {
const currentParams = new URLSearchParams(searchParams);
currentParams.set("search", query);
sendGAEvent("event", "search", { value: currentParams.get("search") });
setIsSearchOpen(false);
router.push(`?${currentParams.toString()}`);
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
handleSearch();
handleSubmit();
}
};

return (
<div className="col-span-2 w-full">
<div className="col-span-2 flex items-center space-x-2">
<Input
type="search"
placeholder="Cari template..."
className="flex-1 shadow-sm"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button
type="submit"
variant="secondary"
onClick={handleSearch}
className="hidden lg:flex"
>
<Search className="mr-2 h-4 w-4" />
Cari
<span className="sr-only">Search</span>
</Button>
<Button
type="submit"
size={"icon"}
variant="secondary"
onClick={handleSearch}
className="flex lg:hidden"
>
<Search className="h-4 w-4" />
<span className="sr-only">Search</span>
</Button>
<Link
href={"/copy-pasta/create"}
className={cn(buttonVariants({}), "item-center")}
onClick={() => {
sendGAEvent("event", "buttonClicked", {
value: "create.copyPasta",
});
}}
>
<PlusIcon className="mr-2 h-4 w-4" />
Tambah
</Link>
<div className="col-span-2 flex flex-col">
<div className="relative flex-1">
{isSearching && (
<div className="absolute z-10 mt-14 flex w-full items-center justify-center rounded-md border bg-primary-foreground px-3 py-2 dark:text-accent">
<LoaderCircle className="w-4 animate-spin" />
</div>
)}
{results.length > 0 && isSearchOpen && (
<ul
className="absolute z-10 mt-14 flex w-full flex-col space-y-1 rounded-md border bg-primary-foreground shadow-md"
onMouseDown={(e) => e.preventDefault()}
>
{results.map((result, index) => (
<Link
href={`/copy-pasta/${result.id}`}
key={index}
className="cursor-pointer rounded-md px-4 py-2 transition-colors hover:bg-secondary dark:text-accent dark:hover:bg-accent-foreground"
>
{trimContent(result.content, 100)}
</Link>
))}
</ul>
)}
</div>
<div className="flex w-full items-center space-x-2">
<Input
type="search"
placeholder="Cari template..."
className="flex-1 shadow-sm"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsSearchOpen(true)}
onBlur={() => setIsSearchOpen(false)}
/>
{isSmallDevice ? (
<>
<ButtonSearch onClick={handleSubmit} />
<ButtonPlus />
</>
) : (
<>
<ButtonSearch onClick={handleSubmit}>Cari</ButtonSearch>
<ButtonPlus>Tambah</ButtonPlus>
</>
)}
</div>
</div>
</div>
);
}

interface ButtonInterface {
onClick: () => void;
children?: JSX.Element | JSX.Element[] | string;
}
function ButtonSearch({ children, onClick }: ButtonInterface) {
return (
<Button
type="submit"
size={children ? "default" : "icon"}
variant="secondary"
onClick={onClick}
>
<Search className="h-4 w-4" />
{children}
<span className="sr-only">Search</span>
</Button>
);
}

function ButtonPlus({
children,
}: {
children?: JSX.Element | JSX.Element[] | string;
}) {
return (
<Link
href={"/copy-pasta/create"}
className={cn(
buttonVariants({ size: children ? "default" : "icon" }),
"item-center",
)}
onClick={() => {
sendGAEvent("event", "buttonClicked", {
value: "create.copyPasta",
});
}}
>
<PlusIcon className="h-4 w-4" />
{children}
</Link>
);
}
4 changes: 3 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export function formatDateToHuman(date: Date, formatString = "PPP") {
}

export function trimContent(content: string, length = 255) {
return content ? content.slice(0, length) + "..." : "😱😱😱";
return content
? content.slice(0, length) + (content.length > 100 ? "..." : "")
: "😱😱😱";
}

export function determineSource(url = "Other") {
Expand Down
23 changes: 23 additions & 0 deletions src/server/api/routers/copyPasta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ export const copyPastaRouter = createTRPCRouter({
};
}),

search: publicProcedure
.input(
z.object({
query: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const results = await ctx.db.copyPasta.findMany({
where: {
content: {
contains: input.query,
mode: "insensitive",
},
approvedAt: {
not: null,
},
},
take: 5,
});

return results;
}),

byId: publicProcedure
.input(
z.object({
Expand Down

0 comments on commit f07dbdd

Please sign in to comment.