From fbe0506efbfd17cd255bb67cdc972255528102e4 Mon Sep 17 00:00:00 2001 From: Bartosz Gotowski Date: Fri, 10 Feb 2023 14:39:26 +0100 Subject: [PATCH] feat: add tag selection and improve ux --- package-lock.json | 38 +++++++ package.json | 2 + src/atoms/selectedTags.ts | 3 + src/components/AnimatePresenceSSR.tsx | 13 +++ src/components/Layout.tsx | 10 +- src/components/List.tsx | 67 ++++++++++++ src/components/Organisation.tsx | 132 ++++++------------------ src/components/Search.tsx | 85 +++++++++++++++ src/components/Tag.tsx | 23 +++++ src/hooks/useDebounce.ts | 16 +++ src/hooks/useSelectedTags.ts | 16 +++ src/pages/index.tsx | 70 ++++--------- src/server/api/routers/organizations.ts | 13 ++- src/styles/theme.ts | 7 +- 14 files changed, 346 insertions(+), 149 deletions(-) create mode 100644 src/atoms/selectedTags.ts create mode 100644 src/components/AnimatePresenceSSR.tsx create mode 100644 src/components/List.tsx create mode 100644 src/components/Search.tsx create mode 100644 src/components/Tag.tsx create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/hooks/useSelectedTags.ts diff --git a/package-lock.json b/package-lock.json index 108fb2c7..db9e78ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", "@next-auth/prisma-adapter": "^1.0.5", + "@next/font": "^13.1.6", "@prisma/client": "^4.9.0", "@tanstack/react-query": "^4.20.0", "@trpc/client": "^10.9.0", @@ -21,6 +22,7 @@ "@trpc/react-query": "^10.9.0", "@trpc/server": "^10.9.0", "framer-motion": "^9.0.2", + "jotai": "^2.0.1", "next": "13.1.6", "next-auth": "^4.19.0", "react": "18.2.0", @@ -43,6 +45,10 @@ "prisma": "^4.9.0", "typescript": "^4.9.4", "wait-on": "^7.0.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" } }, "node_modules/@ampproject/remapping": { @@ -2005,6 +2011,11 @@ "glob": "7.1.7" } }, + "node_modules/@next/font": { + "version": "13.1.6", + "resolved": "https://registry.npmjs.org/@next/font/-/font-13.1.6.tgz", + "integrity": "sha512-AITjmeb1RgX1HKMCiA39ztx2mxeAyxl4ljv2UoSBUGAbFFMg8MO7YAvjHCgFhD39hL7YTbFjol04e/BPBH5RzQ==" + }, "node_modules/@next/swc-android-arm-eabi": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.1.6.tgz", @@ -4918,6 +4929,22 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jotai": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.0.1.tgz", + "integrity": "sha512-b/BpBFkv3nq8HgT6YX5h5/y9VfKIn9OL1dO6gd9bWTgKt6LLe24VIMURTDwSYS888XfubuRQlbepb5IQGAtmcQ==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/js-sdsl": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", @@ -8469,6 +8496,11 @@ "glob": "7.1.7" } }, + "@next/font": { + "version": "13.1.6", + "resolved": "https://registry.npmjs.org/@next/font/-/font-13.1.6.tgz", + "integrity": "sha512-AITjmeb1RgX1HKMCiA39ztx2mxeAyxl4ljv2UoSBUGAbFFMg8MO7YAvjHCgFhD39hL7YTbFjol04e/BPBH5RzQ==" + }, "@next/swc-android-arm-eabi": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.1.6.tgz", @@ -10527,6 +10559,12 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-4.11.4.tgz", "integrity": "sha512-94FdcR8felat4vaTJyL/WVdtlWLlsnLMZP8v+A0Vru18K3bQ22vn7TtpVh3JlgBFNIlYOUlGqwp/MjRPOnIyCQ==" }, + "jotai": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.0.1.tgz", + "integrity": "sha512-b/BpBFkv3nq8HgT6YX5h5/y9VfKIn9OL1dO6gd9bWTgKt6LLe24VIMURTDwSYS888XfubuRQlbepb5IQGAtmcQ==", + "requires": {} + }, "js-sdsl": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", diff --git a/package.json b/package.json index 982096db..5ba3fc04 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", "@next-auth/prisma-adapter": "^1.0.5", + "@next/font": "^13.1.6", "@prisma/client": "^4.9.0", "@tanstack/react-query": "^4.20.0", "@trpc/client": "^10.9.0", @@ -24,6 +25,7 @@ "@trpc/react-query": "^10.9.0", "@trpc/server": "^10.9.0", "framer-motion": "^9.0.2", + "jotai": "^2.0.1", "next": "13.1.6", "next-auth": "^4.19.0", "react": "18.2.0", diff --git a/src/atoms/selectedTags.ts b/src/atoms/selectedTags.ts new file mode 100644 index 00000000..17dc94a4 --- /dev/null +++ b/src/atoms/selectedTags.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const selectedTagsAtom = atom([]); \ No newline at end of file diff --git a/src/components/AnimatePresenceSSR.tsx b/src/components/AnimatePresenceSSR.tsx new file mode 100644 index 00000000..47183afe --- /dev/null +++ b/src/components/AnimatePresenceSSR.tsx @@ -0,0 +1,13 @@ +import { AnimatePresence } from "framer-motion"; +import type { ComponentProps } from "react"; +import React from "react"; + +export const AnimatePresenceSSR = ( + props: ComponentProps +) => { + return typeof window !== "undefined" ? ( + + ) : ( + <>{props.children} + ); +}; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 751ad0ac..1f08cf48 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,12 +1,20 @@ import { chakra, Stack } from "@chakra-ui/react"; import type { ReactNode } from "react"; import React from "react"; +import { Lato } from "@next/font/google"; import { Footer } from "./Footer"; import { Navbar } from "./Navbar"; +const lato = Lato({ + subsets: ["latin"], + weight: ["300", "400", "700"], + variable: "--font-lato", + display: "swap", +}); + export const Layout = ({ children }: { children: ReactNode }) => { return ( - +
{children} diff --git a/src/components/List.tsx b/src/components/List.tsx new file mode 100644 index 00000000..b41e3ca5 --- /dev/null +++ b/src/components/List.tsx @@ -0,0 +1,67 @@ +import type { RouterOutputs } from "@/utils/api"; +import { InfoOutlineIcon } from "@chakra-ui/icons"; +import type { BoxProps } from "@chakra-ui/react"; +import { VStack, Wrap, WrapItem, Text, Box } from "@chakra-ui/react"; +import { motion } from "framer-motion"; +import React from "react"; +import { AnimatePresenceSSR } from "./AnimatePresenceSSR"; +import { Organisation } from "./Organisation"; + +export const List = ({ + data, + ...styles +}: { + data?: RouterOutputs["organizations"]["getAll"]; +} & BoxProps) => { + return ( + + + {data?.length === 0 ? ( + + + + + Brak organizacji, które spełniają twoje zapytanie + + + + ) : null} + + + + {data && data?.length > 0 ? ( + + {data?.length} wyników + + ) : null} + + + {data?.map((org) => ( + + + + + + ))} + + + + + ); +}; diff --git a/src/components/Organisation.tsx b/src/components/Organisation.tsx index 7de165b4..16df8a7c 100644 --- a/src/components/Organisation.tsx +++ b/src/components/Organisation.tsx @@ -1,118 +1,54 @@ import { - Stack, - Heading, Button, Text, - Badge, - Center, - Flex, - useColorModeValue, Wrap, WrapItem, + VStack, + HStack, + IconButton, } from "@chakra-ui/react"; -import NextImage from "next/image"; - -const Tag = ({ tag }: { tag: string }) => { - return ( - - {tag} - - ); -}; +import { FaHeart } from "react-icons/fa"; +import { Tag } from "./Tag"; export const Organisation = ({ name, description, - logoUrl, tags, }: { name: string; description: string; - logoUrl: string; tags: string[]; }) => { return ( -
- - - - - - - - {name} - - - {description} - - - {tags.map((tag) => ( - - - - ))} - - - - - - - -
+ + + W4 + + {name} + + + {tags.map((tag) => ( + + + + ))} + + + {description} + + + + + } aria-label="Dodaj do ulubionych" /> + + ); }; diff --git a/src/components/Search.tsx b/src/components/Search.tsx new file mode 100644 index 00000000..28827f05 --- /dev/null +++ b/src/components/Search.tsx @@ -0,0 +1,85 @@ +import { useDebounce } from "@/hooks/useDebounce"; +import { useSelectedTags } from "@/hooks/useSelectedTags"; +import { SearchIcon } from "@chakra-ui/icons"; +import type { StackProps } from "@chakra-ui/react"; +import { Wrap, WrapItem } from "@chakra-ui/react"; +import { Input, InputGroup, InputRightElement, VStack } from "@chakra-ui/react"; +import type { ComponentProps } from "react"; +import React, { useEffect, useState } from "react"; +import { Tag } from "./Tag"; + +const availableTags = [ + "Druk3D", + "Piwo", + "Fotografia", + "Programowanie", + "Muzyka", + "Taniec", + "Kultura", + "Sport", +]; + +const SelectableTag = ( + props: ComponentProps & { + onToggle: (tag: string) => void; + } +) => { + return ( + { + props.onToggle(props.tag); + }} + {...props} + /> + ); +}; + +export const Search = ({ + value, + setValue, + ...styles +}: { + value: string; + setValue: (value: string) => void; +} & StackProps) => { + const [search, setSearch] = useState(value); + const debouncedSearch = useDebounce(search, 200); + const { toggleTag } = useSelectedTags(); + + useEffect(() => { + setValue(debouncedSearch); + }, [debouncedSearch, setValue]); + + return ( + + + { + setSearch(e.target.value); + }} + /> + + + + + + {availableTags.map((tag) => ( + + { + toggleTag(tag); + }} + /> + + ))} + + + ); +}; diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx new file mode 100644 index 00000000..dcb262aa --- /dev/null +++ b/src/components/Tag.tsx @@ -0,0 +1,23 @@ +import { useSelectedTags } from "@/hooks/useSelectedTags"; +import type { TagProps } from "@chakra-ui/react"; +import { Tag as ChakraTag } from "@chakra-ui/react"; +import React from "react"; + +export const Tag = ({ tag, ...styles }: { tag: string } & TagProps) => { + const { selectedTags } = useSelectedTags(); + + const isSelected = selectedTags.includes(tag); + + return ( + + {tag} + + ); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..041cda7d --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react' + +export const useDebounce = (value: T, delay?: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} + diff --git a/src/hooks/useSelectedTags.ts b/src/hooks/useSelectedTags.ts new file mode 100644 index 00000000..011f2fc3 --- /dev/null +++ b/src/hooks/useSelectedTags.ts @@ -0,0 +1,16 @@ +import { selectedTagsAtom } from '@/atoms/selectedTags'; +import { useAtom } from 'jotai' + +export const useSelectedTags = () => { + const [tags, setTags] = useAtom(selectedTagsAtom); + + const toggleTag = (tag: string) => { + if (tags.includes(tag)) { + setTags(tags.filter((t) => t !== tag)); + } else { + setTags([...tags, tag]); + } + } + + return { selectedTags: tags, toggleTag }; +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e8828919..e2a8bb7f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,67 +1,41 @@ -import { Organisation } from "@/components/Organisation"; +import { List } from "@/components/List"; +import { Search } from "@/components/Search"; +import { useSelectedTags } from "@/hooks/useSelectedTags"; import { api } from "@/utils/api"; -import { InfoOutlineIcon, SearchIcon } from "@chakra-ui/icons"; -import { - Container, - Input, - InputGroup, - InputRightElement, - VStack, - Text, - Wrap, - WrapItem, -} from "@chakra-ui/react"; +import { Container, VStack, Heading, Tag } from "@chakra-ui/react"; import { type NextPage } from "next"; import { useMemo, useState } from "react"; const Home: NextPage = () => { const { data } = api.organizations.getAll.useQuery(); const [search, setSearch] = useState(""); - + const { selectedTags } = useSelectedTags(); const filteredData = useMemo( () => data?.filter( (org) => - org.description.toLowerCase().includes(search.toLowerCase()) || - org.name.toLowerCase().includes(search.toLowerCase()) || - org.tags.some((tag) => - tag.toLowerCase().includes(search.toLowerCase()) - ) + (org.description.toLowerCase().includes(search.toLowerCase()) || + org.name.toLowerCase().includes(search.toLowerCase()) || + org.tags.some((tag) => + tag.toLowerCase().includes(search.toLowerCase()) + )) && + selectedTags.every((tag) => org.tags.includes(tag)) ), - [data, search] + [data, search, selectedTags] ); return ( <> - - - - { - setSearch(e.target.value); - }} - /> - - - - - {filteredData?.length === 0 ? ( - - - - Brak organizacji, które spełniają twoje zapytanie - - - ) : null} - - {filteredData?.map((org) => ( - - - - ))} - + + + + znajdź organizacje dla siebie! + + + Wyszukiwarka organizacji studenckich + + + diff --git a/src/server/api/routers/organizations.ts b/src/server/api/routers/organizations.ts index b3b6e5fd..2fe33b4d 100644 --- a/src/server/api/routers/organizations.ts +++ b/src/server/api/routers/organizations.ts @@ -2,13 +2,24 @@ import { createTRPCRouter, publicProcedure } from "../trpc"; import { faker } from "@faker-js/faker/locale/pl"; +const tags = [ + "Druk3D", + "Piwo", + "Fotografia", + "Programowanie", + "Muzyka", + "Taniec", + "Kultura", + "Sport", +]; + const generateFakeOrganization = () => { return { id: faker.datatype.uuid(), name: faker.company.name(), description: faker.lorem.sentences(2), logoUrl: faker.image.cats(150, 150, true), - tags: [faker.company.bsNoun(), faker.company.bsNoun(), faker.company.bsNoun()] + tags: faker.helpers.arrayElements(tags, 3), } } diff --git a/src/styles/theme.ts b/src/styles/theme.ts index ac519acf..aebb03c2 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -1,3 +1,8 @@ import { extendTheme } from "@chakra-ui/react"; -export const theme = extendTheme(); \ No newline at end of file +export const theme = extendTheme({ + fonts: { + body: "var(--font-lato), -apple-system, Arial, sans-serif", + heading: "var(--font-lato), -apple-system, Arial, sans-serif", + }, +}); \ No newline at end of file