diff --git a/web/src/app.tsx b/web/src/app.tsx index f6a705ac6..594489272 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -6,6 +6,7 @@ import "react-toastify/dist/ReactToastify.css"; import Web3Provider from "context/Web3Provider"; import QueryClientProvider from "context/QueryClientProvider"; import StyledComponentsProvider from "context/StyledComponentsProvider"; +import { FilterProvider } from "context/FilterProvider"; import RefetchOnBlock from "context/RefetchOnBlock"; import Layout from "layout/index"; import Home from "./pages/Home"; @@ -20,16 +21,18 @@ const App: React.FC = () => { - - }> - } /> - } /> - } /> - } /> - } /> - Justice not found here ¯\_( ͡° ͜ʖ ͡°)_/¯} /> - - + + + }> + } /> + } /> + } /> + } /> + } /> + Justice not found here ¯\_( ͡° ͜ʖ ͡°)_/¯} /> + + + diff --git a/web/src/assets/svgs/icons/grid.svg b/web/src/assets/svgs/icons/grid.svg new file mode 100644 index 000000000..eb3fa4e05 --- /dev/null +++ b/web/src/assets/svgs/icons/grid.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/assets/svgs/icons/list.svg b/web/src/assets/svgs/icons/list.svg new file mode 100644 index 000000000..767198a5d --- /dev/null +++ b/web/src/assets/svgs/icons/list.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/components/CasesDisplay/CasesGrid.tsx b/web/src/components/CasesDisplay/CasesGrid.tsx index b9e9c9415..7f465ff1e 100644 --- a/web/src/components/CasesDisplay/CasesGrid.tsx +++ b/web/src/components/CasesDisplay/CasesGrid.tsx @@ -1,12 +1,27 @@ import React from "react"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import { StandardPagination } from "@kleros/ui-components-library"; +import { smallScreenStyle } from "styles/smallScreenStyle"; +import { useFiltersContext } from "context/FilterProvider"; import { CasesPageQuery } from "queries/useCasesQuery"; import DisputeCard from "components/DisputeCard"; +import CasesListHeader from "./CasesListHeader"; -const Container = styled.div` +const GridContainer = styled.div` + display: grid; + gap: 16px; + grid-template-columns: repeat(3, minmax(50px, 1fr)); + justify-content: center; + align-items: center; + gap: 8px; + ${smallScreenStyle(css` + display: flex; + flex-wrap: wrap; + `)} +`; +const ListContainer = styled.div` display: flex; - flex-wrap: wrap; + flex-direction: column; justify-content: center; gap: 8px; `; @@ -26,13 +41,23 @@ export interface ICasesGrid { } const CasesGrid: React.FC = ({ disputes, currentPage, setCurrentPage, numberDisputes, casesPerPage }) => { + const { isList } = useFiltersContext(); return ( <> - - {disputes.map((dispute, i) => { - return ; - })} - + {!isList ? ( + + {disputes.map((dispute, i) => { + return ; + })} + + ) : ( + + {isList && } + {disputes.map((dispute, i) => { + return ; + })} + + )} theme.secondaryText} !important; + } + + ${smallScreenStyle(css` + display: none; + `)} +`; + +const tooltipMsg = + "Users have an economic interest in serving as jurors in Kleros: " + + "collecting the Juror Rewards in exchange for their work. Each juror who " + + "is coherent with the final ruling receive the Juror Rewards composed of " + + "arbitration fees (ETH) + PNK redistribution between jurors."; + +const CasesListHeader: React.FC = () => { + return ( + + + + + + + + Category + + + + + + + + + ); +}; + +export default CasesListHeader; diff --git a/web/src/components/CasesDisplay/Filters.tsx b/web/src/components/CasesDisplay/Filters.tsx index d74c06574..a4b596cd7 100644 --- a/web/src/components/CasesDisplay/Filters.tsx +++ b/web/src/components/CasesDisplay/Filters.tsx @@ -1,6 +1,9 @@ import React from "react"; import styled, { useTheme } from "styled-components"; import { DropdownSelect } from "@kleros/ui-components-library"; +import { useFiltersContext } from "context/FilterProvider"; +import ListIcon from "svgs/icons/list.svg"; +import GridIcon from "svgs/icons/grid.svg"; const Container = styled.div` display: flex; @@ -9,8 +12,33 @@ const Container = styled.div` width: fit-content; `; +const StyledGridIcon = styled(GridIcon)<{ isList: boolean }>` + cursor: pointer; + transition: fill 0.2s ease; + fill: ${({ theme, isList }) => (isList ? theme.secondaryText : theme.primaryBlue)}; + width: 16px; + height: 16px; +`; + +const IconsContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 4px; +`; + +const StyledListIcon = styled(ListIcon)<{ isList: boolean }>` + cursor: pointer; + transition: fill 0.2s ease; + fill: ${({ theme, isList }) => (isList ? theme.primaryBlue : theme.secondaryText)}; + width: 16px; + height: 17px; +`; + const Filters: React.FC = () => { const theme = useTheme(); + const { isList, setIsList } = useFiltersContext(); + return ( { defaultValue={0} callback={() => {}} /> + + setIsList(false)} /> + setIsList(true)} /> + ); }; diff --git a/web/src/components/DisputeCard/DisputeInfo.tsx b/web/src/components/DisputeCard/DisputeInfo.tsx index 0b3568097..6ea8f9940 100644 --- a/web/src/components/DisputeCard/DisputeInfo.tsx +++ b/web/src/components/DisputeCard/DisputeInfo.tsx @@ -7,10 +7,14 @@ import LawBalanceIcon from "svgs/icons/law-balance.svg"; import PileCoinsIcon from "svgs/icons/pile-coins.svg"; import Field from "../Field"; -const Container = styled.div` +const Container = styled.div<{ isCard: boolean }>` display: flex; - flex-direction: column; - gap: 8px; + flex-direction: ${({ isCard }) => (isCard ? "column" : "row")}; + gap: ${({ isCard }) => (isCard ? "8px" : "48px")}; + justify-content: ${({ isCard }) => (isCard ? "center" : "space-between")}; + align-items: center; + width: 100%; + height: 100%; `; const getPeriodPhrase = (period: Periods): string => { @@ -33,16 +37,31 @@ export interface IDisputeInfo { rewards?: string; period?: Periods; date?: number; + isCard?: boolean; } -const DisputeInfo: React.FC = ({ courtId, court, category, rewards, period, date }) => { +const formatDate = (date: number) => { + const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; + const startingDate = new Date(date * 1000); + const formattedDate = startingDate.toLocaleDateString("en-US", options); + return formattedDate; +}; + +const DisputeInfo: React.FC = ({ courtId, court, category, rewards, period, date, isCard = true }) => { return ( - - {category && } - {court && courtId && } - {rewards && } + + {court && courtId && ( + + )} + {category && } + {rewards && } {typeof period !== "undefined" && date && ( - + )} ); diff --git a/web/src/components/DisputeCard/PeriodBanner.tsx b/web/src/components/DisputeCard/PeriodBanner.tsx index fb8dd3e0a..0ad1ac647 100644 --- a/web/src/components/DisputeCard/PeriodBanner.tsx +++ b/web/src/components/DisputeCard/PeriodBanner.tsx @@ -3,7 +3,7 @@ import styled, { Theme } from "styled-components"; import { Periods } from "consts/periods"; const Container = styled.div>` - height: 45px; + height: ${({ isCard }) => (isCard ? "45px" : "100%")}; width: auto; border-top-right-radius: 3px; border-top-left-radius: 3px; @@ -21,11 +21,11 @@ const Container = styled.div>` margin-right: 8px; } } - ${({ theme, period }) => { + ${({ theme, period, isCard }) => { const [frontColor, backgroundColor] = getPeriodColors(period, theme); return ` - border-top: 5px solid ${frontColor}; - background-color: ${backgroundColor}; + ${isCard ? `border-top: 5px solid ${frontColor}` : `border-left: 5px solid ${frontColor}`}; + ${isCard && `background-color: ${backgroundColor}`}; .front-color { color: ${frontColor}; } @@ -41,6 +41,7 @@ const Container = styled.div>` export interface IPeriodBanner { id: number; period: Periods; + isCard?: boolean; } const getPeriodColors = (period: Periods, theme: Theme): [string, string] => { @@ -65,9 +66,9 @@ const getPeriodLabel = (period: Periods): string => { } }; -const PeriodBanner: React.FC = ({ id, period }) => ( - - +const PeriodBanner: React.FC = ({ id, period, isCard = true }) => ( + + {isCard && } ); diff --git a/web/src/components/DisputeCard/index.tsx b/web/src/components/DisputeCard/index.tsx index 2579d0130..8ecc73562 100644 --- a/web/src/components/DisputeCard/index.tsx +++ b/web/src/components/DisputeCard/index.tsx @@ -5,6 +5,7 @@ import { formatEther } from "viem"; import { StyledSkeleton } from "components/StyledSkeleton"; import { Card } from "@kleros/ui-components-library"; import { Periods } from "consts/periods"; +import { useFiltersContext } from "context/FilterProvider"; import { CasesPageQuery } from "queries/useCasesQuery"; import { useCourtPolicy } from "queries/useCourtPolicy"; import { useDisputeTemplate } from "queries/useDisputeTemplate"; @@ -18,8 +19,13 @@ const StyledCard = styled(Card)` width: auto; height: 260px; `; +const StyledListItem = styled(Card)` + display: flex; + width: 100%; + height: 64px; +`; -const Container = styled.div` +const CardContainer = styled.div` height: 215px; padding: 24px; display: flex; @@ -29,6 +35,25 @@ const Container = styled.div` margin: 0; } `; +const ListContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 32px; + width: 100%; + margin-right: 2%; + h3 { + margin: 0; + } +`; + +const ListTitle = styled.div` + display: flex; + height: 100%; + justify-content: start; + align-items: center; + min-width: 40vw; +`; export const getPeriodEndTimestamp = ( lastPeriodChange: string, @@ -46,6 +71,7 @@ const DisputeCard: React.FC = ({ lastPeriodChange, court, }) => { + const { isList } = useFiltersContext(); const currentPeriodIndex = Periods[period]; const rewards = `≥ ${formatEther(court.feeForJuror)} ETH`; const date = @@ -63,18 +89,38 @@ const DisputeCard: React.FC = ({ const category = disputeTemplate ? disputeTemplate.category : undefined; const navigate = useNavigate(); return ( - navigate(`/cases/${id.toString()}`)}> - - -

{title}

- -
-
+ <> + {!isList ? ( + navigate(`/cases/${id.toString()}`)}> + + +

{title}

+ +
+
+ ) : ( + navigate(`/cases/${id.toString()}`)}> + + + +

{title}

+
+ +
+
+ )} + ); }; diff --git a/web/src/components/Field.tsx b/web/src/components/Field.tsx index af7b09df8..5c5b92540 100644 --- a/web/src/components/Field.tsx +++ b/web/src/components/Field.tsx @@ -7,9 +7,10 @@ const FieldContainer = styled.div` display: flex; align-items: center; justify-content: flex-start; + white-space: nowrap; .value { flex-grow: 1; - text-align: end; + text-align: ${({ isCard }) => (isCard ? "end" : "center")}; color: ${({ theme }) => theme.primaryText}; } svg { @@ -27,6 +28,7 @@ const FieldContainer = styled.div` type FieldContainerProps = { width?: string; + isCard?: boolean; }; interface IField { @@ -35,12 +37,17 @@ interface IField { value: string; link?: string; width?: string; + isCard?: boolean; } -const Field: React.FC = ({ icon: Icon, name, value, link, width }) => ( - - {} - +const Field: React.FC = ({ icon: Icon, name, value, link, width, isCard = true }) => ( + + {isCard && ( + <> + + + + )} {link ? ( {value} diff --git a/web/src/context/FilterProvider.tsx b/web/src/context/FilterProvider.tsx new file mode 100644 index 000000000..267059c5e --- /dev/null +++ b/web/src/context/FilterProvider.tsx @@ -0,0 +1,25 @@ +import React, { useState, createContext, useContext } from "react"; + +interface IFilters { + isList: boolean; + setIsList: (arg0: boolean) => void; +} + +const Context = createContext({ + isList: false, + setIsList: () => { + // + }, +}); + +export const FilterProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const [isList, setIsList] = useState(false); + + const value = { + isList, + setIsList, + }; + return {children}; +}; + +export const useFiltersContext = () => useContext(Context); diff --git a/web/src/context/Web3Provider.tsx b/web/src/context/Web3Provider.tsx index 1677ebbac..c6e161578 100644 --- a/web/src/context/Web3Provider.tsx +++ b/web/src/context/Web3Provider.tsx @@ -8,7 +8,7 @@ import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; import { useToggleTheme } from "hooks/useToggleThemeContext"; import { useTheme } from "styled-components"; -const chains = [mainnet, arbitrumGoerli, gnosisChiado]; +const chains = [arbitrumGoerli, gnosisChiado]; const projectId = process.env.WALLETCONNECT_PROJECT_ID ?? "6efaa26765fa742153baf9281e218217"; const { publicClient, webSocketPublicClient } = configureChains(chains, [ diff --git a/web/src/hooks/queries/useCasesQuery.ts b/web/src/hooks/queries/useCasesQuery.ts index c6b813924..6d1cf7a9e 100644 --- a/web/src/hooks/queries/useCasesQuery.ts +++ b/web/src/hooks/queries/useCasesQuery.ts @@ -5,8 +5,8 @@ import { graphqlQueryFnHelper } from "~src/utils/graphqlQueryFnHelper"; export type { CasesPageQuery }; const casesQuery = graphql(` - query CasesPage($skip: Int) { - disputes(first: 3, skip: $skip, orderBy: lastPeriodChange, orderDirection: desc) { + query CasesPage($first: Int, $skip: Int) { + disputes(first: $first, skip: $skip, orderBy: lastPeriodChange, orderDirection: desc) { id arbitrated { id @@ -26,12 +26,12 @@ const casesQuery = graphql(` } `); -export const useCasesQuery = (skip: number) => { +export const useCasesQuery = (skip: number, first = 3) => { const isEnabled = skip !== undefined; return useQuery({ - queryKey: [`useCasesQuery${skip}`], + queryKey: [`useCasesQuery${skip},${first}`], enabled: isEnabled, - queryFn: async () => await graphqlQueryFnHelper(casesQuery, { skip: skip }), + queryFn: async () => await graphqlQueryFnHelper(casesQuery, { skip, first }), }); }; diff --git a/web/src/hooks/useWindowWidth.ts b/web/src/hooks/useWindowWidth.ts new file mode 100644 index 000000000..ff74a14ba --- /dev/null +++ b/web/src/hooks/useWindowWidth.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from "react"; + +export const useWindowWidth = () => { + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + useEffect(() => { + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return windowWidth; +}; diff --git a/web/src/pages/Cases/index.tsx b/web/src/pages/Cases/index.tsx index 86b623fdb..a34ce9a04 100644 --- a/web/src/pages/Cases/index.tsx +++ b/web/src/pages/Cases/index.tsx @@ -1,10 +1,12 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import styled from "styled-components"; import { Routes, Route } from "react-router-dom"; import { useCasesQuery } from "queries/useCasesQuery"; +import { useWindowWidth } from "hooks/useWindowWidth"; +import { BREAKPOINT_TABLET_SCREEN } from "styles/tabletScreenStyle"; import CasesDisplay from "components/CasesDisplay"; import CaseDetails from "./CaseDetails"; - +import { useFiltersContext } from "context/FilterProvider"; const Container = styled.div` width: 100%; min-height: calc(100vh - 144px); @@ -14,8 +16,18 @@ const Container = styled.div` const Cases: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); - const casesPerPage = 3; - const { data } = useCasesQuery(casesPerPage * (currentPage - 1)); + const windowWidth = useWindowWidth(); + const { isList, setIsList } = useFiltersContext(); + const screenIsBig = windowWidth > BREAKPOINT_TABLET_SCREEN; + const casesPerPage = screenIsBig ? 9 : 3; + const { data } = useCasesQuery(casesPerPage * (currentPage - 1), casesPerPage); + + useEffect(() => { + if (!screenIsBig && isList) { + setIsList(false); + } + }, [screenIsBig]); + return ( diff --git a/web/src/styles/tabletScreenStyle.ts b/web/src/styles/tabletScreenStyle.ts new file mode 100644 index 000000000..7b1fe25e7 --- /dev/null +++ b/web/src/styles/tabletScreenStyle.ts @@ -0,0 +1,9 @@ +import { css, DefaultTheme, FlattenInterpolation, ThemeProps } from "styled-components"; + +export const BREAKPOINT_TABLET_SCREEN = 1024; + +export const tabletScreenStyle = (styleFn: () => FlattenInterpolation>) => css` + @media (max-width: ${BREAKPOINT_TABLET_SCREEN}px) { + ${() => styleFn()} + } +`;