From 9e9e9ed44e424553df6f5cee171ece13a7ee40a8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 12 Jun 2022 11:35:08 +0300 Subject: [PATCH] Voting with usePlusVoting hook --- app/db/models/plusSuggestions.server.ts | 2 +- app/modules/plus-server/index.ts | 2 + app/modules/plus-server/usePlusVoting.ts | 141 +++++++++++++++++++++++ app/routes/plus.tsx | 6 + app/routes/plus/suggestions.tsx | 125 ++++++++++++-------- app/routes/plus/voting/index.tsx | 100 +++++++++++++--- app/styles/common.css | 4 + 7 files changed, 314 insertions(+), 66 deletions(-) create mode 100644 app/modules/plus-server/usePlusVoting.ts diff --git a/app/db/models/plusSuggestions.server.ts b/app/db/models/plusSuggestions.server.ts index 585c72bb3..47f2f2834 100644 --- a/app/db/models/plusSuggestions.server.ts +++ b/app/db/models/plusSuggestions.server.ts @@ -131,7 +131,7 @@ function mapFindVisibleForUserRowsToResult( id: row.suggestedId, discordId: row.suggestedDiscordId, discordName: row.suggestedDiscordName, - discordDiscriminator: row.suggestedDiscriminator, + discordDiscriminator: row.suggestedDiscordDiscriminator, discordAvatar: row.suggestedDiscordAvatar, bio: includeBio ? row.suggestedBio : null, }, diff --git a/app/modules/plus-server/index.ts b/app/modules/plus-server/index.ts index a31a9f292..7392bfcbd 100644 --- a/app/modules/plus-server/index.ts +++ b/app/modules/plus-server/index.ts @@ -5,3 +5,5 @@ export { } from "./voting-time"; export type { MonthYear } from "./types"; + +export { usePlusVoting } from "./usePlusVoting"; diff --git a/app/modules/plus-server/usePlusVoting.ts b/app/modules/plus-server/usePlusVoting.ts new file mode 100644 index 000000000..9e4f9979a --- /dev/null +++ b/app/modules/plus-server/usePlusVoting.ts @@ -0,0 +1,141 @@ +import type { UsersForVoting } from "~/db/models/plusVotes.server"; +import * as React from "react"; +import { upcomingVoting } from "./voting-time"; +import type { PlusVotingResult, User } from "~/db/types"; +import invariant from "tiny-invariant"; + +const LOCAL_STORAGE_KEY = "plusVoting"; + +interface PlusVote { + userId: User["id"]; + score: PlusVotingResult["score"]; +} +interface VotingLocalStorageData { + month: number; + year: number; + votes: PlusVote[]; + usersForVoting: UsersForVoting; +} + +export function usePlusVoting(usersForVotingFromServer: UsersForVoting) { + const [usersForVoting, setUsersForVoting] = React.useState(); + const [votes, setVotes] = React.useState([]); + + useLoadInitialStateFromLocalStorageEffect({ + usersForVotingFromServer, + setUsersForVoting, + setVotes, + }); + + const vote = React.useCallback( + ({ score, userId }: PlusVote) => { + setVotes((votes) => { + const newVotes = [...votes, { userId, score }]; + + votesToLocalStorage({ usersForVoting, votes: newVotes }); + + return newVotes; + }); + }, + [usersForVoting] + ); + + const undoLast = React.useCallback(() => { + setVotes((votes) => { + const newVotes = [...votes]; + newVotes.pop(); + + votesToLocalStorage({ usersForVoting, votes: newVotes }); + return newVotes; + }); + }, [usersForVoting]); + + const currentUser = usersForVoting?.[votes.length]; + + return { + vote, + undoLast, + currentUser, + previous: previousUser({ usersForVoting, votes }), + isReady: Boolean(usersForVoting), + }; +} + +function useLoadInitialStateFromLocalStorageEffect({ + usersForVotingFromServer, + setUsersForVoting, + setVotes, +}: { + usersForVotingFromServer: UsersForVoting; + setUsersForVoting: React.Dispatch< + React.SetStateAction + >; + setVotes: React.Dispatch>; +}) { + const { month, year } = upcomingVoting(new Date()); + + React.useEffect(() => { + const usersForVotingFromLocalStorage = + localStorage.getItem(LOCAL_STORAGE_KEY); + + if (!usersForVotingFromLocalStorage) { + setUsersForVoting(usersForVotingFromServer); + return; + } + + const parsedUsersForVoting = JSON.parse( + usersForVotingFromLocalStorage + ) as VotingLocalStorageData; + + if ( + parsedUsersForVoting.month !== month || + parsedUsersForVoting.year !== year + ) { + setUsersForVoting(usersForVotingFromServer); + return; + } + + setUsersForVoting(parsedUsersForVoting.usersForVoting); + setVotes(parsedUsersForVoting.votes); + }, [month, year, usersForVotingFromServer, setUsersForVoting, setVotes]); +} + +function previousUser({ + usersForVoting, + votes, +}: { + usersForVoting?: UsersForVoting; + votes: PlusVote[]; +}) { + if (!usersForVoting) return; + + const previousUser = usersForVoting?.[votes.length - 1]; + if (!previousUser) return; + + const previousScore = votes[votes.length - 1]?.score; + invariant(previousScore); + + return { + user: previousUser, + score: previousScore, + }; +} + +function votesToLocalStorage({ + usersForVoting, + votes, +}: { + usersForVoting?: UsersForVoting; + votes: PlusVote[]; +}) { + const { month, year } = upcomingVoting(new Date()); + + invariant(usersForVoting); + const toLocalStorage: VotingLocalStorageData = { + month, + year, + votes, + usersForVoting, + }; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(toLocalStorage)); +} diff --git a/app/routes/plus.tsx b/app/routes/plus.tsx index 761ecd2e1..1425f0f9e 100644 --- a/app/routes/plus.tsx +++ b/app/routes/plus.tsx @@ -1,6 +1,12 @@ +import type { LinksFunction } from "@remix-run/node"; import { Outlet } from "@remix-run/react"; import { Main } from "~/components/Main"; import { SubNav, SubNavLink } from "~/components/SubNav"; +import styles from "~/styles/plus.css"; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: styles }]; +}; export default function PlusPageLayout() { return ( diff --git a/app/routes/plus/suggestions.tsx b/app/routes/plus/suggestions.tsx index f76447695..2f22323fe 100644 --- a/app/routes/plus/suggestions.tsx +++ b/app/routes/plus/suggestions.tsx @@ -1,6 +1,5 @@ import type { ActionFunction, - LinksFunction, LoaderFunction, MetaFunction, } from "@remix-run/node"; @@ -18,14 +17,13 @@ import { TrashIcon } from "~/components/icons/Trash"; import { upcomingVoting } from "~/modules/plus-server"; import { db } from "~/db"; import type * as plusSuggestions from "~/db/models/plusSuggestions.server"; -import type { PlusSuggestion } from "~/db/types"; +import type { PlusSuggestion, User } from "~/db/types"; import { requireUser, useUser } from "~/modules/auth"; import { canAddCommentToSuggestionFE, canSuggestNewUserFE, canDeleteComment, } from "~/permissions"; -import styles from "~/styles/plus.css"; import { makeTitle, parseRequestFormData, validate } from "~/utils/remix"; import { discordFullName } from "~/utils/strings"; import { actualNumber } from "~/utils/zod"; @@ -33,10 +31,6 @@ import { userPage } from "~/utils/urls"; import { RelativeTime } from "~/components/RelativeTime"; import { databaseTimestampToDate } from "~/utils/dates"; -export const links: LinksFunction = () => { - return [{ rel: "stylesheet", href: styles }]; -}; - export const meta: MetaFunction = () => { return { title: makeTitle("Plus Server suggestions"), @@ -290,53 +284,84 @@ function SuggestedUser({ ) : null} -
- - Comments ({suggested.suggestions.length}) - -
- {suggested.suggestions.map((suggestion) => { - invariant(data.suggestions); - return ( -
- {discordFullName(suggestion.author)} - {suggestion.text} -
- - - {suggestion.createdAtRelative} - - - {canDeleteComment({ - author: suggestion.author, - user, - suggestionId: suggestion.id, - suggestions: data.suggestions, - }) ? ( - - ) : null} -
-
- ); - })} -
-
+ ); } +export function PlusSuggestionComments({ + suggestions, + deleteButtonArgs, + defaultOpen, +}: { + suggestions: plusSuggestions.FindVisibleForUserSuggestedUserInfo["suggestions"]; + deleteButtonArgs?: { + user?: Pick; + suggestions: plusSuggestions.FindVisibleForUser; + tier: string; + suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo; + }; + defaultOpen?: true; +}) { + return ( +
+ + Comments ({suggestions.length}) + +
+ {suggestions.map((suggestion) => { + return ( +
+ {discordFullName(suggestion.author)} + {suggestion.text} +
+ + + {suggestion.createdAtRelative} + + + {deleteButtonArgs && + canDeleteComment({ + author: suggestion.author, + user: deleteButtonArgs.user, + suggestionId: suggestion.id, + suggestions: deleteButtonArgs.suggestions, + }) ? ( + + ) : null} +
+
+ ); + })} +
+
+ ); +} + function CommentDeleteButton({ suggestionId, tier, diff --git a/app/routes/plus/voting/index.tsx b/app/routes/plus/voting/index.tsx index 98d5553ae..e0dff95d4 100644 --- a/app/routes/plus/voting/index.tsx +++ b/app/routes/plus/voting/index.tsx @@ -2,12 +2,21 @@ import type { LoaderFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { formatDistance } from "date-fns"; +import { Avatar } from "~/components/Avatar"; +import { Button } from "~/components/Button"; import { RelativeTime } from "~/components/RelativeTime"; import { db } from "~/db"; import type { UsersForVoting } from "~/db/models/plusVotes.server"; import { getUser } from "~/modules/auth"; -import { monthsVotingRange, upcomingVoting } from "~/modules/plus-server"; +import { + monthsVotingRange, + upcomingVoting, + usePlusVoting, +} from "~/modules/plus-server"; import { isVotingActive } from "~/permissions"; +import { discordFullName } from "~/utils/strings"; +import { assertUnreachable } from "~/utils/types"; +import { PlusSuggestionComments } from "../suggestions"; type PlusVotingLoaderData = // voting is not active OR user is not eligible to vote @@ -20,7 +29,7 @@ type PlusVotingLoaderData = // user can vote | { type: "voting"; - usersForVoting?: UsersForVoting; + usersForVoting: UsersForVoting; } // user already voted | { type: "votingInfo"; votingInfo: { placeholder: true } }; @@ -59,18 +68,79 @@ export const loader: LoaderFunction = async ({ request }) => { export default function PlusVotingPage() { const data = useLoaderData(); - if (data.type === "timeInfo") { - return ( -
- {data.timing === "starts" - ? "Next voting starts" - : "Voting is currently happening. Ends"}{" "} - - {data.relativeTime} - -
- ); + switch (data.type) { + case "timeInfo": { + return ; + } + case "voting": { + return ; + } + case "votingInfo": { + return null; + } + default: { + assertUnreachable(data); + } } - - return null; +} + +function VotingTimingInfo( + data: Extract +) { + return ( +
+ {data.timing === "starts" + ? "Next voting starts" + : "Voting is currently happening. Ends"}{" "} + + {data.relativeTime} + +
+ ); +} + +function Voting(data: Extract) { + const { currentUser, previous, vote, undoLast, isReady } = usePlusVoting( + data.usersForVoting + ); + + if (!isReady) return null; + + return ( +
+ {currentUser ? ( +
+ +

{discordFullName(currentUser.user)}

+
+ + +
+ {currentUser.suggestions ? ( + + ) : null} + {currentUser.user.bio ? ( +
{currentUser.user.bio}
+ ) : null} +
+ ) : null} +
+ ); } diff --git a/app/styles/common.css b/app/styles/common.css index 78a45bb70..7cfcaccf8 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -205,6 +205,10 @@ display: none; } +.flex { + display: flex; +} + .items-center { align-items: center; }