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..338c5b4a4 --- /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..85f4dbf41 100644 --- a/web/src/components/CasesDisplay/CasesGrid.tsx +++ b/web/src/components/CasesDisplay/CasesGrid.tsx @@ -1,13 +1,38 @@ import React from "react"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import { StandardPagination } from "@kleros/ui-components-library"; +import { landscapeStyle } from "styles/landscapeStyle"; +import { useFiltersContext } from "context/FilterProvider"; import { CasesPageQuery } from "queries/useCasesQuery"; import DisputeCard from "components/DisputeCard"; +import CasesListHeader from "./CasesListHeader"; +import { useLocation } from "react-router-dom"; -const Container = styled.div` +const GridContainer = styled.div<{ path: string }>` display: flex; flex-wrap: wrap; justify-content: center; + align-items: center; + gap: 8px; + ${({ path }) => + landscapeStyle(() => + path === "/dashboard" + ? css` + display: flex; + ` + : css` + display: grid; + row-gap: 16px; + column-gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + justify-content: space-between; + ` + )} +`; +const ListContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; gap: 8px; `; @@ -26,13 +51,26 @@ export interface ICasesGrid { } const CasesGrid: React.FC = ({ disputes, currentPage, setCurrentPage, numberDisputes, casesPerPage }) => { + const { isList } = useFiltersContext(); + const location = useLocation(); + + const path = location.pathname; return ( <> - - {disputes.map((dispute, i) => { - return ; - })} - + {!isList ? ( + + {disputes.map((dispute) => { + return ; + })} + + ) : ( + + {isList && } + {disputes.map((dispute) => { + return ; + })} + + )} theme.secondaryText} !important; + } + + ${landscapeStyle( + () => + css` + display: flex; + ` + )} +`; + +const StyledLabel = styled.label` + padding-left: calc(4px + (8 - 4) * ((100vw - 300px) / (900 - 300))); +`; + +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 ( + + + + + + + Court + Category + + + + + + + ); +}; + +export default CasesListHeader; diff --git a/web/src/components/CasesDisplay/Filters.tsx b/web/src/components/CasesDisplay/Filters.tsx index d74c06574..26f706793 100644 --- a/web/src/components/CasesDisplay/Filters.tsx +++ b/web/src/components/CasesDisplay/Filters.tsx @@ -1,6 +1,11 @@ import React from "react"; -import styled, { useTheme } from "styled-components"; +import styled, { useTheme, css } from "styled-components"; +import { useWindowSize } from "react-use"; import { DropdownSelect } from "@kleros/ui-components-library"; +import { useFiltersContext } from "context/FilterProvider"; +import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; +import ListIcon from "svgs/icons/list.svg"; +import GridIcon from "svgs/icons/grid.svg"; const Container = styled.div` display: flex; @@ -9,8 +14,44 @@ const Container = styled.div` width: fit-content; `; +const glowingEffect = css` + filter: drop-shadow(0 0 4px ${({ theme }) => theme.klerosUIComponentsSecondaryPurple}); +`; + +const StyledGridIcon = styled(GridIcon)<{ isList: boolean }>` + cursor: pointer; + transition: filter 0.2s ease; + fill: ${({ theme }) => theme.primaryBlue}; + width: 16px; + height: 16px; + overflow: hidden; + ${({ isList }) => !isList && glowingEffect} +`; + +const IconsContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 4px; +`; + +const StyledListIcon = styled(ListIcon)<{ isList: boolean; isScreenBig: boolean }>` + cursor: pointer; + display: ${({ isScreenBig }) => (isScreenBig ? "block" : "none")}; + transition: filter 0.2s ease; + fill: ${({ theme }) => theme.primaryBlue}; + width: 16px; + height: 16px; + overflow: hidden; + ${({ isList }) => isList && glowingEffect} +`; + const Filters: React.FC = () => { const theme = useTheme(); + const { width } = useWindowSize(); + const { isList, setIsList } = useFiltersContext(); + const screenIsBig = width > BREAKPOINT_LANDSCAPE; + return ( { defaultValue={0} callback={() => {}} /> + + setIsList(false)} /> + { + if (screenIsBig) { + setIsList(true); + } + }} + /> + ); }; diff --git a/web/src/components/DisputeCard/DisputeInfo.tsx b/web/src/components/DisputeCard/DisputeInfo.tsx index dc36b524f..742db46aa 100644 --- a/web/src/components/DisputeCard/DisputeInfo.tsx +++ b/web/src/components/DisputeCard/DisputeInfo.tsx @@ -1,5 +1,6 @@ import React from "react"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; +import { useFiltersContext } from "context/FilterProvider"; import { Periods } from "consts/periods"; import BookmarkIcon from "svgs/icons/bookmark.svg"; import CalendarIcon from "svgs/icons/calendar.svg"; @@ -8,10 +9,20 @@ import PileCoinsIcon from "svgs/icons/pile-coins.svg"; import RoundIcon from "svgs/icons/round.svg"; import Field from "../Field"; -const Container = styled.div` +const Container = styled.div<{ isList: boolean }>` display: flex; - flex-direction: column; + flex-direction: ${({ isList }) => (isList ? "row" : "column")}; gap: 8px; + + ${({ isList }) => + isList && + css` + gap: calc(4px + (24px - 4px) * ((100vw - 300px) / (900 - 300))); + `}; + justify-content: ${({ isList }) => (isList ? "space-around" : "center")}; + align-items: center; + width: 100%; + height: 100%; `; const getPeriodPhrase = (period: Periods): string => { @@ -37,15 +48,29 @@ export interface IDisputeInfo { round?: number; } +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, round }) => { + const { isList } = useFiltersContext(); + return ( - + {court && courtId && } {category && } + {!category && isList && } {round && } {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 6a27b0277..9fea91054 100644 --- a/web/src/components/DisputeCard/index.tsx +++ b/web/src/components/DisputeCard/index.tsx @@ -1,10 +1,12 @@ import React from "react"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import { useNavigate } from "react-router-dom"; 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 { landscapeStyle } from "styles/landscapeStyle"; import { CasesPageQuery } from "queries/useCasesQuery"; import { useCourtPolicy } from "queries/useCourtPolicy"; import { useDisputeTemplate } from "queries/useDisputeTemplate"; @@ -14,13 +16,23 @@ import { isUndefined } from "utils/index"; import { useVotingHistory } from "hooks/queries/useVotingHistory"; const StyledCard = styled(Card)` - max-width: 380px; - min-width: 312px; - width: auto; + width: 312px; height: 260px; + ${landscapeStyle( + () => + css` + width: 380px; + ` + )} +`; +const StyledListItem = styled(Card)` + display: flex; + flex-grow: 1; + width: 100%; + height: 64px; `; -const Container = styled.div` +const CardContainer = styled.div` height: 215px; padding: 24px; display: flex; @@ -30,6 +42,25 @@ const Container = styled.div` margin: 0; } `; +const ListContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + margin-right: 8px; + + h3 { + margin: 0; + } +`; + +const ListTitle = styled.div` + display: flex; + height: 100%; + justify-content: start; + align-items: center; + width: calc(30vw + (40 - 30) * ((100vw - 300px) / (1250 - 300))); +`; export const getPeriodEndTimestamp = ( lastPeriodChange: string, @@ -40,6 +71,11 @@ export const getPeriodEndTimestamp = ( return parseInt(lastPeriodChange) + durationCurrentPeriod; }; +const TruncatedTitle = ({ text, maxLength }) => { + const truncatedText = text.length <= maxLength ? text : text.slice(0, maxLength) + "…"; + return

{truncatedText}

; +}; + const DisputeCard: React.FC = ({ id, arbitrated, @@ -47,6 +83,7 @@ const DisputeCard: React.FC = ({ lastPeriodChange, court, }) => { + const { isList } = useFiltersContext(); const currentPeriodIndex = Periods[period]; const rewards = `≥ ${formatEther(court.feeForJuror)} ETH`; const date = @@ -66,19 +103,41 @@ const DisputeCard: React.FC = ({ const localRounds = votingHistory?.dispute?.disputeKitDispute?.localRounds; const navigate = useNavigate(); return ( - navigate(`/cases/${id.toString()}`)}> - - -

{title}

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

{title}

+ +
+
+ ) : ( + navigate(`/cases/${id.toString()}`)}> + + + + + + + + + )} + ); }; diff --git a/web/src/components/Field.tsx b/web/src/components/Field.tsx index af7b09df8..15a08ce6e 100644 --- a/web/src/components/Field.tsx +++ b/web/src/components/Field.tsx @@ -1,15 +1,17 @@ import React from "react"; import { Link } from "react-router-dom"; import styled from "styled-components"; +import { useFiltersContext } from "context/FilterProvider"; const FieldContainer = styled.div` - width: ${({ width = "100%" }) => width}; + width: ${({ isList }) => (isList ? "auto" : "100%")}; display: flex; align-items: center; justify-content: flex-start; + white-space: nowrap; .value { - flex-grow: 1; - text-align: end; + flex-grow: ${({ isList }) => (isList ? "0" : "1")}; + text-align: ${({ isList }) => (isList ? "center" : "end")}; color: ${({ theme }) => theme.primaryText}; } svg { @@ -27,6 +29,7 @@ const FieldContainer = styled.div` type FieldContainerProps = { width?: string; + isList?: boolean; }; interface IField { @@ -37,18 +40,25 @@ interface IField { width?: string; } -const Field: React.FC = ({ icon: Icon, name, value, link, width }) => ( - - {} - - {link ? ( - - {value} - - ) : ( - - )} - -); +const Field: React.FC = ({ icon: Icon, name, value, link, width }) => { + const { isList } = useFiltersContext(); + return ( + + {!isList && ( + <> + + + + )} + {link ? ( + + {value} + + ) : ( + + )} + + ); +}; export default Field; 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/pages/Cases/CaseDetails/Overview.tsx b/web/src/pages/Cases/CaseDetails/Overview.tsx index 4bcf33f3d..c6b414ee8 100644 --- a/web/src/pages/Cases/CaseDetails/Overview.tsx +++ b/web/src/pages/Cases/CaseDetails/Overview.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import styled, { css } from "styled-components"; import { landscapeStyle } from "styles/landscapeStyle"; import { useParams } from "react-router-dom"; @@ -7,7 +7,7 @@ import { formatEther } from "viem"; import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import { useDisputeTemplate } from "queries/useDisputeTemplate"; import { useCourtPolicy } from "queries/useCourtPolicy"; -import { useCourtPolicyURI } from "queries/useCourtPolicyURI"; +import { useFiltersContext } from "context/FilterProvider"; import { isUndefined } from "utils/index"; import { Periods } from "consts/periods"; import { IPFS_GATEWAY } from "consts/index"; @@ -116,14 +116,20 @@ const Overview: React.FC = ({ arbitrable, courtID, currentPeriodIndex const { id } = useParams(); const { data: disputeTemplate } = useDisputeTemplate(id, arbitrable); const { data: disputeDetails } = useDisputeDetailsQuery(id); - const { data: courtPolicyURI } = useCourtPolicyURI(courtID); const { data: courtPolicy } = useCourtPolicy(courtID); + const { isList, setIsList } = useFiltersContext(); const { data: votingHistory } = useVotingHistory(id); + const localRounds = votingHistory?.dispute?.disputeKitDispute?.localRounds; const courtName = courtPolicy?.name; const court = disputeDetails?.dispute?.court; const rewards = court ? `≥ ${formatEther(court.feeForJuror)} ETH` : undefined; const category = disputeTemplate ? disputeTemplate.category : undefined; - const localRounds = votingHistory?.dispute?.disputeKitDispute?.localRounds; + + useEffect(() => { + if (isList) { + setIsList(false); + } + }, []); return ( <> diff --git a/web/src/pages/Cases/index.tsx b/web/src/pages/Cases/index.tsx index bf4b32cdc..11afa992b 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 { useWindowSize } from "react-use"; +import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; 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); @@ -16,8 +18,18 @@ const Container = styled.div` const Cases: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); - const casesPerPage = 3; - const { data } = useCasesQuery(casesPerPage * (currentPage - 1)); + const { width } = useWindowSize(); + const { isList, setIsList } = useFiltersContext(); + const screenIsBig = width > BREAKPOINT_LANDSCAPE; + const casesPerPage = screenIsBig ? 9 : 3; + const { data } = useCasesQuery(casesPerPage * (currentPage - 1), casesPerPage); + + useEffect(() => { + if (!screenIsBig && isList) { + setIsList(false); + } + }, [screenIsBig, isList, setIsList]); + return ( diff --git a/web/src/pages/Dashboard/index.tsx b/web/src/pages/Dashboard/index.tsx index aaec003e6..7ae82faee 100644 --- a/web/src/pages/Dashboard/index.tsx +++ b/web/src/pages/Dashboard/index.tsx @@ -1,7 +1,10 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import styled from "styled-components"; +import { useWindowSize } from "react-use"; import { useAccount } from "wagmi"; +import { useFiltersContext } from "context/FilterProvider"; import { useCasesQuery } from "queries/useCasesQuery"; +import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import JurorInfo from "./JurorInfo"; import Courts from "./Courts"; import CasesDisplay from "components/CasesDisplay"; @@ -34,10 +37,19 @@ const ConnectWalletContainer = styled.div` const Dashboard: React.FC = () => { const { isConnected } = useAccount(); + const { width } = useWindowSize(); + const screenIsBig = width > BREAKPOINT_LANDSCAPE; + const { isList, setIsList } = useFiltersContext(); const [currentPage, setCurrentPage] = useState(1); const casesPerPage = 3; const { data } = useCasesQuery(casesPerPage * (currentPage - 1)); + useEffect(() => { + if (!screenIsBig && isList) { + setIsList(false); + } + }, [screenIsBig, isList, setIsList]); + return ( {isConnected ? ( diff --git a/web/src/pages/Home/LatestCases.tsx b/web/src/pages/Home/LatestCases.tsx index e471dbf17..7ebd3122c 100644 --- a/web/src/pages/Home/LatestCases.tsx +++ b/web/src/pages/Home/LatestCases.tsx @@ -1,6 +1,7 @@ -import React from "react"; +import React, { useEffect } from "react"; import styled from "styled-components"; import { useCasesQuery } from "queries/useCasesQuery"; +import { useFiltersContext } from "context/FilterProvider"; import DisputeCard from "components/DisputeCard"; import { StyledSkeleton } from "components/StyledSkeleton"; @@ -20,6 +21,12 @@ const Container = styled.div` const LatestCases: React.FC = () => { const { data } = useCasesQuery(0); + const { setIsList } = useFiltersContext(); + + useEffect(() => { + setIsList(false); + }, []); + return (

Latest Cases