mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-10 04:40:46 -05:00
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import type {
|
|
ActionFunction,
|
|
LoaderFunction,
|
|
V2_MetaFunction,
|
|
} from "@remix-run/node";
|
|
import { json } from "@remix-run/node";
|
|
import type { ShouldRevalidateFunction } from "@remix-run/react";
|
|
import { Link, Outlet, useLoaderData, useSearchParams } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import invariant from "tiny-invariant";
|
|
import { z } from "zod";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { Button, LinkButton } from "~/components/Button";
|
|
import { Catcher } from "~/components/Catcher";
|
|
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
|
import { TrashIcon } from "~/components/icons/Trash";
|
|
import { nextNonCompletedVoting } from "~/modules/plus-server";
|
|
import { db } from "~/db";
|
|
import type * as plusSuggestions from "~/db/models/plusSuggestions/queries.server";
|
|
import type { PlusSuggestion, User } from "~/db/types";
|
|
import { requireUser, useUser } from "~/modules/auth";
|
|
import {
|
|
canAddCommentToSuggestionFE,
|
|
canSuggestNewUserFE,
|
|
canDeleteComment,
|
|
canDeleteSuggestionOfThemselves,
|
|
isFirstSuggestion,
|
|
} from "~/permissions";
|
|
import { parseRequestFormData, validate } from "~/utils/remix";
|
|
import { makeTitle } from "~/utils/strings";
|
|
import { discordFullName } from "~/utils/strings";
|
|
import { actualNumber } from "~/utils/zod";
|
|
import { userPage } from "~/utils/urls";
|
|
import { RelativeTime } from "~/components/RelativeTime";
|
|
import { databaseTimestampToDate } from "~/utils/dates";
|
|
import { PLUS_TIERS } from "~/constants";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import { getUserId } from "~/modules/auth/user.server";
|
|
|
|
export const meta: V2_MetaFunction = () => {
|
|
return [
|
|
{ title: makeTitle("Plus Server suggestions") },
|
|
{
|
|
name: "description",
|
|
content: "This month's suggestions for +1, +2 and +3.",
|
|
},
|
|
];
|
|
};
|
|
|
|
const suggestionActionSchema = z.union([
|
|
z.object({
|
|
_action: z.literal("DELETE_COMMENT"),
|
|
suggestionId: z.preprocess(actualNumber, z.number()),
|
|
}),
|
|
z.object({
|
|
_action: z.literal("DELETE_SUGGESTION_OF_THEMSELVES"),
|
|
tier: z.preprocess(
|
|
actualNumber,
|
|
z
|
|
.number()
|
|
.min(Math.min(...PLUS_TIERS))
|
|
.max(Math.max(...PLUS_TIERS))
|
|
),
|
|
}),
|
|
]);
|
|
|
|
export const action: ActionFunction = async ({ request }) => {
|
|
const data = await parseRequestFormData({
|
|
request,
|
|
schema: suggestionActionSchema,
|
|
});
|
|
const user = await requireUser(request);
|
|
|
|
switch (data._action) {
|
|
case "DELETE_COMMENT": {
|
|
const suggestions = db.plusSuggestions.findVisibleForUser({
|
|
...nextNonCompletedVoting(new Date()),
|
|
plusTier: user.plusTier,
|
|
});
|
|
|
|
const flattenedSuggestedUserInfo = Object.entries(suggestions ?? {})
|
|
.flatMap(([tier, suggestions]) =>
|
|
suggestions.map(({ suggestedUser, suggestions }) => ({
|
|
tier: Number(tier),
|
|
suggestedUser,
|
|
suggestions,
|
|
}))
|
|
)
|
|
.find(({ suggestions }) =>
|
|
suggestions.some((s) => s.id === data.suggestionId)
|
|
);
|
|
|
|
validate(suggestions);
|
|
validate(flattenedSuggestedUserInfo);
|
|
validate(
|
|
canDeleteComment({
|
|
user,
|
|
author: flattenedSuggestedUserInfo.suggestions.find(
|
|
(s) => s.id === data.suggestionId
|
|
)!.author,
|
|
suggestionId: data.suggestionId,
|
|
suggestions,
|
|
})
|
|
);
|
|
|
|
const suggestionHasComments =
|
|
flattenedSuggestedUserInfo.suggestions.length > 1;
|
|
if (
|
|
suggestionHasComments &&
|
|
isFirstSuggestion({ suggestionId: data.suggestionId, suggestions })
|
|
) {
|
|
// admin only action
|
|
db.plusSuggestions.deleteSuggestionWithComments({
|
|
...nextNonCompletedVoting(new Date()),
|
|
tier: flattenedSuggestedUserInfo.tier,
|
|
suggestedId: flattenedSuggestedUserInfo.suggestedUser.id,
|
|
});
|
|
} else {
|
|
db.plusSuggestions.del(data.suggestionId);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "DELETE_SUGGESTION_OF_THEMSELVES": {
|
|
validate(canDeleteSuggestionOfThemselves());
|
|
|
|
db.plusSuggestions.deleteAll({ suggestedId: user.id, tier: data.tier });
|
|
|
|
break;
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export interface PlusSuggestionsLoaderData {
|
|
suggestions: plusSuggestions.FindVisibleForUser;
|
|
suggestedForTiers: number[];
|
|
}
|
|
|
|
export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod }) => {
|
|
// only reload if form submission not when user changes tabs
|
|
return Boolean(formMethod && formMethod !== "get");
|
|
};
|
|
|
|
export const loader: LoaderFunction = async ({ request }) => {
|
|
const user = await getUserId(request);
|
|
|
|
return json<PlusSuggestionsLoaderData>({
|
|
suggestions: db.plusSuggestions.findAll({
|
|
...nextNonCompletedVoting(new Date()),
|
|
}),
|
|
suggestedForTiers: user
|
|
? db.plusSuggestions.tiersSuggestedFor({
|
|
...nextNonCompletedVoting(new Date()),
|
|
userId: user.id,
|
|
})
|
|
: [],
|
|
});
|
|
};
|
|
|
|
export default function PlusSuggestionsPage() {
|
|
const data = useLoaderData<PlusSuggestionsLoaderData>();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const user = useUser();
|
|
const tierVisible = searchParamsToLegalTier(searchParams, data.suggestions);
|
|
|
|
const handleTierChange = (tier: string) => {
|
|
setSearchParams({ tier });
|
|
};
|
|
|
|
const visibleSuggestions =
|
|
tierVisible && data.suggestions[tierVisible]
|
|
? data.suggestions[tierVisible]
|
|
: [];
|
|
invariant(visibleSuggestions);
|
|
|
|
return (
|
|
<>
|
|
<Outlet />
|
|
<div className="plus__container">
|
|
<div className="stack md">
|
|
<SuggestedForInfo />
|
|
<div className="stack lg">
|
|
<div
|
|
className={clsx("plus__top-container", {
|
|
"content-centered": !canSuggestNewUserFE({
|
|
user,
|
|
suggestions: data.suggestions,
|
|
}),
|
|
})}
|
|
>
|
|
<div className="plus__radios">
|
|
{Object.entries(data.suggestions)
|
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
|
.map(([tier, suggestions]) => {
|
|
const id = String(tier);
|
|
return (
|
|
<div key={id} className="plus__radio-container">
|
|
<label htmlFor={id} className="plus__radio-label">
|
|
+{tier}{" "}
|
|
<span className="plus__users-count">
|
|
({suggestions.length})
|
|
</span>
|
|
</label>
|
|
<input
|
|
id={id}
|
|
name="tier"
|
|
type="radio"
|
|
checked={tierVisible === tier}
|
|
onChange={() => handleTierChange(tier)}
|
|
data-cy={`plus${tier}-radio`}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{canSuggestNewUserFE({ user, suggestions: data.suggestions }) ? (
|
|
// TODO: resetScroll={false} https://twitter.com/ryanflorence/status/1527775882797907969
|
|
<LinkButton
|
|
to={`new${tierVisible ? `?tier=${tierVisible}` : ""}`}
|
|
prefetch="render"
|
|
size="tiny"
|
|
>
|
|
Suggest
|
|
</LinkButton>
|
|
) : null}
|
|
</div>
|
|
<div className="stack lg">
|
|
{visibleSuggestions.map((u) => {
|
|
invariant(tierVisible);
|
|
return (
|
|
<SuggestedUser
|
|
key={`${u.suggestedUser.id}-${tierVisible}`}
|
|
suggested={u}
|
|
tier={tierVisible}
|
|
/>
|
|
);
|
|
})}
|
|
{visibleSuggestions.length === 0 ? (
|
|
<div className="plus__suggested-info-text text-center">
|
|
No suggestions yet
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function searchParamsToLegalTier(
|
|
searchParams: URLSearchParams,
|
|
suggestions?: plusSuggestions.FindVisibleForUser
|
|
) {
|
|
const tierFromSearchParams = searchParams.get("tier");
|
|
if (
|
|
!tierFromSearchParams ||
|
|
!suggestions ||
|
|
!suggestions[tierFromSearchParams]
|
|
) {
|
|
return tierVisibleInitialState(suggestions);
|
|
}
|
|
|
|
return tierFromSearchParams;
|
|
}
|
|
|
|
function tierVisibleInitialState(
|
|
suggestions?: plusSuggestions.FindVisibleForUser
|
|
) {
|
|
if (!suggestions || Object.keys(suggestions).length === 0) return;
|
|
return String(Math.min(...Object.keys(suggestions).map(Number)));
|
|
}
|
|
|
|
function SuggestedForInfo() {
|
|
const data = useLoaderData<PlusSuggestionsLoaderData>();
|
|
|
|
if (data.suggestedForTiers.length === 0) return null;
|
|
|
|
return (
|
|
<div className="stack md">
|
|
{canDeleteSuggestionOfThemselves() ? (
|
|
<div className="stack horizontal md">
|
|
{data.suggestedForTiers.map((tier) => (
|
|
<FormWithConfirm
|
|
key={tier}
|
|
fields={[
|
|
["_action", "DELETE_SUGGESTION_OF_THEMSELVES"],
|
|
["tier", tier],
|
|
]}
|
|
dialogHeading={`Delete your suggestion to +${tier}? You won't appear in next voting.`}
|
|
>
|
|
<Button
|
|
key={tier}
|
|
size="tiny"
|
|
variant="destructive"
|
|
type="submit"
|
|
>
|
|
Delete your +{tier} suggestion
|
|
</Button>
|
|
</FormWithConfirm>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SuggestedUser({
|
|
suggested,
|
|
tier,
|
|
}: {
|
|
suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo;
|
|
tier: string;
|
|
}) {
|
|
const data = useLoaderData<PlusSuggestionsLoaderData>();
|
|
const user = useUser();
|
|
|
|
invariant(data.suggestions);
|
|
|
|
return (
|
|
<div className="stack md">
|
|
<div className="plus__suggested-user-info">
|
|
<Avatar user={suggested.suggestedUser} size="md" />
|
|
<h2>
|
|
<Link className="all-unset" to={userPage(suggested.suggestedUser)}>
|
|
{suggested.suggestedUser.discordName}
|
|
</Link>
|
|
</h2>
|
|
{canAddCommentToSuggestionFE({
|
|
user,
|
|
suggestions: data.suggestions,
|
|
suggested: { id: suggested.suggestedUser.id },
|
|
targetPlusTier: Number(tier),
|
|
}) ? (
|
|
// TODO: resetScroll={false} https://twitter.com/ryanflorence/status/1527775882797907969
|
|
<LinkButton
|
|
className="plus__comment-button"
|
|
size="tiny"
|
|
variant="outlined"
|
|
to={`comment/${tier}/${suggested.suggestedUser.id}?tier=${tier}`}
|
|
prefetch="render"
|
|
>
|
|
Comment
|
|
</LinkButton>
|
|
) : null}
|
|
</div>
|
|
<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" | "discordId">;
|
|
suggestions: plusSuggestions.FindVisibleForUser;
|
|
tier: string;
|
|
suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo;
|
|
};
|
|
defaultOpen?: true;
|
|
}) {
|
|
return (
|
|
<details open={defaultOpen} className="w-full">
|
|
<summary className="plus__view-comments-action">
|
|
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 horizontal 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,
|
|
suggestedDiscordName,
|
|
isFirstSuggestion = false,
|
|
}: {
|
|
suggestionId: PlusSuggestion["id"];
|
|
tier: string;
|
|
suggestedDiscordName: string;
|
|
isFirstSuggestion?: boolean;
|
|
}) {
|
|
return (
|
|
<FormWithConfirm
|
|
fields={[
|
|
["suggestionId", suggestionId],
|
|
["_action", "DELETE_COMMENT"],
|
|
]}
|
|
dialogHeading={
|
|
isFirstSuggestion
|
|
? `Delete your suggestion of ${suggestedDiscordName} to +${tier}?`
|
|
: `Delete your comment to ${suggestedDiscordName}'s +${tier} suggestion?`
|
|
}
|
|
>
|
|
<Button
|
|
className="plus__delete-button"
|
|
icon={<TrashIcon />}
|
|
variant="minimal-destructive"
|
|
aria-label="Delete comment"
|
|
/>
|
|
</FormWithConfirm>
|
|
);
|
|
}
|
|
|
|
export const CatchBoundary = Catcher;
|