DEV Community

James
James

Posted on • Originally published at jamestedy.hashnode.dev

(Part 4) Build a Simple Chat Character Gallery: Adding Searchbar & Filter

๐Ÿ‘‹ Recap from Previous Post

In the previous post, we focused on building the ChatbotCard componentโ€”an essential part of our Gallery UI. You learned how to:

โœ… Create reusable UI components like Card, CardContent, CardFooter, and Button.
โœ… Set up a flexible utility function cn() to manage dynamic Tailwind class names.
โœ… Define the Chatbot and Persona types for clean data structure.
โœ… Render a responsive grid of chatbot cards on the Gallery page using sample data.
โœ… Ensure the Gallery is the default landing page by updating app/page.tsx.

By the end of that post, your app shouldโ€™ve been able to display a functional and neatly designed grid of chatbot cards that preview character info.


Creating Searchbar & Filter

๐ŸŽฏ Goals for This Post

In this part of the tutorial, weโ€™ll focus on building the Searchbar and Filter functionality for the Gallery page. To keep things simple and digestible, weโ€™ll save the โ€œAdd Chatbotโ€ button for the next post.

By the end of this guide, youโ€™ll have a fully functional UI where users can:

  • Search by Name โ€“ Filter visible ChatbotCards by typing a name in the search bar.
  • Filter by Interests โ€“ Click on an interest tag to show only characters who have that interest.
  • Filter by Personality โ€“ Click on a personality tag to display only characters matching that trait.

Your final result will look like the image belowโ€”a responsive page with cards, a searchbar, and clickable filters ready to enhance user experience.


๐Ÿ“ฑ Step by Step Guide



1. Creating the Searchbar

First thing first, weโ€™ll create a new file called Searchbar.tsx and weโ€™ll put it in src/modules/gallery/components/Searchbar.tsx, the reason is the Searchbar will be used in this page only hence it will be better to put it in components under gallery instead of shared components folder.

Hereโ€™s the code for it:

import React from "react";
import Input from "@/components/ui/input";
import { cn } from "@/lib/utils";

interface SearchBarProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
}

