diff --git a/.eslintrc.json b/.eslintrc.json index 97a2bb84..66f0bf65 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,19 @@ { - "extends": ["next", "next/core-web-vitals"] + "env": { + "browser": true, + "node": true + }, + "extends": ["next/core-web-vitals", "next", "prettier"], + "plugins": ["prettier"], + "rules": { + "prettier/prettier": "warn", + "react/prop-types": ["off"], + "no-console": ["warn", { "allow": ["warn", "error"] }], + "no-unused-vars": [ + "warn", + { "vars": "all", "args": "none", "ignoreRestSiblings": false } + ], + "eol-last": ["warn", "always"], + "react/react-in-jsx-scope": "off" + } } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..084e61e3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Lint Code + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Set up Yarn + run: corepack enable + + - name: Install dependencies + run: yarn install + + - name: Run linter + run: yarn lint && yarn prettier:check diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..6951315c --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "endOfLine": "lf", + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2f917ccc --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +code-check: + yarn lint + yarn prettier:check + +code-fix: + yarn lint:fix + yarn prettier:fix \ No newline at end of file diff --git a/README.md b/README.md index ebc59143..5172341a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ -BTC TECHNICAL MATERIALS SEARCH +![bitcoin-search-home](https://github.com/bitcoinsearch/bitcoinsearch-app/assets/18506343/65833946-63a2-400e-9e91-023f96cac9b2) -This project will serve as a search engine for searching Bitcoin technical related materials. It will aggregate all Bitcoin related materials and performs search on those materials instead of going through thousands of unrelated Google search results. +# Welcome to Bitcoin Search -It will classify materials according to: +## Features -- Relevance -- Domains -- Tags -- Authors +- **Elasticsearch Integration**: Directly interfaces with Elasticsearch, leveraging the dataset curated and indexed by the [scraper](https://github.com/bitcoinsearch/scraper). This integration facilitates robust full-text search capabilities, supporting complex queries, filters (authors, domains), and sorting options. +- **URL-Driven Search State**: Manages the search state through URL parameters using NextJS's router, enabling shareable search URLs and intuitive user navigation. +- **Proxy Server for Security**: Implements a [server-side proxy layer](src/pages/api/elasticSearchProxy/search.ts) for Elasticsearch queries, abstracting away direct access to the Elasticsearch cluster and enriching queries with necessary filters and parameters. ---- - -## Getting started +## Getting started The search engine is built using NextJS and connects to elasticsearch diff --git a/next.config.js b/next.config.js index a843cbee..f9e536e9 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,24 @@ +const mapping = require("./src/config/mapping.json"); +const domains = [ + ...new Set( + Object.keys(mapping.labels).map((domain) => new URL(domain).hostname) + ), +]; + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, -} + images: { + // domain mapping is not an exhaustive list of all the domains we have + // domains, + // allow favicon from any indexed domain for now + remotePatterns: [ + { + protocol: "https", + hostname: "**", + }, + ], + }, +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/package.json b/package.json index 444c4db5..49cda94c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "dev": "GENERATE_SOURCEMAP=false next dev", "build": "GENERATE_SOURCEMAP=false next build", "start": "GENERATE_SOURCEMAP=false next start", - "lint": "next lint" + "lint": "next lint", + "lint:fix": "next lint --fix", + "prettier:check": "prettier --check . --ignore-path .gitignore", + "prettier:fix": "prettier --write . --ignore-path .gitignore" }, "dependencies": { "@chakra-ui/react": "^2.0.0", @@ -19,15 +22,14 @@ "@elastic/search-ui-elasticsearch-connector": "1.20.2", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", + "@fontsource/geist-sans": "^5.0.2", "@netlify/plugin-nextjs": "^4.40.1", "@tanstack/react-query": "^4.29.12", "autoprefixer": "10.4.14", - "eslint": "^8.42.0", - "eslint-config-next": "^13.4.4", "framer-motion": "^6.5.1", "html-to-react": "^1.5.0", "next": "13.4.4", - "node-sass": "^7.0.3", + "node-sass": "^8.0.0", "postcss": "8.4.24", "rc-pagination": "^3.5.0", "react": "18.2.0", @@ -42,6 +44,8 @@ "@types/node": "^20.2.5", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", + "eslint": "^8.42.0", + "eslint-config-next": "^13.4.4", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "prettier": "^2.8.8", diff --git a/postcss.config.js b/postcss.config.js index 33ad091d..12a703d9 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/public/apps/bitcoin-devs.jpg b/public/apps/bitcoin-devs.jpg new file mode 100644 index 00000000..fe7254b3 Binary files /dev/null and b/public/apps/bitcoin-devs.jpg differ diff --git a/public/apps/bitcoin-search.jpg b/public/apps/bitcoin-search.jpg new file mode 100644 index 00000000..b332998e Binary files /dev/null and b/public/apps/bitcoin-search.jpg differ diff --git a/public/apps/bitcoin-tldr.jpg b/public/apps/bitcoin-tldr.jpg new file mode 100644 index 00000000..2f43f108 Binary files /dev/null and b/public/apps/bitcoin-tldr.jpg differ diff --git a/public/apps/bitcoin-transcripts-review.jpg b/public/apps/bitcoin-transcripts-review.jpg new file mode 100644 index 00000000..0f202ebc Binary files /dev/null and b/public/apps/bitcoin-transcripts-review.jpg differ diff --git a/public/apps/bitcoin-transcripts.jpg b/public/apps/bitcoin-transcripts.jpg new file mode 100644 index 00000000..aa1236de Binary files /dev/null and b/public/apps/bitcoin-transcripts.jpg differ diff --git a/public/apps/chat-btc.jpg b/public/apps/chat-btc.jpg new file mode 100644 index 00000000..064ebad1 Binary files /dev/null and b/public/apps/chat-btc.jpg differ diff --git a/public/apps/saving-satoshi.jpg b/public/apps/saving-satoshi.jpg new file mode 100644 index 00000000..554147be Binary files /dev/null and b/public/apps/saving-satoshi.jpg differ diff --git a/public/author_icon.svg b/public/author_icon.svg new file mode 100644 index 00000000..3d38c9c8 --- /dev/null +++ b/public/author_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/btc-main.png b/public/btc-main.png new file mode 100644 index 00000000..038cbb0a Binary files /dev/null and b/public/btc-main.png differ diff --git a/public/btc.png b/public/btc.png deleted file mode 100644 index 3af7d33b..00000000 Binary files a/public/btc.png and /dev/null differ diff --git a/public/circle-tick.svg b/public/circle-tick.svg new file mode 100644 index 00000000..f3c32d35 --- /dev/null +++ b/public/circle-tick.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/cross_icon.svg b/public/cross_icon.svg new file mode 100644 index 00000000..c6e4f669 --- /dev/null +++ b/public/cross_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/dark_cross_icon.svg b/public/dark_cross_icon.svg new file mode 100644 index 00000000..652c430f --- /dev/null +++ b/public/dark_cross_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/dashed_line.svg b/public/dashed_line.svg new file mode 100644 index 00000000..33549da1 --- /dev/null +++ b/public/dashed_line.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/demo-chat.png b/public/demo-chat.png new file mode 100644 index 00000000..114db85f Binary files /dev/null and b/public/demo-chat.png differ diff --git a/public/domain_favicons/default.svg b/public/domain_favicons/default.svg new file mode 100644 index 00000000..4610424d --- /dev/null +++ b/public/domain_favicons/default.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/domain_favicons/default_dark.png b/public/domain_favicons/default_dark.png new file mode 100644 index 00000000..4fcefb4d Binary files /dev/null and b/public/domain_favicons/default_dark.png differ diff --git a/public/domain_favicons/default_light.png b/public/domain_favicons/default_light.png new file mode 100644 index 00000000..dc342ea3 Binary files /dev/null and b/public/domain_favicons/default_light.png differ diff --git a/public/domain_favicons/delving.png b/public/domain_favicons/delving.png new file mode 100644 index 00000000..1d14bc61 Binary files /dev/null and b/public/domain_favicons/delving.png differ diff --git a/public/domain_favicons/github/dark.png b/public/domain_favicons/github/dark.png new file mode 100644 index 00000000..50b81752 Binary files /dev/null and b/public/domain_favicons/github/dark.png differ diff --git a/public/domain_favicons/github/light.png b/public/domain_favicons/github/light.png new file mode 100644 index 00000000..6cb3b705 Binary files /dev/null and b/public/domain_favicons/github/light.png differ diff --git a/public/domain_favicons/mailing_list/dark.png b/public/domain_favicons/mailing_list/dark.png new file mode 100644 index 00000000..7c1d1123 Binary files /dev/null and b/public/domain_favicons/mailing_list/dark.png differ diff --git a/public/domain_favicons/mailing_list/light.png b/public/domain_favicons/mailing_list/light.png new file mode 100644 index 00000000..b2b62adf Binary files /dev/null and b/public/domain_favicons/mailing_list/light.png differ diff --git a/public/domain_favicons/medium/dark.png b/public/domain_favicons/medium/dark.png new file mode 100644 index 00000000..eed70f75 Binary files /dev/null and b/public/domain_favicons/medium/dark.png differ diff --git a/public/domain_favicons/medium/light.png b/public/domain_favicons/medium/light.png new file mode 100644 index 00000000..6f18081b Binary files /dev/null and b/public/domain_favicons/medium/light.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 955fe0c0..6345aa2b 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/filter.svg b/public/filter.svg new file mode 100644 index 00000000..3aa74e41 --- /dev/null +++ b/public/filter.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/public/font/Mona-Sans.woff2 b/public/font/Mona-Sans.woff2 new file mode 100644 index 00000000..d88d5ff2 Binary files /dev/null and b/public/font/Mona-Sans.woff2 differ diff --git a/public/holocat.png b/public/holocat.png new file mode 100644 index 00000000..227afeb8 Binary files /dev/null and b/public/holocat.png differ diff --git a/public/index.html b/public/index.html index 04c939ae..290a9952 100644 --- a/public/index.html +++ b/public/index.html @@ -3,14 +3,23 @@ - + Bitcoin Search - - - - + + + + diff --git a/public/landing/dark/bitcoin-search-image-mobile.webp b/public/landing/dark/bitcoin-search-image-mobile.webp new file mode 100644 index 00000000..5852d4e8 Binary files /dev/null and b/public/landing/dark/bitcoin-search-image-mobile.webp differ diff --git a/public/landing/dark/bitcoin-search-image.webp b/public/landing/dark/bitcoin-search-image.webp new file mode 100644 index 00000000..b416a9d3 Binary files /dev/null and b/public/landing/dark/bitcoin-search-image.webp differ diff --git a/public/landing/dark/filter-image.webp b/public/landing/dark/filter-image.webp new file mode 100644 index 00000000..0c288637 Binary files /dev/null and b/public/landing/dark/filter-image.webp differ diff --git a/public/landing/dark/google-search-image-mobile.webp b/public/landing/dark/google-search-image-mobile.webp new file mode 100644 index 00000000..24229dc0 Binary files /dev/null and b/public/landing/dark/google-search-image-mobile.webp differ diff --git a/public/landing/dark/google-search-image.webp b/public/landing/dark/google-search-image.webp new file mode 100644 index 00000000..7b2732e7 Binary files /dev/null and b/public/landing/dark/google-search-image.webp differ diff --git a/public/landing/dark/sources-image.webp b/public/landing/dark/sources-image.webp new file mode 100644 index 00000000..3b8f41ec Binary files /dev/null and b/public/landing/dark/sources-image.webp differ diff --git a/public/landing/dark/treasure-trove-chart.webp b/public/landing/dark/treasure-trove-chart.webp new file mode 100644 index 00000000..2099fcfc Binary files /dev/null and b/public/landing/dark/treasure-trove-chart.webp differ diff --git a/public/landing/light/bitcoin-search-image-mobile.webp b/public/landing/light/bitcoin-search-image-mobile.webp new file mode 100644 index 00000000..d3f7ed4c Binary files /dev/null and b/public/landing/light/bitcoin-search-image-mobile.webp differ diff --git a/public/landing/light/bitcoin-search-image.webp b/public/landing/light/bitcoin-search-image.webp new file mode 100644 index 00000000..78673f2d Binary files /dev/null and b/public/landing/light/bitcoin-search-image.webp differ diff --git a/public/landing/light/filter-image.webp b/public/landing/light/filter-image.webp new file mode 100644 index 00000000..679665ab Binary files /dev/null and b/public/landing/light/filter-image.webp differ diff --git a/public/landing/light/google-search-image-mobile.webp b/public/landing/light/google-search-image-mobile.webp new file mode 100644 index 00000000..cadc7340 Binary files /dev/null and b/public/landing/light/google-search-image-mobile.webp differ diff --git a/public/landing/light/google-search-image.webp b/public/landing/light/google-search-image.webp new file mode 100644 index 00000000..c72b6269 Binary files /dev/null and b/public/landing/light/google-search-image.webp differ diff --git a/public/landing/light/sources-image.webp b/public/landing/light/sources-image.webp new file mode 100644 index 00000000..a3fdc6fa Binary files /dev/null and b/public/landing/light/sources-image.webp differ diff --git a/public/landing/light/treasure-trove-chart.webp b/public/landing/light/treasure-trove-chart.webp new file mode 100644 index 00000000..4420834c Binary files /dev/null and b/public/landing/light/treasure-trove-chart.webp differ diff --git a/public/lightning_icon_filled.svg b/public/lightning_icon_filled.svg new file mode 100644 index 00000000..c5cb4a54 --- /dev/null +++ b/public/lightning_icon_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/plus_icon.svg b/public/plus_icon.svg new file mode 100644 index 00000000..f8afe45d --- /dev/null +++ b/public/plus_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/search_icon.svg b/public/search_icon.svg new file mode 100644 index 00000000..6d8588c9 --- /dev/null +++ b/public/search_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/sort_icon.svg b/public/sort_icon.svg new file mode 100644 index 00000000..9c4ed67c --- /dev/null +++ b/public/sort_icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/source_icon.svg b/public/source_icon.svg new file mode 100644 index 00000000..8ade5657 --- /dev/null +++ b/public/source_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/svg/award-checkmark.svg b/public/svg/award-checkmark.svg new file mode 100644 index 00000000..e3571c59 --- /dev/null +++ b/public/svg/award-checkmark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/svg/down-arrow.svg b/public/svg/down-arrow.svg new file mode 100644 index 00000000..fd6d4138 --- /dev/null +++ b/public/svg/down-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/svg/rounded-checkmark.svg b/public/svg/rounded-checkmark.svg new file mode 100644 index 00000000..c9c16e5b --- /dev/null +++ b/public/svg/rounded-checkmark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/svg/search-icon.svg b/public/svg/search-icon.svg new file mode 100644 index 00000000..9a0c4760 --- /dev/null +++ b/public/svg/search-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/svg/treasure-icon.svg b/public/svg/treasure-icon.svg new file mode 100644 index 00000000..aaeb8390 --- /dev/null +++ b/public/svg/treasure-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/up_arrow.svg b/public/up_arrow.svg new file mode 100644 index 00000000..b6e8fd80 --- /dev/null +++ b/public/up_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/build-no-chunks.js b/scripts/build-no-chunks.js index ecef83d9..d36a613a 100755 --- a/scripts/build-no-chunks.js +++ b/scripts/build-no-chunks.js @@ -8,8 +8,8 @@ let config = defaults.__get__("config"); config.optimization.splitChunks = { cacheGroups: { - default: false - } + default: false, + }, }; config.optimization.runtimeChunk = false; diff --git a/src/chakra/chakra-theme.js b/src/chakra/chakra-theme.ts similarity index 93% rename from src/chakra/chakra-theme.js rename to src/chakra/chakra-theme.ts index 3a1c7624..1dcc2ceb 100644 --- a/src/chakra/chakra-theme.js +++ b/src/chakra/chakra-theme.ts @@ -1,5 +1,6 @@ import { extendTheme } from "@chakra-ui/react"; import Button from "./components/Button"; +import Menu from "./components/Menu"; const colors = { grey: { @@ -32,6 +33,7 @@ const theme = extendTheme({ colors, components: { Button, + Menu, }, }); diff --git a/src/chakra/components/Button.js b/src/chakra/components/Button.ts similarity index 85% rename from src/chakra/components/Button.js rename to src/chakra/components/Button.ts index e4551828..5d2f6813 100644 --- a/src/chakra/components/Button.js +++ b/src/chakra/components/Button.ts @@ -43,6 +43,15 @@ const Button = { bgColor: "orange.275", }, }), + secondary: { + fontSize: "16px", + fontWeight: "400", + borderRadius: "10px", + backgroundColor: "#292929", + color: "#FFFFFF", + padding: "6px 24px", + transition: "all 200ms ease", + }, "facet-pill": { fontSize: "11px", fontWeight: "400", diff --git a/src/chakra/components/Menu.ts b/src/chakra/components/Menu.ts new file mode 100644 index 00000000..419add60 --- /dev/null +++ b/src/chakra/components/Menu.ts @@ -0,0 +1,25 @@ +import { menuAnatomy } from "@chakra-ui/anatomy"; +import { createMultiStyleConfigHelpers } from "@chakra-ui/styled-system"; + +// This function creates a set of function that helps us create multipart component styles. +const helpers = createMultiStyleConfigHelpers(menuAnatomy.keys); + +const Menu = helpers.defineMultiStyleConfig({ + variants: { + brand: { + list: { + // backgroundColor: "blue.600" + background: "none", + boxShadow: "sm", + border: "none", + paddingBlock: "0px", + }, + item: { + fontWeight: "bold", + background: "none", + }, + }, + }, +}); + +export default Menu; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 87a567af..d5be3cf8 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -31,7 +31,7 @@ const Banner = () => {
{`Development in 2024 `} - {`Apply Today!`} + {`Apply Today!`}
diff --git a/src/components/appMenu/AppItem.tsx b/src/components/appMenu/AppItem.tsx new file mode 100644 index 00000000..359a718d --- /dev/null +++ b/src/components/appMenu/AppItem.tsx @@ -0,0 +1,23 @@ +import Image from "next/image"; +import type { MenuApp } from "./data"; +import Link from "next/link"; + +export const AppItem = ({ href, image, name, title }: MenuApp) => ( + + {name} +

+ {title} +

+ +); diff --git a/src/components/appMenu/data.ts b/src/components/appMenu/data.ts new file mode 100644 index 00000000..07ecf63a --- /dev/null +++ b/src/components/appMenu/data.ts @@ -0,0 +1,58 @@ +import bitcoindevs from "public/apps/bitcoin-devs.jpg"; +import transcriptsreview from "public/apps/bitcoin-transcripts-review.jpg"; +import chatbtc from "public/apps/chat-btc.jpg"; +import savingSatoshi from "public/apps/saving-satoshi.jpg"; +import bitcointldr from "public/apps/bitcoin-tldr.jpg"; +import bitcointranscripts from "public/apps/bitcoin-transcripts.jpg"; +import { StaticImageData } from "next/image"; + +export type MenuApp = { + href: string; + image: string | StaticImageData; + name: string; + title: string; +}; + +export const menuApps = [ + { + href: "https://bitcoindevs.xyz/", + image: bitcoindevs, + name: "Bitcoin Devs", + title: + "Build the future of money - Study & contribute to bitcoin and lightning open source", + }, + { + href: "https://review.btctranscripts.com/", + image: transcriptsreview, + name: "Bitcoin Transcripts Review", + title: "Review technical bitcoin transcripts and earn sats", + }, + { + href: "https://chat.bitcoinsearch.xyz", + image: chatbtc, + name: "Chat BTC", + title: + "Interactive AI chat to learn about bitcoin technology and its history", + }, + { + href: "https://savingsatoshi.com", + image: savingSatoshi, + name: "Saving Satoshi", + title: + "Engaging bitcoin dev intro for coders using technical texts and code challenges", + }, + { + href: "https://tldr.bitcoinsearch.xyz/", + image: bitcointldr, + name: "Bitcoin TLDR", + title: + "Daily summary of key bitcoin tech development discussions and updates", + }, + { + href: "https://btctranscripts.com/", + image: bitcointranscripts, + name: "Bitcoin Transcripts", + title: + "Comprehensive archive of Bitcoin-related transcripts for educational reference", + }, +] satisfies Array; diff --git a/src/components/appMenu/index.tsx b/src/components/appMenu/index.tsx new file mode 100644 index 00000000..bc0f5526 --- /dev/null +++ b/src/components/appMenu/index.tsx @@ -0,0 +1,16 @@ +import { AppItem } from "./AppItem"; +import { menuApps } from "./data"; + +export function AppMenu() { + return ( +
+ +
+ {menuApps.slice(1).map((item) => ( + + ))} +
+ ); +} diff --git a/src/components/customMultiCheckboxFacet/CustomMultiCheckboxFacet.tsx b/src/components/customMultiCheckboxFacet/CustomMultiCheckboxFacet.tsx index 0a453985..e6e6788d 100644 --- a/src/components/customMultiCheckboxFacet/CustomMultiCheckboxFacet.tsx +++ b/src/components/customMultiCheckboxFacet/CustomMultiCheckboxFacet.tsx @@ -1,9 +1,28 @@ -import React, { useRef } from "react"; -import mapping from "../../config/mapping.json"; +import React, { useEffect, useRef } from "react"; import useCheckboxNavigate from "../../hooks/useCheckboxNavigate"; -import styles from "./styles.module.scss"; -import appendClassName from "../../utils/elastic-search-ui-functions" -import { deriveNameFromUrl } from "@/config/mapping-helper"; +import appendClassName from "../../utils/elastic-search-ui-functions"; +import SidebarSection from "../sidebarFacet/SidebarSection"; +import Image from "next/image"; +import { FacetKeys } from "@/types"; +import useUIContext from "@/hooks/useUIContext"; +import LightningIcon from "public/lightning_icon_filled.svg"; +import UpArrow from "public/up_arrow.svg"; +import { useDisclosure } from "@chakra-ui/react"; +import AuthorIcon from "../svgs/AuthorIcon"; +import SourceIcon from "../svgs/SourceIcon"; +import SearchIcon from "../svgs/SearchIcon"; +import PlusIcon from "../svgs/PlusIcon"; + +const facetMapping = { + authors: { + display: "Authors", + icon: , + }, + domain: { + display: "Sources", + icon: , + }, +}; function CustomMultiCheckboxFacet({ className, @@ -17,112 +36,151 @@ function CustomMultiCheckboxFacet({ onSearch, searchPlaceholder, }) { - // This function was modified to add the mapping of names to links using mapping?.labels[filterValue] - function getFilterValueDisplay(filterValue) { - if (filterValue === undefined || filterValue === null) { - return ""; - } - if (Object.prototype.hasOwnProperty.call(filterValue, "name")) { - return filterValue.name; - } - if (label === "domain") { - if (mapping?.labels[filterValue]) { - return mapping?.labels[filterValue]; - } else { - return deriveNameFromUrl(filterValue) - } - } - return String(filterValue); - } + const { isOpen, onToggle, onOpen, onClose } = useDisclosure({ + defaultIsOpen: true, + }); const searchRef = useRef(); const multiCheckboxRef = useRef(); - const { currentNavigateCheckbox, toggleRefocus } = useCheckboxNavigate( - { - checkboxContainer: multiCheckboxRef, - searchEl: searchRef, - options - } - ); + const { currentNavigateCheckbox } = useCheckboxNavigate({ + checkboxContainer: multiCheckboxRef, + searchEl: searchRef, + options, + }); + + const numberFormat = new Intl.NumberFormat("en-US", { + compactDisplay: "short", + notation: "compact", + }); + + useEffect(() => { + const handleFocusIn = () => { + onOpen(); + }; + let searchRefInput = searchRef.current; + if (!searchRefInput) return; + searchRefInput.addEventListener("focusin", () => handleFocusIn()); + return () => { + searchRefInput.removeEventListener("focusin", () => handleFocusIn()); + }; + }, [onOpen, onClose]); return ( -
- {label} + +
+ + {showSearch && ( +
+ { + onSearch(e.target.value); + }} + ref={searchRef} + /> + + + + + arrow + +
+ )} - {showSearch && ( -
- { - onSearch(e.target.value); - }} - ref={searchRef} - /> +
+ {options.length < 1 && ( +

+ No matching options +

+ )} + {options?.map((option) => { + const checked = option.selected; + const value = option.value; + return ( + + ); + })}
- )} +
+
+ ); +} -
- {options.length < 1 &&
No matching options
} - {options?.map((option) => { - const checked = option.selected; - const value = option.value; - return ( - - ); - })} +export const SideBarHeader = ({ label }: { label: FacetKeys }) => { + const { openForm } = useUIContext(); + return ( +
+
+ {facetMapping[label].icon} + + {facetMapping[label]["display"]} +
- - {showMore && ( - + + Suggest a Source + + + + +
)} -
+ ); -} +}; export default CustomMultiCheckboxFacet; diff --git a/src/components/customMultiCheckboxFacet/styles.module.scss b/src/components/customMultiCheckboxFacet/styles.module.scss deleted file mode 100644 index 4e35df20..00000000 --- a/src/components/customMultiCheckboxFacet/styles.module.scss +++ /dev/null @@ -1,48 +0,0 @@ - - -.checkboxLabel { - padding: 2px 4px; - border-radius: 4px; - margin-block: 4px; - - .checkbox_input_wrapper { - flex: 1 1; - display: flex; - align-items: center; - line-height: 1; - } - - .option_count { - padding-inline: 2px; - font-size: 0.85em; - } -} - -.currentNavigatedLabel { - background-color: var(--secondary-blue); - - &.checked { - background-color: var(--secondary-blue); - color: #4f4f4f; - span { - color: #4f4f4f; - } - } -} - -.checked { - background-color: var(--primary-blue); - color: white; - span { - color: white; - } - - &:only-child { - background-color: var(--primary-blue); - color: white; - span { - color: white; - } - } - -} diff --git a/src/components/customPagingInfo/CustomPagingInfo.tsx b/src/components/customPagingInfo/CustomPagingInfo.tsx index 53f72810..19c8c3e5 100644 --- a/src/components/customPagingInfo/CustomPagingInfo.tsx +++ b/src/components/customPagingInfo/CustomPagingInfo.tsx @@ -1,16 +1,14 @@ -import { PagingInfo } from "@elastic/react-search-ui"; - import React from "react"; import useIsInitialStateWithoutFilter from "../../hooks/useIsInitialStateWithoutFilter"; import useSearchQuery from "../../hooks/useSearchQuery"; const CustomPagingInfo = () => { const { hiddenBody } = useIsInitialStateWithoutFilter(); - const { pagingInfo } = useSearchQuery() + const { pagingInfo } = useSearchQuery(); if (hiddenBody) { return null; } - const { totalResults } = pagingInfo + const { totalResults } = pagingInfo; return (
@@ -18,7 +16,7 @@ const CustomPagingInfo = () => {

results

- ) + ); }; export default CustomPagingInfo; diff --git a/src/components/customResults/CustomResults.tsx b/src/components/customResults/CustomResults.tsx index 69863eb1..a6a96a55 100644 --- a/src/components/customResults/CustomResults.tsx +++ b/src/components/customResults/CustomResults.tsx @@ -9,31 +9,33 @@ import { } from "../../config/results-helper"; const CustomResults = ({ clickThroughTags, shouldTrackClickThrough }) => { - const { queryResult } = useSearchQuery(); - const trackClickThrough = () => { - } + const trackClickThrough = () => {}; const formattedResults = [] as Array[]; const similarity = {}; const groupedIndices: Set = new Set(); const groupedDomains = getDomainGrouping(); - const results = queryResult.data?.hits?.hits ?? [] + const results = queryResult.data?.hits?.hits ?? []; results.forEach((item) => { - const result = item._source as EsSearchResult["_source"] + const result = item._source as EsSearchResult["_source"]; const raw_domain = result.domain; + const domainInGroupedDomains = groupedDomains.find( + (url) => new URL(url).href == new URL(raw_domain).href + ); - if (groupedDomains.includes(raw_domain)) { + // if result is a collection grouping or has thread_url then group + if (domainInGroupedDomains || result?.thread_url) { const idx = formattedResults.length; - - const locatorId = generateLocator( + const locatorId = generateLocator({ raw_domain, - result.url, - result.title - ); + url: result.url, + title: result.title, + thread_url: result?.thread_url, + }); const isSimilarIdx = similarity[locatorId]; if (isSimilarIdx !== undefined) { @@ -56,9 +58,13 @@ const CustomResults = ({ clickThroughTags, shouldTrackClickThrough }) => { trackClickThrough, }; return ( -
+
{formattedResults.map((result, idx) => ( - + ))}
); diff --git a/src/components/customResults/Result.tsx b/src/components/customResults/Result.tsx index 8831dfcc..ea3f9eac 100644 --- a/src/components/customResults/Result.tsx +++ b/src/components/customResults/Result.tsx @@ -1,17 +1,17 @@ -import React from "react"; +import React, { useRef } from "react"; import { getResultTags } from "@/config/config-helper"; import FilterTags from "../filterTag/FilterTags"; import sanitizeHtml from "sanitize-html"; import { Parser } from "html-to-react"; -import { Thumbnail } from "./Thumbnail"; -import mapping from "@/config/mapping.json"; -import { getMapping } from "@/config/mapping-helper"; -import { getUrlForCombinedSummary } from "@/utils/tldr"; -import { TruncateLengthInChar } from "@/config/config"; +import { getDomainFavicon, getDomainName } from "@/config/mapping-helper"; +import { TruncateLengthInChar, TruncateLinkInChar } from "@/config/config"; import { EsSearchResult } from "@/types"; +import DateIcon from "../svgs/DateIcon"; +import ResultFavicon from "./ResultFavicon"; +import { useTheme } from "@/context/Theme"; +import { remapUrl } from "@/utils/documents"; const htmlToReactParser = new (Parser as any)(); -const { tldrLists, combinedSummaryTag } = getMapping() type ResultProps = { result: EsSearchResult["_source"]; @@ -20,17 +20,11 @@ type ResultProps = { trackClickThrough: () => void; }; -const Result = ({ - result, - clickThroughTags, - shouldTrackClickThrough, - trackClickThrough, -}: ResultProps) => { +const Result = ({ result }: ResultProps) => { let dateString = null; - const { url, title, body, domain, id } = result; + const { url, title, body, domain } = result; - const isTldrCombinedSummary = tldrLists.includes(domain) && title.includes(combinedSummaryTag) - const mappedUrl = isTldrCombinedSummary ? getUrlForCombinedSummary(url, id) : url + const mappedUrl = remapUrl({ url, domain }); const createdDate = result.created_at; if (createdDate) { @@ -50,75 +44,124 @@ const Result = ({ const getBodyData = (result: ResultProps["result"]) => { switch (result.body_type) { case "markdown": - return body + return body; case "raw": - return result?.summary ?? body + return result?.summary ?? body; case "html": - return body - case "combined-summary": - return body + return body; default: { try { return JSON.parse(`[${body}]`) .map((i) => i.text) - .join(" ") + .join(" "); } catch { - return body || result.body_formatted + return body || result.body_formatted; } } } - } + }; + + const sanitizedBody = sanitizeHtml(getBodyData(result)).trim(); - const sanitizedBody = sanitizeHtml( - getBodyData(result).replaceAll("\n", "") - ).trim() + const strippedUrl = mappedUrl.replace(/^(https?:\/\/)/i, ""); + const truncatedUrl = + strippedUrl.length > TruncateLinkInChar + ? strippedUrl.substring(0, TruncateLinkInChar) + "..." + : strippedUrl; + const truncatedBody = + sanitizedBody.length > TruncateLengthInChar + ? sanitizedBody.substring(0, TruncateLengthInChar) + " ..." + : sanitizedBody; + const parsedBody = htmlToReactParser.parse(truncatedBody); + const siteName = getDomainName(domain); - const truncatedBody = sanitizedBody.length > TruncateLengthInChar ? sanitizedBody.substring(0, TruncateLengthInChar) + " ..." : sanitizedBody - const parsedBody = htmlToReactParser.parse(truncatedBody) + const linkRef = useRef(null); + + const handleCardClick = (e: React.MouseEvent) => { + // e.stopPropagation() + if (e.target === containerRef?.current) { + const link = linkRef.current; + link && link.click(); + } + }; + const { theme } = useTheme(); + const isDark = theme === "dark"; - // removed onClickLink - // const onClickLink = () => { - // if (shouldTrackClickThrough) { - // result?.id && trackClickThrough(result.id, clickThroughTags); - // } - // }; + const containerRef = useRef(null); return ( -
-

- - {htmlToReactParser.parse(sanitizeHtml(title))} - -

- - {mappedUrl} - -
- {mapping.media.includes(result?.domain) && ( - - )} -

+

+
+ + + - -
- {getResultTags().map((field, idx) => { - if (result[field]) - return ( - - ); - })} - {dateString && {dateString}} +
+ {dateString && ( +
+
+ +

+ {dateString} +

+
+
+ )} +
+ {getResultTags().map((field, idx) => { + if (result[field]) + return ( + + ); + })} +
); diff --git a/src/components/customResults/ResultCollection.tsx b/src/components/customResults/ResultCollection.tsx index 0adfe9c2..9cf16770 100644 --- a/src/components/customResults/ResultCollection.tsx +++ b/src/components/customResults/ResultCollection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React from "react"; import { useState } from "react"; import Result from "./Result"; import { TiArrowSortedDown } from "react-icons/ti"; @@ -21,15 +21,13 @@ const ResultCollection = ({ const [initialResult, ...otherResults] = result; return ( -
+
{initialResult && } {otherResults.length ? ( <> -
+
+ )} +
+ + {/* handfan container */} +
+ {/* dropdown showing tags only */} +
+ {/* Each search */} + {defaultSearchTags.map((tagType) => ( +
+

+ {tagType.headline} +

+
+ {tagType.tags.map((tag) => ( +
{ + onTabClick(tagType.type as FacetKeys, tag); + }} + className={`${ + getFilter(tagType.type as FacetKeys).includes( + tag + ) + ? "bg-custom-hover-state" + : "" + } ${ + searchInput === tag + ? "bg-custom-hover-state" + : "" + } px-3 py-1.5 md:py-2 md:px-4 hover:bg-custom-hover-state cursor-pointer rounded-md md:rounded-lg border border-custom-stroke max-w-[max-content]`} + > +

+ {tagType.type === "domain" + ? getDomainLabel(tag) + : tag} +

+
+ ))} +
+
+ ))} +
+ + {/* For auto complete */} +
+
    + {suggestions.map((sug, index) => ( +
  • onSelectSuggestion(sug)} + className={` + data-[navigated='true']:bg-custom-hover-state + outline-none cursor-pointer text-custom-primary-text text-sm md:text-base py-3.5 px-4 md:px-6 md:py-4 hover:bg-custom-hover-state`} + > + {removeMarkdownCharacters(sug.suggestion)} +
  • + ))} +
+
+
+
+ +
+ + ); + }} + + ); +} + +export default SearchBoxView; diff --git a/src/components/customSearchboxView/SearchBoxView.tsx b/src/components/customSearchboxView/SearchBoxView.tsx deleted file mode 100644 index 8178b2a6..00000000 --- a/src/components/customSearchboxView/SearchBoxView.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { FormEvent, useEffect, useState } from "react"; -import Downshift from "downshift"; - -import { Autocomplete } from "@elastic/react-search-ui-views"; - -import type { - AutocompleteResult, - AutocompleteSuggestion, - SearchContextState -} from "@elastic/search-ui"; -import { - BaseContainerProps, - SearchBoxAutocompleteViewProps, - InputViewProps -} from "@elastic/react-search-ui-views"; -import useSearchQuery from "@/hooks/useSearchQuery"; -import appendClassName from "@/utils/elastic-search-ui-functions"; - -export type SearchBoxContainerContext = Pick< - SearchContextState, - | "autocompletedResults" - | "autocompletedSuggestions" - | "searchTerm" - | "setSearchTerm" - | "trackAutocompleteClickThrough" - | "trackAutocompleteSuggestionClickThrough" ->; - -export type SearchBoxContainerProps = BaseContainerProps & - SearchBoxContainerContext & { - view?: React.ComponentType; - autocompleteView?: React.ComponentType; - inputView?: React.ComponentType; - autocompleteMinimumCharacters?: number; - autocompleteResults?: AutocompleteResult | boolean; - autocompleteSuggestions?: boolean | AutocompleteSuggestion; - shouldClearFilters?: boolean; - debounceLength?: number; - inputProps?: any; - onSelectAutocomplete?: any; - onSubmit?: (searchTerm: string) => void; - searchAsYouType?: boolean; - }; - -export type SearchBoxViewProps = BaseContainerProps & - Pick< - SearchBoxContainerProps, - | "autocompleteView" - | "inputView" - | "autocompleteSuggestions" - | "autocompleteResults" - | "autocompleteSuggestions" - | "autocompletedResults" - | "autocompletedSuggestions" - > & { - allAutocompletedItemsCount: number; - autocompletedSuggestionsCount: any; - completeSuggestion: (searchQuery: string) => void; - isFocused: boolean; - notifyAutocompleteSelected: (selection: any) => void; - onChange: (value: string) => void; - onSelectAutocomplete: any; - onSubmit: (e: FormEvent) => void; - useAutocomplete: boolean; - value: string; - inputProps: any; - }; - -function SearchBoxView(props: SearchBoxViewProps) { - const { - className, - allAutocompletedItemsCount, - autocompleteView, - isFocused, - inputProps = { className: "" }, - inputView, - onChange, - onSelectAutocomplete, - onSubmit, - useAutocomplete, - value, - // NOTE: These are explicitly de-structured but not used so that they are - // not passed through to the input with the 'rest' parameter - - autocompletedResults, - - autocompletedSuggestions, - - autocompletedSuggestionsCount, - - completeSuggestion, - - notifyAutocompleteSelected, - ...rest - } = props; - const { searchQuery, makeQuery } = useSearchQuery(); - const focusedClass = isFocused ? "focus" : ""; - const AutocompleteView = Autocomplete; - const InputView = inputView; - - const [searchTerm, setSearchTerm] = useState(searchQuery) - - // sync autocomplete - useEffect(() => { - if (!searchQuery) return - setSearchTerm(searchQuery) - }, [searchQuery]) - - const handleChange = (value: string) => { - onChange(value) - setSearchTerm(value) - } - - return ( - { - // To avoid over dispatching - // if (value === newValue) return; - // onChange(newValue); - handleChange(newValue); - }} - // Because when a selection is made, we don't really want to change - // the inputValue. This is supposed to be a "controlled" value, and when - // this happens we lose control of it. - itemToString={() => searchTerm} - {...rest} - > - {(downshiftProps) => { - const { closeMenu, getInputProps, isOpen } = downshiftProps; - const autocompleteClass = isOpen === true ? " autocomplete" : ""; - return ( -
{ - closeMenu(); - onSubmit(e); - }} - > -
- { - const { className, ...rest } = additionalProps || {}; - return getInputProps({ - "data-transaction-name": "search input", - placeholder: "Search", - ...inputProps, - className: appendClassName("sui-search-box__text-input", [ - inputProps.className, - className, - focusedClass - ]), - ...rest - }); - }} - getButtonProps={(additionalProps) => { - const { className, ...rest } = additionalProps || {}; - return { - "data-transaction-name": "search submit", - type: "submit", - value: "Search", - className: appendClassName( - "button sui-search-box__submit", - className - ), - ...rest - }; - }} - getAutocomplete={() => { - if ( - useAutocomplete && - isOpen && - allAutocompletedItemsCount > 0 - ) { - return ; - } else { - return null; - } - }} - /> -
-
- ); - }} -
- ); -} - -export default SearchBoxView; diff --git a/src/components/customSearchboxView/SearchInput.tsx b/src/components/customSearchboxView/SearchInput.tsx deleted file mode 100644 index 6be42024..00000000 --- a/src/components/customSearchboxView/SearchInput.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { isMac } from "../../utils/userOS"; -import useSearchQuery from "../../hooks/useSearchQuery"; - -function SearchInput({ - getAutocomplete, - getButtonProps, - getInputProps, - openForm, -}) { - const isMacDevice = isMac(); - return ( -
-
- - {getAutocomplete()} -
- - -
-
-

Search:

-

- {isMacDevice ? "⌘" : "CTRL"} + K or / -

-
-
- - Add a source - -
-
-
- ); -} - -export default SearchInput; diff --git a/src/components/errorBoundary/ErrorBoundary.tsx b/src/components/errorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..bf4ca881 --- /dev/null +++ b/src/components/errorBoundary/ErrorBoundary.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import React, { ErrorInfo } from "react"; + +interface Props { + children: React.ReactNode; +} + +interface State { + hasError: boolean; +} + +class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { hasError: false }; + } + + public static getDerivedStateFromError(_: Error): State { + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error({ error, errorInfo }); + this.setState({ hasError: true }); + } + + render() { + if (this.state.hasError) { + return ( +
+
+

+ Oops, something went wrong! +

+ + Go to Homepage + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/filterTag/FilterTags.tsx b/src/components/filterTag/FilterTags.tsx index 02cd246b..7ee55386 100644 --- a/src/components/filterTag/FilterTags.tsx +++ b/src/components/filterTag/FilterTags.tsx @@ -1,34 +1,129 @@ import useURLManager from "@/service/URLManager/useURLManager"; import { FacetKeys } from "@/types"; import { Button } from "@chakra-ui/react"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; +import ArrowLeft from "../svgs/ArrowLeft"; +import ArrowRight from "../svgs/ArrowRight"; type FilterTagProps = { field: FacetKeys; options: string[] | string; -} +}; + +const scrollPadding = 10; const FilterTags = ({ field, options }: FilterTagProps) => { - const { getFilter, addFilter, removeFilter } = useURLManager() + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [startX, setStartX] = useState({ + page: 0, + scrollLeft: 0, + }); + + const [isScrollable, setIsScrollable] = useState(false); + + useEffect(() => { + const container = containerRef?.current; + if (!container) return; + + // Add data attributes to the container to determine if the left and right arrows should be shown + const handleArrowVisibility = () => { + if (!container) return; + try { + container.dataset.showLeftArrow = ( + container.scrollLeft > scrollPadding + ).toString(); + + container.dataset.showRightArrow = ( + container.scrollLeft + container.clientWidth + scrollPadding < + containerRef.current.scrollWidth + ).toString(); + } catch (e) { + container.dataset.showLeftArrow = false.toString(); + container.dataset.showRightArrow = false.toString(); + } + }; + + const handleScroll = () => { + handleArrowVisibility(); + }; + + const handleResize = () => { + if (!container) return; + handleArrowVisibility(); + try { + setIsScrollable(container.scrollWidth > container.clientWidth); + } catch (e) { + setIsScrollable(false); + } + }; + + // call handleArrowVisibility and setIsScrollable on mount + handleArrowVisibility(); + if (container.scrollWidth > container.clientWidth) { + setIsScrollable(true); + } + + container.addEventListener("scroll", () => { + handleScroll(); + }); + window.addEventListener("resize", () => handleResize()); + + return () => { + if (container.scrollWidth > container.clientWidth) { + container.removeEventListener("scroll", () => handleScroll()); + } + window.removeEventListener("resize", () => handleScroll()); + }; + }, []); + + const handleMouseDown = (event) => { + setIsDragging(true); + setStartX({ + page: event.pageX, + scrollLeft: containerRef.current.scrollLeft, + }); + }; + + const handleMouseMove = (event) => { + if (!isDragging) return; + const x = event.pageX; + const scrollOffset = (x - startX.page) * 2; + containerRef.current.scrollLeft = startX.scrollLeft - scrollOffset; + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleArrowClick = (scrollOffset) => { + const newScrollLeft = containerRef.current.scrollLeft + scrollOffset; + containerRef.current.scrollTo({ + left: newScrollLeft, + behavior: "smooth", // Use smooth scrolling behavior + }); + }; + + const { getFilter, addFilter, removeFilter } = useURLManager(); if (!Array.isArray(options)) return null; const onRemove = (value: string) => { - removeFilter({filterType: field, filterValue: value}); + removeFilter({ filterType: field, filterValue: value }); }; const onAdd = (value: string) => { - addFilter({filterType: field, filterValue: value}); + addFilter({ filterType: field, filterValue: value }); }; - - const handleToggleFilter = (filter: typeof formattedOptions[number]) => { + + const handleToggleFilter = (filter: (typeof formattedOptions)[number]) => { if (filter.selected) { onRemove(filter.value); } else { onAdd(filter.value); } }; - - const filterForField = getFilter(field) + + const filterForField = getFilter(field); const formattedOptions = options.map((option) => { return { value: option, @@ -37,18 +132,54 @@ const FilterTags = ({ field, options }: FilterTagProps) => { }); return ( -
- {formattedOptions?.map((a, idx) => ( - - ))} +
+
+ {formattedOptions?.map((a, idx) => ( + + ))} +
+ +
handleArrowClick(-200)} + className="hidden peer-data-[show-left-arrow=true]/facet:flex cursor-pointer items-center pl-2 justify-start h-full w-8 bg-shadow-left absolute left-0 top-0" + > + +
+ +
handleArrowClick(200)} + className="hidden peer-data-[show-right-arrow=true]/facet:flex cursor-pointer items-center pr-2 justify-end h-full w-8 bg-shadow-right absolute right-0 top-0" + > + {" "} + {" "} +
); }; diff --git a/src/components/filterTag/filterTags.scss b/src/components/filterTag/filterTags.scss index 397ef93c..fe35e201 100644 --- a/src/components/filterTag/filterTags.scss +++ b/src/components/filterTag/filterTags.scss @@ -1,4 +1,3 @@ - @mixin result-container { display: inline-flex; flex-wrap: wrap; @@ -16,29 +15,27 @@ @mixin default-result-tag { -webkit-tap-highlight-color: transparent; - @media (hover:none) { - &:hover { - background-color: auto; - color: auto; - } + @media (hover: none) { + &:hover { + background-color: auto; + color: auto; + } } - @media (pointer:fine) { - &:hover { - background-color: var(--chakra-colors-gray-600); - color: var(--chakra-colors-gray-100); - } + @media (pointer: fine) { + &:hover { + background-color: var(--chakra-colors-gray-600); + color: var(--chakra-colors-gray-100); + } } } .authors-result-container { @include result-container; - } .tags-result-container { @include result-container; } - .authors-result-tag { @include default-result-tag; } diff --git a/src/components/footer/BodyFooter.tsx b/src/components/footer/BodyFooter.tsx new file mode 100644 index 00000000..9be7165e --- /dev/null +++ b/src/components/footer/BodyFooter.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Paging } from "@elastic/react-search-ui-views"; +import useSearchQuery from "../../hooks/useSearchQuery"; +import HolocatChatBtc from "./HolocatChatBtc"; + +const BodyFooter = () => { + const { handlePageChange, pagingInfo } = useSearchQuery(); + const { totalResults, current, resultsPerPage } = pagingInfo; + if (!totalResults) { + return null; + } + const totalPages = Math.ceil(totalResults / resultsPerPage); + return ( +
+
+ {current !== 1 && ( +
+ + +
+ ); +}; + +export default BodyFooter; diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index 7ffdd62f..5779ff48 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,40 +1,100 @@ -import React from "react"; -import { Paging } from "@elastic/react-search-ui-views"; -import useSearchQuery from "../../hooks/useSearchQuery"; +import { FaDiscord, FaGithub } from "react-icons/fa"; -const Footer = () => { - const { handlePageChange, pagingInfo } = useSearchQuery(); - const { totalResults, current, resultsPerPage } = pagingInfo; - if (!totalResults) { - return null; - } - const totalPages = Math.ceil(totalResults/resultsPerPage) +const Separator = ({ className }: { className?: string }) => ( +
+); + +const GithubLink = () => ( + + + +); + +const DiscordLink = () => ( + + + +); + +const StatsLink = () => ( + + View our public visitor count + +); + +const ContactSocials = () => { return ( -
- - - ❤️ Bitcoin Dev Project - +
+ + +
+ ); +}; -
+const Footer = () => { + return ( + ); }; diff --git a/src/components/footer/HolocatChatBtc.tsx b/src/components/footer/HolocatChatBtc.tsx new file mode 100644 index 00000000..23d400ad --- /dev/null +++ b/src/components/footer/HolocatChatBtc.tsx @@ -0,0 +1,26 @@ +import Image from "next/image"; +import React from "react"; + +const HolocatChatBtc = () => { + return ( + + ); +}; + +export default HolocatChatBtc; diff --git a/src/components/footer/HomeFooter.tsx b/src/components/footer/HomeFooter.tsx deleted file mode 100644 index 0b548766..00000000 --- a/src/components/footer/HomeFooter.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import useIsInitialStateWithoutFilter from "../../hooks/useIsInitialStateWithoutFilter"; - -const HomeFooter = () => { - const { hiddenBody } = useIsInitialStateWithoutFilter(); - if (!hiddenBody) { - return null; - } - return ( -
- - ❤️ Bitcoin Dev Project - - -
-

- Visitor counts publicly available via{" "} - - umami - -

-
-
- ); -}; - -export default HomeFooter; diff --git a/src/components/footer/footer.scss b/src/components/footer/footer.scss index ae7375a5..2bf35f2b 100644 --- a/src/components/footer/footer.scss +++ b/src/components/footer/footer.scss @@ -1,27 +1,289 @@ -.footer-container { +.chaincode-link { + font-weight: 500; + transition: color ease-in 0.2s; + + &:hover { + color: #ff9900; + } +} + +button.rc-pagination-jump-prev { + font-size: 14px; + width: 16px; display: flex; - flex-direction: column; - align-items: center; // Center items horizontally + align-items: center; + justify-content: center; + margin: 0px 2px 0px 0px; + border: 1px solid var(--stroke); - .pagination-wrapper { // If you have this wrapper from the previous instruction - margin-bottom: 3rem; // Adjust this to create the desired spacing + @media screen and (min-width: 640px) { + width: 48px; + height: 48px; + padding: 20px; + border: none; } - .chaincode-link { - margin-top: 2rem; // Space between the pagination and the Bitcoin Dev link - text-align: center; // Center the text within the link + &::after { + color: var(--primary-text); + content: "<<"; + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } } - .umami { - margin-top: 1rem; // Space between the Bitcoin Dev link and the umami info - text-align: center; // Center the text within the div + a { + color: var(--primary-text); + } + + &:hover { + border: 1px solid var(--stroke); + background-color: transparent; + + &:after { + content: "<<"; + color: var(--primary-text) !important; + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } } } -.chaincode-link { - font-weight: 500; - transition: color ease-in 0.2s; +button.rc-pagination-jump-next { + display: flex; + font-size: 14px; + width: 16px; + padding: 0px; + align-items: center; + justify-content: center; + margin: 0px 2px 0px 0px; + + border: 1px solid var(--stroke); + + @media screen and (min-width: 640px) { + width: 48px; + height: 48px; + padding: 20px; + border: none; + } + + &::after { + color: var(--primary-text); + font-size: 0.5rem; + content: ">>"; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } + &:hover { - color: #ff9900; + border: 1px solid var(--stroke); + background-color: transparent; + + &:after { + content: ">>"; + color: var(--primary-text) !important; + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } + } +} + +.sui-paging { + overflow: hidden; + padding: 4px; + color: var(--primary-text); + display: flex; + align-items: center; + width: 100%; + gap: 0px; + + @media screen and (min-width: 640px) { + padding: 10px; + gap: 8px; + } + + .rc-pagination-previous { + a { + color: var(--primary-text); + } + } + + li.rc-pagination-item-active.rc-pagination-item { + background: #f7931a; + border: none !important; + + a { + color: white !important; + } + + &:hover { + background: #f7931a; + border: none !important; + + a { + color: white !important; + } + } + } + + li.rc-pagination-item { + width: 16px; + border: 1px solid var(--stroke); + display: flex; + padding: 0px; + justify-content: center; + align-items: center; + margin: 0px 2px 0px 0px; + + @media screen and (min-width: 640px) { + width: 48px; + height: 48px; + padding: 20px; + border: none; + } + + &:hover { + border: 1px solid var(--stroke); + background-color: transparent; + + a { + color: var(--black); + } + } + + a { + color: var(--primary-text) !important; + font-size: 0.5rem; + font-weight: 700; + + @media screen and (min-width: 640px) { + font-size: 0.875rem; + } + } + } + + // pagination arrows + li.rc-pagination-prev { + display: flex; + width: 16px; + padding: 0px; + align-items: center; + justify-content: center; + margin: 0px 2px 0px 0px; + border: 1px solid var(--stroke); + + @media screen and (min-width: 640px) { + width: 48px; + height: 48px; + padding: 20px; + border: none; + } + + &::after { + color: var(--primary-text); + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } + + &:hover { + border: 1px solid var(--stroke); + background-color: transparent; + color: var(--primary-text); + + &:after { + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } + } + } + + li.rc-pagination-next { + display: flex; + align-items: center; + width: 16px; + justify-content: center; + margin: 0px 2px 0px 0px; + border: 1px solid var(--stroke); + + @media screen and (min-width: 640px) { + width: 48px; + height: 48px; + padding: 20px; + border: none; + } + + &::after { + color: var(--black); + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } + + &:hover { + border: 1px solid var(--stroke); + background-color: transparent; + + &:after { + color: var(--primary-text) !important; + font-size: 0.5rem; + + @media screen and (min-width: 640px) { + font-size: 14px; + } + } + + a { + color: var(--primary-text); + } + } + } + + li.rc-pagination-jump-prev { + display: none; + } + + li.rc-pagination-jump-next { + display: none; } } diff --git a/src/components/formModal/FormModal.tsx b/src/components/formModal/FormModal.tsx index 75c75a5d..527f3ce3 100644 --- a/src/components/formModal/FormModal.tsx +++ b/src/components/formModal/FormModal.tsx @@ -1,23 +1,37 @@ import { - Box, - Button, - Center, FormControl, - FormHelperText, - FormLabel, - Input, Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay, - Text, + Spinner, } from "@chakra-ui/react"; import React, { FormEvent, useState } from "react"; import { getFormURL } from "../../config/config-helper"; +import CircleCheck from "public/circle-tick.svg"; +import Image from "next/image"; +import { useRouter } from "next/router"; -const FormModal = ({ formOpen, closeForm }) => { - const [urlValue, setUrlValue] = useState(""); +const defaultFieldState = { + value: "", + isValid: true, +}; + +const urlRegex = + /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/; +const emailRegex = /^\S+@\S+\.\S+$/; + +const FormModal = ({ formOpen, closeForm, noResult }) => { + const router = useRouter(); + const [urlState, setUrlState] = useState({ + value: "", + isValid: true, + }); + const [emailState, setEmailState] = useState({ + value: "", + isValid: true, + }); const [formState, setFormState] = useState({ loading: false, error: "", @@ -26,7 +40,7 @@ const FormModal = ({ formOpen, closeForm }) => { const url = getFormURL(); - const submitToSheet = async (data: FormData) => { + const submitToSheet = async (data: FormData): Promise => { const response = await fetch(url, { method: "POST", body: data, @@ -34,16 +48,26 @@ const FormModal = ({ formOpen, closeForm }) => { return response.json(); }; + const clearQueryState = () => { + setUrlState((url) => ({ ...url, value: "" })); + router.query = {}; + router.push("/"); + }; + const handleSubmit = (e: FormEvent) => { e.preventDefault(); const data = new FormData(); - data.append("URL", urlValue); + data.append("URL", urlState.value); + emailState.isValid && data.append("Email", emailState.value.trim()); setFormState((prev) => ({ ...prev, loading: true })); - setUrlValue(""); + submitToSheet(data) .then((res) => { if (res.result === "success") { setFormState({ loading: false, error: "", success: true }); + noResult + ? clearQueryState() + : setUrlState((url) => ({ ...url, value: "" })); } else { throw Error(res.result); } @@ -59,65 +83,164 @@ const FormModal = ({ formOpen, closeForm }) => { const resetAndCloseForm = () => { setFormState({ loading: false, success: false, error: "" }); - setUrlValue(""); + setUrlState(defaultFieldState); + setEmailState(defaultFieldState); closeForm(); }; + const formIsComplete = urlState.isValid && emailState.isValid; + + const validateUrl = (url: string) => { + // if no url is provided, it is valid (removes error state). The required attribute ensures a blank url is not submitted + if (!url) return true; + const isValid = urlRegex.test(url); + return isValid; + }; + const validateEmail = (email: string) => { + // empty string email is valid + const isValid = email ? emailRegex.test(email) : true; + return isValid; + }; + + const handleUrlChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const isValid = validateUrl(value); + setUrlState({ value, isValid }); + }; + const handleEmailChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const isValid = validateEmail(value); + setEmailState({ value, isValid }); + }; + return ( - - - - User Domain Submission - - - - {formState.success ? ( - - - Submitted Successfully - - - ) : formState?.error ? ( - - - {formState.error} - - - ) : ( -
- - - URL - - setUrlValue(e.target.value)} - value={urlValue} - isRequired - maxLength={255} - /> - - Enter a valid url, should contain http/https - -
- + -
-
-
- )} -
+ Submit Source + {formState.loading && } + +
+ + )} + +
); diff --git a/src/components/homeFacetSelection/KeywordsSelection.tsx b/src/components/homeFacetSelection/KeywordsSelection.tsx index d313304a..68dfc2da 100644 --- a/src/components/homeFacetSelection/KeywordsSelection.tsx +++ b/src/components/homeFacetSelection/KeywordsSelection.tsx @@ -1,5 +1,5 @@ import { Button, Container, Heading } from "@chakra-ui/react"; -import React, { useState } from "react"; +import React from "react"; import { getTopKeywords } from "../../config/config-helper"; import useSearchQuery from "@/hooks/useSearchQuery"; import useURLManager from "@/service/URLManager/useURLManager"; @@ -7,8 +7,7 @@ import useIsInitialStateWithoutFilter from "@/hooks/useIsInitialStateWithoutFilt const KeywordsSearchSelection = ({}) => { const { - queryResult: { data, isLoading }, - searchQuery, + queryResult: { isLoading }, makeQuery, } = useSearchQuery(); const { getSearchTerm } = useURLManager(); @@ -18,7 +17,10 @@ const KeywordsSearchSelection = ({}) => { const searchTerm = getSearchTerm(); const topKeywords = getTopKeywords(); - const handleToggleKeyword = (filter: {count: number, value: string}, isSelected: boolean) => { + const handleToggleKeyword = ( + filter: { count: number; value: string }, + isSelected: boolean + ) => { if (isLoading) return; if (!isSelected) { makeQuery(filter.value); @@ -63,4 +65,4 @@ const KeywordsSearchSelection = ({}) => { ); }; -export default KeywordsSearchSelection +export default KeywordsSearchSelection; diff --git a/src/components/homeFacetSelection/index.tsx b/src/components/homeFacetSelection/index.tsx index 6b45325d..c41c4186 100644 --- a/src/components/homeFacetSelection/index.tsx +++ b/src/components/homeFacetSelection/index.tsx @@ -2,20 +2,20 @@ import { Button, Container, Heading } from "@chakra-ui/react"; import React, { useLayoutEffect, useRef } from "react"; import { getTopAuthors } from "../../config/config-helper"; import useURLManager from "@/service/URLManager/useURLManager"; -import useSearchQuery from "@/hooks/useSearchQuery"; import useIsInitialStateWithoutFilter from "@/hooks/useIsInitialStateWithoutFilter"; -const InitialFacetSection = ({ - field = "authors" as const, -}) => { - const { queryResult: { data }, searchQuery} = useSearchQuery() - const { getFilter, addFilter: addFilterNew, removeFilter: removeFilterNew } = useURLManager(); +const InitialFacetSection = ({ field = "authors" as const }) => { + const { + getFilter, + addFilter: addFilterNew, + removeFilter: removeFilterNew, + } = useURLManager(); const { hiddenHomeFacet } = useIsInitialStateWithoutFilter(); - + const filterForField = () => { return getFilter(field); }; - + const topAuthors = getTopAuthors(); const initRender = useRef(true); @@ -29,10 +29,10 @@ const InitialFacetSection = ({ }, []); const onRemove = (value) => { - removeFilterNew({filterType: field, filterValue: value}); + removeFilterNew({ filterType: field, filterValue: value }); }; const onAdd = (value) => { - addFilterNew({filterType: field, filterValue: value}); + addFilterNew({ filterType: field, filterValue: value }); }; const handleToggleFilter = (filter, isSelected: boolean) => { // if (isLoading) return; diff --git a/src/components/landingPage/HomeTextBanner.tsx b/src/components/landingPage/HomeTextBanner.tsx new file mode 100644 index 00000000..83bf26f1 --- /dev/null +++ b/src/components/landingPage/HomeTextBanner.tsx @@ -0,0 +1,26 @@ +import useIsInitialStateWithoutFilter from "@/hooks/useIsInitialStateWithoutFilter"; +import Image from "next/image"; + +const HomeTextBanner = ({ className }: { className: string }) => { + const { hiddenHomeFacet } = useIsInitialStateWithoutFilter(); + + if (hiddenHomeFacet) return null; + + return ( +
+ bitcoin logo +

+ Search the depths of bitcoin’s technical ecosystem +

+
+ ); +}; + +export default HomeTextBanner; diff --git a/src/components/landingPage/LandingPage.tsx b/src/components/landingPage/LandingPage.tsx new file mode 100644 index 00000000..87737403 --- /dev/null +++ b/src/components/landingPage/LandingPage.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "@/context/Theme"; +import TreasureChartLight from "public/landing/light/treasure-trove-chart.webp"; +import TreasureChartDark from "public/landing/dark/treasure-trove-chart.webp"; +import SourcesLight from "public/landing/light/sources-image.webp"; +import SourcesDark from "public/landing/dark/sources-image.webp"; + +export const LandingPage = () => { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const isMobile = window.matchMedia("(max-width: 600px)").matches; + + return ( +
+ + arrow pointing downwards + +
+
+
+

+ WHY USE BITCOIN SEARCH +

+

+ {`Bitcoin Search results are relevant, where traditional search engines aren't`} +

+

+ {`Despite everything Google knows about you, it still thinks you're searching for the electric sparks in the sky or ice hockey teams in Florida. We know what you actually mean.`} +

+
+ +
+
+ google search image +
+ +
+ bitcoin search image +
+
+ +
+
+

+ Treasure Trove of Technical Bitcoin Resources +

+

+ {`We've built the world's largest collection of technical bitcoin-related resources: articles, podcast transcripts, blog posts, and more.`} +

+
+
+ trasure trove chart +
+
+
+
+ +
+
+ filter image +
+ +
+

+ Up-to-Date Information +

+

+ Bitcoin Search monitors our sources to make sure your search results + are up-to-date. +

+
+
+ +
+
+

+ Credible Sources +

+

+ We hand-pick Bitcoin Search sources for their contributions to + technical bitcoin concepts. Examples include the Bitcoin-dev Mailing + List, LN dev Mailing List, Bitcoin Optech, and many more. +

+
+
+ treasure trove chart +
+
+ +
+ +
+
+ ); +}; diff --git a/src/components/loadingBar/loadingBar.scss b/src/components/loadingBar/loadingBar.scss index 0cff8c18..8ef506dd 100644 --- a/src/components/loadingBar/loadingBar.scss +++ b/src/components/loadingBar/loadingBar.scss @@ -1,5 +1,7 @@ .loading-container { position: fixed; + top: 0; + left: 0; width: 100%; align-self: flex-start; display: flex; @@ -21,10 +23,9 @@ animation: searching 3s cubic-bezier(0.17, 0.37, 0.43, 0.67) infinite; } @keyframes searching { - 50% { background-color: var(--chakra-colors-gray-400); // opacity: 0.3; width: 90%; } -} \ No newline at end of file +} diff --git a/src/components/navBar/NavBar.tsx b/src/components/navBar/NavBar.tsx new file mode 100644 index 00000000..bcf507f7 --- /dev/null +++ b/src/components/navBar/NavBar.tsx @@ -0,0 +1,172 @@ +import useIsInitialStateWithoutFilter from "@/hooks/useIsInitialStateWithoutFilter"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { AppMenu } from "../appMenu"; +import AppsIcon from "../svgs/AppsIcon"; +import DayIcon from "../svgs/DayIcon"; +import NightIcon from "../svgs/NightIcon"; +import SearchBox from "@/components/customSearchbox/SearchBox"; +import SearchBoxView from "@/components/customSearchbox/SearchBoxView"; +import Link from "next/link"; +import useSearchQuery from "@/hooks/useSearchQuery"; +import { removeMarkdownCharacters } from "@/utils/elastic-search-ui-functions"; +import { useTheme } from "@/context/Theme"; +import { Tooltip } from "@chakra-ui/react"; + +function ThemeSwitcher() { + const { theme, toggleTheme } = useTheme(); + const isLight = theme === "light"; + const switchStyle = `flex basis-1/2 items-center justify-center rounded-lg transition-[background-color] duration-500`; + + return ( +
+
+ + +
+
+
+ ); +} + +const MenuSwitcher = () => { + const popoverRef = useRef(null); + const buttonRef = useRef(null); + const [open, setIsOpen] = useState(false); + + useEffect(() => { + const handleClickOutside = (event) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target) && + buttonRef.current && + !buttonRef.current.contains(event.target) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+ +
+
+ +
+
+
+ ); +}; + +const NavBar = () => { + const { hiddenHomeFacet } = useIsInitialStateWithoutFilter(); + const { makeQuery } = useSearchQuery(); + const handleSubmit = (input: string) => { + makeQuery(input); + }; + const handleAutoCompleteSelect = (selection) => { + if (!selection.suggestion) return; + makeQuery(removeMarkdownCharacters(selection.suggestion)); + }; + + return ( + + ); +}; + +export default NavBar; diff --git a/src/components/noResultsCard/NoResults.tsx b/src/components/noResultsCard/NoResults.tsx index ee38c05e..24699b67 100644 --- a/src/components/noResultsCard/NoResults.tsx +++ b/src/components/noResultsCard/NoResults.tsx @@ -1,20 +1,28 @@ -import { Button } from "@chakra-ui/react"; +import Link from "next/link"; import React from "react"; const NoResults = ({ openForm }: { openForm: () => void }) => { return ( -
-

No results found

-

You can contribute to our sources by submitting a url

-
- + Suggest a Source +
); diff --git a/src/components/noResultsCard/noResults.scss b/src/components/noResultsCard/noResults.scss index 44d3b3c0..19485389 100644 --- a/src/components/noResultsCard/noResults.scss +++ b/src/components/noResultsCard/noResults.scss @@ -1,6 +1,5 @@ - .no-result { - margin: 25px auto; + margin: 0 auto 25px; width: MIN(70%, 600px); padding: calc(min(5vh, 40px)) 15px; text-align: center; @@ -10,11 +9,4 @@ border-radius: 1rem; box-shadow: 0 0 20px 2px #0002; z-index: 2; - - h2 { - font-size: 20px; - font-weight: 500; - color: var(--chakra-colors-red-400); - margin-bottom: 30px; - } } diff --git a/src/components/sidebarFacet/Facet.tsx b/src/components/sidebarFacet/Facet.tsx index 6add006a..41d4a7ce 100644 --- a/src/components/sidebarFacet/Facet.tsx +++ b/src/components/sidebarFacet/Facet.tsx @@ -1,83 +1,97 @@ -import useSearchQuery from '@/hooks/useSearchQuery'; -import useURLManager from '@/service/URLManager/useURLManager'; -import { FacetKeys } from '@/types' -import React, { FunctionComponent, useMemo, useState } from 'react' +import useSearchQuery from "@/hooks/useSearchQuery"; +import useURLManager from "@/service/URLManager/useURLManager"; +import { FacetKeys } from "@/types"; +import { getFilterValueDisplay, matchCharactersWithRegex } from "@/utils/facet"; +import React, { FunctionComponent, useMemo, useState } from "react"; type ViewProps = Omit & { - onMoreClick: () => void; - showMore: boolean; showSearch: boolean; onSearch: (x: string) => void; searchPlaceholder: string; onRemove: (x: string) => void; onSelect: (x: string) => void; options: FacetList[]; -} +}; type FacetProps = { field: FacetKeys; isFilterable: boolean; label: string; - view: FunctionComponent -} + view: FunctionComponent; + callback?: (x?: any) => void; +}; type FacetAggregateBucketItem = { key: string; doc_count: number; -} -type FacetAggregateBucket = FacetAggregateBucketItem[] +}; +type FacetAggregateBucket = FacetAggregateBucketItem[]; type FacetList = { value: string; count: number; - selected: boolean -} + selected: boolean; +}; - -const Facet = ({field, isFilterable, label, view}: FacetProps) => { - const [itemsToShow, setItemsToShow] = useState(10) - const [searchTermFacet, setSearchTermFacet] = useState("") - const { searchQuery, queryResult: { data } } = useSearchQuery() +const Facet = ({ field, isFilterable, label, view, callback }: FacetProps) => { + // const [itemsToShow, setItemsToShow] = useState(10) + const [searchTermFacet, setSearchTermFacet] = useState(""); + const { + queryResult: { data }, + } = useSearchQuery(); // temporary conditional - const fieldAggregate: FacetAggregateBucket = field === "domain" ? (data?.aggregations?.["domains"]?.["buckets"] ?? []) : (data?.aggregations?.[field]?.["buckets"] ?? []) - const { getFilter, addFilter, removeFilter } = useURLManager() - + const fieldAggregate: FacetAggregateBucket = + field === "domain" + ? data?.aggregations?.["domains"]?.["buckets"] ?? [] + : data?.aggregations?.[field]?.["buckets"] ?? []; + const { getFilter, addFilter, removeFilter } = useURLManager(); + const selectedList = getFilter(field); - const baseOptions = fieldAggregate.map(item => { - const selected = selectedList.includes(item.key) - return ({ + const baseOptions = fieldAggregate.map((item) => { + const selected = selectedList.includes(item.key); + return { value: item.key, count: item.doc_count, selected, - }) - }) + label: getFilterValueDisplay(item.key, field), + }; + }); const options = useMemo(() => { - return baseOptions.filter(item => searchTermFacet.trim() ? item.value.includes(searchTermFacet) : true).slice(0, itemsToShow) - }, [searchTermFacet, baseOptions, itemsToShow]) - - const showMore = itemsToShow < baseOptions.length - - const onMoreClick = () => { - setItemsToShow(prev => prev + 10) - } + return baseOptions.filter((item) => + searchTermFacet.trim() + ? matchCharactersWithRegex(item.label, searchTermFacet) + : true + ); + }, [searchTermFacet, baseOptions]); const onSearch = (val: string) => { - setSearchTermFacet(val) - } + setSearchTermFacet(val); + }; const onRemove = (value: string) => { - removeFilter({filterType: field, filterValue: value}) - } + removeFilter({ filterType: field, filterValue: value }); + callback && callback(value); + }; const onSelect = (value: string) => { - addFilter({filterType: field, filterValue: value}) - } + addFilter({ filterType: field, filterValue: value }); + callback && callback(value); + }; - const searchPlaceholder = `Filter ${label}` + const searchPlaceholder = `Filter ${label}`; - const viewProps = {onRemove, onSelect, onSearch, onMoreClick, showMore, options, field, showSearch: isFilterable, label, searchPlaceholder} + const viewProps = { + onRemove, + onSelect, + onSearch, + options, + field, + showSearch: isFilterable, + label, + searchPlaceholder, + }; return React.createElement(view, Object.assign({}, viewProps)); -} +}; -export default Facet \ No newline at end of file +export default Facet; diff --git a/src/components/sidebarFacet/FilterMenu.tsx b/src/components/sidebarFacet/FilterMenu.tsx new file mode 100644 index 00000000..81da1b5e --- /dev/null +++ b/src/components/sidebarFacet/FilterMenu.tsx @@ -0,0 +1,94 @@ +import { Facet } from "@/types"; +import Image from "next/image"; +import React from "react"; +import SidebarSection from "./SidebarSection"; +import useSearchQuery from "@/hooks/useSearchQuery"; +import { getFacetFields } from "@/config/config-helper"; +import { getFilterValueDisplay } from "@/utils/facet"; +import useURLManager from "@/service/URLManager/useURLManager"; +import CrossIcon from "public/cross_icon.svg"; +import DarkCrossIcon from "public/dark_cross_icon.svg"; +import FilterMenuIcon from "../svgs/FilterMenuIcon"; +import { useTheme } from "@/context/Theme"; + +const FilterMenu = () => { + const { filterFields } = useSearchQuery(); + + return ( + <> + +
+ +

Filters

+
+
+ + + ); +}; + +const AppliedFilters = ({ filters }: { filters: Facet[] }) => { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const { removeFilterTypes, removeFilter } = useURLManager(); + if (!filters?.length) return null; + const clearAllFilters = () => { + removeFilterTypes({ + filterTypes: ["authors", "domain"], + sortField: "sort_by", + }); + }; + return ( + +
+

Applied Filters

+
+ + Clear all + + + clear all + +
+
+
+ {getFacetFields().map((facet) => { + return filters + .filter((filter) => filter.field === facet) + .map((filter) => ( +
+ removeFilter({ + filterType: filter.field, + filterValue: filter.value, + }) + } + > + + {getFilterValueDisplay(filter.value, filter.field)} + + remove +
+ )); + })} +
+
+ ); +}; + +export default FilterMenu; diff --git a/src/components/sidebarFacet/ResultSize.tsx b/src/components/sidebarFacet/ResultSize.tsx new file mode 100644 index 00000000..13fed5ad --- /dev/null +++ b/src/components/sidebarFacet/ResultSize.tsx @@ -0,0 +1,29 @@ +import useSearchQuery from "@/hooks/useSearchQuery"; +import React from "react"; + +const ResultSize = () => { + const { + resultsPerPage: currentSize, + totalResults, + current, + } = useSearchQuery().pagingInfo; + + const range = { + start: Math.max(currentSize * (current - 1), 1), + end: Math.min(currentSize * current, totalResults), + }; + return ( + <> +
+ Showing + + {range.start} - {range.end} + + of {totalResults} results +
+
+ + ); +}; + +export default ResultSize; diff --git a/src/components/sidebarFacet/ShowFilterResultsMobile.tsx b/src/components/sidebarFacet/ShowFilterResultsMobile.tsx new file mode 100644 index 00000000..6913e24c --- /dev/null +++ b/src/components/sidebarFacet/ShowFilterResultsMobile.tsx @@ -0,0 +1,19 @@ +import useSearchQuery from "@/hooks/useSearchQuery"; +import useUIContext from "@/hooks/useUIContext"; +import React from "react"; + +const ShowFilterResultsMobile = () => { + const { totalResults } = useSearchQuery().pagingInfo; + const { sidebarToggleManager } = useUIContext(); + return ( +
sidebarToggleManager.updater(false)} + > + {`Show ${totalResults} results`} +
+ ); +}; + +export default ShowFilterResultsMobile; diff --git a/src/components/sidebarFacet/SidebarSection.tsx b/src/components/sidebarFacet/SidebarSection.tsx new file mode 100644 index 00000000..0f7c7373 --- /dev/null +++ b/src/components/sidebarFacet/SidebarSection.tsx @@ -0,0 +1,23 @@ +import appendClassName from "@/utils/elastic-search-ui-functions"; +import React from "react"; + +const SidebarSection = ({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default SidebarSection; diff --git a/src/components/sidebarFacet/Sorting/SortingView.tsx b/src/components/sidebarFacet/Sorting/SortingView.tsx new file mode 100644 index 00000000..c018c9e3 --- /dev/null +++ b/src/components/sidebarFacet/Sorting/SortingView.tsx @@ -0,0 +1,88 @@ +import { + FormControl, + Menu, + MenuButton, + MenuItem, + MenuList, +} from "@chakra-ui/react"; +import { SortingViewProps } from "./types"; +import Image from "next/image"; +import SidebarSection from "../SidebarSection"; +import SortIcon from "@/components/svgs/SortIcon"; +import LightningIcon from "public/lightning_icon_filled.svg"; + +const SortingView = ({ + onChange, + options, + value, + label, + option, +}: SortingViewProps) => { + return ( + + + + + +
+

+ {option.label} +

+ + arrow + +
+
+ +
+ {options.map((item, index) => ( + { + onChange(item.value); + }} + data-selected={option.value === item.value} + className="group" + p={0} + m={0} + aria-label={`sort by ${item.label}`} + > +
+ + + {item.label} + +
+
+ ))} +
+
+
+
+
+ ); +}; + +export default SortingView; diff --git a/src/components/sidebarFacet/Sorting/types.ts b/src/components/sidebarFacet/Sorting/types.ts new file mode 100644 index 00000000..e6899315 --- /dev/null +++ b/src/components/sidebarFacet/Sorting/types.ts @@ -0,0 +1,23 @@ +import type { FunctionComponent } from "react"; + +export type SortingViewProps = Omit< + FacetProps, + "view" | "field" | "sortOptions" +> & { + onChange: (x: string) => void; + options: SortOption[]; + option: SortOption; + value: string; +}; + +type SortOption = { + label: string; + value: string; +}; + +type FacetProps = { + field: string; + label: string; + view: FunctionComponent; + sortOptions: SortOption[]; +}; diff --git a/src/components/sidebarFacet/SortingFacet.tsx b/src/components/sidebarFacet/SortingFacet.tsx index a6e00f93..2699bbcf 100644 --- a/src/components/sidebarFacet/SortingFacet.tsx +++ b/src/components/sidebarFacet/SortingFacet.tsx @@ -1,49 +1,68 @@ -import useURLManager from '@/service/URLManager/useURLManager'; -import React, { FunctionComponent } from 'react' +import useURLManager from "@/service/URLManager/useURLManager"; +import React, { FunctionComponent } from "react"; type ViewProps = Omit & { onChange: (x: string) => void; options: SortOption[]; + option: SortOption; value: string; -} +}; type SortOption = { label: string; value: string; - field: string; -} +}; type FacetProps = { field: string; label: string; view: FunctionComponent; - sortOptions: SortOption[] -} + sortOptions: SortOption[]; + callback?: (x?: SortOption) => void; +}; + +const SortingFacet = ({ + field, + label, + view, + sortOptions, + callback, +}: FacetProps) => { + const { getSort, addSort, removeSort } = useURLManager(); -const SortingFacet = ({field, label, view, sortOptions}: FacetProps) => { - const {getSort, addSort, removeSort } = useURLManager() - const sortField = getSort(field) ?? ""; - const selectedOption = sortOptions.find(option => option.value === sortField) ?? { - label: "-", + const selectedOption = sortOptions.find( + (option) => option.value === sortField + ) ?? { + label: "Relevance", value: " ", - field - } + }; const onChange = (x: string) => { if (x.trim()) { - const selectedOption = sortOptions.find(option => option.value === x) - selectedOption && addSort(selectedOption.field, selectedOption.value) + const selectedOption = sortOptions.find((option) => option.value === x); + selectedOption.value && addSort(field, selectedOption.value); + if (callback) { + callback(selectedOption); + } } else { - removeSort(field) + removeSort(field); + if (callback) { + callback(selectedOption); + } } + }; - } - - const viewProps = {onChange, options: sortOptions, label, value: selectedOption.value} + const viewProps = { + onChange, + options: sortOptions, + label, + value: selectedOption.value, + option: selectedOption, + }; return React.createElement(view, Object.assign({}, viewProps)); -} +}; -export default SortingFacet \ No newline at end of file +export default SortingFacet; diff --git a/src/components/svgs/AppsIcon.tsx b/src/components/svgs/AppsIcon.tsx new file mode 100644 index 00000000..11d26826 --- /dev/null +++ b/src/components/svgs/AppsIcon.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const AppsIcon = (props: SVGProps) => ( + + + +); + +export default AppsIcon; diff --git a/src/components/svgs/ArrowLeft.tsx b/src/components/svgs/ArrowLeft.tsx new file mode 100644 index 00000000..3731d934 --- /dev/null +++ b/src/components/svgs/ArrowLeft.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const ArrowLeft = (props: SVGProps) => { + return ( + + + + ); +}; +export default ArrowLeft; diff --git a/src/components/svgs/ArrowRight.tsx b/src/components/svgs/ArrowRight.tsx new file mode 100644 index 00000000..cb7ebc13 --- /dev/null +++ b/src/components/svgs/ArrowRight.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const ArrowRight = (props: SVGProps) => { + return ( + + + + ); +}; +export default ArrowRight; diff --git a/src/components/svgs/AuthorIcon.tsx b/src/components/svgs/AuthorIcon.tsx new file mode 100644 index 00000000..0cda4f04 --- /dev/null +++ b/src/components/svgs/AuthorIcon.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const AuthorIcon = (props: SVGProps) => ( + + + +); + +export default AuthorIcon; diff --git a/src/components/svgs/CloseIconOutlined.tsx b/src/components/svgs/CloseIconOutlined.tsx new file mode 100644 index 00000000..9689e338 --- /dev/null +++ b/src/components/svgs/CloseIconOutlined.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const CloseIconOutlined = (props: SVGProps) => ( + + + +); +export default CloseIconOutlined; diff --git a/src/components/svgs/DateIcon.tsx b/src/components/svgs/DateIcon.tsx new file mode 100644 index 00000000..626183dc --- /dev/null +++ b/src/components/svgs/DateIcon.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const DateIcon = (props: SVGProps) => { + return ( + + + + ); +}; +export default DateIcon; diff --git a/src/components/svgs/DayIcon.tsx b/src/components/svgs/DayIcon.tsx new file mode 100644 index 00000000..69984d27 --- /dev/null +++ b/src/components/svgs/DayIcon.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const DayIcon = (props: SVGProps) => ( + + + +); + +export default DayIcon; diff --git a/src/components/svgs/FilterCloseIcon.tsx b/src/components/svgs/FilterCloseIcon.tsx new file mode 100644 index 00000000..06e5389f --- /dev/null +++ b/src/components/svgs/FilterCloseIcon.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const FilterCloseIcon = (props: SVGProps) => ( + + + +); +export default FilterCloseIcon; diff --git a/src/components/svgs/FilterIcon.tsx b/src/components/svgs/FilterIcon.tsx new file mode 100644 index 00000000..7d9ecdbd --- /dev/null +++ b/src/components/svgs/FilterIcon.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const FilterIcon = (props: SVGProps) => ( + + + +); +export default FilterIcon; diff --git a/src/components/svgs/FilterMenuIcon.tsx b/src/components/svgs/FilterMenuIcon.tsx new file mode 100644 index 00000000..12bacc9b --- /dev/null +++ b/src/components/svgs/FilterMenuIcon.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const FilterMenuIcon = (props: SVGProps) => ( + + + +); + +export default FilterMenuIcon; diff --git a/src/components/svgs/GithubIcon.tsx b/src/components/svgs/GithubIcon.tsx new file mode 100644 index 00000000..5f986dad --- /dev/null +++ b/src/components/svgs/GithubIcon.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const GithubIcon = (props: SVGProps) => ( + + + + +); + +export default GithubIcon; diff --git a/src/components/svgs/NightIcon.tsx b/src/components/svgs/NightIcon.tsx new file mode 100644 index 00000000..9958a5ee --- /dev/null +++ b/src/components/svgs/NightIcon.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const NightIcon = ({ + svgProps, + pathProps, +}: { + svgProps: SVGProps; + pathProps?: SVGProps; +}) => ( + + + +); + +export default NightIcon; diff --git a/src/components/svgs/PlusIcon.tsx b/src/components/svgs/PlusIcon.tsx new file mode 100644 index 00000000..964f86fa --- /dev/null +++ b/src/components/svgs/PlusIcon.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; + +const PlusIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; + +export default PlusIcon; diff --git a/src/components/svgs/SearchIcon.tsx b/src/components/svgs/SearchIcon.tsx new file mode 100644 index 00000000..9d180f96 --- /dev/null +++ b/src/components/svgs/SearchIcon.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const SearchIcon = (props: SVGProps) => ( + + + + +); +export default SearchIcon; diff --git a/src/components/svgs/SortIcon.tsx b/src/components/svgs/SortIcon.tsx new file mode 100644 index 00000000..38e582be --- /dev/null +++ b/src/components/svgs/SortIcon.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const SortIcon = (props: SVGProps) => ( + + + + + + + +); + +export default SortIcon; diff --git a/src/components/svgs/SourceIcon.tsx b/src/components/svgs/SourceIcon.tsx new file mode 100644 index 00000000..4ac4349a --- /dev/null +++ b/src/components/svgs/SourceIcon.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { SVGProps } from "react"; + +const SourceIcon = (props: SVGProps) => ( + + + +); + +export default SourceIcon; diff --git a/src/components/svgs/TimeIcon.tsx b/src/components/svgs/TimeIcon.tsx new file mode 100644 index 00000000..ecb34706 --- /dev/null +++ b/src/components/svgs/TimeIcon.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const TimeIcon = (props: SVGProps) => ( + + + +); +export default TimeIcon; diff --git a/src/config/config-helper.ts b/src/config/config-helper.ts index 0619529b..aefe48bc 100644 --- a/src/config/config-helper.ts +++ b/src/config/config-helper.ts @@ -1,4 +1,3 @@ -import { SearchResponse, SearchResponseBody } from "@elastic/elasticsearch/lib/api/types"; import config from "./engine.json"; import { FacetKeys } from "@/types"; @@ -59,7 +58,7 @@ export function getUrlField() { // } export function getFacetFields() { - return getConfig().facets as FacetKeys[] || []; + return (getConfig().facets as FacetKeys[]) || []; } export function getSortFields() { @@ -153,7 +152,7 @@ export function buildSearchOptionsFromConfig() { const searchOptions = { result_fields: [], - search_fields: [] + search_fields: [], }; searchOptions.result_fields = resultFields; searchOptions.search_fields = searchFields; @@ -164,7 +163,6 @@ export function buildFacetConfigFromConfig() { const config = getConfig(); const facets = (config.facets || []).reduce((acc, n) => { - acc[n] = { type: "value", size: 100, diff --git a/src/config/config.ts b/src/config/config.ts index 0829cab2..efaa4f39 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,16 +1,16 @@ export const URLSearchParamsKeyword = { SEARCH: "search", PAGE: "page", - SIZE: "size" + SIZE: "size", + FILTER: "filter", + SORT: "sort", } as const; export const defaultParam = { - size: 10 + size: 25, }; -export const urlParamsPrefix = { - FILTER: "filter", - SORT: "sort" -} +export const TruncateLengthInChar = 300; +export const TruncateLinkInChar = 50; -export const TruncateLengthInChar = 300 +export const aggregatorSize = 100; diff --git a/src/config/engine.json b/src/config/engine.json index 72f4db3a..d79829c8 100644 --- a/src/config/engine.json +++ b/src/config/engine.json @@ -23,16 +23,16 @@ ], "searchFields": [], "fields": [], - "sortFields": ["created_at"], + "sortFields": ["created_at", "sort_by"], "facets": ["authors", "domain"], "titleField": "title", "urlField": "url", - "facetSearch": ["authors"], + "facetSearch": ["authors", "domain"], "resultTags": ["authors"], "topAuthors": [ "Ava Chow", "Anthony Towns", - "Greg Maxwell", + "Gregory Maxwell", "Matt Corallo", "Pieter Wuille", "Rusty Russell", diff --git a/src/config/mapping-helper.ts b/src/config/mapping-helper.ts index 2210b59f..fa2119bb 100644 --- a/src/config/mapping-helper.ts +++ b/src/config/mapping-helper.ts @@ -6,34 +6,103 @@ export const getDomainGrouping = () => { return mapping.collection; }; -export const getDomainLabel = (domain_url, plainString = false) => { - const label = mapping?.labels[domain_url] || null; +export const getDomainLabel = (domain_url: string, plainString = false) => { + const label = mapping?.labels[domain_url] || domain_url; if (!plainString) return label; return label && typeof label === "string" ? label.toLowerCase().replace(/ /g, "_") : null; }; +export const getDomainFavicon = (domain_url: string, isDark: boolean) => { + const url = new URL(domain_url); + const baseUrl = url.origin; + const iconMapping = mapping.icon[baseUrl]; + if (iconMapping) { + if (typeof iconMapping === "object") { + return isDark ? iconMapping.dark : iconMapping.light; + } else if (typeof iconMapping === "string") { + return iconMapping; + } + } + return baseUrl + "/favicon.ico"; +}; + +export const fetchDomainFavicon = (domain_url: string): Promise => { + const url = new URL(domain_url).origin; + return fetch(url) + .then((response) => response.text()) + .then((html) => { + // Create a temporary DOM element to parse the HTML + const temp = document.createElement("div"); + temp.innerHTML = html; + + // Find all elements with rel="icon" or rel="shortcut icon" + const links: NodeListOf = temp.querySelectorAll( + 'link[rel="icon"], link[rel="shortcut icon"]' + ); + + // If found, return the href attribute of the first matching element + if (links.length > 0) { + return url + links[0].getAttribute("href"); + } else { + return null; + } + }) + .catch((error) => { + return null; + }); +}; + export const deriveNameFromUrl = (domain_url: string) => { try { - let title + let title; const newUrl = new URL(domain_url); - const {hostname, pathname} = newUrl - if (newUrl.hostname === "github.com") { - title = pathname.split("/").slice(1).map(word => word[0].toUpperCase() + word.slice(1)).join(" ") - } else { - const domainWithoutTld = hostname.replace(tldRegex, "") - title = domainWithoutTld.split('.').map(word => word[0].toUpperCase() + word.slice(1)).join(" ") - } + const { hostname, pathname } = newUrl; + if (newUrl.hostname === "github.com") { + title = pathname + .split("/") + .slice(1, 3) + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(" "); + } else { + const domainWithoutTld = hostname.replace(tldRegex, ""); + title = domainWithoutTld + .split(".") + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(" "); + } if (typeof title !== "string" || !title.trim()) { - throw new Error() + throw new Error(); } - return title + return title; } catch (err) { - return domain_url + return null; } -} +}; export const getMapping = () => { - return mapping -} + return mapping; +}; + +export const getDomainName = (domain: string) => { + // get site name from mapping.json if it exists + const mappedDomainName = getMappedDomainName(domain); + if (mappedDomainName) return mappedDomainName; + + const fullDomainName = deriveNameFromUrl(domain); + if (fullDomainName) return fullDomainName; + + // Regex finds the site name e.g google.com will return google + const siteName = + typeof domain === "string" + ? domain.match( + /(?:https?:\/\/)?(?:www\.)?([^./]+)\.(?:com|org|nl|co\.uk)/ + ) + : ""; + return siteName?.[1] ?? domain; +}; + +const getMappedDomainName = (mappedUrl: string): string | undefined => { + return mapping.labels[mappedUrl]; +}; diff --git a/src/config/mapping.json b/src/config/mapping.json index 84de29c1..4cbb0097 100644 --- a/src/config/mapping.json +++ b/src/config/mapping.json @@ -1,10 +1,10 @@ { "labels": { - "https://bitcointalk.org": "Bitcointalk", + "https://bitcointalk.org/": "Bitcointalk", "https://bitcoin.stackexchange.com": "Bitcoin StackExchange", - "https://lists.linuxfoundation.org/pipermail/bitcoin-dev/": "Bitcoin dev mailing list", - "https://lists.linuxfoundation.org/pipermail/lightning-dev/": "LN dev mailing list", - "https://btctranscripts.com/": "BTC transcripts", + "https://lists.linuxfoundation.org/pipermail/bitcoin-dev/": "Bitcoin Dev mailing list archive", + "https://lists.linuxfoundation.org/pipermail/lightning-dev/": "LN Dev mailing list", + "https://btctranscripts.com/": "BTC Transcripts", "https://bitcoinops.org/en/": "Bitcoin Optech", "https://bitcoincore.org": "Bitcoin Core", "https://github.com/bitcoin/bips": "Bitcoin BIPs", @@ -35,23 +35,23 @@ "https://petertodd.org": "Peter Todd Blog", "https://bitcoin-dev.blog": "Bitcoin Dev blog", "https://blog.muun.com": "Muun Blog", - "https://github.com/bitcoinbook/bitcoinbook": "Mastering Lightning", + "https://github.com/bitcoinbook/bitcoinbook": "Mastering Bitcoin", "https://blog.keys.casa": "Casa Blog", "https://burakkeceli.medium.com": "Burak Blog", "https://lightningdevkit.org": "Lightning Dev Kit Docs", "https://abytesjourney.com": "A Byte's Journey", "https://fanismichalakis.fr": "Fanis Michalakis", - "https://lightning.engineering": "Lightning Labs Blog", + "https://lightning.engineering": "Lightning Labs", "https://fjahr.com": "Fabian Jahr Blog", "https://blog.lightning.engineering": "Lightning Labs Blog", "https://derpturkey.com": "Derp Turkey", "https://insights.deribit.com": "Deribit Blogs", "https://eklitzke.org": "Evan Klitzke Blog", - "https://old.reddit.com": "Reddit", + "https://old.reddit.com": "Old Reddit", "https://gist.github.com": "GitHub Gists", "https://bip324.com": "BIP324", "https://r6.ca": "Russell O'Connor Blog", - "https://erisian.com.au": "AJ Towns BLog", + "https://erisian.com.au": "AJ Towns Blog", "https://suredbits.com": "Suredbits", "https://en.bitcoin.it": "Bitcoin Wiki", "https://blog.chainside.net": "Chainside", @@ -59,13 +59,35 @@ "https://linkedin.com": "LinkedIn", "https://coindesk.com": "CoinDesk", "https://reddit.com": "Reddit", - "https://delvingbitcoin.org/": "Delving Bitcoin" + "https://delvingbitcoin.org/": "Delving Bitcoin", + "https://gnusha.org/pi/bitcoindev/": "Bitcoin Dev mailing list active", + "https://mailing-list.bitcoindevs.xyz/bitcoindev/": "Bitcoin Dev mailing list" + }, + "icon": { + "https://bitcointalk.org": "/domain_favicons/bitcointalk.svg", + "https://github.com": { + "light": "/domain_favicons/github/light.png", + "dark": "/domain_favicons/github/dark.png" + }, + "https://gist.github.com": { + "light": "/domain_favicons/github/light.png", + "dark": "/domain_favicons/github/dark.png" + }, + "https://lists.linuxfoundation.org": { + "light": "/domain_favicons/mailing_list/light.png", + "dark": "/domain_favicons/mailing_list/dark.png" + }, + "https://medium.com": { + "light": "/domain_favicons/medium/light.png", + "dark": "/domain_favicons/medium/dark.png" + }, + "https://delvingbitcoin.org": "/domain_favicons/delving.png" }, "media": ["https://btctranscripts.com/"], "collection": [ "https://lists.linuxfoundation.org/pipermail/bitcoin-dev/", "https://lists.linuxfoundation.org/pipermail/lightning-dev/", - "https://bitcointalk.org", + "https://bitcointalk.org/", "https://bitcoin.stackexchange.com" ], "tldrLists": [ diff --git a/src/config/results-helper.ts b/src/config/results-helper.ts index a267cc01..8dfce2f1 100644 --- a/src/config/results-helper.ts +++ b/src/config/results-helper.ts @@ -1,10 +1,22 @@ import { EsSearchResult } from "@/types"; import { getDomainLabel } from "./mapping-helper"; -export const generateLocator = (raw_domain: string, url: string, title: string) => { +type GenerateLocatorArgs = { + raw_domain: string; + url: string; + title: string; + thread_url?: string; +}; + +export const generateLocator = ({ + raw_domain, + url, + title, + thread_url, +}: GenerateLocatorArgs) => { const label = getDomainLabel(raw_domain, true); switch (raw_domain) { - case "https://bitcointalk.org": { + case "https://bitcointalk.org/": { const id = locatorForBitcoinTalk(url) ?? title; return appendIdWithDomain(id, label); } @@ -12,8 +24,13 @@ export const generateLocator = (raw_domain: string, url: string, title: string) const id = locatorForBitcoinStackExchange(url) ?? title; return appendIdWithDomain(id, label); } + default: - return appendIdWithDomain(title, label); + let id = title; + if (thread_url) { + id = locathorForThreads(thread_url); + } + return appendIdWithDomain(id, label); } }; @@ -23,13 +40,17 @@ export const locatorForBitcoinTalk = (url: string) => { return topicId || null; }; -export const locatorForBitcoinStackExchange = (url) => { +export const locatorForBitcoinStackExchange = (url: string) => { const urlPath = new URL(url)?.pathname; const id = urlPath && urlPath?.split("questions")[1]; if (!id) return null; return id; }; +export const locathorForThreads = (thread_url: string) => { + return thread_url; +}; + export const locatorForMailingList = (url: string) => { // TODO: write a more robust regex pattern matching for id const id = url.match(/([0-9]){4,}\w+/)[0]; @@ -41,7 +62,10 @@ const appendIdWithDomain = (id, label) => { return `${id}_${label}`; }; -export const sortGroupedResults = (groupedIndices: Set, results: Array>) => { +export const sortGroupedResults = ( + groupedIndices: Set, + results: Array> +) => { if (groupedIndices.size) { groupedIndices.forEach((idx) => { const domain = results[idx][0]?.domain; @@ -62,19 +86,17 @@ export const sortGroupedResults = (groupedIndices: Set, results: Array { +const domainSorting = ( + domain: EsSearchResult["_source"]["domain"], + prev: EsSearchResult["_source"], + next: EsSearchResult["_source"] +) => { if (!prev?.url || !next?.url) return 0; switch (domain) { case "https://lists.linuxfoundation.org/pipermail/bitcoin-dev/": - return ( - locatorForMailingList(next.url) - - locatorForMailingList(prev.url) - ); + return locatorForMailingList(next.url) - locatorForMailingList(prev.url); case "https://lists.linuxfoundation.org/pipermail/lightning-dev/": - return ( - locatorForMailingList(next.url) - - locatorForMailingList(prev.url) - ); + return locatorForMailingList(next.url) - locatorForMailingList(prev.url); default: return 0; } diff --git a/src/context/SearchQueryContext.tsx b/src/context/SearchQueryContext.tsx index b386ec6d..e45c3aa2 100644 --- a/src/context/SearchQueryContext.tsx +++ b/src/context/SearchQueryContext.tsx @@ -1,90 +1,147 @@ -import { UseQueryResult, useQuery } from "@tanstack/react-query"; -import React, { createContext, useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/router" +import { UseQueryResult } from "@tanstack/react-query"; +import React, { createContext, useCallback, useMemo } from "react"; +import { useRouter } from "next/router"; import { URLSearchParamsKeyword, defaultParam } from "@/config/config"; -import { AggregationsAggregate, SearchResponse } from "@elastic/elasticsearch/lib/api/types"; import { useSearch } from "@/service/api/search/useSearch"; -import { getFacetFields, getSortFields } from "@/config/config-helper"; -import { appendFilterName, generateFilterQuery, generateSortFields } from "@/service/URLManager/helper"; -import { Facet } from "@/types"; +import { + generateFilterQuery, + generateSortFields, +} from "@/service/URLManager/helper"; +import { EsSearchResponse, Facet } from "@/types"; -export type QueryObject = Record +export type QueryObject = Record; export type PagingInfoType = { - resultsPerPage: number, - current: number, - totalResults: number | null -} + resultsPerPage: number; + current: number; + totalResults: number | null; +}; export type SearchQueryContextType = { - searchQuery: string, - queryResult: UseQueryResult>, unknown>, - makeQuery: (queryString: string) => void, - handlePageChange: (page: number) => void, - pagingInfo: PagingInfoType, -} + searchQuery: string; + queryResult: UseQueryResult; + makeQuery: (queryString: string) => void; + handlePageChange: (page: number) => void; + pagingInfo: PagingInfoType; + filterFields: Facet[]; +}; -export const SearchQueryContext = createContext(null); +// Create a context for sharing search-related data and methods across components +export const SearchQueryContext = createContext( + null +); -export const SearchQueryProvider = ({ children }: { children: React.ReactNode}) => { - // URL +/** + * React context provider designed to encapsulate and manage the state and logic associated with search functionality. + * It leverages the NextJS router to read and update the URL's query parameters for search queries + * and pagination, and uses React Query for fetching search results from an Elasticsearch backend. + * + * Flow: + * 1. Extracts search parameters (search query, filters, sorting, page, and results per page) from the URL's query parameters. + * 2. Utilizes useMemo to memoize calculations for search parameters and URLSearchParams for efficient query manipulation. + * 3. The `useSearch` hook fetches search results based on the current search parameters. + * 4. Provides two main functions, `makeQuery` and `handlePageChange`, to update the URL's query parameters + * and thereby trigger new searches or page changes. These updates are performed through the NextJS router, + * allowing for client-side navigation without full page reloads. + * 5. Any change to the URL's query parameters triggers a re-fetch of search results via `useSearch`, + * updating the `queryResult` state with the new results. + * 6. The updated search state (current search query, search results, pagination information) is made available + * to child components through the `SearchQueryContext`, enabling a reactive search UI that updates in response + * to user actions and URL changes. + * + * This provider enhances the search experience by enabling URL-driven search state, client-side navigation, + * and seamless integration with React Query for data fetching, making it a central piece of the application's search functionality. + */ + +export const SearchQueryProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + // Extract search parameters from the URL const router = useRouter(); const searchParams = router.query; const rawSearchQuery = searchParams[URLSearchParamsKeyword.SEARCH] as string; const pageQuery = searchParams[URLSearchParamsKeyword.PAGE] as string; const sizeQuery = searchParams[URLSearchParamsKeyword.SIZE] as string; - const filterFields = generateFilterQuery(router.asPath.slice(1)) - const sortFields = generateSortFields(router.asPath.slice(1)) + // Generate filter and sort criteria from the URL path + const filterFields = generateFilterQuery(router.asPath.slice(1)); + const sortFields = generateSortFields(router.asPath.slice(1)); + // dynamic representation of the query parameters present in the current URL const urlParams = useMemo(() => { - return new URLSearchParams(router.asPath.slice(1)) - }, [router]) + return new URLSearchParams(router.asPath.slice(1)); + }, [router]); + // Memoize derived state from URL search parameters const searchQuery = useMemo(() => { - return rawSearchQuery ?? "" - }, [rawSearchQuery]) + return rawSearchQuery ?? ""; + }, [rawSearchQuery]); const page = useMemo(() => { - return pageQuery ? parseInt(pageQuery) - 1 ?? 0 : 0 - }, [pageQuery]) - - const resultsPerPage = sizeQuery ? (parseInt(sizeQuery) ?? defaultParam[URLSearchParamsKeyword.SIZE]) : defaultParam[URLSearchParamsKeyword.SIZE] - - const setSearchParams = useCallback((queryObject: QueryObject) => { - Object.keys(queryObject).map(objectKey => { - urlParams.set(objectKey, queryObject[objectKey]) - }) - router.push(router.pathname + "?" + urlParams.toString(), undefined, { shallow: true }) - }, [router, urlParams]) + return pageQuery ? parseInt(pageQuery) - 1 ?? 0 : 0; + }, [pageQuery]); + + const resultsPerPage = sizeQuery + ? parseInt(sizeQuery) ?? defaultParam[URLSearchParamsKeyword.SIZE] + : defaultParam[URLSearchParamsKeyword.SIZE]; + const setSearchParams = useCallback( + (queryObject: QueryObject) => { + Object.keys(queryObject).map((objectKey) => { + urlParams.set(objectKey, queryObject[objectKey]); + }); + router.push(router.pathname + "?" + urlParams.toString(), undefined, { + shallow: true, + }); + }, + [router, urlParams] + ); + + // Use custom search hook with current search criteria const queryResult = useSearch({ queryString: searchQuery, size: resultsPerPage, page, filterFields, sortFields, - }) + }); + // Function to initiate a new search with the given queryString const makeQuery = (queryString: string) => { - router.query = {} - urlParams.delete(URLSearchParamsKeyword.PAGE) - urlParams.set(URLSearchParamsKeyword.SEARCH, queryString.trim()) - router.push(router.pathname + "?" + urlParams.toString(), undefined, { shallow: true }) + router.query = {}; + urlParams.delete(URLSearchParamsKeyword.PAGE); // new search query resets the user back to the first page of results + urlParams.set(URLSearchParamsKeyword.SEARCH, queryString.trim()); // new search query + router.push(`${router.pathname}?${urlParams.toString()}`, undefined, { + shallow: true, + }); }; + // Function to handle page changes in pagination const handlePageChange = (page: number) => { - setSearchParams({page: JSON.stringify(page)}) - } + setSearchParams({ page: JSON.stringify(page) }); + }; + // Compile paging information from search results and current state const pagingInfo = { resultsPerPage, - current: page + 1, - totalResults: queryResult.data?.hits?.total["value"] as unknown as number ?? null - } + current: page + 1, // Adjust for zero-based index + totalResults: + (queryResult.data?.hits?.total["value"] as unknown as number) ?? null, + }; return ( - + {children} ); diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx new file mode 100644 index 00000000..4928d17b --- /dev/null +++ b/src/context/Theme.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light"; + +const themeKey = "theme"; + +const ThemeContext = createContext<{ theme: Theme; toggleTheme: () => void }>( + null +); + +export function useTheme() { + const contextValue = useContext(ThemeContext); + + if (!contextValue) { + throw new Error("Wrap your components tree with a ThemeProvider component"); + } + + return contextValue; +} + +export const ThemeProvider = (props: React.PropsWithChildren) => { + const [theme, setTheme] = useState(() => { + const storedThemePreference = localStorage.getItem(themeKey) as Theme; + return ( + storedThemePreference || + (window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light") + ); + }); + + useEffect(() => { + document.body.classList.toggle("dark", theme === "dark"); + + localStorage.setItem(themeKey, theme); + }, [theme]); + + const toggleTheme = () => { + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + }; + + return ( + + {props.children} + + ); +}; diff --git a/src/context/UIContext.tsx b/src/context/UIContext.tsx new file mode 100644 index 00000000..d3936bda --- /dev/null +++ b/src/context/UIContext.tsx @@ -0,0 +1,52 @@ +import React, { createContext, useState } from "react"; + +export type UIContextType = { + openForm: () => void; + closeForm: () => void; + isOpen: boolean; + sidebarToggleManager: { + state: boolean; + updater: (x?: boolean) => void; + }; +}; + +export const UIContext = createContext(null); + +export const UIContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [isOpen, setIsOpen] = useState(false); + + const [isSBToggleOpen, setIsSBToggleOpen] = useState(false); + + const openForm = () => { + setIsOpen(true); + }; + + const closeForm = () => { + setIsOpen(false); + }; + + const toggleSB = (SBControlBool?: boolean) => { + if (SBControlBool !== undefined) { + setIsSBToggleOpen(SBControlBool); + return; + } + setIsSBToggleOpen((prev) => !prev); + }; + + const sidebarToggleManager = { + state: isSBToggleOpen, + updater: toggleSB, + }; + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useCheckboxNavigate.ts b/src/hooks/useCheckboxNavigate.ts index 97034362..4b1ab503 100644 --- a/src/hooks/useCheckboxNavigate.ts +++ b/src/hooks/useCheckboxNavigate.ts @@ -5,9 +5,13 @@ type ChekboxNavigateProps = { checkboxContainer: React.MutableRefObject; searchEl: React.MutableRefObject; options: any[]; -} +}; -const useCheckboxNavigate = ({checkboxContainer, searchEl, options}: ChekboxNavigateProps) => { +const useCheckboxNavigate = ({ + checkboxContainer, + searchEl, + options, +}: ChekboxNavigateProps) => { const checkboxNavIndex = useRef(null); // const savedNavIndex = useRef(0); const [currentNavigateCheckbox, setcurrentNavigateCheckbox] = useState(""); @@ -21,7 +25,8 @@ const useCheckboxNavigate = ({checkboxContainer, searchEl, options}: ChekboxNavi useEffect(() => { const multiCheckboxWrapper = checkboxContainer.current; const multiCheckboxList = - multiCheckboxWrapper && Array.from(multiCheckboxWrapper?.children) as HTMLElement[]; + multiCheckboxWrapper && + (Array.from(multiCheckboxWrapper?.children) as HTMLElement[]); const searchInput = searchEl.current; // focus back to search when options changes if (refocus.current) { @@ -75,12 +80,14 @@ const useCheckboxNavigate = ({checkboxContainer, searchEl, options}: ChekboxNavi // Enter e.preventDefault(); const input = multiCheckboxList[currentCheckboxNavIndex] - ? multiCheckboxList[currentCheckboxNavIndex].querySelector("input") + ? multiCheckboxList[currentCheckboxNavIndex].querySelector( + '[role="button"]' + ) : null; if (input) { // savedNavIndex.current = // multiCheckboxList[currentCheckboxNavIndex].dataset?.checkbox; - input.click(); + (input as HTMLButtonElement).click(); } break; } diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts index c28de128..a9449595 100644 --- a/src/hooks/useGlobalHotkey.ts +++ b/src/hooks/useGlobalHotkey.ts @@ -12,7 +12,7 @@ export const useSearchFocusHotkey = () => { } keyEvent.preventDefault(); - const element: HTMLElement = document.querySelector(".sui-search-box__text-input"); + const element: HTMLElement = document.querySelector(".search-box"); if (element) { element.focus(); } diff --git a/src/hooks/useIsInitialStateWithoutFilter.ts b/src/hooks/useIsInitialStateWithoutFilter.ts index 17a97afe..00fc2bce 100644 --- a/src/hooks/useIsInitialStateWithoutFilter.ts +++ b/src/hooks/useIsInitialStateWithoutFilter.ts @@ -7,22 +7,25 @@ const useIsInitialStateWithoutFilter = () => { let hiddenHomeFacet = true; const { searchQuery, queryResult } = useSearchQuery(); - const router = useRouter() + const router = useRouter(); + + const hasFilters = generateFilterQuery(router.asPath.slice(1)).length; + let isHomePage = Object.keys(router.query).length === 0; - const hasFilters = generateFilterQuery(router.asPath.slice(1)).length - const resultLength = queryResult.data?.hits?.total["value"]; - + // visible if if ( - resultLength && (searchQuery || hasFilters) + resultLength && + (searchQuery || hasFilters) + // resultLength ) { hiddenBody = false; } else { - hiddenHomeFacet = false + hiddenHomeFacet = false; } - return { hiddenBody, hiddenHomeFacet }; + return { hiddenBody, hiddenHomeFacet, isHomePage }; }; export default useIsInitialStateWithoutFilter; diff --git a/src/hooks/useScrollTop.ts b/src/hooks/useScrollTop.ts index 72a790fd..2e2eb568 100644 --- a/src/hooks/useScrollTop.ts +++ b/src/hooks/useScrollTop.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; -const useScrollTop = ({ current }: {current: number}) => { +const useScrollTop = ({ current }: { current: number }) => { const initialRender = useRef(true); useEffect(() => { if (initialRender.current) { @@ -10,6 +10,6 @@ const useScrollTop = ({ current }: {current: number}) => { window.scrollTo({ top: 0, left: 0, behavior: "smooth" }); }, [current]); return null; -} +}; export default useScrollTop; diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts index 573d806d..5c7c7ac3 100644 --- a/src/hooks/useSearchQuery.ts +++ b/src/hooks/useSearchQuery.ts @@ -1,5 +1,5 @@ -import { useContext } from 'react' -import {SearchQueryContext} from '../context/SearchQueryContext'; +import { useContext } from "react"; +import { SearchQueryContext } from "../context/SearchQueryContext"; const useSearchQuery = () => { return useContext(SearchQueryContext); diff --git a/src/hooks/useTailwindBreakpoint.ts b/src/hooks/useTailwindBreakpoint.ts new file mode 100644 index 00000000..cff50322 --- /dev/null +++ b/src/hooks/useTailwindBreakpoint.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import resolveConfig from "tailwindcss/resolveConfig"; +import tailwindConfig from "../../tailwind.config"; + +const resolvedConfig = resolveConfig(tailwindConfig); + +type DefaultBreakpoints = "sm" | "md" | "lg" | "xl" | "2xl"; + +const breakpoints = (() => { + const object: Record = {} as Record< + string, + string + >; + + const screens = resolvedConfig.theme.screens; + + switch (true) { + case !screens: + case typeof screens !== "object": + case typeof screens === "object" && typeof screens === null: + break; + default: { + for (const key of Object.keys(screens)) { + object[key] = `(min-width: ${screens[key]})`; + } + } + } + + return object; +})(); + +export function useTailwindBreakpoint( + breakpoint: T +) { + const [isMatch, setIsMatch] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(breakpoints[breakpoint]); + setIsMatch(mediaQuery.matches); + + const handleResize = (e: MediaQueryListEvent) => { + setIsMatch(e.matches); + }; + + mediaQuery.addEventListener("change", handleResize); + + return () => mediaQuery.removeEventListener("change", handleResize); + }, [breakpoint]); + + return isMatch; +} diff --git a/src/hooks/useUIContext.ts b/src/hooks/useUIContext.ts new file mode 100644 index 00000000..e4c873bc --- /dev/null +++ b/src/hooks/useUIContext.ts @@ -0,0 +1,8 @@ +import { useContext } from "react"; +import { UIContext } from "../context/UIContext"; + +const useUIContext = () => { + return useContext(UIContext); +}; + +export default useUIContext; diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 6b2d1a7f..0b996b0e 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -1,51 +1,63 @@ -import { SearchBox } from "@elastic/react-search-ui"; -import React, { useEffect } from "react"; -import SearchInput from "../components/customSearchboxView/SearchInput"; -import HomeFacetSelection from "../components/homeFacetSelection"; -import KeywordsSelection from "../components/homeFacetSelection/KeywordsSelection"; +import FilterIcon from "@/components/svgs/FilterIcon"; +import useUIContext from "@/hooks/useUIContext"; +import { removeMarkdownCharacters } from "@/utils/elastic-search-ui-functions"; +import React from "react"; +import SearchBoxView from "../components/customSearchbox/SearchBoxView"; import useSearchQuery from "../hooks/useSearchQuery"; -import SearchBoxView from "../components/customSearchboxView/SearchBoxView" -import { InputViewProps } from "@elastic/react-search-ui-views"; +import SearchBox from "@/components/customSearchbox/SearchBox"; +import FilterCloseIcon from "@/components/svgs/FilterCloseIcon"; -const Header = ({openForm}) => { - useEffect(() => { - }, []) - const { makeQuery } = useSearchQuery(); - const SearchInputWrapper = ({ ...rest }: InputViewProps) => { - return ; - }; +const Header = ({ openForm }) => { + const { sidebarToggleManager } = useUIContext(); + const { makeQuery, filterFields, pagingInfo } = useSearchQuery(); + + const numberOfAppliedFilters = filterFields.length; const handleSubmit = (input: string) => { makeQuery(input); }; - const handleAutoCompleteSelect = (selection, autoCompleteData, defaultFunction) => { + const handleAutoCompleteSelect = (selection) => { if (!selection.suggestion) return; - makeQuery(selection.suggestion); + makeQuery(removeMarkdownCharacters(selection.suggestion)); }; return ( - <> +
- - - +
0} + className="relative data-[has-results='false']:hidden md:hidden peer-data-[input-focus='true']/search:hidden" + > + + {Boolean(numberOfAppliedFilters) && ( +
+ + {numberOfAppliedFilters} + +
+ )} +
+
); }; diff --git a/src/layout/Layout.tsx b/src/layout/Layout.tsx new file mode 100644 index 00000000..d3ebe951 --- /dev/null +++ b/src/layout/Layout.tsx @@ -0,0 +1,61 @@ +import ResultSize from "@/components/sidebarFacet/ResultSize"; +import useIsInitialStateWithoutFilter from "@/hooks/useIsInitialStateWithoutFilter"; +import useUIContext from "@/hooks/useUIContext"; +import React from "react"; + +type LayoutProps = Record; + +const Layout = ({ + header, + sideContent, + bodyContent, + bodyHeader, + bodyFooter, +}: LayoutProps) => { + const { hiddenBody } = useIsInitialStateWithoutFilter(); + + const { sidebarToggleManager } = useUIContext(); + + return ( +
+ + {!hiddenBody && ( +
+ +
+
+ +
+ {bodyContent} + {bodyFooter} +
+
+ )} +
+ ); +}; + +export default Layout; diff --git a/src/layout/Metadata.tsx b/src/layout/Metadata.tsx new file mode 100644 index 00000000..bcfe7f7f --- /dev/null +++ b/src/layout/Metadata.tsx @@ -0,0 +1,51 @@ +import Head from "next/head"; +import Script from "next/script"; +import React from "react"; + +const Metadata = () => { + return ( + <> + + Bitcoin Search + + + + + + + + + + + + + + ); +}; + +export default Metadata; diff --git a/src/layout/SideBar.tsx b/src/layout/SideBar.tsx index 39efef3f..11b25804 100644 --- a/src/layout/SideBar.tsx +++ b/src/layout/SideBar.tsx @@ -1,60 +1,65 @@ -import { Sorting } from "@elastic/react-search-ui-views"; - import React from "react"; import CustomMultiCheckboxFacet from "../components/customMultiCheckboxFacet/CustomMultiCheckboxFacet"; import { getFacetFields, getFacetWithSearch } from "../config/config-helper"; -import useIsInitialStateWithoutFilter from "../hooks/useIsInitialStateWithoutFilter"; import Facet from "@/components/sidebarFacet/Facet"; import SortingFacet from "@/components/sidebarFacet/SortingFacet"; +import SortingView from "@/components/sidebarFacet/Sorting/SortingView"; +import ResultSize from "@/components/sidebarFacet/ResultSize"; +import FilterMenu from "@/components/sidebarFacet/FilterMenu"; +import ShowFilterResultsMobile from "@/components/sidebarFacet/ShowFilterResultsMobile"; +import useUIContext from "@/hooks/useUIContext"; const SideBar = () => { - const { hiddenBody, hiddenHomeFacet } = useIsInitialStateWithoutFilter(); + const { sidebarToggleManager } = useUIContext(); + const isMobile = window + ? window.matchMedia("(max-width: 600px)").matches + : false; - if (hiddenBody) { - return null; - } + const sortCallback = () => { + if (isMobile) { + sidebarToggleManager.updater(false); + } + }; + const facetCallback = () => { + if (isMobile) { + sidebarToggleManager.updater(false); + } + }; return ( -
- {hiddenHomeFacet - ? getFacetFields().map((field) => ( - - )) - : getFacetFields() - .filter((field) => !getFacetWithSearch().includes(field)) - .map((field) => ( - - ))} +
+
+ +
+ + {getFacetFields().map((field) => ( + + ))} +
); }; diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 00000000..a6d223ba --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; + +import Footer from "@/components/footer/Footer"; +import NavBar from "@/components/navBar/NavBar"; + +export default function Custom404() { + return ( +
+
+ +
+
+

+ 404 - Page Not Found +

+

+ The page you are looking for might have been removed, had its name + changed or is temporarily unavailable. +

+ + Go to Homepage + +
+
+
+
+
+
+
+ ); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 85fa2230..01427ff9 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,3 +1,9 @@ +import "@fontsource/geist-sans/400.css"; +import "@fontsource/geist-sans/500.css"; +import "@fontsource/geist-sans/600.css"; +import "@fontsource/geist-sans/700.css"; +import "@fontsource/geist-sans/800.css"; + import "../styles/globals.css"; import "../styles/custom.scss"; import "../components/customResults/styles.results.scss"; @@ -6,20 +12,24 @@ import "../components/footer/footer.scss"; import "../components/loadingBar/loadingBar.scss"; import "../components/noResultsCard/noResults.scss"; import { ChakraProvider } from "@chakra-ui/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Hydrate, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import { SearchQueryProvider } from "@/context/SearchQueryContext"; import { buildAutocompleteQueryConfig, - buildFacetConfigFromConfig, - buildSearchOptionsFromConfig, getConfig, } from "@/config/config-helper"; import AppSearchAPIConnector from "@elastic/search-ui-app-search-connector"; import { SearchProvider } from "@elastic/react-search-ui"; import theme from "@/chakra/chakra-theme"; import { SearchDriverOptions } from "@elastic/search-ui"; -import Head from "next/head"; -import Script from 'next/script'; +import { UIContextProvider } from "@/context/UIContext"; +import { ThemeProvider } from "@/context/Theme"; +import Metadata from "@/layout/Metadata"; +import ErrorBoundary from "@/components/errorBoundary/ErrorBoundary"; const queryClient = new QueryClient(); @@ -44,26 +54,25 @@ const config: SearchDriverOptions = { export default function App({ Component, pageProps }) { return ( - - - - - - - - - Bitcoin Search - - - - - - -