mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 11:05:09 -05:00
Voting with usePlusVoting hook
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -5,3 +5,5 @@ export {
|
||||
} from "./voting-time";
|
||||
|
||||
export type { MonthYear } from "./types";
|
||||
|
||||
export { usePlusVoting } from "./usePlusVoting";
|
||||
|
||||
141
app/modules/plus-server/usePlusVoting.ts
Normal file
141
app/modules/plus-server/usePlusVoting.ts
Normal file
@@ -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<UsersForVoting>();
|
||||
const [votes, setVotes] = React.useState<PlusVote[]>([]);
|
||||
|
||||
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<UsersForVoting | undefined>
|
||||
>;
|
||||
setVotes: React.Dispatch<React.SetStateAction<PlusVote[]>>;
|
||||
}) {
|
||||
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));
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
<details>
|
||||
<summary
|
||||
className="plus__view-comments-action"
|
||||
data-cy="comments-summary"
|
||||
>
|
||||
Comments ({suggested.suggestions.length})
|
||||
</summary>
|
||||
<div className="stack sm mt-2">
|
||||
{suggested.suggestions.map((suggestion) => {
|
||||
invariant(data.suggestions);
|
||||
return (
|
||||
<fieldset key={suggestion.id} className="plus__comment">
|
||||
<legend>{discordFullName(suggestion.author)}</legend>
|
||||
{suggestion.text}
|
||||
<div className="stack vertical xs items-center">
|
||||
<span className="plus__comment-time">
|
||||
<RelativeTime
|
||||
timestamp={databaseTimestampToDate(
|
||||
suggestion.createdAt
|
||||
).getTime()}
|
||||
>
|
||||
{suggestion.createdAtRelative}
|
||||
</RelativeTime>
|
||||
</span>
|
||||
{canDeleteComment({
|
||||
author: suggestion.author,
|
||||
user,
|
||||
suggestionId: suggestion.id,
|
||||
suggestions: data.suggestions,
|
||||
}) ? (
|
||||
<CommentDeleteButton
|
||||
suggestionId={suggestion.id}
|
||||
tier={tier}
|
||||
suggestedDiscordName={suggested.suggestedUser.discordName}
|
||||
isFirstSuggestion={suggested.suggestions.length === 1}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
<PlusSuggestionComments
|
||||
suggestions={suggested.suggestions}
|
||||
deleteButtonArgs={{
|
||||
suggested,
|
||||
user,
|
||||
tier,
|
||||
suggestions: data.suggestions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusSuggestionComments({
|
||||
suggestions,
|
||||
deleteButtonArgs,
|
||||
defaultOpen,
|
||||
}: {
|
||||
suggestions: plusSuggestions.FindVisibleForUserSuggestedUserInfo["suggestions"];
|
||||
deleteButtonArgs?: {
|
||||
user?: Pick<User, "id">;
|
||||
suggestions: plusSuggestions.FindVisibleForUser;
|
||||
tier: string;
|
||||
suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo;
|
||||
};
|
||||
defaultOpen?: true;
|
||||
}) {
|
||||
return (
|
||||
<details open={defaultOpen} className="w-full">
|
||||
<summary
|
||||
className="plus__view-comments-action"
|
||||
data-cy="comments-summary"
|
||||
>
|
||||
Comments ({suggestions.length})
|
||||
</summary>
|
||||
<div className="stack sm mt-2">
|
||||
{suggestions.map((suggestion) => {
|
||||
return (
|
||||
<fieldset key={suggestion.id} className="plus__comment">
|
||||
<legend>{discordFullName(suggestion.author)}</legend>
|
||||
{suggestion.text}
|
||||
<div className="stack vertical xs items-center">
|
||||
<span className="plus__comment-time">
|
||||
<RelativeTime
|
||||
timestamp={databaseTimestampToDate(
|
||||
suggestion.createdAt
|
||||
).getTime()}
|
||||
>
|
||||
{suggestion.createdAtRelative}
|
||||
</RelativeTime>
|
||||
</span>
|
||||
{deleteButtonArgs &&
|
||||
canDeleteComment({
|
||||
author: suggestion.author,
|
||||
user: deleteButtonArgs.user,
|
||||
suggestionId: suggestion.id,
|
||||
suggestions: deleteButtonArgs.suggestions,
|
||||
}) ? (
|
||||
<CommentDeleteButton
|
||||
suggestionId={suggestion.id}
|
||||
tier={deleteButtonArgs.tier}
|
||||
suggestedDiscordName={
|
||||
deleteButtonArgs.suggested.suggestedUser.discordName
|
||||
}
|
||||
isFirstSuggestion={
|
||||
deleteButtonArgs.suggested.suggestions.length === 1
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentDeleteButton({
|
||||
suggestionId,
|
||||
tier,
|
||||
|
||||
@@ -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<PlusVotingLoaderData>();
|
||||
|
||||
if (data.type === "timeInfo") {
|
||||
return (
|
||||
<div className="text-sm text-center">
|
||||
{data.timing === "starts"
|
||||
? "Next voting starts"
|
||||
: "Voting is currently happening. Ends"}{" "}
|
||||
<RelativeTime timestamp={data.timestamp}>
|
||||
{data.relativeTime}
|
||||
</RelativeTime>
|
||||
</div>
|
||||
);
|
||||
switch (data.type) {
|
||||
case "timeInfo": {
|
||||
return <VotingTimingInfo {...data} />;
|
||||
}
|
||||
case "voting": {
|
||||
return <Voting {...data} />;
|
||||
}
|
||||
case "votingInfo": {
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function VotingTimingInfo(
|
||||
data: Extract<PlusVotingLoaderData, { type: "timeInfo" }>
|
||||
) {
|
||||
return (
|
||||
<div className="text-sm text-center">
|
||||
{data.timing === "starts"
|
||||
? "Next voting starts"
|
||||
: "Voting is currently happening. Ends"}{" "}
|
||||
<RelativeTime timestamp={data.timestamp}>
|
||||
{data.relativeTime}
|
||||
</RelativeTime>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Voting(data: Extract<PlusVotingLoaderData, { type: "voting" }>) {
|
||||
const { currentUser, previous, vote, undoLast, isReady } = usePlusVoting(
|
||||
data.usersForVoting
|
||||
);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{currentUser ? (
|
||||
<div className="stack md items-center">
|
||||
<Avatar
|
||||
discordAvatar={currentUser.user.discordAvatar}
|
||||
discordId={currentUser.user.discordId}
|
||||
size="lg"
|
||||
/>
|
||||
<h2>{discordFullName(currentUser.user)}</h2>
|
||||
<div className="stack vertical md">
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => vote({ score: -1, userId: currentUser.user.id })}
|
||||
>
|
||||
-1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => vote({ score: 1, userId: currentUser.user.id })}
|
||||
>
|
||||
+1
|
||||
</Button>
|
||||
</div>
|
||||
{currentUser.suggestions ? (
|
||||
<PlusSuggestionComments
|
||||
suggestions={currentUser.suggestions}
|
||||
defaultOpen
|
||||
/>
|
||||
) : null}
|
||||
{currentUser.user.bio ? (
|
||||
<article>{currentUser.user.bio}</article>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,6 +205,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user