export const SearchBar = ({
  value,
  onChange,
  placeholder = "Search chatbots...",
}: SearchBarProps) => {
  return (
    <div className="relative flex-grow">
      <Input
        type="text"
        value={value}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
          onChange(e.target.value)
        }
        placeholder={placeholder}
        className={cn(
          "w-full",
          "border-2 border-purple-200 bg-white/80 backdrop-blur-sm",
          "text-gray-700 placeholder:text-purple-400/70",
          "rounded-xl shadow-sm",
          "focus:border-purple-500 focus:ring-4 focus:ring-purple-500/20",
          "focus:bg-white focus:shadow-lg focus:shadow-purple-500/10",
          "transition-all duration-300 ease-out",
          "hover:border-purple-300 hover:shadow-md"
        )}
        aria-label="Search chatbots"
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The Searchbar UI follows a common design found in many modern web apps. It accepts standard props such as value, onChange, and placeholder. This allows us to control its state and update the input dynamically as the user types.

If you take a closer look, you might notice weโ€™re using a component that hasnโ€™t been created yet: the Input component from our shared UI folder and thatโ€™s what weโ€™ll work on next.

Creating Input component

The Input component is essentially a styled wrapper around the standard <input> element. Its purpose is to maintain a consistent design and behavior across all input fields in our project.

Since this is a shared UI component, weโ€™ll place it in src/components/ui/input.tsx.

Hereโ€™s the code:

import React, { FC, InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";

type InputProps = InputHTMLAttributes<HTMLInputElement>;

const Input: FC<InputProps> = ({ className, ...props }) => {
  return (
    <input
      className={cn(
        "flex h-10 w-full rounded-md border border-input bg-white dark:bg-gray-800 px-3 py-2 text-sm text-black dark:text-black ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 dark:placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
        className
      )}
      {...props}
    />
  );
};

export default Input;
Enter fullscreen mode Exit fullscreen mode

Integrating the Searchbar into the Gallery Page

Now that our Searchbar component is complete, we can add it to the GalleryPage and connect it to the componentโ€™s state. This will allow us to dynamically filter the displayed ChatbotCards based on the character names as the user types.

First, letโ€™s create a new <div> positioned alongside the existing ChatbotCard components. Inside this new container, weโ€™ll place the Searchbar component.


    <div className="container mx-auto px-4 py-8">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900">Chatbot Gallery</h1>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
        <div className="md:col-span-1">
          <div
            className="sticky top-4 space-y-6"
            role="search"
            aria-label="Search and filter chatbots"
          >
            <div className="flex gap-x-2">
              <SearchBar
                value={""}
                onChange={() => {}}
                placeholder="Search chatbots by name or description..."
              />
            </div>
          </div>
        </div>
        <div className="md:col-span-3">
          <div
            className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
            role="region"
            aria-label="Chatbot list"
            aria-live="polite"
          >
            {/* ------------ Rest of the HTMLS ------------- */}
          </div>
        </div>
      </div>
    </div>
Enter fullscreen mode Exit fullscreen mode

Next, weโ€™ll add a new state inside the GalleryPage component called searchTerm. This state will store the value typed into the search bar.

Weโ€™ll then pass searchTerm as the value prop to the Searchbar, and set the onChange prop to update it using setSearchTerm.

Hereโ€™s the full example of how that looks in context:

const GalleryPage = () => {
  const [searchTerm, setSearchTerm] = useState("");

  const onCardClick = () => {
    // Do nothing yet
  };

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900">Chatbot Gallery</h1>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
        <div className="md:col-span-1">
          <div
            className="sticky top-4 space-y-6"
            role="search"
            aria-label="Search and filter chatbots"
          >
            <div className="flex gap-x-2">
              <SearchBar
                value={searchTerm}
                onChange={setSearchTerm}
                placeholder="Search chatbots by name or description..."
              />
            </div>
          </div>
        </div>
        <div className="md:col-span-3">
          <div
            className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
            role="region"
            aria-label="Chatbot list"
            aria-live="polite"
          >
            {/* ------------ Unrelated Parts ------------- */}
          </div>
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Next, weโ€™ll filter the chatbot data based on the searchTerm, and pass the filtered result to the ChatbotCards group. This allows the list to dynamically update and only show chatbot cards that match the userโ€™s input in the search bar.

First, weโ€™ll rename the existing sample data from filteredChatbots to chatbotsData to avoid confusion. Then, inside the GalleryPage component, weโ€™ll create a new variable called filteredChatbots.

This new variable will filter chatbotsData using the searchTerm, returning a refined list of chatbot cards that match the search query.

Hereโ€™s an example of the code:

// ... imports and sample datas 

const GalleryPage = () => {
  const [searchTerm, setSearchTerm] = useState("");

  const onCardClick = () => {
    // Do nothing yet
  };

  const filteredChatbots = useMemo(() => {
    return chatbotsData.filter((persona) => {
      const matchesSearch = persona.name
        .toLowerCase()
        .includes(searchTerm.toLowerCase());

      return matchesSearch;
    });
  }, [chatbotsData, searchTerm]);

// ... rest of the codes
Enter fullscreen mode Exit fullscreen mode

By now, your search functionality should be working. Typing into the search bar will dynamically filter the displayed ChatbotCards based on their names.

It should look something like this:

2. Creating the Filter

Now, let's move on to building the filter functionality.

On desktop, filters will be displayed as clickable tag-like buttons (as shown in the image above). On mobile, the same filters will be accessible through dropdown menus for a more compact and user-friendly interface (see image below). Once you select an interest or personality from the dropdown, a tag will appear showing your selected filterโ€”making it easy to see and manage which filters are currently active.

First, weโ€™ll begin by building the desktop view of the filter component.

Create a new file named FilterControl.tsx, located at: src/modules/gallery/components/FilterControl.tsx.

Since each tag-like filter should be interactive (clickable), weโ€™ll use the reusable Button component to render them. This ensures consistent styling and behavior across the app.

Hereโ€™s the code for FilterControl.tsx:

import React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface FilterControlsProps {
  value: string[];
  selectedValue: string[];
  onToggle: (value: string) => void;
}

export const FilterControls = ({
  value,
  selectedValue,
  onToggle,
}: FilterControlsProps) => {
  // Shared tag styling for both mobile and desktop
  const getTagStyles = (isSelected: boolean) => {
    return cn(
      "rounded-full px-4 py-2 text-sm font-semibold",
      "transition-all duration-200",
      isSelected
        ? "bg-gradient-to-r from-purple-600 to-pink-500 text-white shadow-md shadow-purple-500/25 hover:shadow-lg hover:shadow-purple-500/40"
        : "border-2 border-purple-200 text-purple-600 hover:bg-purple-100 hover:border-purple-300 hover:shadow-md"
    );
  };

  return (
    <>
      {/* Desktop view: Button group */}
      <div className="hidden md:flex flex-wrap gap-2">
        {value.map((e) => (
          <Button
            key={e}
            onClick={() => onToggle(e)}
            variant={selectedValue.includes(e) ? "default" : "outline"}
            size="sm"
            className={getTagStyles(selectedValue.includes(e))}
            aria-pressed={selectedValue.includes(e)}
            role="checkbox"
            aria-checked={selectedValue.includes(e)}
          >
            {e}
          </Button>
        ))}
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we created a helper function called getTagStyles to keep things tidy and avoid repeating the same classNames. This function will also be reused by other components in this file later on.

The FilterControls component accepts several props:

  • values: the values to be displayed.
  • selectedValue: the currently selected filter.
  • onToggle: a callback function triggered when the button is clicked

These props allow the filter controls to behave dynamically and respond to user interaction effectively.

Next, weโ€™ll move on to building the mobile view, starting with the dropdown button. For this, weโ€™ll use the native <select> and <option> elements, styled to match the rest of our UI.

Hereโ€™s the updated code for FilterControls:

import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface FilterControlsProps {
  values: string[];
  selectedValue: string[];
  onToggle: (value: string) => void;
}

export const FilterControls = ({
  values,
  selectedValue,
  onToggle,
}: FilterControlsProps) => {
  const selectRef = useRef<HTMLSelectElement>(null);

  // For mobile view, we'll use a Select component
  const handleSelectChange = (value: string) => {
    if (value) {
      onToggle(value);
      // Reset the select to default option after selection
      if (selectRef.current) {
        selectRef.current.value = "";
      }
    }
  };

  // Shared tag styling for both mobile and desktop
  const getTagStyles = (isSelected: boolean) => {
    return cn(
      "rounded-full px-4 py-2 text-sm font-semibold",
      "transition-all duration-200",
      isSelected
        ? "bg-gradient-to-r from-purple-600 to-pink-500 text-white shadow-md shadow-purple-500/25 hover:shadow-lg hover:shadow-purple-500/40"
        : "border-2 border-purple-200 text-purple-600 hover:bg-purple-100 hover:border-purple-300 hover:shadow-md"
    );
  };

  return (
    <>
      {/* Mobile view: Select dropdown and tags */}
      <div className="md:hidden space-y-3">
        <select
          ref={selectRef}
          onChange={(e) => handleSelectChange(e.target.value)}
          className={cn(
            "w-full h-12 px-4 py-3 text-sm bg-white/80 backdrop-blur-sm",
            "border-2 border-purple-200 rounded-xl",
            "text-gray-700 placeholder:text-purple-400/70",
            "focus:border-purple-500 focus:ring-4 focus:ring-purple-500/20",
            "focus:bg-white focus:shadow-lg focus:shadow-purple-500/10",
            "transition-all duration-300 ease-out",
            "hover:border-purple-300 hover:shadow-md"
          )}
          aria-label="Filter by personality"
        >
          <option value="">Select an interest</option>
          {values.map((e) => (
            <option key={e} value={e}>
              {e}
            </option>
          ))}
        </select>
      </div>

      {/* Desktop view: Button group */}
      <div className="hidden md:flex flex-wrap gap-2">
        {values.map((e) => (
          <Button
            key={e}
            onClick={() => onToggle(e)}
            variant={selectedValue.includes(e) ? "default" : "outline"}
            size="sm"
            className={getTagStyles(selectedValue.includes(e))}
            aria-pressed={selectedValue.includes(e)}
            role="checkbox"
            aria-checked={selectedValue.includes(e)}
          >
            {e}
          </Button>
        ))}
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

As shown in the code above, we added a new container next to the desktop viewโ€™s container to hold the dropdown menu. This mobile-specific container is only visible on screens smaller than the medium breakpoint (i.e., mobile devices).

We also introduced a new function to handle dropdown selections called handleSelectChange. When a user selects an option, this function calls the onToggle handler to update the selected filter, and then resets the <select> value to allow for repeated selections. The selected values will then be displayed as buttons below the dropdown which weโ€™ll implement next.

next weโ€™ll work on the tags like component for the mobile view. itโ€™s pretty simple and similar to the desktop view part.

Hereโ€™s the updated code:

import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface FilterControlsProps {
  values: string[];
  selectedValues: string[];
  onToggle: (value: string) => void;
}

export const FilterControls = ({
  values,
  selectedValues,
  onToggle,
}: FilterControlsProps) => {
  const selectRef = useRef<HTMLSelectElement>(null);

  // For mobile view, we'll use a Select component
  const handleSelectChange = (value: string) => {
    if (value) {
      onToggle(value);
      // Reset the select to default option after selection
      if (selectRef.current) {
        selectRef.current.value = "";
      }
    }
  };

  // Shared tag styling for both mobile and desktop
  const getTagStyles = (isSelected: boolean) => {
    return cn(
      "rounded-full px-4 py-2 text-sm font-semibold",
      "transition-all duration-200",
      isSelected
        ? "bg-gradient-to-r from-purple-600 to-pink-500 text-white shadow-md shadow-purple-500/25 hover:shadow-lg hover:shadow-purple-500/40"
        : "border-2 border-purple-200 text-purple-600 hover:bg-purple-100 hover:border-purple-300 hover:shadow-md"
    );
  };

  return (
    <>
      {/* Mobile view: Select dropdown and tags */}
      <div className="md:hidden space-y-3">
        <select
          ref={selectRef}
          onChange={(e) => handleSelectChange(e.target.value)}
          className={cn(
            "w-full h-12 px-4 py-3 text-sm bg-white/80 backdrop-blur-sm",
            "border-2 border-purple-200 rounded-xl",
            "text-gray-700 placeholder:text-purple-400/70",
            "focus:border-purple-500 focus:ring-4 focus:ring-purple-500/20",
            "focus:bg-white focus:shadow-lg focus:shadow-purple-500/10",
            "transition-all duration-300 ease-out",
            "hover:border-purple-300 hover:shadow-md"
          )}
          aria-label="Filter by personality"
        >
          <option value="">Select an interest</option>
          {values.map((e) => (
            <option key={e} value={e}>
              {e}
            </option>
          ))}
        </select>

        {/* Mobile selected tags display */}
        {selectedValues.length > 0 && (
          <div className="flex flex-wrap gap-2 mt-2">
            {selectedValues.map((e) => (
              <Button
                key={e}
                onClick={() => onToggle(e)}
                variant="default"
                size="sm"
                className={getTagStyles(true)}
                aria-pressed={true}
                role="checkbox"
                aria-checked={true}
              >
                {e}
              </Button>
            ))}
          </div>
        )}
      </div>

      {/* Desktop view: Button group */}
      <div className="hidden md:flex flex-wrap gap-2">
        {values.map((e) => (
          <Button
            key={e}
            onClick={() => onToggle(e)}
            variant={selectedValues.includes(e) ? "default" : "outline"}
            size="sm"
            className={getTagStyles(selectedValues.includes(e))}
            aria-pressed={selectedValues.includes(e)}
            role="checkbox"
            aria-checked={selectedValues.includes(e)}
          >
            {e}
          </Button>
        ))}
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The new section marked with the comment Mobile selected tags display checks whether there are any selectedValues. If so, it displays them accordingly.

Now, the final step is to integrate these components into the GalleryPage and manage the state for the selected values.

Letโ€™s begin with the Interest Filter. First, add the FilterControl component right below the Searchbar. Then, create a new state to hold the selected filter value and define a function to handle the onToggle logic for the FilterControl.

Hereโ€™s an example implementation:

const GalleryPage = () => {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedInterest, setSelectedInterest] = useState<string[]>([]);

  const onCardClick = () => {
    // Do nothing yet
  };

  // Get all unique interests
  const allInterests = useMemo(() => {
    const interests = new Set<string>();
    chatbotsData.forEach((persona) => {
      // Collect unique interests from personas
      if (persona.interests) {
        persona.interests.split(",").forEach((el) => interests.add(el.trim()));
      }
    });
    return Array.from(interests).sort();
  }, [chatbotsData]);

  const filteredChatbots = useMemo(() => {
    return chatbotsData.filter((persona) => {
      const matchesSearch = persona.name
        .toLowerCase()
        .includes(searchTerm.toLowerCase());

      const matchesInterests =
        selectedInterest.length === 0 ||
        selectedInterest.some((e) => persona.interests?.includes(e));

      return matchesSearch && matchesInterests;
    });
  }, [chatbotsData, searchTerm, selectedInterest]);

  const handleInterestsToggle = (tag: string) => {
    setSelectedInterest((prev) =>
      prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
    );
  };

  return (
// .... rest of the code
            <div className="flex gap-x-2">
              <SearchBar
                value={searchTerm}
                onChange={setSearchTerm}
                placeholder="Search chatbots by name or description..."
              />
            </div>
            {/* Filter for Interests */}
            <div className="bg-white p-4 rounded-lg shadow">
              <h2 className="text-lg font-medium mb-3">Filter by Interests</h2>
              <FilterControls
                values={allInterests}
                selectedValues={selectedInterest}
                onToggle={handleInterestsToggle}
              />
            </div>
/// .... rest of the code
  );
};
Enter fullscreen mode Exit fullscreen mode

As shown above, weโ€™ve made several updates to the GalleryPage:

  • Added a useState hook to manage the selectedInterests.
  • Defined allInterests to extract all unique interests from the chatbot data.
  • Updated filteredChatbots to include filtering logic based on selected interests.
  • Created handleInterestsToggle to handle changes when a filter button is toggled.

At this point, the filter should be functional and working as expected. Feel free to try it out and see it in action!

The final step is to implement the filter for Personality. This will follow the same pattern as the Interests filterโ€”so if youโ€™d like, you can give it a try yourself first before checking the solution below.

Hereโ€™s the code example:

"use client";

import React, { useMemo, useState } from "react";
import { ChatbotCard } from "./components/ChatbotCard";
import { SearchBar } from "./components/Searchbar";
import { FilterControls } from "./components/FilterControl";

const chatbotsData = [
  {
    id: "1",
    name: "Chatbot 1",
    avatarUrl: "",
    behaviour: "Chatbot 1 behaviour",
    personality: "professional, detail-oriented, calm",
    interests: "planning, data organization, note taking",
    tone_style: "formal, friendly",
    created_at: new Date().getTime(),
    updated_at: new Date().getTime(),
  },
  {
    id: "2",
    name: "Chatbot 2",
    avatarUrl: "",
    behaviour: "Chatbot 2 behaviour",
    personality: "playful, witty, chill",
    interests: "astrology, indie music, coffee",
    tone_style: "casual, friendly, flirty sometimes",
    created_at: new Date().getTime(),
    updated_at: new Date().getTime(),
  },
  {
    id: "3",
    name: "Chatbot 3",
    avatarUrl: "",
    behaviour: "Chatbot 3 behaviour",
    personality: "playful, teasing, proud",
    interests: "Sweets, Desserts, Fashion",
    tone_style: "casual with friends, cold with others",
    created_at: new Date().getTime(),
    updated_at: new Date().getTime(),
  },
];

const GalleryPage = () => {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedInterest, setSelectedInterest] = useState<string[]>([]);
  const [selectedPersonality, setSelectedPersonality] = useState<string[]>([]);

  const onCardClick = () => {
    // Do nothing yet
  };

  // Get all unique interests
  const allInterests = useMemo(() => {
    const interests = new Set<string>();
    chatbotsData.forEach((persona) => {
      // Collect unique interests from personas
      if (persona.interests) {
        persona.interests.split(",").forEach((el) => interests.add(el.trim()));
      }
    });
    return Array.from(interests).sort();
  }, [chatbotsData]);

  // Get all unique personality
  const allPersonality = useMemo(() => {
    const personalities = new Set<string>();
    chatbotsData.forEach((persona) => {
      // Collect unique interests from personas
      if (persona.personality) {
        persona.personality
          .split(",")
          .forEach((el) => personalities.add(el.trim()));
      }
    });
    return Array.from(personalities).sort();
  }, [chatbotsData]);

  const filteredChatbots = useMemo(() => {
    return chatbotsData.filter((persona) => {
      const matchesSearch = persona.name
        .toLowerCase()
        .includes(searchTerm.toLowerCase());

      const matchesInterests =
        selectedInterest.length === 0 ||
        selectedInterest.some((e) => persona.interests?.includes(e));

      const matchesPersonalities =
        selectedPersonality.length === 0 ||
        selectedPersonality.some((e) => persona.personality?.includes(e));

      return matchesSearch && matchesInterests && matchesPersonalities;
    });
  }, [chatbotsData, searchTerm, selectedInterest, selectedPersonality]);

  const handleInterestsToggle = (tag: string) => {
    setSelectedInterest((prev) =>
      prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
    );
  };

  const handlePersonalityToggle = (tag: string) => {
    setSelectedPersonality((prev) =>
      prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
    );
  };

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900">Chatbot Gallery</h1>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
        <div className="md:col-span-1">
          <div
            className="sticky top-4 space-y-6"
            role="search"
            aria-label="Search and filter chatbots"
          >
            <div className="flex gap-x-2">
              <SearchBar
                value={searchTerm}
                onChange={setSearchTerm}
                placeholder="Search chatbots by name or description..."
              />
            </div>

            {/* Filter for Interests */}
            <div className="bg-white p-4 rounded-lg shadow">
              <h2 className="text-lg font-medium mb-3">Filter by Interests</h2>
              <FilterControls
                values={allInterests}
                selectedValues={selectedInterest}
                onToggle={handleInterestsToggle}
              />
            </div>

            {/* Filter for Personality */}
            <div className="bg-white p-4 rounded-lg shadow">
              <h2 className="text-lg font-medium mb-3">
                Filter by Personality
              </h2>
              <FilterControls
                values={allPersonality}
                selectedValues={selectedPersonality}
                onToggle={handlePersonalityToggle}
              />
            </div>
          </div>
        </div>
        <div className="md:col-span-3">
          <div
            className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
            role="region"
            aria-label="Chatbot list"
            aria-live="polite"
          >
            {filteredChatbots.map((persona) => {
              const interests = persona.interests
                ? persona.interests.split(",").map((i) => i.trim())
                : [];
              const personality = persona.personality
                ? persona.personality.split(",").map((p) => p.trim())
                : [];

              return (
                <div key={persona.id}>
                  <ChatbotCard
                    chatbot={{
                      id: persona.id,
                      name: persona.name,
                      avatarUrl: "",
                      behavior: persona.behaviour || "",
                      personality: personality,
                      interests: interests,
                      tone_style: persona.tone_style || "",
                      createdAt:
                        new Date(persona.created_at).toISOString() ||
                        new Date().toISOString(),
                      updatedAt:
                        new Date(persona.updated_at).toISOString() ||
                        new Date().toISOString(),
                    }}
                    onClick={onCardClick}
                  />
                </div>
              );
            })}

            {filteredChatbots.length === 0 && (
              <div className="col-span-full text-center py-12">
                <p className="text-sm text-gray-500">
                  No chatbots match your search criteria
                </p>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
};

export default GalleryPage;
Enter fullscreen mode Exit fullscreen mode

As you can see above, we added the same pieces of logic we used previously for the Interests filter. These include:

  • handlePersonalityToggle โ€“ handles toggle changes for the Personality filter.
  • allPersonality โ€“ extracts all unique personality traits from the chatbot data.
  • A new useState hook to store the selectedPersonality value.
  • An updated filteredChatbots logic to include the Personality filter

Thatโ€™s all for today! ๐ŸŽ‰

You can now try out what youโ€™ve built in this post. You should be able to filter chatbot results by name (using the Searchbar), personality, and/or interests. It should looks like these images below

๐Ÿš€ Whatโ€™s Next?

In the next post, weโ€™ll work on adding the Add Chatbot button and form, including how to integrate it with the API we discussed back in Part 2.

New posts will be released every 2โ€“3 days, so make sure to subscribe or bookmark this page to stay updated!

Please check Part 5 here:
๐Ÿ‘‰ (Part 5) Build a Simple Chat Character Gallery: Adding Create New Chatbot Feature

Top comments (0)