diff --git a/app/App.tsx b/app/App.tsx index 2467250..faa41d5 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -7,6 +7,7 @@ import Wagmi from "~/providers/Wagmi"; import { AppLayout } from "~/components/AppLayout"; import { Outlet } from "@remix-run/react"; import { RosetteStone } from "./providers/RosetteStone"; +import { FnEntriesFilters } from "./providers/FnEntriesFilters"; export type AppContext = { displayTopBar(display: boolean): void; @@ -24,17 +25,19 @@ export const App = () => { - - - + + + + + diff --git a/app/components/EntriesFilters/DateRangeCustom/index.tsx b/app/components/EntriesFilters/DateRangeCustom/index.tsx new file mode 100644 index 0000000..b977032 --- /dev/null +++ b/app/components/EntriesFilters/DateRangeCustom/index.tsx @@ -0,0 +1,47 @@ +import React, { useCallback, useRef } from "react"; +import { DateRange } from "react-date-range"; +import styled from "styled-components"; +import { useOnClickOutside } from "~/hooks/useOutsideAlerter"; + +type DataRangeCustomProps = { + show: boolean; + setShow: (show: boolean) => void; + onChange: (dateRange: any) => void; + ranges: [{ startDate: Date; endDate: Date; key: string }]; +}; + +const DateRangeCustom = ({ + show, + setShow, + onChange, + ranges = [{ startDate: new Date(), endDate: new Date(), key: "selection" }], +}: DataRangeCustomProps) => { + const ref = useRef(); + + const handleClose = useCallback(() => { + setShow(false); + }, [setShow]); + + useOnClickOutside(ref, handleClose); + + return ( + + + + ); +}; + +const DateRangeContainer = styled.div` + z-index: 1; + position: absolute; + display: ${({ show }: { show: boolean }) => (show ? "block" : "none")}; + text-align: center; + margin-top: 60px; + background-color: rgba(0, 0, 0, 1); +`; + +export default DateRangeCustom; diff --git a/app/components/EntriesFilters/DateRangeCustom/style.css b/app/components/EntriesFilters/DateRangeCustom/style.css new file mode 100644 index 0000000..177057e --- /dev/null +++ b/app/components/EntriesFilters/DateRangeCustom/style.css @@ -0,0 +1,26 @@ +.rdrCalendarWrapper { + background-color: #FDE9BC; + border: 2px solid #CFC7B5; + border-radius: 12px; +} + +.rdrDateDisplayWrapper { + background-color: #A2957A; + border-radius: 10px 10px 0 0 ; +} + +.rdrDateDisplayItemActive { + border: 2px solid #F86E38; +} + +.rdrDateDisplayItem input { + background-color: #2B2727; + color: #FDE9BC; + font-size: 16px; + border-radius: 3px; +} + +.rdrDateDisplayItem { + box-shadow: none; + background-color: transparent; +} \ No newline at end of file diff --git a/app/components/EntriesFilters/index.tsx b/app/components/EntriesFilters/index.tsx new file mode 100644 index 0000000..a612072 --- /dev/null +++ b/app/components/EntriesFilters/index.tsx @@ -0,0 +1,389 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Button, + GU, + SearchInput, + TextInput, + useTheme, +} from "@blossom-labs/rosette-ui"; +import styled from "styled-components"; +import Select from "react-select"; +import DateRangeCustom from "~/components/EntriesFilters/DateRangeCustom"; +import { useFnEntriesFilters } from "~/providers/FnEntriesFilters"; +import { useOnClickOutside } from "~/hooks/useOutsideAlerter"; + +type EntriesFiltersProps = { + compactMode: boolean; + tabletMode: boolean; +}; + +type SubmitterFilterProps = { + tabletMode: boolean; + compactMode: boolean; + placeholder: string; + value: string; + onChange: (e: any) => void; +}; + +export const EntriesFilters = ({ + compactMode, + tabletMode, +}: EntriesFiltersProps) => { + const theme = useTheme(); + const [toggleDate, setToggleDate] = useState(false); + const { externalFilters, internalFilters, clearValues } = + useFnEntriesFilters(); + + const { submitterFilter, statusFilter, dateRangeFilter, searchFilter } = + externalFilters; + + const { sortingOption } = internalFilters; + + const [submitterFilterValue, setSubmitterFilter] = submitterFilter; + const [, setStatusFilter, statusRef] = statusFilter; + const [dateRange, setDateRange] = dateRangeFilter; + const [search, setSearch] = searchFilter; + + const [, setSorting, sortingRef] = sortingOption; + + const handleStatusFilterChange = (selected: any) => { + if (selected) { + setStatusFilter(selected.value); + } + }; + + const handleSortingOptionChange = (selected: any) => { + if (selected) { + setSorting(selected.value); + } + }; + + const handleSubmitterFilterChange = (e: any) => { + setSubmitterFilter(e.target.value); + }; + + const handleDateRangeChange = (ranges: any) => { + if (ranges.selection || ranges.range1) { + const range = ranges.selection || ranges.range1; + setDateRange({ + startDate: range.startDate, + endDate: range.endDate, + active: true, + }); + } + }; + + return ( + + + + + + + + + + + + + ; + case "added": + return ; + case "pending": + return
Timer
; + } + }; + + return ( + + + + {currentStatus} + + {renderDynamicSection()} + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 363px; + height: 200px; + justify-content: space-around; + border: 1px solid #8a8069; + border-radius: 20px; + outline: 0; +`; + +const StatusContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${2 * GU}px; + padding: ${1 * GU}px; +`; + +const DynamicSection = styled.div` + display: flex; + flex-direction: column; + width: ${35 * GU}px; + margin: auto; +`; + +const Label = styled.div` + font-size: 16px; + color: #a2957a; + margin-left: ${4 * GU}px; + margin-top: ${3 * GU}px; +`; +const StatusTitle = styled.div` + font-weight: 400; + font-size: 20px; + color: #fde9bc; + margin-left: ${4 * GU}px; +`; diff --git a/app/hooks/useOutsideAlerter.ts b/app/hooks/useOutsideAlerter.ts new file mode 100644 index 0000000..bc5293f --- /dev/null +++ b/app/hooks/useOutsideAlerter.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; + +export function useOnClickOutside(ref: any, handler: any) { + useEffect( + () => { + const listener = (event: any) => { + if (!ref.current || ref.current.contains(event.target)) { + return; + } + handler(event); + }; + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, + [ref, handler] + ); +} \ No newline at end of file diff --git a/app/providers/FnEntriesFilters.tsx b/app/providers/FnEntriesFilters.tsx new file mode 100644 index 0000000..2dc15e1 --- /dev/null +++ b/app/providers/FnEntriesFilters.tsx @@ -0,0 +1,233 @@ +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { FnDescriptionStatus } from "~/types"; +import type { FnEntry } from "~/types"; + +export type ExternalFilters = { + submitterFilter: [string, (submitter: string) => void]; + statusFilter: [ + FnDescriptionStatus | "", + (status: FnDescriptionStatus | "") => void, + React.Ref + ]; + dateRangeFilter: [any, (dateRange: any) => void]; + searchFilter: [string, (searchTerm: string) => void]; +}; + +type DateRangeSelection = { + startDate: Date; + endDate: Date; + key: string; + active: boolean; +}; + +type FnEntriesFiltersContextProps = { + entries: FnEntry[]; + setEntries: (entries: FnEntry[]) => void; + filteredEntries: FnEntry[]; + externalFilters: ExternalFilters; + internalFilters: { + sortingOption: [string | null, (option: string) => void, React.Ref]; + }; + clearValues: () => void; + loading: boolean; +}; + +const FnEntriesFiltersContext = + React.createContext( + {} as FnEntriesFiltersContextProps + ); + +export function FnEntriesFilters({ children }: { children: React.ReactNode }) { + const [entries, setEntries] = useState([]); + const [sortingOption, setSortingOption] = useState(null); + const [dateRange, setDateRange] = useState({ + startDate: new Date(), + endDate: new Date(), + key: "selection", + active: false, + }); + + // todo: add types + const [submitterFilter, setSubmitterFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState( + "" + ); + const [abiSearchFilter, setAbiSearchFilter] = useState(""); + + const [filteredEntries, setFilteredEntries] = useState([]); + + const sortingRef = useRef(); + const statusRef = useRef(); + + useEffect(() => { + let filtered = entries; + + const statusFiltering = () => { + switch (statusFilter) { + case FnDescriptionStatus.Added: + filtered = filtered.filter( + (e) => e.status === FnDescriptionStatus.Added + ); + break; + case FnDescriptionStatus.Available: + filtered = filtered.filter( + (e) => e.status === FnDescriptionStatus.Available + ); + break; + case FnDescriptionStatus.Pending: + filtered = filtered.filter( + (e) => e.status === FnDescriptionStatus.Pending + ); + break; + case FnDescriptionStatus.Challenged: + filtered = filtered.filter( + (e) => e.status === FnDescriptionStatus.Challenged + ); + break; + default: + filtered = entries; + break; + } + }; + + const searchFiltering = () => { + if (abiSearchFilter) { + filtered = filtered.filter((e) => { + return e.abi.toLowerCase().includes(abiSearchFilter.toLowerCase()); + }); + } + }; + const sorting = () => { + switch (sortingOption) { + case "newest": + filtered = filtered.sort((a, b) => { + return b.upsertAt - a.upsertAt; + }); + break; + // how to sort by relevance? + case "relevance": + filtered = filtered.slice().sort((a, b) => { + if (a.abi > b.abi) { + return 1; + } + if (a.abi < b.abi) { + return -1; + } + return 0; + }); + break; + } + }; + const submitterFiltering = () => { + if (submitterFilter !== "") { + filtered = filtered.filter((entry) => { + return entry.submitter + .toLowerCase() + .includes(submitterFilter.toString().toLowerCase()); + }); + } + }; + const rangeFiltering = () => { + if ( + dateRange && + dateRange.active && + dateRange.startDate && + dateRange.endDate + ) { + filtered = filtered.filter((entry) => { + return ( + entry.upsertAt >= dateRange.startDate.getTime() && + entry.upsertAt <= dateRange.endDate.getTime() + ); + }); + } + }; + + statusFiltering(); + submitterFiltering(); + rangeFiltering(); + searchFiltering(); + sorting(); + setFilteredEntries(filtered); + }, [ + entries, + statusFilter, + sortingOption, + setFilteredEntries, + submitterFilter, + dateRange, + abiSearchFilter, + ]); + + const handleSortingOptionChange = useCallback((option: string) => { + setSortingOption(option); + }, []); + + const handleSubmitterFilterChange = useCallback((newSubmitter: any) => { + setSubmitterFilter(newSubmitter); + }, []); + + const handleStatusFilterChange = useCallback( + (status: FnDescriptionStatus | "") => { + setStatusFilter(status); + }, + [] + ); + + const handleSearchChange = useCallback((newSearch: any) => { + setAbiSearchFilter(newSearch); + }, []); + + const clearValues = () => { + setSubmitterFilter(""); + setDateRange({ + startDate: new Date(), + endDate: new Date(), + key: "selection", + active: false, + }); + handleStatusFilterChange(""); + setAbiSearchFilter(""); + setFilteredEntries(entries); + setSortingOption(""); + if (sortingRef.current) { + sortingRef.current.clearValue(); + } + if (statusRef.current) { + statusRef.current.clearValue(); + } + }; + + return ( + + {children} + + ); +} + +export const useFnEntriesFilters = () => { + return useContext(FnEntriesFiltersContext); +}; diff --git a/app/routes/entries/$entry.tsx b/app/routes/entries/$entry.tsx index db431bf..4d2908d 100644 --- a/app/routes/entries/$entry.tsx +++ b/app/routes/entries/$entry.tsx @@ -1,10 +1,20 @@ +import { + BackButton, + textStyle, + IdentityBadge, + GU, + useViewport, +} from "@blossom-labs/rosette-ui"; +import { useEffect, useState } from "react"; import type { LoaderFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; +import { useLoaderData, useNavigate } from "@remix-run/react"; import styled from "styled-components"; import { AppScreen } from "~/components/AppLayout/AppScreen"; -import type { FnEntrySubgraphData } from "~/types"; +import { StatusModule } from "~/components/EntryScreen/StatusModule"; +import type { FnEntryMetadata, FnEntrySubgraphData } from "~/types"; import { fetchFnEntry } from "~/utils/server/subgraph.server"; +import { fetchEntryMetadata } from "~/utils/server/entries-data.server"; export const loader: LoaderFunction = async ({ params }) => { const entryId = params.entry; @@ -18,28 +28,151 @@ export const loader: LoaderFunction = async ({ params }) => { const entry = await fetchFnEntry(entryId); - return json({ entry }); + if (!entry) { + throw new Response("Entry not found", { + status: 404, + statusText: "Entry not found", + }); + } + + const entryMetadata = await fetchEntryMetadata(entry.cid); + + if (entryMetadata.abi) { + const abi = formatAbi(entryMetadata.abi); + entryMetadata.abi = abi; + } + + return json({ entry, entryMetadata }); }; type LoaderData = { entry?: FnEntrySubgraphData; + entryMetadata?: FnEntryMetadata; }; export default function EntryRoute() { - const { entry } = useLoaderData(); + const { entry, entryMetadata } = useLoaderData(); + const navigate = useNavigate(); + const [date, setDate] = useState(); + const { below } = useViewport(); + const compactMode = below("large"); + + useEffect(() => { + if (entry) { + const rawDate = new Date(entry.upsertAt); + setDate(formatRawDate(rawDate)); + } + }, [entry]); return ( - {entry?.sigHash} + + + navigate(`/entries`)} /> + {entryMetadata?.abi} + {`Contract ${""}`} + {`Publicated on ${date?.toString()}`} + + Submitter + + + + Description + {entryMetadata?.notice} + + + + ); } -const Container = styled.div` +function formatRawDate(rawDate: Date) { + const string = rawDate.toString(); + const dateArray = string.split(" "); + const formattedDate = `${dateArray[1]} ${dateArray[2]}, ${dateArray[3]}`; + return formattedDate; +} + +function formatAbi(abi: string) { + const formattedAbi = abi.replace("function ", ""); + return formattedAbi; +} + +const DescriptionContainer = styled.div` display: flex; - justify-content: center; + flex-direction: column; +`; + +const DescriptionContent = styled.div` + font-family: "Avenir"; + font-style: normal; + ${textStyle("title4")}; + color: ${({ theme }) => theme.positiveContent}; +`; + +const DescriptionTitle = styled.div` + display: flex; + justify-content: start; + align-items: start; + height: 100%; + width: 100%; + ${textStyle("title3")}; + color: ${({ theme }) => theme.surfaceOpened}; +`; + +const SubmitterTitle = styled.div` + margin-bottom: ${1 * GU}px; + ${textStyle("body2")}; + color: ${({ theme }) => theme.surfaceOpened}; +`; + +const SubmitterContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const DateTitle = styled.div` + ${textStyle("title4")}; + color: ${({ theme }) => theme.surfaceOpened}; +`; + +const ContractTitle = styled.div` + display: flex; + justify-content: start; align-items: start; height: 100%; width: 100%; + ${textStyle("title4")}; color: ${({ theme }) => theme.content}; `; + +const EntryTitle = styled.div` + font-weight: 400; + font-size: 36px; + color: ${({ theme }) => theme.content};}; +`; + +const EntryInfoContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + align-items: start; + width: 650px; + gap: ${2 * GU}px; +`; + +const EntryContainer = styled.div<{ + compactMode: boolean; + tabletMode: boolean; +}>` + display: flex; + flex-direction: ${({ compactMode }) => (compactMode ? "column" : "row")}; + justify-content: center; + align-items: ${({ compactMode }) => (compactMode ? "center" : "start")}; + height: 100%; + width: 100%; + grid-gap: ${10 * GU}px; + padding-top: ${({ compactMode, tabletMode }) => + compactMode ? 3 * GU : tabletMode ? 5 * GU : 9 * GU}px; +`; diff --git a/app/routes/entries/index.tsx b/app/routes/entries/index.tsx index dfa110f..9550a85 100644 --- a/app/routes/entries/index.tsx +++ b/app/routes/entries/index.tsx @@ -1,4 +1,5 @@ import { GU, textStyle, useViewport } from "@blossom-labs/rosette-ui"; +import { useEffect } from "react"; import type { LoaderFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, useNavigate } from "@remix-run/react"; @@ -6,10 +7,25 @@ import styled from "styled-components"; import { AppScreen } from "~/components/AppLayout/AppScreen"; import { SmoothDisplayContainer } from "~/components/SmoothDisplayContainer"; import { StatusLabel } from "~/components/StatusLabel"; +import { EntriesFilters } from "~/components/EntriesFilters"; +import { useFnEntriesFilters } from "~/providers/FnEntriesFilters"; import type { FnEntry } from "~/types"; import { fetchEntries } from "~/utils/server/entries-data.server"; import { fetchFnEntries } from "~/utils/server/subgraph.server"; +// date range styles +import dateStyles from "react-date-range/dist/styles.css"; +import defaultDateStyles from "react-date-range/dist/theme/default.css"; +import customDateStyles from "../../components/EntriesFilters/DateRangeCustom/style.css"; + +export function links() { + return [ + { rel: "stylesheet", href: dateStyles }, + { rel: "stylesheet", href: defaultDateStyles }, + { rel: "stylesheet", href: customDateStyles }, + ]; +} + export const loader: LoaderFunction = async () => { const fnsSubgraphData = await fetchFnEntries(); @@ -26,16 +42,23 @@ export default function Entries() { const { fns } = useLoaderData(); const { below, within } = useViewport(); + const { setEntries, filteredEntries } = useFnEntriesFilters(); + const compactMode = below("medium"); const tabletMode = within("medium", "large"); + useEffect(() => { + setEntries(fns); + }, [fns, setEntries]); + return ( - {fns.length ? ( + + {filteredEntries.length ? ( - {fns.map((f) => ( + {filteredEntries.map((f) => ( ))} @@ -64,36 +87,37 @@ function EntryCard({ fn }: { fn: FnEntry }) { const Container = styled.div<{ compactMode: boolean; tabletMode: boolean }>` display: flex; - justify-content: center; - align-items: start; - padding-top: ${({ compactMode, tabletMode }) => - compactMode ? 3 * GU : tabletMode ? 5 * GU : 9 * GU}px; + flex-direction: column; + justify-content: start; + align-items: center; height: 100%; width: 100%; + padding-top: ${({ compactMode, tabletMode }) => + compactMode ? 3 * GU : tabletMode ? 5 * GU : 9 * GU}px; `; const ListContainer = styled.div<{ compactMode: boolean; tabletMode: boolean }>` display: grid; - grid-gap: ${({ compactMode, tabletMode }) => - compactMode ? 3 * GU : tabletMode ? 3 * GU : 5 * GU}px; grid-template-columns: ${({ compactMode, tabletMode }) => `repeat(${compactMode ? "1" : tabletMode ? "2" : "3"}, ${ compactMode ? "327" : tabletMode ? "336" : "350" }px)`}; + grid-gap: ${({ compactMode, tabletMode }) => + compactMode ? 3 * GU : tabletMode ? 3 * GU : 5 * GU}px; margin-bottom: ${5 * GU}px; `; const EntryContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 223px; padding: ${3 * GU}px; background: ${(props) => props.theme.surface}; border: 1px solid ${(props) => props.theme.border}; border-radius: ${2.5 * GU}px; box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15); cursor: pointer; - display: flex; - flex-direction: column; - justify-content: space-between; - height: 223px; `; const NoticeContainer = styled.div` @@ -110,8 +134,8 @@ const InfoContainer = styled.div` display: flex; justify-content: space-between; align-items: center; - border-top: 1px solid ${(props) => props.theme.border}; padding-top: ${4 * GU}px; + border-top: 1px solid ${(props) => props.theme.border}; `; const Hash = styled.div` diff --git a/app/types.ts b/app/types.ts index 5321e0c..9f8c1d4 100644 --- a/app/types.ts +++ b/app/types.ts @@ -4,6 +4,8 @@ export type ValueOrArray = T | ValueOrArray[]; export enum FnDescriptionStatus { Available = "available", + Challenged = "challenged", + Pending = "pending", Added = "added", } diff --git a/app/utils/client/icons.client.ts b/app/utils/client/icons.client.ts index 86b2fea..3179f36 100644 --- a/app/utils/client/icons.client.ts +++ b/app/utils/client/icons.client.ts @@ -1,4 +1,4 @@ -import { IconCheck } from "@blossom-labs/rosette-ui"; +import { IconCheck, IconClock, IconWarning } from "@blossom-labs/rosette-ui"; import type { FunctionComponent } from "react"; import ethereum from "~/assets/ethereum.svg"; import gnosisChain from "~/assets/gnosis-chain.svg"; @@ -35,5 +35,17 @@ export const getFnEntryStatusIconData = ( Icon: IconCheck, color: "#8DCE3A", }; + case FnDescriptionStatus.Pending: + return { + Icon: IconClock, + color: "#F5A623", + } + case FnDescriptionStatus.Challenged: + return { + Icon: IconWarning, + color: "#F7513E" + } + case FnDescriptionStatus.Available: + return undefined; } }; diff --git a/package.json b/package.json index 29a63e0..ffb5fd8 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,15 @@ "@uniswap/widgets": "^1.1.1", "@vercel/node": "^2.4.0", "clipboard-polyfill": "^4.0.0-rc1", + "date-fns": "^2.29.1", "ethers": "^5.6.1", "ipfs-http-client": "56.0.3", "lodash.debounce": "^4.0.8", "react": "^17.0.2", + "react-date-range": "^1.4.0", "react-dom": "^17.0.2", "react-redux": "^8.0.2", + "react-select": "^5.4.0", "redux": "^4.2.0", "styled-components": "^5.3.3", "wagmi": "^0.2.28", @@ -42,6 +45,7 @@ "@remix-run/serve": "^1.6.3", "@types/lodash.debounce": "^4.0.7", "@types/react": "^17.0.24", + "@types/react-date-range": "^1.4.4", "@types/react-dom": "^17.0.9", "@types/styled-components": "^5.1.24", "@types/stylis": "^4.0.2",